diff --git a/docs/changelog.mdx b/docs/changelog.mdx index b63ab6c5..d3e58332 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -10,6 +10,15 @@ upgrades with `berth upgrade BOX`, or by running the again. ## Unreleased +- **A long Codex turn stays working until it ends.** Codex's `notify` comes + at the end of each of its messages, not only the turn's, so a turn read as + finished after its first message. Once a session's Codex hooks have said a + prompt started, only its `Stop` hook ends the turn; Codex versions without + hooks still finish with `notify`. `berthd integrations install codex` (and + an upgrade) also adds `PreToolUse` and `PostToolUse` hooks: a question in + Codex's question box shows as needs you, and answering it, or Codex carrying + on by itself after a turn ended, shows it working again. If Codex asks, + trust the new hooks with `/hooks`. - **An agent asking you a question no longer reads as done.** When a task's first prompt led Claude Code to ask something (its question form, with choices), Shipyard could read the question off the screen as nothing, diff --git a/docs/guides/agent-integrations.mdx b/docs/guides/agent-integrations.mdx index 51e8a16a..efb31e96 100644 --- a/docs/guides/agent-integrations.mdx +++ b/docs/guides/agent-integrations.mdx @@ -34,10 +34,14 @@ with the command that installs them. `~/.cursor/hooks.json`, after any hooks already there (Orca's are kept). - **Codex**: installs Shipyard's skills in `~/.agents/skills/`, where Codex reads user skills (a copy an older version left in `~/.codex/skills/` is - removed), adds `SessionStart` / `UserPromptSubmit` / `PermissionRequest` / - `Stop` hooks to `~/.codex/hooks.json` (trust them once in Codex with - `/hooks`), and a `notify` line to `~/.codex/config.toml` for versions - without hooks. Codex runs a single notify program, so when the config + removed), adds `SessionStart` / `UserPromptSubmit` / `PreToolUse` / + `PostToolUse` / `PermissionRequest` / `Stop` hooks to `~/.codex/hooks.json` + (trust them once in Codex with `/hooks`), and a `notify` line to + `~/.codex/config.toml` for versions without hooks. Codex's question box is + a tool, so its `PreToolUse` means "needs you" (never with the questions). + Notify comes at the end of each of Codex's messages, not only the turn's: + once a session's hooks have said a prompt started, its `Stop` hook alone + ends the turn. Codex runs a single notify program, so when the config already sets one to something else, Shipyard leaves it and prints the line to change yourself. - **Gemini CLI**: `SessionStart` / `BeforeAgent` / `Notification` / diff --git a/docs/reference/events.mdx b/docs/reference/events.mdx index a4f407af..a350ea93 100644 --- a/docs/reference/events.mdx +++ b/docs/reference/events.mdx @@ -42,8 +42,8 @@ and dashes. | Event | Data | | --- | --- | | `agent.ready` | `path`, `agent`, `session` (when the hook knows it) and the tool's own id (below): a new agent at its prompt. After a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged unfinished but not waiting on you, with `source: "turncheck"`, `choice` and `seq`: the session stays `finished`, with `unfinished: true` for `choice: "working"` (something still runs) or `parked: true` for `choice: "parked"` (it waits on a reply or a later time); neither notifies | -| `agent.started` | the same, with `signal`: `prompt` (working on a prompt) or `tool` (working again after an approval). For an agent that cannot say it started, Shipyard sends it at the send, with `source: "send"` and `turn` | -| `agent.waiting` | the same, with `reason` (`permission`, `question`): needs you. At a startup question Shipyard sends it itself, with `reason: "startup question"`; after a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged to need you, with `reason: "turn check"`, `source: "turncheck"` and the model's `choice` (`needs_input` or `blocked`) | +| `agent.started` | the same, with `signal`: `prompt` (working on a prompt) or `tool` (working again after an approval or an answer). For an agent that cannot say it started, Shipyard sends it at the send, with `source: "send"` and `turn` | +| `agent.waiting` | the same, with `reason` (`permission`, `question`): needs you. Codex's async question box adds `question_box: "async"`: its turn can end before the answer, and the session keeps waiting until you prompt again. At a startup question Shipyard sends it itself, with `reason: "startup question"`; after a finished turn [turn check](/concepts/sessions-and-agents#agent-state) judged to need you, with `reason: "turn check"`, `source: "turncheck"` and the model's `choice` (`needs_input` or `blocked`) | | `agent.finished` | the same: done with its turn; `status: "error"` when it ended on a failure | | `agent.exited` | the same: the agent itself quit | | `task.created` | `location`, `name`, `path`, `branch`, `session`, `agent`, `from_session` | @@ -64,9 +64,12 @@ Cursor `conversation_id` and `status`). A hook names the Shipyard session it runs in as `session`, from `$BERTH_SESSION`. Hooks spooled while berthd was down carry `spooled: true` and the time they ran. Shipyard does not publish a tool use (`agent.started` with `signal: "tool"`) for an agent that is -already working: it would change nothing. When an agent's hooks cannot tell, -Shipyard reads the terminal and sends these itself with `origin: "screen"` and -`source: "screen"`. See [turns](/concepts/sessions-and-agents#turns). +already working: it would change nothing. Nor does it publish Codex's +`notify` (`agent.finished` with `via: "notify"`) once the session's hooks +have said a prompt started: notify comes at the end of each of Codex's +messages, and its `Stop` hook ends the turn. When an agent's hooks cannot +tell, Shipyard reads the terminal and sends these itself with +`origin: "screen"` and `source: "screen"`. See [turns](/concepts/sessions-and-agents#turns). ### Locations and worktrees diff --git a/internal/box/turns.go b/internal/box/turns.go index 426c5c6e..50d95b17 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -172,6 +172,9 @@ type sessTrack struct { // they said a prompt started, so its sends can wait for that. Hooked bool `json:"hooked,omitempty"` SawStart bool `json:"saw_start,omitempty"` + // AsyncAsk is set while the session waits on Codex's async question + // box, which a turn can end before anyone answers. + AsyncAsk bool `json:"async_ask,omitempty"` // Prompted is set once the ledger has seen a prompt for the session, // sent or typed: the first one may name it (see Box.nameAfter). Prompted bool `json:"prompted,omitempty"` @@ -632,6 +635,14 @@ func sameDir(a, b string) bool { var stateOf = map[string]string{adapters.Ready: "idle", adapters.Started: "running", adapters.Waiting: "waiting", adapters.Finished: "finished", adapters.Exited: "exited"} +// notifyAfterHooks says whether a finished event is Codex's notify for a +// session whose hooks have said a prompt started. Notify comes at the end +// of each of Codex's messages, not only the turn's, so there its Stop hook +// ends the turn. A Codex without hooks still ends turns with notify. +func notifyAfterHooks(s *sessTrack, typ string, data map[string]any) bool { + return typ == adapters.Finished && str(data, "via") == "notify" && s.SawStart +} + func (t *Turns) agentEvent(e events.Event) { s, ambiguous := t.resolve(e) if s == nil { @@ -647,6 +658,9 @@ func (t *Turns) agentEvent(e events.Event) { } return } + if notifyAfterHooks(s, e.Type, e.Data) { + return + } source := str(e.Data, "source") if source == "" { s.Hooked = true @@ -682,6 +696,12 @@ func (t *Turns) agentEvent(e events.Event) { s.set("idle", e) } case adapters.Started: + // A question the agent's own hook reported is answered when the + // agent says so (a tool, a prompt), not when its screen changes. + if source == "screen" && cur != nil && cur.State == "waiting" && lastWait(cur) == "question" { + return + } + s.AsyncAsk = false signal := str(e.Data, "signal") switch { case source == "send": @@ -747,8 +767,15 @@ func (t *Turns) agentEvent(e events.Event) { } cur.State = "waiting" } + s.AsyncAsk = str(e.Data, "question_box") == "async" s.set("waiting", e) case adapters.Finished: + // A turn that ends on its own async question (Codex's question box + // returns at once, then the turn stops) still waits for the answer. + // A question answered within the turn (a structured driver's) ends + // with it. + asked := cur != nil && cur.State == "waiting" && source == "" && s.AsyncAsk + s.AsyncAsk = asked if cur != nil { if source == "screen" || source == "reconcile" { cur.Fidelity = "screen" // its end was read off the screen @@ -760,6 +787,10 @@ func (t *Turns) agentEvent(e events.Event) { } } s.Reconcile = false + if asked { + s.set("waiting", e) + break + } s.set("finished", e) t.kickInbox() case adapters.Exited: @@ -843,10 +874,12 @@ func (t *Turns) kickInbox() { } // Redundant says whether an agent event would change nothing: a tool use -// while the agent is already working. Those are frequent, so the box does -// not publish them. +// while the agent is already working, which is frequent, or Codex's notify +// once its hooks are heard (notifyAfterHooks). The box does not publish +// them. func (t *Turns) Redundant(typ string, data map[string]any) bool { - if typ != adapters.Started || str(data, "signal") != "tool" { + tool := typ == adapters.Started && str(data, "signal") == "tool" + if !tool && typ != adapters.Finished { return false } t.mu.Lock() @@ -857,6 +890,9 @@ func (t *Turns) Redundant(typ string, data map[string]any) bool { if s == nil { return false } + if !tool { + return notifyAfterHooks(s, typ, data) + } return s.State == "running" } @@ -1729,3 +1765,11 @@ func (t *Turns) waitSessionState(ctx context.Context, name string, want map[stri } } } + +// lastWait is the reason of the turn's latest wait, or "". +func lastWait(tr *Turn) string { + if n := len(tr.Waits); n > 0 { + return tr.Waits[n-1].Reason + } + return "" +} diff --git a/internal/box/turns_test.go b/internal/box/turns_test.go index d97d5a70..74131880 100644 --- a/internal/box/turns_test.go +++ b/internal/box/turns_test.go @@ -180,6 +180,106 @@ func TestAFinishedOnlyAgentWorksFromTheSend(t *testing.T) { } } +// Codex's notify comes at the end of each of its messages, not only the +// turn's. Once its hooks said a prompt started, Stop alone ends the turn, +// and the notify is not published; a Codex without hooks still ends its +// turn with notify. +func TestCodexNotifyEndsATurnOnlyWithoutHooks(t *testing.T) { + tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"}, Session{Name: "old", Dir: "/v", Agent: "codex"}) + hook(bus, "agent.ready", "cx", "/w", "codex") + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt") + if !tr.Redundant("agent.finished", map[string]any{"session": "cx", "via": "notify"}) { + t.Fatal("a hooked Codex's notify would be published") + } + hook(bus, "agent.finished", "cx", "/w", "codex", "via", "notify") // its first message + if st, _ := tr.State("cx"); st.State != "running" || turnState(t, tr, "cx#1") != "running" { + t.Fatalf("after a notify mid-turn: session %+v, turn %s", st, turnState(t, tr, "cx#1")) + } + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") + hook(bus, "agent.finished", "cx", "/w", "codex") // Stop + if st, _ := tr.State("cx"); st.State != "finished" || turnState(t, tr, "cx#1") != "finished" { + t.Fatalf("after Stop: session %+v, turn %s", st, turnState(t, tr, "cx#1")) + } + if tr.Redundant("agent.finished", map[string]any{"session": "cx"}) { + t.Fatal("Stop would not be published") + } + + sent := bus.Publish(events.Event{Type: "session.sent", Data: map[string]any{"name": "old"}}) + mine, _ := tr.ForSent("old", sent.Seq) + if tr.Redundant("agent.finished", map[string]any{"session": "old", "via": "notify"}) { + t.Fatal("the notify of a Codex without hooks would not be published") + } + hook(bus, "agent.finished", "old", "/v", "codex", "via", "notify") + if st := turnState(t, tr, mine.ID); st != "finished" { + t.Fatalf("without hooks, notify left the turn %s", st) + } +} + +// Codex's question box is a tool: its PreToolUse makes the turn wait, and +// the PostToolUse once it is answered has it running again. Any other tool +// use after a turn ended (Codex carrying on by itself once a background +// command finishes) shows the agent working. +func TestACodexQuestionWaitsUntilItIsAnswered(t *testing.T) { + tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"}) + hook(bus, "agent.ready", "cx", "/w", "codex") + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt") + hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question") // PreToolUse request_user_input_async + got, _ := tr.Get("cx#1") + if st, _ := tr.State("cx"); st.State != "waiting" || got.State != "waiting" || len(got.Waits) != 1 || got.Waits[0].Reason != "question" { + t.Fatalf("asking: session %+v, turn %+v", st, got) + } + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") // PostToolUse, answered + got, _ = tr.Get("cx#1") + if st, _ := tr.State("cx"); st.State != "running" || got.State != "running" || got.Waits[0].End.IsZero() { + t.Fatalf("answered: session %+v, turn %+v", st, got) + } + hook(bus, "agent.finished", "cx", "/w", "codex") + if tr.Redundant("agent.started", map[string]any{"session": "cx", "signal": "tool"}) { + t.Fatal("a tool use after the turn ended would not be published") + } + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") // PreToolUse Bash + if st, _ := tr.State("cx"); st.State != "running" { + t.Fatalf("a tool use after the turn ended left the session %+v", st) + } + if all := tr.List("cx", 10); len(all) != 1 || all[0].State != "finished" { + t.Fatalf("a tool use made or reopened a turn: %+v", all) + } +} + +// Codex asks with its async question box and ends its turn: the turn is +// over, but the session still waits for the answer, until you prompt again. +func TestATurnThatEndsOnItsQuestionStillWaits(t *testing.T) { + tr, bus := ledger(t, Session{Name: "cx", Dir: "/w", Agent: "codex"}) + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt") + hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question", "question_box", "async") + // The question box redraws the screen; that is not an answer. + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool", "source", "screen") + hook(bus, "agent.finished", "cx", "/w", "codex") + got, _ := tr.Get("cx#1") + if st, _ := tr.State("cx"); st.State != "waiting" || got.State != "finished" { + t.Fatalf("ended on its question: session %+v, turn %+v", st, got) + } + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt") + if st, _ := tr.State("cx"); st.State != "running" { + t.Fatalf("answered: session %+v", st) + } + // Work after the question means it may have been answered: the end is an end. + hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question", "question_box", "async") + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "tool") + hook(bus, "agent.finished", "cx", "/w", "codex") + if st, _ := tr.State("cx"); st.State != "finished" { + t.Fatalf("worked on after asking: session %+v", st) + } + // A question that blocks the turn (Plan mode's, or a structured + // driver's) is answered within it, so its turn's end is an end. + hook(bus, "agent.started", "cx", "/w", "codex", "signal", "prompt") + hook(bus, "agent.waiting", "cx", "/w", "codex", "reason", "question") + hook(bus, "agent.finished", "cx", "/w", "codex") + if st, _ := tr.State("cx"); st.State != "finished" { + t.Fatalf("ended after a blocking question: session %+v", st) + } +} + // E13: an agent finishes while berthd is down. Its hook is spooled, and // published when berthd is back, so the turn ends; with no spooled hook, // the open turn is left for the screen to settle. diff --git a/internal/integrations/accounts.go b/internal/integrations/accounts.go index 780f5dc4..fcd362eb 100644 --- a/internal/integrations/accounts.go +++ b/internal/integrations/accounts.go @@ -185,12 +185,11 @@ func accountHooks(agent, dir string) (hooked, current bool) { b, _ := os.ReadFile(filepath.Join(dir, "settings.json")) return bytes.Contains(b, []byte("hook claude Stop")), bytes.Contains(b, []byte("hook claude PostToolUse")) case "codex": - // hooks.json is this release's; a notify line alone is an older - // berth's, or a Codex without hooks. + // hooks.json with PreToolUse is this release's; one without it, or a + // notify line alone, is an older berth's (or a Codex without hooks). h, _ := os.ReadFile(filepath.Join(dir, "hooks.json")) - current = bytes.Contains(h, []byte("hook codex Stop")) - if current { - return true, true + if bytes.Contains(h, []byte("hook codex Stop")) { + return true, bytes.Contains(h, []byte("hook codex PreToolUse")) } c, _ := os.ReadFile(filepath.Join(dir, "config.toml")) return bytes.Contains(c, []byte(`"hook", "codex"`)), false diff --git a/internal/integrations/adapters/codex.go b/internal/integrations/adapters/codex.go index 7992f955..877ae1ce 100644 --- a/internal/integrations/adapters/codex.go +++ b/internal/integrations/adapters/codex.go @@ -1,7 +1,11 @@ package adapters +import "strings" + // Codex reports through its hooks (hooks.json, trusted once with /hooks) -// and, on versions without them, only through notify when a turn ends. +// and, on versions without them, only through notify. Notify comes at the +// end of each of its messages, not only the turn's: once a session's hooks +// are heard, the ledger leaves the end to Stop. var Codex = register(&Adapter{ Name: "codex", Caps: Caps{Ready: true, Started: true, Waiting: true, Finished: true, FinalMessage: true, Via: "hooks"}, @@ -23,7 +27,27 @@ var Codex = register(&Adapter{ // after it, and drops it before the event is published. d["title"] = Title(in.Str("prompt")) return Started, d, true + case "PreToolUse": + // Codex's question box is a tool. Only that it asks is kept, + // never the questions. + if tool := in.Str("tool_name"); strings.HasPrefix(tool, "request_user_input") { + d["reason"] = "question" + if strings.HasPrefix(tool, "request_user_input_async") { + // It returns at once and the turn may end before + // anyone answers (the ledger keeps that wait). + d["question_box"] = "async" + } + return Waiting, d, true + } + d["signal"] = "tool" + return Started, d, true case "PostToolUse": + // The async question box returns at once, before anyone + // answers; the wait holds until the turn ends. + if strings.HasPrefix(in.Str("tool_name"), "request_user_input_async") { + return "", nil, false + } + // Working again: a tool ran, or the question was answered. d["signal"] = "tool" return Started, d, true case "PermissionRequest": diff --git a/internal/integrations/install.go b/internal/integrations/install.go index 1fe5a792..e8c3c0b1 100644 --- a/internal/integrations/install.go +++ b/internal/integrations/install.go @@ -64,7 +64,9 @@ func installCodexHooks(f files, rel, bin string) (bool, error) { return editJSON(f, rel, func(root map[string]any) bool { hooks := object(root, "hooks") changed := false - for _, event := range []string{"SessionStart", "UserPromptSubmit", "PermissionRequest", "Stop"} { + // PreToolUse says when Codex asks a question (its question box is a + // tool); it and PostToolUse mark the agent busy again after one. + for _, event := range []string{"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"} { if addNested(hooks, event, hookCommand(bin, "codex", event)) { changed = true } diff --git a/internal/integrations/install_test.go b/internal/integrations/install_test.go index abe48d9b..1dd6fb4a 100644 --- a/internal/integrations/install_test.go +++ b/internal/integrations/install_test.go @@ -103,6 +103,37 @@ func TestQuotedBinaryPaths(t *testing.T) { } } +// Codex's question box and tool uses come through PreToolUse and +// PostToolUse. A hooks.json from an older berth, without them, reads as +// outdated so an upgrade adds them: once, after the hooks already there. +func TestCodexToolHooksAreAddedToAnOlderInstallOnce(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + os.WriteFile(path, []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/opt/berthd hook codex Stop"}]}],"PreToolUse":[{"hooks":[{"type":"command","command":"my-guard"}]}]}}`), 0o644) + if hooked, current := accountHooks("codex", dir); !hooked || current { + t.Fatalf("older hooks: hooked %v current %v", hooked, current) + } + if changed, err := InstallCodexHooks(path, "/opt/berthd"); !changed || err != nil { + t.Fatalf("install: %v changed=%v", err, changed) + } + if changed, _ := InstallCodexHooks(path, "/opt/berthd"); changed { + t.Fatal("a second install changed the file again") + } + hooks := readJSON(t, path)["hooks"].(map[string]any) + for event, n := range map[string]int{"SessionStart": 1, "UserPromptSubmit": 1, "PreToolUse": 2, "PostToolUse": 1, "PermissionRequest": 1, "Stop": 1} { + if list, _ := hooks[event].([]any); len(list) != n { + t.Errorf("%s hooks = %v", event, list) + } + } + pre := hooks["PreToolUse"].([]any) + if !containsCommand(pre[:1], "my-guard") || !containsCommand(pre[1:], "/opt/berthd hook codex PreToolUse") { + t.Fatalf("PreToolUse hooks = %v", pre) + } + if hooked, current := accountHooks("codex", dir); !hooked || !current { + t.Fatalf("after install: hooked %v current %v", hooked, current) + } +} + func TestNewAgentHooksInstallOnceAndKeepOtherSettings(t *testing.T) { dir := t.TempDir() gem := filepath.Join(dir, "settings.json") diff --git a/internal/integrations/toolhook_test.go b/internal/integrations/toolhook_test.go index 7ec24390..049a9318 100644 --- a/internal/integrations/toolhook_test.go +++ b/internal/integrations/toolhook_test.go @@ -21,6 +21,10 @@ func TestTranslate(t *testing.T) { "agent.finished", map[string]any{"path": "/w/cal", "conversation_id": "c1", "agent_session_id": "c1", "status": "completed", "agent": "cursor"}}, {"codex", "notify", `{"type":"agent-turn-complete","turn-id":"t1","cwd":"/w","last-assistant-message":"secret plan"}`, "agent.finished", map[string]any{"path": "/w", "turn_id": "t1", "via": "notify", "agent": "codex"}}, + {"codex", "PreToolUse", `{"cwd":"/w","hook_event_name":"PreToolUse","session_id":"x1","turn_id":"t1","tool_name":"request_user_input_async","tool_input":{"questions":["Which branch?"]},"tool_use_id":"u1","transcript_path":"/secret/rollout.jsonl"}`, + "agent.waiting", map[string]any{"path": "/w", "agent_session_id": "x1", "turn_id": "t1", "reason": "question", "question_box": "async", "agent": "codex"}}, + {"codex", "PreToolUse", `{"cwd":"/w","session_id":"x1","turn_id":"t1","tool_name":"Bash","tool_input":{"command":"go test ./..."}}`, + "agent.started", map[string]any{"path": "/w", "agent_session_id": "x1", "turn_id": "t1", "signal": "tool", "agent": "codex"}}, {"orca", "worktree.archived", `{"cwd":"/w/x"}`, "worktree.archived", map[string]any{"path": "/w/x", "agent": "orca"}}, } { @@ -56,6 +60,9 @@ func TestTranslateNeverCopiesContent(t *testing.T) { {"claude", "UserPromptSubmit", `{"prompt":"\nfirst line\nSECRET-TEXT","cwd":"/w","session_id":"s"}`}, {"codex", "UserPromptSubmit", `{"prompt":"first line\n\nSECRET-TEXT","cwd":"/w"}`}, {"codex", "notify", `{"type":"agent-turn-complete","input-messages":["SECRET-TEXT"],"last-assistant-message":"SECRET-TEXT"}`}, + {"codex", "PreToolUse", `{"cwd":"/w","tool_name":"request_user_input_async","tool_input":{"questions":["SECRET-TEXT"]},"transcript_path":"/SECRET-TEXT"}`}, + {"codex", "PreToolUse", `{"cwd":"/w","tool_name":"Bash","tool_input":{"command":"SECRET-TEXT"}}`}, + {"codex", "PostToolUse", `{"cwd":"/w","tool_name":"request_user_input","tool_input":{"questions":["SECRET-TEXT"]},"tool_response":"SECRET-TEXT"}`}, {"cursor", "stop", `{"prompt":"SECRET-TEXT","workspace_roots":["/w"]}`}, } { e, _ := Translate(tc[0], tc[1], []byte(tc[2])) @@ -134,6 +141,9 @@ func TestClaudeToolUseAndPermissionHooks(t *testing.T) { func TestEveryAgentAdapterMapsItsTurn(t *testing.T) { for _, tc := range [][4]string{ {"codex", "UserPromptSubmit", `{"cwd":"/w","session_id":"x"}`, "agent.started"}, + {"codex", "PreToolUse", `{"cwd":"/w","tool_name":"request_user_input"}`, "agent.waiting"}, + {"codex", "PreToolUse", `{"cwd":"/w","tool_name":"Bash"}`, "agent.started"}, + {"codex", "PostToolUse", `{"cwd":"/w","tool_name":"Bash"}`, "agent.started"}, {"codex", "PermissionRequest", `{"cwd":"/w"}`, "agent.waiting"}, {"codex", "Stop", `{"cwd":"/w"}`, "agent.finished"}, {"cursor", "beforeSubmitPrompt", `{"workspace_roots":["/w"]}`, "agent.started"}, @@ -166,3 +176,14 @@ func TestOmpAgentEndThatContinuesIsNotATurnEnd(t *testing.T) { t.Fatal("an event berth does not use was announced") } } + +// The async question box returns at once: its PostToolUse comes before any +// answer, so it says nothing and the wait holds until the turn ends. +func TestCodexAsyncQuestionStaysAWait(t *testing.T) { + if e, ok := Translate("codex", "PostToolUse", []byte(`{"cwd":"/w","tool_name":"request_user_input_async"}`)); ok { + t.Fatalf("async question's PostToolUse published %+v", e) + } + if e, ok := Translate("codex", "PostToolUse", []byte(`{"cwd":"/w","tool_name":"request_user_input"}`)); !ok || e.Type != "agent.started" { + t.Fatalf("answered question: %+v ok=%v", e, ok) + } +}