Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/bundled/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ 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, 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 `<turn_aborted>` note in the session after every reply.
Expand Down
76 changes: 76 additions & 0 deletions src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,82 @@ 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<string, string>;
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("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({
...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"));
Expand Down
18 changes: 17 additions & 1 deletion src/bundled/codex/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ type Active = {
steering: Promise<void>;
reply: Request;
finished: boolean;
/** 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;
Expand Down Expand Up @@ -423,7 +428,16 @@ 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. 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;
Comment on lines +436 to +440

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the queue barrier until the lane backlog drains

queued belongs to the current Active, but execute() creates the next one without it (lines 685–701). After a differing-model request A and then a plain request B queue behind the original turn, A starts with queued unset even though B is still on lane.tail. A later plain/matching request C therefore takes turn/steer into A and runs before B. This leaves the ordering issue unresolved across turn transitions; a follow-up depending on B can execute before B has run. Track pending work at the lane level, or otherwise carry the barrier across active-turn replacement until no earlier jobs remain. Extend the new regression to deliver C while A is active and B is still queued.

this.start(request, text, key, lane, entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Keep later follow-ups behind a queued model/effort pick

While turn 1 is active, send A with a differing model/effort, then B with no pick (or a pick matching turn 1). A takes this early return and queues behind lane.tail, but B still steers turn 1 and updates its reply handle. B therefore executes before the earlier A. Fixture probes confirmed a turn/steer targeting turn 1 while A remained queued; the selected settings are preserved for A, but message order is not.

Once a differing pick is queued, route subsequent follow-ups behind that queue boundary rather than continuing to steer the old turn. Add a regression test for differing-pick → plain-follow-up ordering.

return;
}
Expand Down Expand Up @@ -776,6 +790,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]) => [
Expand Down
Loading