Skip to content
Merged
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
8 changes: 5 additions & 3 deletions apps/server/src/chat/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,10 +639,12 @@ test("the atomic harness verifies a checkout before posting and every later sess
await browserTurn(context, "@chopin Continue");
expect(await Chat.invoke(context, user, "Again", other)).toBe("checkout-unverified");
await browserTurn(context, "@chopin Once more");
let offsets = events.filter(event => event.kind === "chat:state" && event.busy)
.map(event => (event.turn as Wire.Turn).entryOffset);
let busy = events.filter(event => event.kind === "chat:state" && event.busy)
.map(event => event.turn as Wire.Turn);
// Invoked instructions post first; browser turns reserve their prompt before it is appended.
expect(offsets).toEqual([1, 2, 3]);
expect(busy.filter(turn => !turn.quiet).map(turn => turn.entryOffset)).toEqual([1, 2, 3]);
// Each turn goes quiet once its words are all out, the same turn at the same offset.
expect(busy.filter(turn => turn.quiet).map(turn => turn.entryOffset)).toEqual([1, 2, 3]);
expect(seen).toHaveLength(3);
for (let turn of seen) {
expect(turn.full?.cwd).toBe(matching);
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/chat/job-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,22 @@ test("a final persist failure settles the job and releases the queue", async ()
expect(state.chat.busy).toBe(false);
expect(state.chat.waiting).toEqual([]);
});

test("a background job's turn is quiet, so Chat draws nothing for it and offers no Stop", async () => {
let h = harness();
let running = Chat.job(h.context, JOB, "prompt", "session");
await until(() => h.prompts.length === 1);
let states = h.frames.filter(frame => frame.kind === "chat:state") as Array<
{ busy: boolean; turn?: { quiet?: boolean } }
>;
expect(states.at(-1)?.busy).toBe(true);
expect(states.at(-1)?.turn?.quiet).toBe(true);
await runJobTool(h.chat, "refine_decision", async () => ({ output: {} }));
h.emit("session.idle");
expect(await running).toEqual({ status: "done", output: "{}" });
let ended = h.frames.filter(frame => frame.kind === "chat:state").at(-1) as
| { busy: boolean; turn?: unknown }
| undefined;
expect(ended).toMatchObject({ busy: false });
expect(ended?.turn).toBeUndefined();
});
39 changes: 39 additions & 0 deletions apps/server/src/chat/reply-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function fixture(turns: Part[][], decide: Decide | undefined = verdict(0)) {
capturedDecisions: undefined as (() => number) | undefined,
keptDecisions: undefined as (() => number) | undefined,
repair: undefined as boolean | undefined,
quiet: undefined as ((quiet: boolean) => void) | undefined,
publish: (part: Part) => published.push(part),
};
return { options, published, prompts, controller, run: () => publishPlannerReplies(options) };
Expand Down Expand Up @@ -121,6 +122,44 @@ test("a finished message is checked while what follows it streams, and the check
expect(f.prompts).toHaveLength(1);
});

test("once the reply's words are all out, the turn is quiet while its check runs", async () => {
let checking = Promise.withResolvers<void>();
let checked = Promise.withResolvers<void>();
let f = fixture([text("I updated the document.")], async (request, options) => {
checked.resolve();
await checking.promise;
return verdict(0)(request, options);
});
let quiet: boolean[] = [];
let published = 0;
f.options.quiet = value => {
quiet.push(value);
published = f.published.length;
};
let running = f.run();
await checked.promise;
await Bun.sleep(0);
// Quiet only after the last of the words, while the check still runs.
expect(quiet).toEqual([true]);
expect(published).toBe(text("I updated the document.").length);
checking.resolve();
await running;
expect(quiet).toEqual([true]);
});

test("a repair step the check starts is work again, and its end is quiet", async () => {
let f = fixture(
[text("Which scope?"), text("I put the scope question beside the goal.")],
async (request, options) => verdict(said(f).length === 1 ? 1 : 0)(request, options),
);
f.options.capturedDecisions = () => 1;
let quiet: Array<[boolean, number]> = [];
f.options.quiet = value => quiet.push([value, f.prompts.length]);
await f.run();
// Quiet after the first reply, work again before the repair's prompt, quiet after it.
expect(quiet).toEqual([[true, 1], [false, 1], [true, 2]]);
});

test("tool activity passes through while a decision tool is waiting", async () => {
let tool: Part = { type: "tool-input-start", id: "ask", toolName: "ask" };
let f = fixture([[tool]]);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/chat/reply-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export async function publishPlannerReplies(options: {
/** Count decisions that already existed and that the Planner repeated this turn. */
keptDecisions?: () => number;
publish: (part: Part) => void;
/**
* Told `true` once an attempt's words are all out, so what is left of the turn (the check) is not
* shown as work, and `false` as a repair step begins, which is.
*/
quiet?: (quiet: boolean) => void;
beginAttempt?: () => void;
}): Promise<void> {
let { session, decide, publish } = options;
Expand All @@ -132,6 +137,7 @@ export async function publishPlannerReplies(options: {
let toolsTruncated = false;
for (let attempt = 0; attempt < 2; attempt++) {
if (signal.aborted) return;
if (attempt > 0) options.quiet?.(false);
let asked = false;
let messages = new Map<string, string>();
let checks: Promise<void>[] = [];
Expand Down Expand Up @@ -206,6 +212,7 @@ export async function publishPlannerReplies(options: {
if (failure) throw failure;
if (signal.aborted) return;
for (let id of messages.keys()) check(id);
options.quiet?.(true);
await Promise.all(checks);
if (signal.aborted || !asked) return;
if (options.repair === false) {
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ function now(): number {
return Math.floor(Date.now() / 1000);
}

function begin(handle: string, entryOffset: number): Wire.Turn {
function begin(handle: string, entryOffset: number, quiet = false): Wire.Turn {
let startedAt = Date.now();
return {
id: ulid(),
Expand All @@ -534,9 +534,21 @@ function begin(handle: string, entryOffset: number): Wire.Turn {
entryOffset,
startedAt,
responded: false,
...(quiet ? { quiet: true } : {}),
};
}

/**
* Says whether what is left of the running turn is for people to watch. A reply's words are all
* out while its after-answer check runs; a repair step the check starts is work again.
*/
function quieten(chat: Chat, server: Server<SocketData>, room: string, quiet: boolean): void {
if (!chat.turn || !!chat.turn.quiet === quiet) return;
if (quiet) chat.turn.quiet = true;
else delete chat.turn.quiet;
state(chat, server, room);
}

function digest(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
Expand Down Expand Up @@ -2980,7 +2992,8 @@ async function run(

if (!reserved) {
chat.busy = true;
chat.turn = begin(handle, chat.entries.length);
// A background job's turn is neither saved nor shown: Chat draws nothing for it.
chat.turn = begin(handle, chat.entries.length, !!jobTurn);
chat.acting = thread;
chat.actingChannel = about?.threadChannel;
(jobTurn ? jobState : state)(chat, server, room);
Expand Down Expand Up @@ -3189,6 +3202,7 @@ async function run(
&& record.origin === "planner" && !!record.followUp && holdsQuestion(record.status)
).length,
publish: part => translate(context, part),
quiet: quiet => quieten(chat, server, room, quiet),
beginAttempt: () => {
chat.toolEntries = new Map();
if (chat.turnImages) chat.turnImages.calls = new Set();
Expand Down
Loading
Loading