diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index dd92278f5..8eed47317 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -39,6 +39,9 @@ While work is active, new mentions steer it, incorporating the follow-up into th ongoing task. Messages arriving during startup wait for the turn to start and then steer it; idle conversations continue the saved session. If a turn finishes while a follow-up is being delivered, the follow-up starts another turn. +A successful final send stops the request counting as working at once; Codex +still ends its turn with one more model call. Interrupting the turn instead would +leave a `` note in the session after every reply. By default, Buzz tools reply to the latest accepted steering message. The agent can also choose another thread or channel with tool arguments. There are no special chat commands. Asking the agent to stop is a steering request diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index b2d15575a..20677e425 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -704,7 +704,7 @@ it("serializes owner follow-ups and routes tool replies to the latest accepted r await vi.waitFor(() => expect(f.sent.filter((w) => w.method === "turn/steer")).toHaveLength(2), ); - // Each mention counts as working until the turn it was steered into ends. + // Each mention counts as working until the turn it was steered into answers. let settled = 0; for (const [done] of f.workingUntil.mock.calls) void Promise.resolve(done).then(() => settled++); @@ -1428,6 +1428,14 @@ it("delivers pending owner steering after a final send and posts its own answer" await vi.waitFor(() => expect(f.sent.some((wire) => wire.method === "turn/steer")).toBe(true), ); + // The prior answer settles only the first request. + let steered = false; + void Promise.resolve(f.workingUntil.mock.calls[1]?.[0]).then(() => { + steered = true; + }); + await f.workingUntil.mock.calls[0]?.[0]; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(steered).toBe(false); await f.complete(0, "NEW ANSWER"); await vi.waitFor(() => expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"), @@ -1451,6 +1459,9 @@ it("finishes final sends normally and preserves the agent's background terminals expect(f.publish).toHaveBeenCalledTimes(1); expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Working"); expect(f.sent.some((wire) => wire.method === "turn/interrupt")).toBe(false); + // The request is answered, so it stops counting as working before Codex's + // closing model call ends the turn. + await f.workingUntil.mock.calls[0]?.[0]; const threadId = (f.starts()[0]?.params as { threadId: string } | undefined) ?.threadId; f.emit({ diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 065389402..a043bf69a 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -45,6 +45,8 @@ type Request = { /** The model and effort the owner picked for this turn. */ model?: string; effort?: string; + /** Called once a final send answers this request, or one steered after it. */ + answered: () => void; }; type Saved = { threadId: string; workspace: string }; type Active = { @@ -60,6 +62,8 @@ type Active = { signal: AbortSignal; version: number; sentVersion?: number; + /** Every request this turn has taken in, the first and each steered one. */ + requests: Request[]; }; type Lane = { tail: Promise; @@ -338,6 +342,12 @@ export class CodexRuntime { const toolRoot = channel?.channelType === "dm" ? undefined : rootOf(trigger.event); const root = settings.scope === "channel" ? undefined : toolRoot; + let answered!: () => void; + // Codex makes one more model call after a final send before its turn + // ends; the agent is done working on this request once it has replied. + const replied = new Promise((resolve) => { + answered = resolve; + }); const request: Request = { scope: snapshot.scope ?? "", event: trigger.event, @@ -353,6 +363,7 @@ export class CodexRuntime { }, interest: trigger.type === "watch" ? (trigger.interest?.instructions ?? "") : "", + answered, }; if (!validWorkspace(settings.workspace)) { await this.publish( @@ -394,6 +405,7 @@ export class CodexRuntime { ), }); active.reply = request; + active.requests.push(request); active.version++; } catch (error) { if (entry.abort.signal.aborted) return; @@ -413,10 +425,14 @@ export class CodexRuntime { ); // The steered turn, or one started in its place, answers this mention. const steered = lane; - delivery.workingUntil(accepted.then(() => steered.tail)); + delivery.workingUntil( + Promise.race([replied, accepted.then(() => steered.tail)]), + ); return; } - delivery.workingUntil(this.start(request, text, key, lane, entry)); + delivery.workingUntil( + Promise.race([replied, this.start(request, text, key, lane, entry)]), + ); } private start( request: Request, @@ -493,6 +509,7 @@ export class CodexRuntime { const args = call.arguments as Record; const version = active.version; const request = active.reply; + const answering = [...active.requests]; const client = appClient(request.agent, { spawn: this.spawn, cwd: active.workspace, @@ -534,6 +551,7 @@ export class CodexRuntime { const final = call.tool === "send" && args.final === true; if (final) { active.sentVersion = Math.max(active.sentVersion ?? -1, version); + for (const taken of answering) taken.answered(); } return { success: true, contentItems: [{ type: "inputText", text }] }; } @@ -603,6 +621,7 @@ export class CodexRuntime { workspace: request.settings.workspace, signal, version: 0, + requests: [request], }; void active.ready.catch(() => undefined); lane.active = active;