From 527fd699a37cdc4d67f37a7696171d9246ebf7db Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 12 Oct 2026 18:14:32 +1100 Subject: [PATCH 1/2] fix(codex): queue a differing model/effort pick as a fresh turn instead of steering A mention that arrived while the lane already had an active Codex turn was always sent as `turn/steer`, which never read the delivery's model or effort, so a pick made for a busy agent was silently dropped. The Codex app-server's `turn/steer` request carries only input and context; model and effort exist solely on `turn/start`, so the pick cannot be forwarded on the steer path. Record the resolved model and effort on the active turn, and when a mid-turn pick differs from either, queue the message on the lane's existing tail (as the "turn already finished" fallback does) so it runs as the next turn with the picked settings. Matching or absent picks still steer. Claude Code applies picks mid-turn already and is unchanged. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- src/bundled/codex/README.md | 4 ++- src/bundled/codex/runtime.test.ts | 54 +++++++++++++++++++++++++++++++ src/bundled/codex/runtime.ts | 13 +++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index c4ed9cabd..45f346ee6 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -40,7 +40,9 @@ BUZZODZ_PROFILE=codex-agents BUZZ_DEV_VIEWER= bin/just desktop While work is active, new mentions steer it, incorporating the follow-up into the 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. +while a follow-up is being delivered, the follow-up starts another turn. A +follow-up that picks a model or effort other than the running turn's cannot +steer it, so it waits and runs as the next 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. diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index e77f74f4d..11492fc5d 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -713,6 +713,60 @@ it("starts a turn on the model its owner picked, at an effort that model support { model: "test-model", effort: "low" }, ]); }); +it("runs a mid-turn pick that differs from the running turn as the next turn instead of steering", async () => { + const f = fixture(); + const settings = (wire: Wire) => { + const { threadId, model, effort } = wire.params as Record; + return { threadId, model, effort }; + }; + await f.runtime.run(f.delivery("first")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.runtime.run({ + ...f.delivery("deeper"), + model: "deep-model", + effort: "xhigh", + }); + await f.complete(0, "FIRST"); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + // An effort pick alone queues too, on the agent's own model. + await f.runtime.run({ ...f.delivery("harder"), effort: "high" }); + await f.complete(1, "SECOND"); + await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); + expect(f.sent.filter((wire) => wire.method === "turn/steer")).toHaveLength(0); + expect(f.starts().map(settings)).toEqual([ + { threadId: "thread-1", model: "test-model", effort: "low" }, + { threadId: "thread-1", model: "deep-model", effort: "xhigh" }, + { threadId: "thread-1", model: "test-model", effort: "high" }, + ]); + f.complete(2, "THIRD"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(3)); + expect( + f.publish.mock.calls.map(([event]) => (event as EventData).content), + ).toEqual(["FIRST", "SECOND", "THIRD"]); +}); +it("steers a mid-turn message whose pick matches the running turn, or has none", async () => { + const f = fixture(); + await f.runtime.run({ + ...f.delivery("first"), + model: "test-model", + effort: "low", + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.runtime.run({ + ...f.delivery("same"), + model: "test-model", + effort: "low", + }); + await f.runtime.run(f.delivery("plain")); + await vi.waitFor(() => + expect(f.sent.filter((wire) => wire.method === "turn/steer")).toHaveLength( + 2, + ), + ); + f.complete(); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + expect(f.starts()).toHaveLength(1); +}); it("serializes owner follow-ups and routes tool replies to the latest accepted request", async () => { const f = fixture(); await f.runtime.run(f.delivery("first")); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index d0cde7f3d..ecafd84a6 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -67,6 +67,9 @@ type Active = { steering: Promise; reply: Request; finished: boolean; + /** What the turn runs on, once resolved; a differing pick queues a turn. */ + model?: string; + effort?: string; workspace: string; signal: AbortSignal; version: number; @@ -423,7 +426,13 @@ export class CodexRuntime { request.failActivity("Codex was stopped"); return; } - if (active.finished) { + // Steering cannot carry a model or effort, so a differing pick + // waits for this turn and runs as the next one. + if ( + active.finished || + (request.model && request.model !== active.model) || + (request.effort && request.effort !== active.effort) + ) { this.start(request, text, key, lane, entry); return; } @@ -776,6 +785,8 @@ export class CodexRuntime { [request.effort, settings.effort].find( (level) => level && supported.includes(level), ) ?? selected.defaultReasoningEffort; + active.model = model; + active.effort = effort; const mcp = Object.fromEntries( Object.entries(normalized.config.mcp_servers ?? {}).map( ([name, server]) => [ From 7dd4d2462120f9f464dca0d38dca5f693ab3861e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 13 Oct 2026 17:26:35 +1100 Subject: [PATCH 2/2] fix(codex): queue follow-ups behind a queued model/effort pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A differing model/effort pick made while a Codex turn was active queued as the next turn, but a later follow-up with no pick (or one matching the running turn) still steered the active turn, so it ran before the earlier queued message. Mark the active turn once a later turn has been queued behind it, and route every later follow-up on that lane through the same queue so messages run in the order they arrived. Adds a regression test for differing pick → plain follow-up ordering, which fails without the fix. Addresses https://github.com/block/buzz-app/pull/889#discussion_r4246321826 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- src/bundled/codex/README.md | 3 ++- src/bundled/codex/runtime.test.ts | 22 ++++++++++++++++++++++ src/bundled/codex/runtime.ts | 7 ++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index 45f346ee6..156591689 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -42,7 +42,8 @@ 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 follow-up that picks a model or effort other than the running turn's cannot -steer it, so it waits and runs as the next turn. +steer it, so it waits and runs as the next turn, and later follow-ups wait +behind it to keep their order. 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. diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 11492fc5d..e98b2e4f5 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -744,6 +744,28 @@ it("runs a mid-turn pick that differs from the running turn as the next turn ins f.publish.mock.calls.map(([event]) => (event as EventData).content), ).toEqual(["FIRST", "SECOND", "THIRD"]); }); +it("keeps a plain follow-up behind a queued differing pick instead of steering past it", async () => { + const f = fixture(); + await f.runtime.run(f.delivery("first")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.runtime.run({ ...f.delivery("deeper"), model: "deep-model" }); + await f.runtime.run(f.delivery("plain")); + await f.complete(0, "FIRST"); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + await f.complete(1, "SECOND"); + await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); + expect(f.sent.filter((wire) => wire.method === "turn/steer")).toHaveLength(0); + const input = (index: number) => JSON.stringify(f.starts()[index]?.params); + expect(input(1)).toContain("deeper"); + expect(input(1)).toContain('"model":"deep-model"'); + expect(input(2)).toContain("plain"); + expect(input(2)).toContain('"model":"test-model"'); + f.complete(2, "THIRD"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(3)); + expect( + f.publish.mock.calls.map(([event]) => (event as EventData).content), + ).toEqual(["FIRST", "SECOND", "THIRD"]); +}); it("steers a mid-turn message whose pick matches the running turn, or has none", async () => { const f = fixture(); await f.runtime.run({ diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index ecafd84a6..8452dabb2 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -70,6 +70,8 @@ type Active = { /** What the turn runs on, once resolved; a differing pick queues a turn. */ model?: string; effort?: string; + /** A later turn is queued behind this one, so steering would reorder. */ + queued?: true; workspace: string; signal: AbortSignal; version: number; @@ -427,12 +429,15 @@ export class CodexRuntime { return; } // Steering cannot carry a model or effort, so a differing pick - // waits for this turn and runs as the next one. + // waits for this turn and runs as the next one. Everything after + // it queues too: steering now would run later messages first. if ( active.finished || + active.queued || (request.model && request.model !== active.model) || (request.effort && request.effort !== active.effort) ) { + active.queued = true; this.start(request, text, key, lane, entry); return; }