diff --git a/AGENTS.md b/AGENTS.md
index d7bbf8654..f0f4c7499 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -164,6 +164,6 @@ Fields live in `~/.codex/ucode.config.toml` and `/etc/codex/managed_config.toml`
| `model_catalog_json` | Create/replace | Create/replace | In `~/.codex/config.toml`; `ug`'s own catalog reference, for a static model list |
| `otel.trace_exporter` | Ignore | Create/replace | In `~/.codex/config.toml` on Linux when the config enables tracing, for Codex desktop; another exporter and other telemetry settings left alone |
| `mcp_servers` | Ignore | Merge | Managed file; add/update the config's MCP server entries, other entries left alone |
-| Smart Router and Smart Router Orchestrator hooks | Merge | Merge | Launch-only `PreToolUse`, plus `UserPromptSubmit` and compact `SessionStart` with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`; Codex combines them with its native hook sources and applies project trust; `features.hooks` is enabled for a smart-routed launch |
+| Smart Router and Smart Router Orchestrator hooks | Merge | Merge | Launch-only `PreToolUse` and `PostToolUse`, plus `UserPromptSubmit` and compact `SessionStart` with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`; Codex combines them with its native hook sources and applies project trust; `features.hooks` is enabled for a smart-routed launch |
diff --git a/README.md b/README.md
index 5853a7568..997fb4c87 100644
--- a/README.md
+++ b/README.md
@@ -286,6 +286,21 @@ is enabled, ug warns and falls back to subagent routing because the first-prompt
wrapper requires a Unix terminal.
The generated shell hooks expect Git Bash; PowerShell-only setups are not covered.
+Smart-routed interactive Codex sessions preserve a routed child's model and reasoning
+effort through native `close_agent` / `resume_agent` and subsequent `send_input`, including
+routed descendants. Both routing modes use the Codex app-server; subagent-only routing
+keeps the parent's model. UG passes the working directory to the remote TUI so Codex
+can show its normal folder-trust prompt and resolve the initial sandbox from its current
+configuration. Explicit `--cd`/`-C` and permission options are preserved.
+Codex must trust the generated hooks. If settings cannot be
+restored, UG blocks the follow-up with a retry message.
+
+This protection is verified with Codex 0.159.3 and requires its native thread-settings APIs.
+It covers children created through UG's smart-routing launcher, including later resumes
+in another smart-routed launch against the same workspace. Direct Codex, `codex exec`,
+explicit-model launch bypasses, and standalone evaluation plugins do not acquire the guard.
+Native multi-agent v2 retains its own saved-settings restore path.
+
### Smart Router Orchestrator
Use `SMART_ROUTER_CONFIG_VERSION` at launch to select a smart-routing configuration.
diff --git a/scripts/reproduce_codex_resume.py b/scripts/reproduce_codex_resume.py
new file mode 100644
index 000000000..dcde61cae
--- /dev/null
+++ b/scripts/reproduce_codex_resume.py
@@ -0,0 +1,535 @@
+"""Native Codex with UG's actual hooks and a deterministic local router/provider."""
+
+import argparse
+import http.server
+import json
+import os
+import shutil
+import signal
+import socket
+import subprocess
+import threading
+import time
+from pathlib import Path
+
+from websockets.sync.client import connect
+
+from ucode.codex_config import codex_config_args
+from ucode.os_compatibility import subprocess_cross_os
+from ucode.smart_routing import codex_resume, v2
+from ucode.smart_routing.session_env import start_session
+
+PARENT = "gpt-5.1"
+CHILD = "gpt-5.1-codex-mini"
+LEAF = "gpt-5.1-nano"
+
+
+def rpc(connection, method, params, ident=1):
+ connection.send(json.dumps({"id": ident, "method": method, "params": params}))
+ deadline = time.monotonic() + 20
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(f"{method} timed out")
+ message = json.loads(connection.recv(timeout=remaining))
+ if message.get("id") == ident:
+ if "error" in message:
+ raise RuntimeError(message)
+ return message["result"]
+
+
+def initialize(connection):
+ rpc(
+ connection,
+ "initialize",
+ {
+ "clientInfo": {"name": "ug-resume-reproduction", "version": "1"},
+ "capabilities": {"experimentalApi": True},
+ },
+ )
+ connection.send(json.dumps({"method": "initialized", "params": {}}))
+
+
+def model_metadata(model):
+ return {
+ "slug": model,
+ "display_name": model,
+ "description": "Local synthetic fixture",
+ "default_reasoning_level": "medium",
+ "supported_reasoning_levels": [
+ {"effort": effort, "description": effort} for effort in ["low", "medium", "high"]
+ ],
+ "supports_reasoning_summaries": True,
+ "support_verbosity": False,
+ "shell_type": "shell_command",
+ "visibility": "list",
+ "supported_in_api": True,
+ "priority": 1,
+ "experimental_supported_tools": [],
+ "truncation_policy": {"mode": "tokens", "limit": 10000},
+ "context_window": 128000,
+ "multi_agent_version": "v1",
+ "model_messages": {"instructions_template": "Execute the local test fixture."},
+ }
+
+
+class Provider(http.server.ThreadingHTTPServer):
+ daemon_threads = True
+
+ def __init__(self, restart, nested, no_effort):
+ super().__init__(("127.0.0.1", 0), Handler)
+ self.root_actions = ["spawn", "wait", "live", "wait", "close"]
+ self.root_actions += ["finish"] if restart else ["resume", "resumed", "wait", "finish"]
+ self.child_actions = ["spawn", "wait", "finish"] if nested else []
+ self.child_id = self.leaf_id = None
+ self.stage = "initial"
+ self.records = []
+ self.routes = []
+ self.results = {}
+ self.nested = nested
+ self.no_effort = no_effort
+
+
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ def do_GET(self):
+ self.send_response(404)
+ self.end_headers()
+
+ def do_POST(self):
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
+ peer = self.server
+ if self.path.endswith("routes:select"):
+ selected = LEAF if "nested" in body["task"]["prompt"] else CHILD
+ peer.routes.append({"request": body, "selected": selected})
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "route_selection": [
+ {
+ "route_option": {
+ "model": selected,
+ "harness": "codex",
+ }
+ }
+ ]
+ }
+ ).encode()
+ )
+ return
+ meta = body.get("client_metadata") or {}
+ parent_id = meta.get("x-codex-parent-thread-id")
+ child = bool(self.headers.get("x-openai-subagent"))
+ thread_id = meta.get("thread_id")
+ reasoning = body.get("reasoning") or {}
+ peer.records.append(
+ {
+ "thread_id": thread_id,
+ "parent_id": parent_id,
+ "child": child,
+ "stage": peer.stage,
+ "model": body.get("model"),
+ "effort": reasoning.get("effort"),
+ }
+ )
+ for item in body.get("input", []):
+ if item.get("type") != "function_call_output":
+ continue
+ call_id, result = item.get("call_id"), item.get("output")
+ peer.results[call_id] = result
+ if call_id in ("root-spawn", "child-spawn"):
+ try:
+ value = json.loads(result)
+ except (ValueError, TypeError):
+ # Preserve native tool failures and complete the parent turn
+ # so the final assertions can report the failed fixture.
+ peer.root_actions = ["finish"]
+ peer.child_actions = ["finish"]
+ continue
+ if call_id == "root-spawn":
+ peer.child_id = value["agent_id"]
+ else:
+ peer.leaf_id = value["agent_id"]
+ is_leaf = child and body["model"] == LEAF
+ # After a defective restore, model is no longer a reliable role marker.
+ is_leaf = is_leaf or thread_id == peer.leaf_id
+ actions = [] if is_leaf else peer.child_actions if child else peer.root_actions
+ action = actions.pop(0) if actions else "finish"
+ response_id = "response-" + str(len(peer.records))
+ if action == "finish":
+ item = {
+ "type": "message",
+ "role": "assistant",
+ "id": "msg-" + response_id,
+ "content": [{"type": "output_text", "text": "Done."}],
+ }
+ else:
+ target = peer.leaf_id if child else peer.child_id
+ if action == "spawn":
+ name = "spawn_agent"
+ arguments = {
+ "message": "Inspect the nested fixture." if child else "Inspect the fixture.",
+ "model": PARENT,
+ }
+ if not peer.no_effort:
+ arguments["reasoning_effort"] = "medium" if child else "high"
+ elif action == "wait":
+ name, arguments = "wait_agent", {"targets": [target], "timeout_ms": 10000}
+ elif action in ("live", "resumed"):
+ if not child:
+ peer.stage = action
+ if action == "resumed" and peer.nested:
+ peer.child_actions = ["resumed", "wait", "finish"]
+ name, arguments = (
+ "send_input",
+ {"target": target, "message": "Continue the fixture."},
+ )
+ elif action == "close":
+ name, arguments = "close_agent", {"target": target}
+ elif action == "resume":
+ name, arguments = "resume_agent", {"id": target}
+ else:
+ raise AssertionError(action)
+ call_id = ("child-" if child else "root-") + (
+ action if action != "wait" else response_id
+ )
+ item = {
+ "type": "function_call",
+ "namespace": "multi_agent_v1",
+ "name": name,
+ "call_id": call_id,
+ "arguments": json.dumps(arguments),
+ }
+ events = [
+ {"type": "response.created", "response": {"id": response_id}},
+ {"type": "response.output_item.done", "item": item},
+ {
+ "type": "response.completed",
+ "response": {
+ "id": response_id,
+ "usage": {
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "total_tokens": 0,
+ },
+ },
+ },
+ ]
+ payload = "".join(
+ "event: " + e["type"] + "\ndata: " + json.dumps(e) + "\n\n" for e in events
+ ).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+
+def run(args):
+ binary = Path(args.binary)
+ if not binary.is_absolute() or not binary.is_file():
+ raise ValueError("--binary must name an installed absolute Codex executable")
+ version = subprocess_cross_os.run(
+ [str(binary), "--version"], capture_output=True, text=True, timeout=10, check=True
+ )
+ if version.stdout.strip() != "codex-cli " + args.version:
+ raise ValueError(f"Unexpected Codex version: {version.stdout.strip()}")
+ sandbox_binary = shutil.which("bwrap")
+ if sandbox_binary is None:
+ raise RuntimeError("This reproduction requires Linux with bubblewrap installed")
+ out = args.output.resolve()
+ out.mkdir(parents=True, exist_ok=False)
+ peer = Provider(args.restart, args.nested, args.no_effort)
+ fixture_thread = threading.Thread(target=peer.serve_forever, daemon=True)
+ fixture_thread.start()
+ workspace = f"http://127.0.0.1:{peer.server_port}"
+ models = [model_metadata(model) for model in (PARENT, CHILD, LEAF)]
+ if args.no_effort:
+ for model in models[1:]:
+ model.update(
+ default_reasoning_level=None,
+ supported_reasoning_levels=[],
+ supports_reasoning_summaries=False,
+ )
+ catalog = out / "models.json"
+ catalog.write_text(json.dumps({"models": models}))
+ runtime, ug_state = out / "codex-home", out / "ug-state"
+ runtime.mkdir()
+ ug_state.mkdir()
+ native_events = []
+ root_id = None
+ try:
+ for phase in range(2 if args.restart else 1):
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ url = f"ws://127.0.0.1:{port}"
+ env = {
+ key: os.environ[key]
+ for key in ("PATH", "HOME", "CODEX_HOME", "TMPDIR")
+ if key in os.environ
+ }
+ env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
+ env["ENABLE_SMART_ROUTING_SUBAGENT_ONLY"] = "1"
+ env["ENABLE_SMART_ROUTING_V2"] = "0"
+ env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "0"
+ env.pop("SMART_ROUTER_CONFIG_VERSION", None)
+ env["DATABRICKS_BEARER"] = "local-fixture"
+ session = start_session(env)
+ codex_resume.configure_session(session, url)
+ overlay = {
+ "model": PARENT,
+ "model_reasoning_effort": "low",
+ "model_catalog_json": str(catalog),
+ "model_provider": "resume_fixture",
+ "model_providers.resume_fixture": {
+ "name": "Local fixture",
+ "base_url": workspace + "/v1",
+ "wire_api": "responses",
+ "requires_openai_auth": False,
+ "supports_websockets": False,
+ },
+ "cli_auth_credentials_store": "ephemeral",
+ "chatgpt_base_url": workspace + "/backend-api/",
+ "features.multi_agent": True,
+ "features.multi_agent_v2": False,
+ "features.shell_tool": False,
+ "features.hooks": True,
+ "agents.max_depth": 3,
+ "check_for_update_on_startup": False,
+ "web_search": "disabled",
+ "history.persistence": "none",
+ "approval_policy": "never",
+ "sqlite_home": str(out / "state"),
+ "hooks": v2._v2_hooks({"workspace": workspace}, [PARENT, CHILD, LEAF]),
+ }
+ if args.baseline:
+ # Keep the real router; omit only the new lifecycle guard hooks.
+ overlay["hooks"] = {
+ "PreToolUse": [
+ group
+ for group in overlay["hooks"]["PreToolUse"]
+ if any(
+ "codex-router-hook route-subagent" in hook["command"]
+ for hook in group["hooks"]
+ )
+ ]
+ }
+ for key in ("UCODE_SESSION_ENV_FILE", "UCODE_SMART_ROUTER_PYTHON"):
+ overlay["shell_environment_policy.set." + key] = env[key]
+ home_path = os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
+ cmd = [
+ sandbox_binary,
+ "--bind",
+ "/",
+ "/",
+ "--bind",
+ str(runtime),
+ home_path,
+ "--bind",
+ str(ug_state),
+ str(Path.home() / ".ucode"),
+ "--die-with-parent",
+ "--",
+ args.binary,
+ "app-server",
+ "--listen",
+ url,
+ *codex_config_args(overlay),
+ ]
+ with (out / f"phase-{phase}.stderr.txt").open("w") as stderr:
+ process = subprocess_cross_os.popen(
+ cmd,
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=stderr,
+ start_new_session=True,
+ )
+ try:
+ connection = None
+ for _ in range(100):
+ if process.poll() is not None:
+ raise RuntimeError((out / f"phase-{phase}.stderr.txt").read_text())
+ try:
+ connection = connect(url, open_timeout=1)
+ break
+ except OSError:
+ time.sleep(0.05)
+ if connection is None:
+ raise RuntimeError("app-server did not start")
+ with connection:
+ initialize(connection)
+ hooks = rpc(connection, "hooks/list", {"cwds": [str(out)]}, ident=10)
+ (out / f"phase-{phase}.hooks.json").write_text(json.dumps(hooks, indent=2))
+ fixture_hooks = [hook for entry in hooks["data"] for hook in entry["hooks"]]
+ if any(hook["source"] != "sessionFlags" for hook in fixture_hooks):
+ raise RuntimeError("The isolated fixture discovered non-fixture hooks")
+ # Review/approve only the generated test hooks via Codex's native trust API.
+ trust = {
+ h["key"]: {"trusted_hash": h["currentHash"]} for h in fixture_hooks
+ }
+ rpc(
+ connection,
+ "config/batchWrite",
+ {
+ "edits": [
+ {
+ "keyPath": "hooks.state",
+ "value": trust,
+ "mergeStrategy": "upsert",
+ }
+ ],
+ "reloadUserConfig": True,
+ },
+ ident=11,
+ )
+ if root_id is None:
+ result = rpc(
+ connection,
+ "thread/start",
+ {
+ "cwd": str(out),
+ "model": PARENT,
+ "modelProvider": "resume_fixture",
+ },
+ ident=12,
+ )
+ root_id = result["thread"]["id"]
+ else:
+ peer.root_actions = ["resume", "resumed", "wait", "finish"]
+ rpc(
+ connection,
+ "thread/resume",
+ {"threadId": root_id, "modelProvider": "resume_fixture"},
+ ident=12,
+ )
+ rpc(
+ connection,
+ "turn/start",
+ {
+ "threadId": root_id,
+ "input": [
+ {"type": "text", "text": "Execute the synthetic fixture."},
+ ],
+ },
+ ident=13,
+ )
+ deadline = time.monotonic() + 90
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("native fixture exceeded 90 seconds")
+ event = json.loads(connection.recv(timeout=min(45, remaining)))
+ native_events.append(event)
+ if (
+ event.get("method") == "turn/completed"
+ and event["params"]["threadId"] == root_id
+ ):
+ assert event["params"]["turn"]["status"] == "completed", event
+ break
+ if phase == (1 if args.restart else 0):
+ snapshots = [
+ rpc(connection, "thread/read", {"threadId": tid}, ident=30 + i)
+ for i, tid in enumerate(
+ [peer.child_id] + ([peer.leaf_id] if args.nested else [])
+ )
+ ]
+ (out / "native-settings.json").write_text(
+ json.dumps(snapshots, indent=2)
+ )
+ for snapshot in snapshots:
+ thread = snapshot["thread"]
+ expected = LEAF if thread["id"] == peer.leaf_id else CHILD
+ effort = (
+ None
+ if args.no_effort
+ else "medium"
+ if expected == LEAF
+ else "high"
+ )
+ if args.baseline:
+ expected, effort = PARENT, "low"
+ assert (thread["model"], thread["reasoningEffort"]) == (
+ expected,
+ effort,
+ ), thread
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ (session.parent / "codex-app-server.json").unlink(missing_ok=True)
+ session.unlink()
+ session.parent.rmdir()
+ finally:
+ peer.shutdown()
+ fixture_thread.join(timeout=5)
+ assert not fixture_thread.is_alive()
+ peer.server_close()
+ for name, data in [
+ ("requests", peer.records),
+ ("routes", peer.routes),
+ ("tool-results", peer.results),
+ ("events", native_events),
+ ]:
+ (out / (name + ".json")).write_text(json.dumps(data, indent=2))
+ failures = [
+ e
+ for e in native_events
+ if e.get("method") == "hook/completed" and e["params"]["run"]["status"] != "completed"
+ ]
+ assert not failures, failures
+ assert peer.routes, "actual router hook did not run"
+ for record in peer.records:
+ expected_model = (
+ LEAF if record["thread_id"] == peer.leaf_id else CHILD if record["child"] else PARENT
+ )
+ expected_effort = (
+ (None if args.no_effort else "medium" if expected_model == LEAF else "high")
+ if record["child"]
+ else "low"
+ )
+ if args.baseline and record["child"] and record["stage"] == "resumed":
+ expected_model, expected_effort = PARENT, "low"
+ assert (record["model"], record["effort"]) == (expected_model, expected_effort), record
+ assert {r["stage"] for r in peer.records if r["thread_id"] == peer.child_id} == {
+ "initial",
+ "live",
+ "resumed",
+ }
+ if args.nested:
+ assert {r["stage"] for r in peer.records if r["thread_id"] == peer.leaf_id} == {
+ "initial",
+ "resumed",
+ }
+ result = {
+ "passed": True,
+ "baseline": args.baseline,
+ "restart": args.restart,
+ "nested": args.nested,
+ "no_effort": args.no_effort,
+ "routing_calls": len(peer.routes),
+ "children": [r for r in peer.records if r["child"]],
+ }
+ (out / "result.json").write_text(json.dumps(result, indent=2))
+ print(json.dumps(result), flush=True)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--binary", required=True)
+ parser.add_argument("--version", default="0.159.3")
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--baseline", action="store_true")
+ parser.add_argument("--restart", action="store_true")
+ parser.add_argument("--nested", action="store_true")
+ parser.add_argument("--no-effort", action="store_true")
+ run(parser.parse_args())
diff --git a/src/ucode/cli.py b/src/ucode/cli.py
index 624c18524..d929277a5 100644
--- a/src/ucode/cli.py
+++ b/src/ucode/cli.py
@@ -2501,7 +2501,7 @@ def codex_router_hook_cmd(
import json
import sys
- if not smart_routing_v2.smart_routing_enabled(
+ if event not in {"guard-resume", "bind-route"} and not smart_routing_v2.smart_routing_enabled(
effective_environment(agent=AGENT_CODEX), default=False, agent=AGENT_CODEX
):
return
@@ -2518,6 +2518,39 @@ def codex_router_hook_cmd(
return
if not isinstance(payload, dict):
return
+ from ucode.smart_routing import codex_resume
+
+ if event in {"guard-resume", "bind-route"}:
+ try:
+ app_server_url = codex_resume.app_server_url()
+ if not host or not app_server_url:
+ raise RuntimeError("Resume protection needs an active Codex app-server")
+ routes = codex_resume.for_workspace(host)
+ with codex_resume.AppServer(app_server_url) as client:
+ if event == "bind-route":
+ routes.bind_spawn(payload, client)
+ else:
+ codex_resume.guard_tool(payload, routes, client)
+ except codex_resume.GUARD_ERRORS as exc:
+ print(
+ f"Could not preserve Codex child settings ({type(exc).__name__}).", file=sys.stderr
+ )
+ if event == "guard-resume":
+ sys.stdout.write(
+ json.dumps(
+ {
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": "Unity Gateway could not restore the child's saved "
+ "model settings. Retry the resume or follow-up after checking the app-server.",
+ }
+ }
+ )
+ )
+ else:
+ raise typer.Exit(1) from exc
+ return
if event == "session-start":
record_session_start(payload)
return
@@ -2566,6 +2599,21 @@ def codex_router_hook_cmd(
audit_decision=True,
)
if output is not None:
+ try:
+ if codex_resume.app_server_url():
+ codex_resume.for_workspace(host).remember_spawn(
+ payload, output["hookSpecificOutput"]["updatedInput"]["model"]
+ )
+ except codex_resume.GUARD_ERRORS as exc:
+ print(f"Could not save Codex child settings ({type(exc).__name__}).", file=sys.stderr)
+ output = {
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": "Unity Gateway could not save the routed child's "
+ "model settings. Check access to Unity Gateway's local state before retrying.",
+ }
+ }
sys.stdout.write(json.dumps(output))
diff --git a/src/ucode/smart_routing/codex_hooks.py b/src/ucode/smart_routing/codex_hooks.py
index 421cabda9..c44eecdbc 100644
--- a/src/ucode/smart_routing/codex_hooks.py
+++ b/src/ucode/smart_routing/codex_hooks.py
@@ -5,8 +5,8 @@
import copy
import shlex
import subprocess
+import sys
-from ucode.databricks import build_auth_token_argv
from ucode.smart_routing import hooks
ROUTING_HOOK_COMMAND_MARKER = "codex-router-hook"
@@ -65,6 +65,23 @@ def merge_pre_tool_use_hooks(
return doc["hooks"]["PreToolUse"]
+def resume_hook_groups(state: dict) -> dict[str, list[dict]]:
+ return {
+ "PreToolUse": [
+ {
+ "matcher": ".*(resume_agent|send_input)$",
+ "hooks": [_routing_command_hook(_routing_hook_argv(state, "guard-resume"))],
+ }
+ ],
+ "PostToolUse": [
+ {
+ "matcher": "Agent|.*spawn_agent$",
+ "hooks": [_routing_command_hook(_routing_hook_argv(state, "bind-route"))],
+ }
+ ],
+ }
+
+
def _pre_tool_use_hook_group(state: dict, *, available_models: list[str] | None = None) -> dict:
route_argv = _routing_hook_argv(
state,
@@ -82,15 +99,17 @@ def _routing_hook_argv(
) -> list[str]:
workspace = str(state.get("workspace") or "")
argv = [
- build_auth_token_argv(workspace, state.get("profile"), use_pat=bool(state.get("use_pat")))[
- 0
- ],
+ sys.executable,
+ "-m",
+ "ucode.cli",
ROUTING_HOOK_COMMAND_MARKER,
event,
]
- if event != "route-subagent":
+ if event not in {"route-subagent", "guard-resume", "bind-route"}:
return argv
argv += ["--host", workspace]
+ if event != "route-subagent":
+ return argv
profile = state.get("profile")
if isinstance(profile, str) and profile:
argv += ["--profile", profile]
diff --git a/src/ucode/smart_routing/codex_interposer.py b/src/ucode/smart_routing/codex_interposer.py
index 5d39b5d1b..b6d84e507 100644
--- a/src/ucode/smart_routing/codex_interposer.py
+++ b/src/ucode/smart_routing/codex_interposer.py
@@ -60,6 +60,8 @@ def __init__(
available_models: list[str] | None = None,
route_decision: RouteDecisionFn | None = None,
switch_message_fn: SwitchMessageFn | None = None,
+ on_model_override: Callable[[str], None] | None = None,
+ use_server_sandbox_default: bool = False,
) -> None:
self.target = target_model
self.available_models = list(available_models or [])
@@ -67,6 +69,10 @@ def __init__(
self.switch_message = switch_message
self.route_decision = route_decision
self.switch_message_fn = switch_message_fn
+ self.on_model_override = on_model_override
+ self.model_override_requests: dict[str | int, str] = {}
+ self.use_server_sandbox_default = use_server_sandbox_default
+ self.sandbox_default_requests: set[str | int] = set()
self.thread_id: str | None = None
self.settings: dict | None = None
self.first_turn_seen = False
@@ -84,6 +90,32 @@ def on_tui_frame(self, raw: str) -> TuiFrameResult:
if not isinstance(msg, dict):
return TuiFrameResult(raw, needs_settings_update=False)
params = msg.get("params")
+ request_id = msg.get("id")
+ if (
+ self.use_server_sandbox_default
+ and msg.get("method") in {"thread/start", "thread/resume", "thread/fork"}
+ and isinstance(request_id, (str, int))
+ and isinstance(params, dict)
+ ):
+ self.sandbox_default_requests.add(request_id)
+ if (
+ msg.get("method") == "thread/start"
+ and params.get("sandbox") == "read-only"
+ and params.get("permissions") is None
+ ):
+ # Remote TUI config predates folder-trust onboarding. Let Codex
+ # resolve its current policy instead of applying that stale default.
+ params.pop("sandbox")
+ return TuiFrameResult(json.dumps(msg), needs_settings_update=False)
+ if (
+ self.on_model_override is not None
+ and isinstance(request_id, (str, int))
+ and msg.get("method") in {TURN_START, SETTINGS_UPDATE, "thread/resume"}
+ and isinstance(params, dict)
+ and isinstance(params.get("threadId"), str)
+ and any(params.get(key) is not None for key in ("model", "effort", "collaborationMode"))
+ ):
+ self.model_override_requests[request_id] = params["threadId"]
if msg.get("method") == TURN_START and isinstance(params, dict):
if isinstance(params.get("threadId"), str):
self.thread_id = params["threadId"]
@@ -125,6 +157,16 @@ def on_engine_frame(self, raw: str) -> list[dict]:
return []
if not isinstance(msg, dict):
return []
+ request_id = msg.get("id")
+ if isinstance(request_id, (str, int)) and ("result" in msg or "error" in msg):
+ if request_id in self.sandbox_default_requests:
+ self.sandbox_default_requests.remove(request_id)
+ if "error" not in msg and isinstance(msg.get("result"), dict):
+ self.use_server_sandbox_default = False
+ target = self.model_override_requests.pop(request_id, None)
+ if target is not None and "error" not in msg and isinstance(msg.get("result"), dict):
+ assert self.on_model_override is not None
+ self.on_model_override(target)
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
result = msg.get("result") if isinstance(msg.get("result"), dict) else {}
for src in (params, result):
@@ -211,6 +253,8 @@ async def _handle_tui(
workspace: str | None = None,
token_provider: TokenProvider | None = None,
switch_message_fn: SwitchMessageFn | None = None,
+ on_model_override: Callable[[str], None] | None = None,
+ use_server_sandbox_default: bool = False,
) -> None:
path = getattr(getattr(tui, "request", None), "path", "/") or "/"
uri = upstream_uri.rstrip("/") + path
@@ -238,6 +282,8 @@ def route_decision(prompt: str):
available_models,
route_decision,
switch_message_fn,
+ on_model_override,
+ use_server_sandbox_default,
)
async with connect(uri, max_size=None) as upstream:
@@ -300,6 +346,8 @@ async def _serve(
workspace: str | None = None,
token_provider: TokenProvider | None = None,
switch_message_fn: SwitchMessageFn | None = None,
+ on_model_override: Callable[[str], None] | None = None,
+ use_server_sandbox_default: bool = False,
):
async def handler(tui):
try:
@@ -313,6 +361,8 @@ async def handler(tui):
workspace,
token_provider,
switch_message_fn,
+ on_model_override,
+ use_server_sandbox_default,
)
except Exception as exc: # noqa: BLE001
log(f"[ERR] session: {exc!r}")
@@ -332,6 +382,8 @@ def start_interposer_thread(
workspace: str | None = None,
token_provider: TokenProvider | None = None,
switch_message_fn: SwitchMessageFn | None = None,
+ on_model_override: Callable[[str], None] | None = None,
+ use_server_sandbox_default: bool = False,
switch_message: str | None = None,
log_path: Path | None = None,
ready_timeout: float = 10.0,
@@ -365,6 +417,8 @@ def run() -> None:
workspace,
token_provider,
switch_message_fn,
+ on_model_override,
+ use_server_sandbox_default,
)
)
holder["port"] = holder["server"].sockets[0].getsockname()[1]
diff --git a/src/ucode/smart_routing/codex_resume.py b/src/ucode/smart_routing/codex_resume.py
new file mode 100644
index 000000000..687713e01
--- /dev/null
+++ b/src/ucode/smart_routing/codex_resume.py
@@ -0,0 +1,328 @@
+"""Preserve routed Codex v1 child settings across native close/resume calls."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import socket
+import sqlite3
+import time
+from contextlib import closing, contextmanager
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from websockets.exceptions import WebSocketException
+from websockets.sync.client import connect
+
+from ucode import config_io
+from ucode.smart_routing.session_env import session_env_path
+
+RPC_TIMEOUT_SECONDS = 15
+GUARD_ERRORS = (
+ OSError,
+ RuntimeError,
+ ValueError,
+ KeyError,
+ TypeError,
+ sqlite3.Error,
+ WebSocketException,
+)
+
+
+def _caller(payload: dict) -> str | None:
+ # Codex keeps session_id at the root for hooks run by descendants.
+ value = payload.get("agent_id") or payload.get("session_id")
+ return value if isinstance(value, str) else None
+
+
+class AppServer:
+ """A short-lived native client; never starts or resumes an unloaded child."""
+
+ def __init__(self, url: str):
+ parsed = urlsplit(url)
+ if parsed.scheme != "ws" or parsed.hostname != "127.0.0.1":
+ raise ValueError("Codex resume protection requires a loopback app-server")
+ self.url = url
+ if parsed.port is None:
+ raise ValueError("Codex app-server URL has no port")
+ self.port = parsed.port
+ self.next_id = 0
+ self.events: list[dict] = []
+ self.connection = None
+ self.socket = None
+
+ def __enter__(self):
+ return self
+
+ def _open(self):
+ # Passing a socket works with websockets 13+ and prevents environment
+ # proxy settings from forwarding this local control connection.
+ transport = socket.create_connection(("127.0.0.1", self.port), RPC_TIMEOUT_SECONDS)
+ try:
+ # A supplied socket bypasses websockets' timeout reset. Only the RPC
+ # deadline should expire while its receiver waits for confirmation.
+ transport.settimeout(None)
+ self.connection = connect(self.url, sock=transport, open_timeout=RPC_TIMEOUT_SECONDS)
+ self.socket = self.connection.__enter__()
+ except Exception:
+ transport.close()
+ raise
+ self.deadline = time.monotonic() + RPC_TIMEOUT_SECONDS
+ try:
+ self.call(
+ "initialize",
+ {
+ "clientInfo": {"name": "ug-resume-guard", "version": "1"},
+ "capabilities": {"experimentalApi": True},
+ },
+ )
+ self.socket.send(json.dumps({"method": "initialized", "params": {}}))
+ except Exception:
+ self.connection.__exit__(None, None, None)
+ self.connection = None
+ self.socket = None
+ raise
+
+ def __exit__(self, *_exc):
+ if self.connection is not None:
+ self.connection.__exit__(*_exc)
+
+ def receive(self) -> dict:
+ assert self.socket is not None
+ remaining = self.deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("Codex settings restoration timed out")
+ message = json.loads(self.socket.recv(timeout=remaining))
+ if not isinstance(message, dict):
+ raise ValueError("Invalid Codex app-server response")
+ return message
+
+ def call(self, method: str, params: dict) -> dict:
+ if self.socket is None:
+ self._open()
+ assert self.socket is not None
+ self.next_id += 1
+ self.socket.send(json.dumps({"id": self.next_id, "method": method, "params": params}))
+ while True:
+ message = self.receive()
+ if message.get("id") == self.next_id:
+ if "error" in message or not isinstance(message.get("result"), dict):
+ raise RuntimeError(f"Codex rejected {method}")
+ return message["result"]
+ self.events.append(message)
+
+ def read(self, thread_id: str) -> dict:
+ thread = self.call("thread/read", {"threadId": thread_id, "includeTurns": False}).get(
+ "thread"
+ )
+ if not isinstance(thread, dict) or thread.get("id") != thread_id:
+ raise ValueError("Invalid Codex thread settings")
+ return thread
+
+ def restore(self, thread_id: str, model: str, effort: str | None) -> None:
+ thread = self.read(thread_id)
+ status = thread.get("status")
+ if not isinstance(status, dict):
+ raise ValueError("Invalid Codex thread status")
+ if status.get("type") == "notLoaded":
+ raise RuntimeError("Resume the child before sending follow-up input")
+ if thread.get("canAcceptDirectInput") is not True:
+ raise RuntimeError("Child does not accept native v1 settings updates")
+ if "reasoningEffort" in thread and (thread.get("model"), thread["reasoningEffort"]) == (
+ model,
+ effort,
+ ):
+ return
+ # Attach only after proving the child is loaded. Native resume_agent owns
+ # subtree restoration and graph bookkeeping; the guard must not replace it.
+ current = self.call("thread/resume", {"threadId": thread_id, "excludeTurns": True})
+ update: dict = {"threadId": thread_id, "model": model}
+ if effort is not None:
+ update["effort"] = effort
+ else:
+ # The public effort:null field means "unchanged". A collaboration
+ # settings update can clear it while retaining the current mode.
+ mode = copy.deepcopy(current["collaborationMode"])
+ if not isinstance(mode, dict) or not isinstance(mode.get("settings"), dict):
+ raise ValueError("Invalid Codex collaboration mode")
+ mode["settings"].update(model=model, reasoning_effort=None)
+ update["collaborationMode"] = mode
+ self.events.clear()
+ self.call("thread/settings/update", update)
+ # The RPC acknowledgement only means queued, not applied. Do not release
+ # send_input until the native thread confirms both settings.
+ while True:
+ event = self.events.pop(0) if self.events else self.receive()
+ params = event.get("params")
+ if not isinstance(params, dict):
+ continue
+ settings = params.get("threadSettings")
+ if (
+ event.get("method") == "thread/settings/updated"
+ and params.get("threadId") == thread_id
+ and isinstance(settings, dict)
+ and "effort" in settings
+ and (settings.get("model"), settings.get("effort")) == (model, effort)
+ ):
+ return
+
+
+class RoutedChildren:
+ def __init__(self, path: Path):
+ self.path = path
+
+ @contextmanager
+ def database(self):
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with closing(sqlite3.connect(self.path, timeout=5)) as db, db:
+ db.row_factory = sqlite3.Row
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS spawns "
+ "(parent TEXT, call_id TEXT, model TEXT NOT NULL, PRIMARY KEY(parent, call_id))"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS children "
+ "(id TEXT PRIMARY KEY, parent TEXT NOT NULL, model TEXT, effort TEXT, "
+ "ready INTEGER NOT NULL DEFAULT 0, pending INTEGER NOT NULL DEFAULT 0)"
+ )
+ yield db
+
+ def remember_spawn(self, payload: dict, model: str) -> None:
+ parent, call_id = _caller(payload), payload.get("tool_use_id")
+ if not isinstance(parent, str) or not isinstance(call_id, str):
+ raise ValueError("Codex omitted the routed spawn identity")
+ with self.database() as db:
+ db.execute("INSERT OR REPLACE INTO spawns VALUES (?, ?, ?)", (parent, call_id, model))
+
+ def bind_spawn(self, payload: dict, client: AppServer) -> None:
+ response = payload.get("tool_response")
+ if isinstance(response, str):
+ response = json.loads(response)
+ if not isinstance(response, dict) or not isinstance(response.get("agent_id"), str):
+ return
+ child = response["agent_id"]
+ parent, call_id = _caller(payload), payload.get("tool_use_id")
+ if not isinstance(parent, str) or not isinstance(call_id, str):
+ return
+ with self.database() as db:
+ spawn = db.execute(
+ "SELECT model FROM spawns WHERE parent=? AND call_id=?", (parent, call_id)
+ ).fetchone()
+ # Retain unrouted edges too: a routed descendant may have an unrouted parent.
+ db.execute(
+ "INSERT OR IGNORE INTO children (id, parent, model) VALUES (?, ?, ?)",
+ (child, parent, spawn["model"] if spawn else None),
+ )
+ if spawn is None:
+ return
+ thread = client.read(child)
+ if thread.get("canAcceptDirectInput") is False:
+ # Native v2 has its own persisted-model reload path.
+ with self.database() as db:
+ db.execute("UPDATE children SET model=NULL WHERE id=?", (child,))
+ db.execute("DELETE FROM spawns WHERE parent=? AND call_id=?", (parent, call_id))
+ return
+ self.capture(child, parent, thread, expected_model=spawn["model"])
+ with self.database() as db:
+ db.execute("DELETE FROM spawns WHERE parent=? AND call_id=?", (parent, call_id))
+
+ def capture(
+ self,
+ child: str,
+ parent: str,
+ thread: dict,
+ *,
+ expected_model: str | None = None,
+ pending: bool = False,
+ ) -> None:
+ model, effort = thread.get("model"), thread.get("reasoningEffort")
+ if (
+ thread.get("id") != child
+ or thread.get("parentThreadId") != parent
+ or not isinstance(model, str)
+ or "reasoningEffort" not in thread
+ or (effort is not None and not isinstance(effort, str))
+ or (expected_model is not None and model != expected_model)
+ ):
+ raise ValueError("Codex child settings do not match the routed spawn")
+ with self.database() as db:
+ db.execute(
+ "UPDATE children SET model=?, effort=?, ready=1, pending=? WHERE id=? AND pending=0",
+ (model, effort, pending, child),
+ )
+
+ def prepare_resume(self, target: str, client: AppServer) -> None:
+ with self.database() as db:
+ children = db.execute(
+ "WITH RECURSIVE tree(id) AS (SELECT ? UNION SELECT c.id FROM children c "
+ "JOIN tree t ON c.parent=t.id) SELECT c.* FROM children c JOIN tree t ON c.id=t.id "
+ "WHERE c.model IS NOT NULL",
+ (target,),
+ ).fetchall()
+ for child in children:
+ if child["pending"]:
+ continue
+ # Capture deliberate changes made before closing, but never recapture
+ # the inherited defaults from an earlier, still-pending native resume.
+ self.capture(
+ child["id"],
+ child["parent"],
+ client.read(child["id"]),
+ expected_model=child["model"] if not child["ready"] else None,
+ pending=True,
+ )
+
+ def restore_before_input(self, target: str, client: AppServer) -> None:
+ with self.database() as db:
+ child = db.execute("SELECT * FROM children WHERE id=?", (target,)).fetchone()
+ if child is None or child["model"] is None or not child["pending"]:
+ return
+ if not child["ready"]:
+ raise RuntimeError("The routed child's original settings were not captured")
+ client.restore(target, child["model"], child["effort"])
+ self.allow_override(target)
+
+ def allow_override(self, target: str) -> None:
+ with self.database() as db:
+ db.execute("UPDATE children SET pending=0 WHERE id=?", (target,))
+
+
+def for_workspace(workspace: str) -> RoutedChildren:
+ key = hashlib.sha256(workspace.rstrip("/").encode()).hexdigest()
+ return RoutedChildren(config_io.APP_DIR / "codex-routed-children" / f"{key}.sqlite3")
+
+
+def configure_session(path: Path, url: str) -> None:
+ # Keep the port out of command text: changing it would invalidate native
+ # hook trust on every launch.
+ config_io.atomic_write_json(path.parent / "codex-app-server.json", {"url": url})
+
+
+def app_server_url() -> str | None:
+ try:
+ path = session_env_path().parent / "codex-app-server.json"
+ except RuntimeError:
+ return None
+ if not path.exists():
+ return None
+ metadata = json.loads(path.read_text(encoding="utf-8"))
+ value = metadata.get("url") if isinstance(metadata, dict) else None
+ if not isinstance(value, str):
+ raise ValueError("Invalid Codex app-server session metadata")
+ return value
+
+
+def guard_tool(payload: dict, routes: RoutedChildren, client: AppServer) -> None:
+ tool = payload.get("tool_name", "")
+ args = payload.get("tool_input")
+ if not isinstance(args, dict) or not isinstance(tool, str):
+ return
+ resume = tool.endswith("resume_agent")
+ target = args.get("id") if resume else args.get("target", args.get("id"))
+ if not isinstance(target, str):
+ return
+ if resume:
+ routes.prepare_resume(target, client)
+ elif tool.endswith("send_input"):
+ routes.restore_before_input(target, client)
diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py
index f568af8e7..baf7f148e 100644
--- a/src/ucode/smart_routing/v2.py
+++ b/src/ucode/smart_routing/v2.py
@@ -45,7 +45,6 @@
list_anthropic_models,
maybe_add_gpt_1m_suffix,
)
-from ucode.launcher import exec_or_spawn
from ucode.os_compatibility import subprocess_cross_os
from ucode.os_compatibility.file_lock_cross_os import (
acquire_exclusive_file_lock,
@@ -65,7 +64,11 @@
sync_first_prompt_hook,
sync_smart_routing_hooks,
)
-from ucode.smart_routing.codex_hooks import merge_pre_tool_use_hooks, routing_models
+from ucode.smart_routing.codex_hooks import (
+ merge_pre_tool_use_hooks,
+ resume_hook_groups,
+ routing_models,
+)
from ucode.smart_routing.config import apply_config, resolve_environment
from ucode.smart_routing.session_env import SESSION_ENV_VAR, SESSION_PYTHON_ENV_VAR, start_session
from ucode.ui import print_note, print_warning
@@ -782,10 +785,46 @@ def _v2_hooks(state: dict, available_models: list[str]) -> dict:
"PreToolUse": merge_pre_tool_use_hooks([], state, available_models=available_models),
}
}
+ for event, groups in resume_hook_groups(state).items():
+ doc["hooks"].setdefault(event, []).extend(groups)
orchestrator.sync_hooks(doc, agent="codex")
return doc["hooks"]
+def _codex_has_sandbox_override(tool_args: list[str]) -> bool:
+ args = iter(tool_args)
+ for arg in args:
+ if arg == "--":
+ break
+ if arg in {
+ "--sandbox",
+ "--profile",
+ "--full-auto",
+ "--auto-review",
+ "--approve-for-me",
+ "--dangerously-bypass-approvals-and-sandbox",
+ "--yolo",
+ } or arg.startswith(("--sandbox=", "--profile=", "-s", "-p")):
+ return True
+ override = ""
+ if arg in {"--config", "-c"}:
+ override = next(args, "")
+ elif arg.startswith("--config="):
+ override = arg.removeprefix("--config=")
+ elif arg.startswith("-c"):
+ override = arg[2:].removeprefix("=")
+ key = override.partition("=")[0].strip().split(".")[0].strip("\"'")
+ # These selections are not replayed in the native thread/start config.
+ if key in {
+ "sandbox_mode",
+ "sandbox_permissions",
+ "profile",
+ "profiles",
+ }:
+ return True
+ return False
+
+
def launch_codex(
state: dict,
tool_args: list[str],
@@ -794,6 +833,9 @@ def launch_codex(
start_model: str | None,
render_overlay: Callable[..., dict],
) -> NoReturn:
+ # These Codex-only modules load websockets; keep them off the Claude launch path.
+ from ucode.smart_routing import codex_interposer, codex_resume
+
workspace = state.get("workspace")
if not workspace:
raise RuntimeError(
@@ -824,9 +866,12 @@ def launch_codex(
catalog_path = custom_catalog_path()
if catalog_path is not None:
overlay["model_catalog_json"] = str(catalog_path)
+ app_port = _free_port()
+ app_server_url = _loopback_websocket_url(app_port)
overlay["hooks"] = _v2_hooks(state, available_models)
overlay["features.hooks"] = True
session_env_path = _prepare_smart_router_session("codex")
+ codex_resume.configure_session(session_env_path, app_server_url)
# Codex constructs tool subprocess environments through its shell policy.
# Pass both the session marker and its launching interpreter through that policy.
overlay[f"shell_environment_policy.set.{SESSION_ENV_VAR}"] = str(session_env_path)
@@ -834,27 +879,18 @@ def launch_codex(
SESSION_PYTHON_ENV_VAR
]
config_args = codex_config_args(overlay)
- if not first_prompt_routing_enabled(agent=AGENT_CODEX):
- # Subagent-only routing needs neither the app-server nor the interposer:
- # the hooks ride in the CLI config, so launch the TUI directly.
- exec_or_spawn([binary, *config_args, *tool_args])
- app_port = _free_port()
- app_server_url = _loopback_websocket_url(app_port)
-
- # Codex-only, and pulls in `websockets`; keep it off the Claude launch path.
- from ucode.smart_routing import codex_interposer
-
- # Preserve the user's normal CODEX_HOME (including MCP servers, skills, and
- # preferences) and layer only ucode's gateway settings at CLI precedence.
- app_server = subprocess_cross_os.popen(
- [binary, "app-server", *config_args, "--listen", app_server_url],
- env=os.environ.copy(),
- stdin=subprocess.DEVNULL,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
+ app_server = None
stop_interposer = None
try:
+ # Preserve the user's normal CODEX_HOME (including MCP servers, skills, and
+ # preferences) and layer only ucode's gateway settings at CLI precedence.
+ app_server = subprocess_cross_os.popen(
+ [binary, "app-server", *config_args, "--listen", app_server_url],
+ env=os.environ.copy(),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
if not _wait_for_app_server(app_port, timeout=APP_SERVER_READY_TIMEOUT_SECONDS):
raise RuntimeError(
"Codex app-server did not become ready for smart routing; check workspace auth."
@@ -863,8 +899,10 @@ def launch_codex(
LOOPBACK_HOST,
app_server_url,
available_models=available_models,
- workspace=workspace,
+ workspace=workspace if first_prompt_routing_enabled(agent=AGENT_CODEX) else None,
token_provider=lambda: _launch_token(state, workspace),
+ on_model_override=codex_resume.for_workspace(workspace).allow_override,
+ use_server_sandbox_default=not _codex_has_sandbox_override(tool_args),
switch_message_fn=format_routing_notice,
log_path=CODEX_INTERPOSER_LOG,
)
@@ -879,8 +917,26 @@ def launch_codex(
if key in overlay
}
)
+ # Remote Codex only checks project trust when --cd is explicit. Preserve
+ # native onboarding and its sandbox defaults for the local workspace.
+ cwd_args = ["--cd", str(Path.cwd())]
+ for arg in tool_args:
+ if arg == "--":
+ break
+ if arg == "--cd" or arg.startswith(("--cd=", "-C")):
+ cwd_args = []
+ break
tui = subprocess_cross_os.popen(
- [binary, *provider_args, "--remote", tui_url, "--model", start_model, *tool_args]
+ [
+ binary,
+ *provider_args,
+ *cwd_args,
+ "--remote",
+ tui_url,
+ "--model",
+ start_model,
+ *tool_args,
+ ]
)
try:
returncode = tui.wait()
@@ -888,11 +944,18 @@ def launch_codex(
tui.send_signal(signal.SIGINT)
returncode = tui.wait()
finally:
- if stop_interposer is not None:
- stop_interposer()
- app_server.terminate()
try:
- app_server.wait(timeout=PROCESS_SHUTDOWN_TIMEOUT_SECONDS)
- except Exception: # noqa: BLE001
- app_server.kill()
+ if stop_interposer is not None:
+ stop_interposer()
+ finally:
+ try:
+ if app_server is not None:
+ app_server.terminate()
+ try:
+ app_server.wait(timeout=PROCESS_SHUTDOWN_TIMEOUT_SECONDS)
+ except subprocess.TimeoutExpired:
+ app_server.kill()
+ app_server.wait()
+ finally:
+ (session_env_path.parent / "codex-app-server.json").unlink(missing_ok=True)
sys.exit(returncode)
diff --git a/tests/README.md b/tests/README.md
index 17c67b403..9cdb132b0 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -61,9 +61,16 @@ Offline PTY checks verify that the Claude background-task wait observes "No task
running" or a native task menu containing only completed rows before sending `/exit`, without
stopping tasks or confirming an exit dialog. Running/scheduled sections, incomplete row counts,
and completed-task text outside the native menu cannot satisfy the wait.
+When `/tasks` opens one agent's detail panel, the helper uses its Left-arrow navigation to
+return to the full list. If that closes the panel to the input prompt, it reopens `/tasks`.
+Offline PTY checks cover both navigation outcomes, repeated detail panels, and running and
+completed agents. A completed detail panel cannot satisfy the wait while navigation is pending;
+all tasks must finish before exit, including when the selected agent is already done.
The live Claude subagent skill-toggle journey uses this wait after its final calculation.
-CUJ4's Claude preset sessions also use it after verifying delegated file tasks: a completed
-answer does not prove that a resumed child has stopped running.
+CUJ4's Claude preset sessions wait for the parent's answer with the delegated file's value
+before opening the task view, since the parent can resume a child while forming its reply.
+They then wait for background completion: the parent's answer does not prove that a resumed
+child has stopped running.
They cover Claude/Codex helper dispatch and rejection of routing decisions without
the agent-specific prompt-submission evidence.
The shared preset CUJ runs both agents for every version and derives routing and
@@ -95,6 +102,9 @@ Child HTTP matching accepts Claude's single appended transport newline after the
checkpoint; additional text, whitespace, and Codex prompt changes remain rejected.
Offline CUJ regressions cover Codex delegated-turn completion and exact task-request matching;
notifications alone, Claude title requests, and parent continuations do not qualify.
+Codex parent-turn evidence accepts native runtime-context updates for the same turn, including
+midnight date changes. Untagged, mixed user/context, and other-turn messages still end the match;
+duplicate prompts remain rejected.
Unrelated and pre-checkpoint request bodies are excluded before JSON decoding.
CUJ4 accepts both top-level tool definitions (Codex 0.154.0) and Responses Lite
`additional_tools` input items (0.162.1). Encrypted child messages match the routed
@@ -270,9 +280,25 @@ without tier rules, applying recommendations when tiers exist, and serializing t
API field. These are unit/component checks; live request-count coverage is not included.
`test_codex_smart_routing_v2.py` checks that the remote Codex TUI receives the
-gateway provider on Windows while Unix launch arguments stay unchanged and routing
-hooks stay with the app-server. This is component coverage, not a live Windows
-sign-in or TUI test.
+gateway provider on Windows and an explicit working directory for native trust
+onboarding on all platforms. The launch-argument cases run with both POSIX and
+Windows settings. Caller-supplied `--cd`/`-C` forms, permission options, and prompts
+after `--` are preserved. Routing hooks stay with the app-server.
+Initial thread creation defers an implicit read-only sandbox to Codex's current server
+configuration, including after a rejected startup request. Explicit sandbox/profile
+selections, named permission selections, resumed/forked sessions, and later thread starts
+remain unchanged. This is component coverage, not a live Windows sign-in or TUI test.
+
+`test_codex_resume.py` covers SQLite persistence across guard restarts, saved model/effort
+restoration (including unset effort), nested callers sharing the root session ID,
+descendant traversal through unrouted parents, live follow-ups, deliberate overrides,
+native-v2 exclusion, identity mismatches, repeated/overlapping resumes, and denial when
+metadata, persistence, or native confirmation fails. Its loopback WebSocket peer substitutes
+the external Codex service. Missing confirmation must reach the RPC deadline without
+a competing socket timeout closing the connection, and a later retry must still restore
+the saved settings. The CLI and route store run unchanged.
+`test_codex_smart_routing_v2.py` also checks the app-server lifecycle in both routing modes,
+session-endpoint cleanup, and hooks bound to the launching Python executable.
Claude picker composition is checked directly through the catalog and renderer functions in
`test_agent_claude.py`; focused CLI cases cover source selection and launch precedence.
@@ -348,6 +374,9 @@ Claude checks local mod confirmations and the inference payload's `smart_router_
Codex checks saved session controls and native tool-result confirmations after each toggle.
Both explicitly request their children,
including while routing is off.
+The Codex journey checks every native parent turn against the configured model and
+requires no first-prompt route or rewrite in the interposer log. Subagent-only launches
+use that interposer to protect resumed children.
The managed-fixture banner journeys separately cover the selector unset (managed default) and
`first_prompt_and_subagent_no_orch_v0` for real first-prompt tasks.
Preset parameterization augments only routing hooks, skill toggles, and first-prompt routing;
@@ -419,7 +448,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi
| `test_ug_codex_app_reports_unknown_argument` | Pass an invalid option directly to `ug codex app` with legacy smart routing off/on | Real Codex parser error and status preserved |
| `test_ug_codex_app_server_client_initializes` | Connect a stdio client, direct/`--` separator, with legacy smart routing off/on | Actual JSON-RPC initialize response; no non-JSON stdout; no routing |
| `test_smart_routing_claude_route_subagent_hook`, `test_smart_routing_codex_route_subagent_hook` | Pipe a native spawn payload to the installed Claude mod command or Codex PreToolUse hook with legacy subagent routing or each supported routing preset | Claude returns model/rationale/updatedInput JSON naming the routed agent the mod labels; Codex returns an allow-hook decision; requested model replaced by a routed agent definition (Claude) or bundled catalog slug (Codex) from the offered models; one audited decision matching the session and task |
-| `test_smart_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI with legacy routing-only/orchestration flags or the three subagent-only presets, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | Routing-only cases install `smart-router`; orchestration cases also install `smart-router-orchestrator`; all three native children complete; only routing-enabled phases show a native model/rationale row (Claude) or subagent banner (Codex) and produce a live routing decision correlated with the child; Claude's recorded task inference payloads carry `smart_router_recipe_name` as launch recipe -> DISABLED -> launch recipe, omit the recipe header, and receive successful gateway responses; opted-in Codex banners include `[orchestrator on]`; no first-prompt routing wrapper; the customer full-mode preset is intentionally outside this journey; normal exit |
+| `test_smart_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI with legacy routing-only/orchestration flags or the three subagent-only presets, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | Routing-only cases install `smart-router`; orchestration cases also install `smart-router-orchestrator`; all three native children complete; only routing-enabled phases show a native model/rationale row (Claude) or subagent banner (Codex) and produce a live routing decision correlated with the child; Claude's recorded task inference payloads carry `smart_router_recipe_name` as launch recipe -> DISABLED -> launch recipe, omit the recipe header, and receive successful gateway responses; opted-in Codex banners include `[orchestrator on]`; the parent's first prompt is not routed; Codex retains its configured model on every parent turn and records no first-prompt route or rewrite; the customer full-mode preset is intentionally outside this journey; normal exit |
| `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured |
| `test_ug_configure_claude_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace |
| `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup |
@@ -613,7 +642,7 @@ desktop GUI or systemd login/reboot pass.
| Saved Claude picker selection | Unit/component checks in `test_agent_claude.py` cover mapped family aliases, `[1m]`/`[200k]` variants, and unavailable-model fallback without changing saved settings. No dedicated live journey. |
| Fresh provider/parent validation and mixed Bedrock filtering | Not covered after removing the duplicate model-discovery suites |
| TUI initial prompt supplied on the launch command line | Not yet covered; headless prompt arguments are covered |
-| Follow-up turns and conversation resume | Not covered; reopen proves startup, not conversation resume |
+| Follow-up turns and conversation resume | Routed Codex v1 child close/resume has component coverage and the native reproduction below. Other resume paths and a live gateway resume journey remain uncovered; reopen alone proves startup |
| Claude/Codex interactive smart routing | First-prompt routing is covered by the `managed_fixture` banner journeys and CUJ4. The live subagent-only journeys spawn native children and invoke the installed Smart Router skill to verify routing on -> off -> on within one Claude/Codex session. The scheduled harness-latest workflow repeats CUJ4, routing hooks, skill toggles, and banners with both latest agents. Plugin-refresh survival and native daemon/background dispatch remain deferred. |
| Full allow/deny tool-permission matrix | Not covered; onboarding/trust uses actual TUI choices |
| Desktop Codex app, Isaac itself, auto-upgrades | Not covered by command forwarding or pinned-version tests |
@@ -626,6 +655,34 @@ and reproduction. Follow [AGENTS.md](AGENTS.md) and [CLAUDE.md](CLAUDE.md) when
adding, modifying, or removing tests. The ordinary suite enforces both the
no-mocking boundary and the Scenario/Expected docstring format.
+### Native Codex resume reproduction
+
+[`scripts/reproduce_codex_resume.py`](../scripts/reproduce_codex_resume.py) uses an installed
+Codex 0.159.3 binary, UG's actual CLI hooks, and deterministic loopback router/Responses services.
+It needs Linux and `bwrap` to isolate native/UG state without changing user configuration.
+Generated fixture hooks are approved through Codex's native trust API. No gateway credentials
+or model inference are used; this is component reproduction, not a live gateway integration test.
+
+Run from a checkout with its Python dependencies already installed, using new output directories:
+
+```bash
+PYTHONPATH="$PWD/src" uv run --no-sync python scripts/reproduce_codex_resume.py \
+ --binary /absolute/path/to/codex --version 0.159.3 \
+ --output /tmp/ug-resume-baseline --baseline
+PYTHONPATH="$PWD/src" uv run --no-sync python scripts/reproduce_codex_resume.py \
+ --binary /absolute/path/to/codex --version 0.159.3 \
+ --output /tmp/ug-resume-fixed
+```
+
+The baseline keeps routing enabled and omits the guard hooks; success means it observed the
+child switching from `gpt-5.1-codex-mini`/high to the parent's `gpt-5.1`/low after resume.
+The fixed case requires the child's model/effort to stay unchanged in provider requests
+and native thread settings. Both check live follow-ups and preserve the parent's settings.
+Add `--restart`, `--nested`, or `--no-effort` to test a new native process, subtree restoration,
+or unset effort. Outputs include routing calls, requests, native events/settings, tool results,
+and `result.json`; missing stages or failed hooks fail the run. Other native Codex versions,
+Windows/macOS, TUI interaction, and production incidence are not established by this check.
+
Native launch-option component tests (`native_claude/test_mod.py`) check two real Claude
processes with different resolved routing baselines. Python cases cover Claude-specific presets,
unknown selectors, and absent versus empty flags. These checks do not establish gateway/TUI coverage.
diff --git a/tests/e2e_cuj/README.md b/tests/e2e_cuj/README.md
index 45b2f5a0a..e6672e1fd 100644
--- a/tests/e2e_cuj/README.md
+++ b/tests/e2e_cuj/README.md
@@ -90,6 +90,10 @@ requests and parent continuations from child-inference checks. Only Claude child
after the routing checkpoint may append one transport newline to that exact prompt.
Codex 0.162.1 encrypts child instructions, so those requests instead match the routed
task name to the child message's recipient. The child's hidden-value and model checks remain.
+Codex parent evidence requires the exact prompt and a completed native turn. Runtime-context
+messages, including midnight date updates, are ignored only when their native metadata identifies
+context alone and the active turn. New or duplicate user prompts and other-turn updates remain
+rejected. Offline regressions cover each case.
Parent and child requests use the same verified thinking-display recovery as CUJ3:
only known display/safeguards rejections followed by native removal of the rejected field
and a final non-empty 200 are accepted. Model, prompt, budget, and effort must remain unchanged.
@@ -102,7 +106,7 @@ and child inference payloads carry the launch recipe in `smart_router_recipe_nam
without a recipe header; Codex retains the recipe header. This tests requested delegation, not automatic orchestrator delegation.
Claude's recipe is compared with the recorded routing request's selector rather than
a hardcoded default recipe name.
-The wait uses the child's answer directly; parent-answer reconstruction is reserved for first-prompt tasks.
+Delegation evidence uses the child's answer directly; parent-turn reconstruction is reserved for first-prompt tasks.
Offline checks reject parent-only answers and accept child answers before the parent replies.
Offline evidence regressions do not establish a live CUJ pass.
Selector cases have separate TUI artifact names, and the session environment is restored afterward.
@@ -115,8 +119,10 @@ Claude sessions start in auto mode; unless a routed first prompt switches the se
Claude's informational auto-mode classifier billing notice over the transcript. The CUJ terminal
waits for that exact notice to render stably, presses Enter (continue), and observes dismissal
before continuing. Later occurrences are handled the same way; any other dialog still fails.
-After verifying the delegated task, Claude sessions also wait in the native `/tasks` view
-until no background work remains, including a child resumed after its initial answer.
+After verifying the delegated task, Claude sessions wait for the parent's answer containing
+the delegated file's value before opening `/tasks`: a parent still forming its reply can
+resume a completed child. They then wait in the native task view until no background work
+remains, including any resumed child that outlives the parent's answer.
Only then does the test close the task view and submit `/exit`.
Each preset also runs public `ug revert` in cleanup, including after a failed assertion, so
interactive launches' OS-managed settings cannot contaminate the next preset's configuration.
diff --git a/tests/e2e_cuj/helpers/evidence.py b/tests/e2e_cuj/helpers/evidence.py
index 5140563a6..c44d7a67e 100644
--- a/tests/e2e_cuj/helpers/evidence.py
+++ b/tests/e2e_cuj/helpers/evidence.py
@@ -234,6 +234,16 @@ def _completed_turn(records, task):
and payload.get("type") == "message"
and payload.get("role") == "user"
):
+ # Midnight can refresh Codex's runtime context mid-turn. Only skip
+ # updates identified by the native context kind and current turn ID.
+ message_metadata = payload.get("internal_chat_message_metadata_passthrough") or {}
+ if (
+ message_metadata.get("content_item_kinds")
+ == ["environments.environment_context"]
+ and active_turn
+ and message_metadata.get("turn_id") == active_turn
+ ):
+ continue
source, prompt = "response", message_text(payload.get("content"))
if source:
if prompt == task.prompt:
diff --git a/tests/e2e_cuj/test_cuj4_smart_routing.py b/tests/e2e_cuj/test_cuj4_smart_routing.py
index 8ba46efc0..01025f955 100644
--- a/tests/e2e_cuj/test_cuj4_smart_routing.py
+++ b/tests/e2e_cuj/test_cuj4_smart_routing.py
@@ -471,6 +471,11 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION):
for request in requests
), agent
if agent == CLAUDE:
+ # The parent can resume a completed child while forming its answer.
+ tui.wait_until(
+ lambda task=child_task, agent=agent: task.completed(session, agent),
+ "the parent answer with the delegated file's value",
+ )
tui.wait_for_background_tasks()
for enabled in (False, True):
tui.submit(f"/smart-router {'on' if enabled else 'off'}")
diff --git a/tests/integration/README.md b/tests/integration/README.md
index e9f5f2f75..cdf41ee19 100644
--- a/tests/integration/README.md
+++ b/tests/integration/README.md
@@ -34,6 +34,27 @@ The shared request recorder and native compatibility-retry checks live in
configuration glue and imports those helpers; request forwarding, header
redaction, and paired response assertions retain their behavior.
+Routed Codex v1 child close/resume is covered by the component suite
+`../test_codex_resume.py` and the optional native Codex 0.159.3 reproduction documented in
+`../README.md`. The latter uses UG's real hooks and native lifecycle with synthetic local
+router/provider responses, including restart, nested children, and unset effort. This suite
+does not yet contain a live gateway child-resume journey. Claude and external evaluation
+plugins are outside that fix's launch path.
+The component suite also verifies that a missing settings confirmation times out at
+the RPC deadline, preserves the pending restoration, and permits a successful retry.
+The connection's socket timeout must not race that deadline.
+
+Both Codex routing modes use the app-server. The launcher supplies a working directory
+to the remote TUI so its native folder-trust prompt runs on fresh projects. Component
+coverage in `../test_codex_smart_routing_v2.py` runs the launch-argument cases with both
+POSIX and Windows settings, checks the default directory, and preserves explicit
+`--cd`/`-C` forms, permission flags, and literal prompt arguments after `--`.
+It also checks initial sandbox resolution through Codex's current server configuration,
+without replacing explicit selections or changing resumed and later threads.
+The live Codex subagent toggle journey compares every native parent turn with the
+configured model and requires no first-prompt route or rewrite in the interposer log.
+The interposer also protects resumed children in subagent-only sessions.
+
CUJ3 and CUJ4 require a non-empty HTTP 200 for inference. Claude 2.1.290's known
thinking-display 400 is accepted only when the next inference request with the same system
context and session metadata removes `display`,
@@ -50,6 +71,9 @@ Claude child HTTP prompts may carry one appended newline after the route-selecti
offline cases reject any other prompt changes and retain exact Codex matching.
Offline CUJ cases also cover Codex delegated-turn completion and task-request matching;
see `../e2e_cuj/README.md` for the evidence requirements.
+Codex's native runtime-context updates within a turn, such as a date change at midnight, do not
+count as new user prompts. Offline checks require the context-only native kind and matching turn
+ID, and still reject untagged or mixed messages, other-turn updates, and duplicate prompts.
The [catalog discovery journey](../e2e_cuj/README.md) uses the CUJ3 workspace to check
agent-compatible pickers, schema exclusions, configured defaults, and real inference.
@@ -76,10 +100,17 @@ completed task rows before submitting `/exit`. Claude 2.1.290 retains finished c
checkmarked `done` rows and no running or scheduled section. The helper observes menu dismissal
after Escape before typing `/exit`. Offline PTY checks reject active/scheduled tasks, partial
lists, and completed-task text outside the native menu.
+If `/tasks` opens a single agent's detail panel, the helper presses its advertised Left-arrow
+key to return to the full list before checking completion. If that closes the panel to the
+input prompt, it reopens `/tasks` and still requires completion evidence from the full list.
+Offline PTY cases cover both navigation outcomes, repeated detail panels, delayed navigation,
+and other tasks that remain active even when the selected agent is already complete. The helper
+must wait for navigation to finish; a still-visible completed detail panel is insufficient.
It neither stops tasks nor confirms an exit dialog. Process exit retains its 30-second
timeout. Offline PTY checks cover this ordering.
-The shared wait also runs before CUJ4's Claude preset sessions exit, after delegated-task
-evidence is checked, because Claude can resume a child after its first completed answer.
+CUJ4's Claude preset sessions first wait for the parent's answer with the delegated file's
+value, then open the task view and use the shared wait. The parent can resume a completed
+child while forming its reply, and that resumed child can outlive the parent's answer.
`../test_startup_imports.py` covers the bare version fast path, deferred imports,
metadata fallback, and other-argument dispatch as component checks. Installation
diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py
index b20a928bc..e46e6cc8e 100644
--- a/tests/integration/test_ug_smart_routing_hooks.py
+++ b/tests/integration/test_ug_smart_routing_hooks.py
@@ -569,7 +569,8 @@ def test_smart_router_skill_toggles_codex_subagent_routing(
confirmation in the native transcript and changes the saved routing controls, even with
collapsed terminal output; all three uniquely tagged calculations complete in native child
sessions; only the first and third show the subagent-routing banner and produce live gateway
- decisions correlated with those children. No first-prompt interposer starts.
+ decisions correlated with those children. The parent retains its configured model across
+ all native turns and the interposer records no first-prompt routing attempt.
With orchestration enabled, both routed banners must include [orchestrator on].
"""
session = live_session
@@ -581,7 +582,9 @@ def test_smart_router_skill_toggles_codex_subagent_routing(
session.env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "1"
else:
session.env["SMART_ROUTER_CONFIG_VERSION"] = SMART_ROUTER_CONFIG_VERSION
- use_managed_config_fixture(session, "codex_smart_routing")
+ config_path = use_managed_config_fixture(session, "codex_smart_routing")
+ codex_config = json.loads(config_path.read_text())["enabled_agents"][0]["config"]
+ expected_model = codex_config["default_models"]["default_model"]
session.run(
"configure",
"--workspace",
@@ -614,4 +617,17 @@ def test_smart_router_skill_toggles_codex_subagent_routing(
tui.exit_normally()
transcript = "".join(tui.output)
assert SMART_ROUTING_BANNER not in transcript, transcript
- session.assert_not_routed()
+ routing_log = session.routing_log("codex")
+ assert "[ROUTE]" not in routing_log, routing_log
+ assert "[REWRITE]" not in routing_log, routing_log
+ parents = [
+ records
+ for path, records in agent_sessions(session, "codex").items()
+ if not is_child_session("codex", path, records)
+ ]
+ assert len(parents) == 1, "Expected one native parent session"
+ parent_models = [
+ row["payload"]["model"] for row in parents[0] if row.get("type") == "turn_context"
+ ]
+ session.record("parent-models.json", {"expected": expected_model, "observed": parent_models})
+ assert parent_models and set(parent_models) == {expected_model}, parent_models
diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py
index 9a35c9e29..e083b163e 100644
--- a/tests/integration/utils/terminal.py
+++ b/tests/integration/utils/terminal.py
@@ -43,9 +43,6 @@ def _claude_background_task_detail(text):
def _claude_background_tasks_complete(text):
if re.search(r"(?m)^\s*No tasks currently running\s*$", text):
return True
- detail = _claude_background_task_detail(text)
- if detail is not None:
- return re.match(r"✔ Completed\b", detail[1]) is not None
menu = _claude_background_task_menu(text)
if menu is None:
return False
@@ -570,8 +567,30 @@ def wait_for_background_tasks(self, timeout=180):
"""Wait in Claude's native task view without stopping or detaching work."""
assert self.agent == "claude", self.agent
self.submit("/tasks")
+ returning_to_list = False
+
+ def completed(text):
+ nonlocal returning_to_list
+ # With one active task, /tasks opens its detail panel instead of the list.
+ # A completed detail panel does not establish that every task has stopped.
+ if _claude_background_task_detail(text) is not None:
+ if not returning_to_list:
+ self.send("\x1b[D", "return from task details to the full background-task list")
+ returning_to_list = True
+ return False
+ # A directly opened detail panel can return to the input prompt.
+ # Reopen the task view; the prompt alone says nothing about active work.
+ if returning_to_list and re.search(
+ r"(?m)^[ \t]*─+[ \t]*\n[ \t]*❯[ \t]*\n[ \t]*─+[ \t]*$",
+ text,
+ ):
+ self.submit("/tasks")
+ returning_to_list = False
+ return False
+ return _claude_background_tasks_complete(text)
+
self.wait_for(
- _claude_background_tasks_complete,
+ completed,
"Claude's task view reporting no running tasks",
timeout=timeout,
)
diff --git a/tests/test_codex_resume.py b/tests/test_codex_resume.py
new file mode 100644
index 000000000..fa4772387
--- /dev/null
+++ b/tests/test_codex_resume.py
@@ -0,0 +1,494 @@
+"""Component coverage; the loopback peer substitutes only the native app-server."""
+
+import json
+import threading
+
+import pytest
+from typer.testing import CliRunner
+from websockets.sync.server import serve
+
+from ucode.cli import app
+from ucode.smart_routing import codex_interposer, codex_resume, codex_routing
+
+
+class NativePeer:
+ def __init__(self):
+ self.threads = {}
+ self.calls = []
+ self.confirm = True
+ self.include_effort = True
+
+ def add(self, child, parent, model="child-model", effort="high"):
+ self.threads[child] = {
+ "id": child,
+ "parentThreadId": parent,
+ "model": model,
+ "reasoningEffort": effort,
+ "canAcceptDirectInput": True,
+ "status": {"type": "idle"},
+ }
+
+ def handle(self, connection):
+ for raw in connection:
+ call = json.loads(raw)
+ if "id" not in call:
+ continue
+ self.calls.append(call)
+ method, params = call["method"], call["params"]
+ result = {}
+ event = None
+ if method == "thread/read":
+ result = {"thread": self.threads[params["threadId"]]}
+ elif method == "thread/resume":
+ result = {
+ "collaborationMode": {
+ "mode": "plan",
+ "settings": {
+ "model": "parent-model",
+ "reasoning_effort": "low",
+ "developer_instructions": "Retain this instruction.",
+ },
+ }
+ }
+ elif method == "thread/settings/update":
+ thread = self.threads[params["threadId"]]
+ effort = params.get("effort")
+ if "collaborationMode" in params:
+ effort = params["collaborationMode"]["settings"]["reasoning_effort"]
+ thread.update(model=params["model"], reasoningEffort=effort)
+ event = {
+ "method": "thread/settings/updated",
+ "params": {
+ "threadId": params["threadId"],
+ "threadSettings": {"model": params["model"], "effort": effort},
+ },
+ }
+ connection.send(json.dumps({"id": call["id"], "result": result}))
+ if event and self.confirm:
+ if not self.include_effort:
+ event["params"]["threadSettings"].pop("effort")
+ connection.send(json.dumps(event))
+
+
+@pytest.fixture
+def native_peer():
+ peer = NativePeer()
+ with serve(peer.handle, "127.0.0.1", 0) as server:
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ peer.url = f"ws://127.0.0.1:{server.socket.getsockname()[1]}"
+ try:
+ yield peer
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+ assert not thread.is_alive()
+
+
+def spawn(
+ routes, peer, child="child", parent="parent", *, routed=True, call_id="spawn-1", root=None
+):
+ payload = {
+ "session_id": root or parent,
+ "tool_use_id": call_id,
+ "tool_response": json.dumps({"agent_id": child}),
+ }
+ if root is not None:
+ payload["agent_id"] = parent
+ if routed:
+ routes.remember_spawn(payload, peer.threads[child]["model"])
+ with codex_resume.AppServer(peer.url) as client:
+ routes.bind_spawn(payload, client)
+
+
+def settings_calls(peer):
+ return [c["params"] for c in peer.calls if c["method"] == "thread/settings/update"]
+
+
+@pytest.mark.parametrize("effort", ["high", None])
+def test_resume_restores_saved_model_and_effort_across_guard_restart(tmp_path, native_peer, effort):
+ peer = native_peer
+ peer.add("child", "parent", effort=effort)
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ codex_resume.guard_tool(
+ {
+ "tool_name": "multi_agent_v1resume_agent",
+ "tool_input": {
+ "id": "child",
+ },
+ },
+ routes,
+ client,
+ )
+ # The external native v1 runtime incorrectly inherits these parent defaults.
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ routes = codex_resume.RoutedChildren(routes.path)
+ with codex_resume.AppServer(peer.url) as client:
+ codex_resume.guard_tool(
+ {
+ "tool_name": "multi_agent_v1send_input",
+ "tool_input": {
+ "target": "child",
+ "message": "Continue.",
+ },
+ },
+ routes,
+ client,
+ )
+ assert peer.threads["child"]["model"] == "child-model"
+ assert peer.threads["child"]["reasoningEffort"] == effort
+ (update,) = settings_calls(peer)
+ if effort is None:
+ assert update == {
+ "threadId": "child",
+ "model": "child-model",
+ "collaborationMode": {
+ "mode": "plan",
+ "settings": {
+ "model": "child-model",
+ "reasoning_effort": None,
+ "developer_instructions": "Retain this instruction.",
+ },
+ },
+ }
+ else:
+ assert update == {"threadId": "child", "model": "child-model", "effort": effort}
+
+
+def test_live_followups_and_unrouted_children_keep_their_current_settings(tmp_path, native_peer):
+ peer = native_peer
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ for child, routed in (("routed", True), ("unrouted", False)):
+ peer.add(child, "parent")
+ spawn(routes, peer, child, routed=routed, call_id=child)
+ peer.threads[child].update(model="user-selection", reasoningEffort="medium")
+ with codex_resume.AppServer(peer.url) as client:
+ routes.restore_before_input("routed", client)
+ routes.prepare_resume("unrouted", client)
+ routes.restore_before_input("unrouted", client)
+ routes.restore_before_input("parent", client)
+ assert settings_calls(peer) == []
+ assert {t["model"] for t in peer.threads.values()} == {"user-selection"}
+
+
+def test_resume_preserves_deliberate_model_change_before_close(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ peer.threads["child"].update(model="user-selection", reasoningEffort="medium")
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ # A repeated resume before input must not replace the saved settings.
+ routes.prepare_resume("child", client)
+ routes.restore_before_input("child", client)
+ assert settings_calls(peer) == [
+ {
+ "threadId": "child",
+ "model": "user-selection",
+ "effort": "medium",
+ }
+ ]
+
+
+def test_resume_protects_routed_descendants_through_unrouted_parent(tmp_path, native_peer):
+ peer = native_peer
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ peer.add("middle", "parent")
+ peer.add("leaf", "middle", model="leaf-model", effort="max")
+ peer.add("sibling", "parent", model="sibling-model")
+ spawn(routes, peer, "middle", routed=False)
+ spawn(routes, peer, "leaf", "middle", root="parent")
+ spawn(routes, peer, "sibling", call_id="spawn-2")
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("middle", client)
+ for thread in peer.threads.values():
+ thread.update(model="parent-model", reasoningEffort="low")
+ routes.restore_before_input("leaf", client)
+ routes.restore_before_input("sibling", client)
+ assert settings_calls(peer) == [{"threadId": "leaf", "model": "leaf-model", "effort": "max"}]
+
+
+def test_nested_spawn_with_same_call_id_is_bound_to_its_actual_parent(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ peer.add("leaf", "child", model="leaf-model", effort="medium")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ root = {"session_id": "parent", "tool_use_id": "spawn", "tool_response": {"agent_id": "child"}}
+ nested = {
+ "session_id": "parent",
+ "agent_id": "child",
+ "tool_use_id": "spawn",
+ "tool_response": {"agent_id": "leaf"},
+ }
+ routes.remember_spawn(root, "child-model")
+ routes.remember_spawn(nested, "leaf-model")
+ with codex_resume.AppServer(peer.url) as client:
+ routes.bind_spawn(nested, client)
+ routes.bind_spawn(root, client)
+ routes.prepare_resume("child", client)
+ for thread in peer.threads.values():
+ thread.update(model="parent-model", reasoningEffort="low")
+ routes.restore_before_input("child", client)
+ routes.restore_before_input("leaf", client)
+ assert settings_calls(peer) == [
+ {"threadId": "child", "model": "child-model", "effort": "high"},
+ {"threadId": "leaf", "model": "leaf-model", "effort": "medium"},
+ ]
+
+
+def test_failed_confirmation_keeps_protection_pending(tmp_path, native_peer, monkeypatch):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ peer.confirm = False
+ monkeypatch.setattr(codex_resume, "RPC_TIMEOUT_SECONDS", 0.5)
+ with codex_resume.AppServer(peer.url) as client:
+ client.read("child")
+ # The socket's connect timeout must not race the RPC confirmation deadline.
+ assert client.socket.socket.gettimeout() is None
+ with pytest.raises(TimeoutError):
+ routes.restore_before_input("child", client)
+ # Model the failure before the external runtime applies the update.
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ peer.confirm = True
+ with codex_resume.AppServer(peer.url) as client:
+ routes.restore_before_input("child", client)
+ assert len(settings_calls(peer)) == 2
+
+
+def test_unset_effort_needs_explicit_native_confirmation(tmp_path, native_peer, monkeypatch):
+ peer = native_peer
+ peer.add("child", "parent", effort=None)
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ peer.include_effort = False
+ monkeypatch.setattr(codex_resume, "RPC_TIMEOUT_SECONDS", 0.5)
+ with codex_resume.AppServer(peer.url) as client, pytest.raises(TimeoutError):
+ routes.restore_before_input("child", client)
+
+
+def test_guard_does_not_resume_an_unloaded_child(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"]["status"] = {"type": "notLoaded"}
+ with pytest.raises(RuntimeError, match="Resume the child"):
+ routes.restore_before_input("child", client)
+ assert "thread/resume" not in [c["method"] for c in peer.calls]
+
+
+def test_native_v2_is_not_registered_for_v1_repairs(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ peer.threads["child"]["canAcceptDirectInput"] = False
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ routes.restore_before_input("child", client)
+ assert settings_calls(peer) == []
+
+
+def test_child_identity_mismatch_does_not_bind_a_route(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "unrelated-parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ with pytest.raises(ValueError, match="do not match"):
+ spawn(routes, peer)
+
+
+def test_explicit_override_after_resume_releases_the_guard(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="user-selection")
+ routes.allow_override("child")
+ routes.restore_before_input("child", client)
+ assert settings_calls(peer) == []
+ assert peer.threads["child"]["model"] == "user-selection"
+
+
+@pytest.mark.parametrize("method", ["turn/start", "thread/settings/update", "thread/resume"])
+@pytest.mark.parametrize("accepted", [True, False])
+def test_only_successful_native_overrides_release_the_guard(
+ tmp_path, native_peer, method, accepted
+):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ acknowledged = []
+
+ def override(target):
+ acknowledged.append(target)
+ routes.allow_override(target)
+
+ session = codex_interposer._Session(None, log=lambda _message: None, on_model_override=override)
+ request = json.dumps(
+ {
+ "id": 7,
+ "method": method,
+ "params": {
+ "threadId": "child",
+ "model": "user-selection",
+ "effort": "medium",
+ },
+ }
+ )
+ assert session.on_tui_frame(request).frame == request
+ assert acknowledged == []
+ session.on_engine_frame(json.dumps({"id": "unrelated", "result": {}}))
+ assert acknowledged == []
+ if accepted:
+ peer.threads["child"].update(model="user-selection", reasoningEffort="medium")
+ response = (
+ {"result": {}} if accepted else {"error": {"code": -32602, "message": "Invalid model"}}
+ )
+ session.on_engine_frame(json.dumps({"id": 7, **response}))
+ assert acknowledged == (["child"] if accepted else [])
+ with codex_resume.AppServer(peer.url) as client:
+ routes.restore_before_input("child", client)
+ assert peer.threads["child"]["model"] == ("user-selection" if accepted else "child-model")
+ assert peer.threads["child"]["reasoningEffort"] == ("medium" if accepted else "high")
+
+
+def configure_hook_session(tmp_path, monkeypatch, url):
+ path = tmp_path / "session" / "env.json"
+ path.parent.mkdir()
+ path.write_text("{}", encoding="utf-8")
+ monkeypatch.setenv("UCODE_SESSION_ENV_FILE", str(path))
+ codex_resume.configure_session(path, url)
+
+
+def test_cli_protects_existing_routes_when_new_routing_is_disabled(
+ tmp_path, monkeypatch, native_peer
+):
+ peer = native_peer
+ peer.add("child", "parent")
+ configure_hook_session(tmp_path, monkeypatch, peer.url)
+ workspace = "https://example.databricks.com"
+ routes = codex_resume.for_workspace(workspace)
+ payload = {
+ "session_id": "parent",
+ "tool_use_id": "spawn",
+ "tool_response": {"agent_id": "child"},
+ }
+ routes.remember_spawn(payload, "child-model")
+ runner = CliRunner()
+ result = runner.invoke(
+ app, ["codex-router-hook", "bind-route", "--host", workspace], input=json.dumps(payload)
+ )
+ assert result.exit_code == 0, result.output
+ for tool in ("resume_agent", "send_input"):
+ result = runner.invoke(
+ app,
+ ["codex-router-hook", "guard-resume", "--host", workspace],
+ input=json.dumps(
+ {
+ "tool_name": "multi_agent_v1" + tool,
+ "tool_input": {"id": "child"},
+ }
+ ),
+ )
+ assert result.exit_code == 0, result.output
+ assert result.stdout == ""
+ if tool == "resume_agent":
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ assert settings_calls(peer) == [{"threadId": "child", "model": "child-model", "effort": "high"}]
+
+
+@pytest.mark.parametrize("metadata", [None, "invalid json"])
+def test_cli_denies_follow_up_without_valid_session_metadata(tmp_path, monkeypatch, metadata):
+ configure_hook_session(tmp_path, monkeypatch, "ws://127.0.0.1:1")
+ path = tmp_path / "session" / "codex-app-server.json"
+ if metadata is None:
+ path.unlink()
+ else:
+ path.write_text(metadata, encoding="utf-8")
+ result = CliRunner().invoke(
+ app,
+ ["codex-router-hook", "guard-resume", "--host", "https://example.com"],
+ input=json.dumps(
+ {
+ "tool_name": "multi_agent_v1send_input",
+ "tool_input": {"target": "child"},
+ }
+ ),
+ )
+ assert result.exit_code == 0, result.output
+ output = json.loads(result.stdout)["hookSpecificOutput"]
+ assert output["permissionDecision"] == "deny"
+ assert "Could not preserve Codex child settings" in result.stderr
+
+
+def test_cli_denies_routed_spawn_when_its_settings_cannot_be_saved(tmp_path, monkeypatch):
+ configure_hook_session(tmp_path, monkeypatch, "ws://127.0.0.1:1")
+ workspace = "https://example.databricks.com"
+ routes = codex_resume.for_workspace(workspace)
+ routes.path.parent.mkdir(parents=True)
+ routes.path.write_text("not a SQLite database", encoding="utf-8")
+ monkeypatch.setenv("ENABLE_SMART_ROUTING_SUBAGENT_ONLY", "1")
+ monkeypatch.setenv("DATABRICKS_BEARER", "fixture-token")
+ monkeypatch.setattr(codex_routing, "DECISIONS_PATH", tmp_path / "decisions.jsonl")
+ # Substitute the external router response; the hook and persistence paths stay real.
+ monkeypatch.setattr(
+ codex_routing.routing,
+ "select_route",
+ lambda *args, **kwargs: (
+ codex_routing.RoutingDecision(
+ model="child-model", raw_model="child-model", rationale="Fixture"
+ ),
+ None,
+ ),
+ )
+ result = CliRunner().invoke(
+ app,
+ ["codex-router-hook", "route-subagent", "--host", workspace, "--model", "child-model"],
+ input=json.dumps(
+ {
+ "session_id": "parent",
+ "tool_use_id": "spawn",
+ "tool_name": "multi_agent_v1spawn_agent",
+ "tool_input": {"message": "Inspect the fixture"},
+ }
+ ),
+ )
+ assert result.exit_code == 0, result.output
+ output = json.loads(result.stdout)["hookSpecificOutput"]
+ assert output["permissionDecision"] == "deny"
+ assert "updatedInput" not in output
+ assert "Could not save Codex child settings" in result.stderr
+
+
+def test_overlapping_resume_cannot_replace_a_pending_snapshot(tmp_path, native_peer):
+ peer = native_peer
+ peer.add("child", "parent")
+ routes = codex_resume.RoutedChildren(tmp_path / "routes.sqlite3")
+ spawn(routes, peer)
+ with codex_resume.AppServer(peer.url) as client:
+ routes.prepare_resume("child", client)
+ peer.threads["child"].update(model="parent-model", reasoningEffort="low")
+ # A second guard may have read pending=0 before the first guard committed.
+ routes.capture("child", "parent", client.read("child"), pending=True)
+ routes.restore_before_input("child", client)
+ assert settings_calls(peer) == [{"threadId": "child", "model": "child-model", "effort": "high"}]
diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py
index 4c26cdaad..c4aa2082e 100644
--- a/tests/test_codex_smart_routing_v2.py
+++ b/tests/test_codex_smart_routing_v2.py
@@ -2,13 +2,16 @@
import json
import os
+import shlex
+import sys
+from pathlib import Path
from types import SimpleNamespace
import pytest
from ucode import codex_config
from ucode.agents import LaunchOptions, codex
-from ucode.smart_routing import codex_interposer, codex_routing, v2
+from ucode.smart_routing import codex_interposer, codex_resume, codex_routing, v2
WS = "https://example.databricks.com"
@@ -280,6 +283,8 @@ def start_interposer(*args, **kwargs):
assert processes[0].kwargs["env"]["CODEX_HOME"] == "/user/codex-home"
tui_argv = processes[1].argv
expected_tui_args = [
+ "--cd",
+ str(Path.cwd()),
"--remote",
"ws://127.0.0.1:41002",
"--model",
@@ -310,6 +315,28 @@ def start_interposer(*args, **kwargs):
assert interposer_args["kwargs"]["switch_message_fn"] is v2.format_routing_notice
assert stopped == [True]
assert processes[0].terminated is True
+ assert codex_resume.app_server_url() is None
+
+ def test_app_server_start_failure_removes_session_endpoint(self, monkeypatch):
+ monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1")
+ monkeypatch.setattr(v2, "get_databricks_token", lambda *_args: "fixture-token")
+ endpoint_during_launch = []
+
+ def failed_start(*_args, **_kwargs):
+ endpoint_during_launch.append(codex_resume.app_server_url())
+ raise OSError("could not start Codex")
+
+ monkeypatch.setattr(v2.subprocess_cross_os, "popen", failed_start)
+ with pytest.raises(OSError, match="could not start Codex"):
+ v2.launch_codex(
+ {"workspace": WS, "codex_models": ["gpt-start"]},
+ [],
+ binary="codex",
+ start_model="gpt-start",
+ render_overlay=lambda *_args, **_kwargs: {},
+ )
+ assert endpoint_during_launch[0].startswith("ws://127.0.0.1:")
+ assert codex_resume.app_server_url() is None
def test_managed_http_headers_reach_app_server_config(self, monkeypatch):
# Smart routing rebuilds the overlay and passes it to the app-server as `-c` overrides that
@@ -364,47 +391,111 @@ def send_signal(self, _signal):
assert "x-databricks-workspace" in provider_arg
assert "eng-ml-inference" in provider_arg
- def test_subagent_only_launch_runs_tui_directly(self, tmp_path, monkeypatch):
+ @pytest.mark.parametrize("platform_name", ["posix", "nt"])
+ @pytest.mark.parametrize(
+ ("tool_args", "add_default_cwd", "use_server_sandbox_default"),
+ [
+ (["--search"], True, True),
+ (["--cd", "project with spaces"], False, True),
+ (["--cd=../other project"], False, True),
+ (["-C", "../other project"], False, True),
+ (["-C../other project"], False, True),
+ (["--sandbox", "read-only", "--ask-for-approval", "never"], True, False),
+ (["-sread-only"], True, False),
+ (["--sandbox=read-only"], True, False),
+ (["--config", 'sandbox_mode="read-only"'], True, False),
+ (["-csandbox_mode='read-only'"], True, False),
+ (["-c=sandbox_mode='read-only'"], True, False),
+ (['--config="sandbox_mode"="read-only"'], True, False),
+ (["-c", "default_permissions='restricted'"], True, True),
+ (["-c", "permissions.restricted.file_system={}"], True, True),
+ (["-c", "sandbox_workspace_write.writable_roots=[]"], True, True),
+ (["-c", "profile='restricted'"], True, False),
+ (["--profile", "restricted"], True, False),
+ (["-prestricted"], True, False),
+ (["--approve-for-me"], True, False),
+ (["--dangerously-bypass-approvals-and-sandbox"], True, False),
+ (["--ask-for-approval", "never"], True, True),
+ (["-c", "model_reasoning_effort='high'"], True, True),
+ (["--", "--cd=part of the prompt --sandbox=read-only"], True, True),
+ ],
+ )
+ def test_subagent_only_launch_protects_resume_without_routing_parent(
+ self,
+ tmp_path,
+ monkeypatch,
+ platform_name,
+ tool_args,
+ add_default_cwd,
+ use_server_sandbox_default,
+ ):
monkeypatch.setenv(v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR, "1")
monkeypatch.setenv("CODEX_HOME", str(tmp_path))
+ monkeypatch.setattr(v2, "os", SimpleNamespace(name=platform_name, environ=os.environ))
monkeypatch.setattr(codex, "ug_version", lambda: "0.1.0")
monkeypatch.setattr(codex, "agent_version", lambda binary: "0.148.0")
monkeypatch.setattr(v2, "get_databricks_token", lambda *_args, **_kwargs: "token")
- monkeypatch.setattr(
- v2.subprocess,
- "Popen",
- lambda *_args, **_kwargs: pytest.fail("subagent-only routing spawns no app-server"),
- )
- monkeypatch.setattr(
- codex_interposer,
- "start_interposer_thread",
- lambda *_args, **_kwargs: pytest.fail("subagent-only routing must not interpose"),
- )
- execd = []
+ interposer_args = {}
- def fake_exec(argv):
- execd.append(argv)
- raise SystemExit(0)
+ def start_interposer(*_args, **kwargs):
+ interposer_args.update(kwargs)
+ return 41002, lambda: None
- monkeypatch.setattr(v2, "exec_or_spawn", fake_exec)
+ monkeypatch.setattr(codex_interposer, "start_interposer_thread", start_interposer)
+ monkeypatch.setattr(v2, "_free_port", lambda: 41001)
+ monkeypatch.setattr(v2, "_wait_for_app_server", lambda *_args, **_kwargs: True)
+ spawned = []
+
+ def fake_spawn(argv, **_kwargs):
+ spawned.append(argv)
+ return SimpleNamespace(wait=lambda timeout=None: 0, terminate=lambda: None)
+
+ monkeypatch.setattr(v2.subprocess_cross_os, "popen", fake_spawn)
with pytest.raises(SystemExit) as exc:
v2.launch_codex(
{"workspace": WS, "codex_models": ["system.ai.gpt-5-6-sol"]},
- ["--search"],
+ tool_args,
binary="codex",
start_model="gpt-start",
render_overlay=codex.render_overlay,
)
assert exc.value.code == 0
- (argv,) = execd
- assert argv[0] == "codex"
- assert argv[-1] == "--search"
+ argv, tui_argv = spawned
+ assert argv[:2] == ["codex", "app-server"]
+ provider_args = []
+ if platform_name == "nt":
+ provider_args = [
+ "--config",
+ 'model_provider="Databricks"',
+ "--config",
+ next(arg for arg in argv if arg.startswith("model_providers.Databricks=")),
+ ]
+ cwd_args = ["--cd", str(Path.cwd())] if add_default_cwd else []
+ assert tui_argv == [
+ "codex",
+ *provider_args,
+ *cwd_args,
+ "--remote",
+ "ws://127.0.0.1:41002",
+ "--model",
+ "gpt-start",
+ *tool_args,
+ ]
+ assert interposer_args["workspace"] is None
+ assert interposer_args["use_server_sandbox_default"] is use_server_sandbox_default
assert 'model="gpt-start"' in argv
hook_override = next(arg for arg in argv if arg.startswith("hooks.PreToolUse="))
assert "codex-router-hook route-subagent" in hook_override
assert "--model system.ai.gpt-5-6-sol" in hook_override
+ assert "guard-resume" in hook_override
+ assert "ws://" not in hook_override
+ post_hook = next(arg for arg in argv if arg.startswith("hooks.PostToolUse="))
+ assert "bind-route" in post_hook
+ assert not (
+ Path(os.environ["UCODE_SESSION_ENV_FILE"]).parent / "codex-app-server.json"
+ ).exists()
assert (
"shell_environment_policy.set.UCODE_SESSION_ENV_FILE="
+ json.dumps(os.environ["UCODE_SESSION_ENV_FILE"])
@@ -436,8 +527,9 @@ def test_v2_pre_tool_hook_leaves_saved_hooks_to_codex(self, tmp_path, monkeypatc
["system.ai.gpt-5-6-sol"],
)["PreToolUse"]
- assert len(configured) == 1
+ assert len(configured) == 2
assert configured[0]["matcher"] == "Agent|.*spawn_agent$"
+ assert configured[1]["matcher"] == ".*(resume_agent|send_input)$"
assert "--model system.ai.gpt-5-6-sol" in configured[0]["hooks"][0]["command"]
assert (codex_home / "config.toml").read_bytes() == before
@@ -464,10 +556,16 @@ def test_v2_pre_tool_hook_uses_current_model(self, tmp_path, monkeypatch):
hook["command"]
for group in configured
for hook in group["hooks"]
- if "codex-router-hook" in hook["command"]
+ if "codex-router-hook route-subagent" in hook["command"]
]
assert len(routing_commands) == 1
- assert routing_commands[0].startswith("/bin/ug codex-router-hook route-subagent ")
+ assert shlex.split(routing_commands[0])[:5] == [
+ sys.executable,
+ "-m",
+ "ucode.cli",
+ "codex-router-hook",
+ "route-subagent",
+ ]
assert "--model system.ai.gpt-5-6-sol" in routing_commands[0]
assert "--model old" not in routing_commands[0]
@@ -702,6 +800,75 @@ async def fail_to_serve(*args, **kwargs):
class TestInterposerSession:
+ @pytest.mark.parametrize("use_server_default", [False, True])
+ @pytest.mark.parametrize(
+ "sandbox", [None, "read-only", "workspace-write", "danger-full-access"]
+ )
+ def test_initial_thread_uses_server_for_stale_sandbox_default(
+ self, use_server_default, sandbox
+ ):
+ sess = codex_interposer._Session(
+ None, log=lambda _m: None, use_server_sandbox_default=use_server_default
+ )
+ request = {
+ "id": 1,
+ "method": "thread/start",
+ "params": {
+ "cwd": "/project",
+ "sandbox": sandbox,
+ "approvalPolicy": "never",
+ "model": "gpt-selected",
+ "config": {"sandbox_workspace_write": {"network_access": False}},
+ },
+ }
+ result = sess.on_tui_frame(json.dumps(request))
+ expected = json.loads(json.dumps(request))
+ if use_server_default and sandbox == "read-only":
+ del expected["params"]["sandbox"]
+ assert json.loads(result.frame) == expected
+ assert result.needs_settings_update is False
+
+ sess.on_engine_frame(json.dumps({"id": 1, "result": {"thread": {"id": "t1"}}}))
+ request["id"] = 2
+ frame = json.dumps(request)
+ assert sess.on_tui_frame(frame).frame == frame
+
+ def test_initial_sandbox_default_survives_a_rejected_start(self):
+ sess = codex_interposer._Session(None, log=lambda _m: None, use_server_sandbox_default=True)
+ request = {"id": "start", "method": "thread/start", "params": {"sandbox": "read-only"}}
+ expected = {"id": "start", "method": "thread/start", "params": {}}
+ assert json.loads(sess.on_tui_frame(json.dumps(request)).frame) == expected
+ sess.on_engine_frame(
+ json.dumps({"id": "start", "error": {"message": "unsupported history"}})
+ )
+ assert json.loads(sess.on_tui_frame(json.dumps(request)).frame) == expected
+
+ @pytest.mark.parametrize("method", ["turn/start", "thread/resume", "thread/settings/update"])
+ def test_server_sandbox_default_leaves_followups_and_restoration_unchanged(self, method):
+ sess = codex_interposer._Session(None, log=lambda _m: None, use_server_sandbox_default=True)
+ frame = json.dumps({"id": 1, "method": method, "params": {"sandbox": "read-only"}})
+ assert sess.on_tui_frame(frame).frame == frame
+
+ def test_server_sandbox_default_preserves_explicit_profile_selection(self):
+ sess = codex_interposer._Session(None, log=lambda _m: None, use_server_sandbox_default=True)
+ frame = json.dumps(
+ {
+ "id": 1,
+ "method": "thread/start",
+ "params": {"sandbox": "read-only", "permissions": ":read-only"},
+ }
+ )
+ assert sess.on_tui_frame(frame).frame == frame
+
+ @pytest.mark.parametrize("method", ["thread/resume", "thread/fork"])
+ def test_restored_session_keeps_sandbox_selection_for_new_threads(self, method):
+ sess = codex_interposer._Session(None, log=lambda _m: None, use_server_sandbox_default=True)
+ frame = json.dumps({"id": 1, "method": method, "params": {"sandbox": "read-only"}})
+ assert sess.on_tui_frame(frame).frame == frame
+ sess.on_engine_frame(json.dumps({"id": 1, "result": {"thread": {"id": "restored"}}}))
+ frame = json.dumps({"id": 2, "method": "thread/start", "params": {"sandbox": "read-only"}})
+ assert sess.on_tui_frame(frame).frame == frame
+
def _turn_start(self, model: str, thread_id: str = "t1", prompt: str = "Fix the parser") -> str:
return json.dumps(
{
diff --git a/tests/test_cuj_evidence.py b/tests/test_cuj_evidence.py
index 65824c74c..41c0e6526 100644
--- a/tests/test_cuj_evidence.py
+++ b/tests/test_cuj_evidence.py
@@ -212,6 +212,63 @@ def test_cuj_evidence_codex_rejects_wrong_turn(tmp_path):
assert completed_turn(CODEX, wrong_turn, task) is None
+@pytest.mark.parametrize(
+ "case",
+ ["runtime", "untagged", "user_text", "mixed", "wrong_turn", "missing_turn", "duplicate"],
+)
+def test_cuj_evidence_codex_midnight_environment_update(tmp_path, case):
+ task = FileTask(SimpleNamespace(cwd=tmp_path))
+ rows = records(CODEX, task, "gpt-6-luna")
+ update = {
+ "type": "response_item",
+ "payload": {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": (
+ "\n 2026-10-13\n"
+ ""
+ ),
+ }
+ ],
+ "internal_chat_message_metadata_passthrough": {
+ "turn_id": "turn",
+ "content_item_kinds": ["environments.environment_context"],
+ },
+ },
+ }
+ metadata = update["payload"]["internal_chat_message_metadata_passthrough"]
+ if case == "untagged":
+ update["payload"].pop("internal_chat_message_metadata_passthrough")
+ elif case == "user_text":
+ metadata["content_item_kinds"] = ["user.text"]
+ elif case == "mixed":
+ metadata["content_item_kinds"].append("user.text")
+ update["payload"]["content"].append({"type": "input_text", "text": "A different task."})
+ elif case == "wrong_turn":
+ metadata["turn_id"] = "later"
+ elif case == "missing_turn":
+ metadata.pop("turn_id")
+ rows.insert(-1, update)
+ if case == "duplicate":
+ rows.insert(-1, copy.deepcopy(rows[3]))
+ with pytest.raises(AssertionError, match="Prompt was submitted more than once"):
+ completed_turn(CODEX, rows, task)
+ elif case == "runtime":
+ turn = completed_turn(CODEX, rows, task)
+ assert turn is not None
+ assert (turn.session_id, turn.turn_id, turn.models, turn.answer) == (
+ "session",
+ "turn",
+ ["gpt-6-luna"],
+ task.value,
+ )
+ else:
+ assert completed_turn(CODEX, rows, task) is None
+
+
def _tagged_codex_notification(row):
notification = copy.deepcopy(row)
notification["payload"]["content"] = [{"type": "input_text", "text": ""}]
diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py
index ec07f1c65..64569d2b3 100644
--- a/tests/test_e2e_cuj_helpers.py
+++ b/tests/test_e2e_cuj_helpers.py
@@ -239,7 +239,10 @@ def test_wait_until_acknowledges_ready_billing_notices_and_observes_dismissal(tm
"transcript",
],
)
-def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view):
+@pytest.mark.parametrize(
+ "entry", ["list", "running-detail", "completed-detail", "detail-to-prompt"]
+)
+def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view, entry):
"""An offline terminal fixture requires /tasks, completion, Escape, then /exit."""
views = {
"empty": "No tasks currently running",
@@ -283,7 +286,22 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view)
"transcript": ("Background\n\n Completed (1)\n✔ ug_subagent_one done · Sonnet 5\n\n❯"),
}
views["resumed"] = views["mixed"]
+ detail = (
+ "Background task update waiting while this panel is open\n"
+ "agent › Read file contents\n"
+ + ("✔ Completed · 9s · Haiku 4.5\n" if entry == "completed-detail" else "9s · Haiku 4.5\n")
+ + "← to go back · Esc/Enter/Space to close"
+ + (" · x to stop" if entry in ("running-detail", "detail-to-prompt") else "")
+ + " · f to foreground"
+ )
+ panels = [detail] * (2 if entry == "detail-to-prompt" else int(entry != "list"))
+ full_view = views[view]
+ if view in ("detail", "detail_running"):
+ panels.append(full_view)
+ full_view = views["completed" if view == "detail" else "running"]
complete = view in ("empty", "completed", "detail", "resumed")
+ # Each delayed panel can require another rendered /tasks submission.
+ timeout = (5 if complete else 2) + 2 * len(panels)
script = tmp_path / "ug"
script.write_text(
f"""#!{sys.executable}
@@ -299,7 +317,20 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view)
time.sleep(0.8)
previous = termios.tcgetattr(sys.stdin.fileno())
tty.setraw(sys.stdin.fileno())
-print("\\x1b[2J\\x1b[H" + {views[view]!r}.replace("\\n", "\\r\\n"), flush=True)
+panels = {panels!r}
+for index, panel in enumerate(panels):
+ print("\\x1b[2J\\x1b[H" + panel.replace("\\n", "\\r\\n"), flush=True)
+ assert sys.stdin.read(3) == "\\x1b[D", "Must return to the full task list"
+ time.sleep(0.8)
+ assert not select.select([sys.stdin], [], [], 0)[0], "Repeated task-list navigation"
+ if index + 1 < len(panels) or {view in ("detail", "detail_running")!r}:
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, previous)
+ print("\\x1b[2J\\x1b[HWaiting for 1 background agent to finish", flush=True)
+ print("─" * 40 + "\\n❯\\n" + "─" * 40, flush=True)
+ print(" ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents", flush=True)
+ assert sys.stdin.readline().strip() == "/tasks", "Must reopen the task view"
+ tty.setraw(sys.stdin.fileno())
+print("\\x1b[2J\\x1b[H" + {full_view!r}.replace("\\n", "\\r\\n"), flush=True)
if {view == "resumed"!r}:
time.sleep(0.8)
assert not select.select([sys.stdin], [], [], 0)[0], "Exited with a resumed child running"
@@ -314,7 +345,7 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view)
session = UserSession(tmp_path, script, tmp_path / "artifacts", "token")
with Terminal(session, "waits-before-exit", [], agent=CLAUDE) as tui:
if complete:
- tui.wait_for_background_tasks(timeout=5)
+ tui.wait_for_background_tasks(timeout=timeout)
tui.exit_normally()
assert tui.child.exitstatus == 0
close = next(
@@ -322,15 +353,20 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view)
for action in tui.actions
if action["reason"] == "close the completed background-task view"
)
- assert views[view].splitlines()[0] in close["screen_before"]
+ assert full_view.splitlines()[0] in close["screen_before"]
if view == "resumed":
assert "Running (1)" not in close["screen_before"]
assert "Completed (2)" in close["screen_before"]
else:
with pytest.raises(AssertionError, match="task view reporting no running tasks"):
- tui.wait_for_background_tasks(timeout=2)
+ tui.wait_for_background_tasks(timeout=timeout)
assert not tui.ended
assert not any("/exit" in action["keys"] for action in tui.actions)
+ assert full_view in tui.visible
+ back = [action for action in tui.actions if action.get("keys") == "\x1b[D"]
+ assert len(back) == len(panels)
+ for action, panel in zip(back, panels, strict=True):
+ assert panel in action["screen_before"]
def test_mcp_list_poll_retries_a_failed_probe_until_rows_match(monkeypatch):