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
4 changes: 3 additions & 1 deletion src/bundled/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ BUZZODZ_PROFILE=codex-agents2 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.
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
Expand Down
54 changes: 54 additions & 0 deletions src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,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<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("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
13 changes: 12 additions & 1 deletion src/bundled/codex/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ 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;
workspace: string;
signal: AbortSignal;
version: number;
Expand Down Expand Up @@ -376,7 +379,13 @@ export class CodexRuntime {
try {
await active.ready;
if (entry.abort.signal.aborted) 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);

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 @@ -688,6 +697,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