From 527fd699a37cdc4d67f37a7696171d9246ebf7db Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 12 Oct 2026 18:14:32 +1100 Subject: [PATCH 1/3] 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/3] 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; } From 1579953e8eebf6fbf42f619d0b0951d3229b01c9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 13 Oct 2026 21:11:05 +1100 Subject: [PATCH 3/3] fix(codex): keep the follow-up queue barrier until the lane drains The barrier against steering past queued work lived on the active turn, so it was lost when the next queued turn started. After a differing pick A and a plain follow-up B queued behind a running turn, A started without the barrier while B still waited, and a later plain follow-up C steered into A and ran before B. Count the turns waiting on the lane's tail instead, and keep routing follow-ups through the queue while any are waiting. Extends the regression test to deliver C while A runs and B is still queued; it fails without the fix. Addresses https://github.com/block/buzz-app/pull/889#discussion_r4246321826 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/codex/runtime.test.ts | 12 +++++++++--- src/bundled/codex/runtime.ts | 14 ++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index e98b2e4f5..d30e6f19d 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -752,19 +752,25 @@ it("keeps a plain follow-up behind a queued differing pick instead of steering p await f.runtime.run(f.delivery("plain")); await f.complete(0, "FIRST"); await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + // "plain" is still queued while "deeper" runs, so this cannot steer either. + await f.runtime.run(f.delivery("later")); + await new Promise((resolve) => setTimeout(resolve, 0)); await f.complete(1, "SECOND"); await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); + await f.complete(2, "THIRD"); + await vi.waitFor(() => expect(f.starts()).toHaveLength(4)); 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(input(3)).toContain("later"); + f.complete(3, "FOURTH"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(4)); expect( f.publish.mock.calls.map(([event]) => (event as EventData).content), - ).toEqual(["FIRST", "SECOND", "THIRD"]); + ).toEqual(["FIRST", "SECOND", "THIRD", "FOURTH"]); }); it("steers a mid-turn message whose pick matches the running turn, or has none", async () => { const f = fixture(); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 8452dabb2..ab94c34fd 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -70,8 +70,6 @@ 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; @@ -83,6 +81,8 @@ type Active = { }; type Lane = { tail: Promise; + /** Turns waiting on `tail` to start; steering past them would reorder. */ + queued: number; active?: Active; }; type Entry = { @@ -415,7 +415,7 @@ export class CodexRuntime { const entry = this.entry(request); let lane = entry.lanes.get(key); if (!lane) { - lane = { tail: entry.after }; + lane = { tail: entry.after, queued: 0 }; entry.lanes.set(key, lane); } if (lane.active) { @@ -430,14 +430,14 @@ export class CodexRuntime { } // Steering cannot carry a model or effort, so a differing pick // waits for this turn and runs as the next one. Everything after - // it queues too: steering now would run later messages first. + // it queues too until the lane drains: steering would run later + // messages first. if ( active.finished || - active.queued || + lane.queued || (request.model && request.model !== active.model) || (request.effort && request.effort !== active.effort) ) { - active.queued = true; this.start(request, text, key, lane, entry); return; } @@ -509,9 +509,11 @@ export class CodexRuntime { entry: Entry, ) { const current = lane; + current.queued++; const job = lane.tail .catch(() => undefined) .then(async () => { + current.queued--; if (entry.abort.signal.aborted) { request.failActivity("Codex was stopped"); return;