Skip to content
Closed
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
3 changes: 3 additions & 0 deletions src/bundled/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<turn_aborted>` 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
Expand Down
13 changes: 12 additions & 1 deletion src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++);
Expand Down Expand Up @@ -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"),
Expand All @@ -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({
Expand Down
23 changes: 21 additions & 2 deletions src/bundled/codex/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<void>;
Expand Down Expand Up @@ -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<void>((resolve) => {
answered = resolve;
});
const request: Request = {
scope: snapshot.scope ?? "",
event: trigger.event,
Expand All @@ -353,6 +363,7 @@ export class CodexRuntime {
},
interest:
trigger.type === "watch" ? (trigger.interest?.instructions ?? "") : "",
answered,
};
if (!validWorkspace(settings.workspace)) {
await this.publish(
Expand Down Expand Up @@ -394,6 +405,7 @@ export class CodexRuntime {
),
});
active.reply = request;
active.requests.push(request);
active.version++;
} catch (error) {
if (entry.abort.signal.aborted) return;
Expand All @@ -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,
Expand Down Expand Up @@ -493,6 +509,7 @@ export class CodexRuntime {
const args = call.arguments as Record<string, unknown>;
const version = active.version;
const request = active.reply;
const answering = [...active.requests];
const client = appClient(request.agent, {
spawn: this.spawn,
cwd: active.workspace,
Expand Down Expand Up @@ -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 }] };
}
Expand Down Expand Up @@ -603,6 +621,7 @@ export class CodexRuntime {
workspace: request.settings.workspace,
signal,
version: 0,
requests: [request],
};
void active.ready.catch(() => undefined);
lane.active = active;
Expand Down
Loading