From 8bfe2beadaf8ec1be9d69636f90ccfb707098bff Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 16:54:45 -0800 Subject: [PATCH] Let a person's message to chopin go ahead of a background job instead of being refused While chopin kept the document's title, or ran another background job, a message to it (an @chopin message, or any message from someone alone in the document) was refused with "Stop it to ask now", because a job's isolated turn takes no steering. Every member message asks for a title job, so this followed almost every reply. A message to the Planner, or an invoke_planner message, now preempts a background job. It is saved and acknowledged at once, the job's turn is stopped, and the person's turn starts as soon as the job's has wound down, ahead of anything waiting; a job being handed the Chat waits behind it instead. A write the job's own tool has under way finishes first and stands as its result, and no new write starts, so a title or a decision write-up is never half written or written twice. Without a write, the job reports the new outcome preempted and goes back to pending with its attempts unspent; the end of the person's turn wakes the queue and it runs again. A newer request for the same thing that is already waiting replaces it. One job gives way at most three times in a row; the next run is let finish with the person's turn right behind it, so it still completes. Turns a person started keep taking steering, and are never preempted. A job's turn is quiet (Turn.quiet): Chat draws no working row and offers no Stop for it, and since a message during it is no longer refused, Stop never appears for it now. The person's turn that takes its place is an ordinary watched turn; the job's run after it is quiet again. Measured with the unit fake harness (a 3 s title job, 10 ms saves), the time from send to the Planner receiving the person's turn went from 3,023 ms (refused until the job ended) to 20.4 ms; in the browser against the real server with a remote machine it was 163 ms. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent apps/server/src/mcp (2568 pass, 0 fail) Assistant-verification: playwright steering, composer-send-stop, conversation-initiative, harness, harness-children, harness-comments, harness-decisions-deck, harness-investigation (64 passed) Assistant-verification: new unit tests and browser spec fail on origin/main 4b1e6c63c Assistant-verification: after rebasing onto 74772eec4: bun run types; bun run ci; bun test apps/server/src/chat apps/server/src/conversation-plan apps/server/src/agent (2446 pass); playwright steering, composer-send-stop, chat-after-answer, harness, harness-children, harness-comments, harness-decisions-deck, harness-investigation (57 passed) User-preference: a person's message to chopin must never be refused because a background job is running Co-authored-by: Alex Lavaee --- AGENTS.md | 18 +- apps/server/src/agent/job-scope.ts | 18 ++ apps/server/src/chat/job-fence.ts | 3 + apps/server/src/chat/job-queue.test.ts | 30 ++- apps/server/src/chat/job-queue.ts | 4 + apps/server/src/chat/preempt.test.ts | 213 ++++++++++++++++++ apps/server/src/chat/service.ts | 152 +++++++++++-- apps/server/src/chat/steering.test.ts | 14 +- apps/server/src/conversation-plan/jobs.ts | 25 +- .../conversation-plan/planner-job-support.ts | 5 +- .../planner-jobs-preemption.test.ts | 47 ++++ .../src/conversation-plan/planner-jobs.ts | 5 +- apps/web/DESIGN.md | 2 +- docs/hosted-agent.md | 16 +- docs/local-agent-mcp.md | 3 +- e2e/steering.e2e.ts | 63 +++++- 16 files changed, 565 insertions(+), 53 deletions(-) create mode 100644 apps/server/src/chat/preempt.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-preemption.test.ts diff --git a/AGENTS.md b/AGENTS.md index b9650f3af..1c706c99a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -343,10 +343,18 @@ acknowledged at once and given to that turn (`PlannerSession.steer`, through the harness's own mid-run input: Atomic's `steer`, a `steer` frame to a remote client that offered `steer-turn`), which reads it at its next step as `@handle: text`, in the same turn. Steering takes the same write access as starting a turn: `chat:send` is -checked before `Chat.send`, so a reader cannot steer. A harness that cannot take -input mid-run (the Copilot SDK), or a running turn that is a background job's, refuses -the message with `planner-busy`: "chopin can't take messages while it works on this -machine. Stop it to ask now." (the server log names the harness). A machine that is away, +checked before `Chat.send`, so a reader cannot steer. A background job never makes a +person wait: a message to the Planner (or an `invoke_planner` message) while a job's turn +runs, or while one is being handed the Chat, is acknowledged at once and the job gives way +(`preempt` in `apps/server/src/chat/service.ts`). Its turn is stopped and starts the +person's turn as it ends; a write its own tool has under way finishes first and stands as its +result, and with none it goes back to pending, its attempts unspent, and runs again when that +turn's end wakes the queue. One job (kind and target) gives way at most three times in a row; +the next run is let finish, with the person's turn right behind it. A turn a person started, or +one Chopin started for a Build or a comment, is never preempted. A harness that cannot take +input mid-run (the Copilot SDK) refuses a message while a turn runs with `planner-busy`: +"chopin can't take messages while it works on this machine. Stop it to ask now." (the server +log names the harness). A machine that is away, in its hand-over window before the client resumes ("Still connecting to @mona's machine, so this message wasn't sent. Send it again in a moment."), or with none attached, refuses it with the connection's own words (`planner-not-attached`), @@ -357,7 +365,7 @@ to be given to, and is answered as a passing trouble (`temporarily-unavailable`) turn ends before it can read the message, an idle Chat starts a turn for it and any other says plainly that Chopin did not read it. A comment note, an `invoke_planner as system` call and a task draft need a turn of their own, so a running turn refuses them before anything is -saved. Only Chopin's own scheduling still waits for the running turn, in `chat.waiting`: +saved. Only Chopin's own scheduling still waits for the running turn (or, for a job, a person's), in `chat.waiting`: background jobs and server-authored instructions nobody typed (initiative, an ended workflow run, readiness, decision follow-ups). `planner_deferred_turns` is no longer written or read; at start the writer-lease holder closes any row an earlier release left open diff --git a/apps/server/src/agent/job-scope.ts b/apps/server/src/agent/job-scope.ts index fccd8e3c9..69cf5e4e2 100644 --- a/apps/server/src/agent/job-scope.ts +++ b/apps/server/src/agent/job-scope.ts @@ -79,7 +79,25 @@ export async function runJobTool( if (tool !== JOB_TOOLS[job.kind]) { throw new Error("a background Planner job may run only its own tool"); } + // A job giving way to a person's turn writes nothing more; it runs again later. + if (chat.backgroundTurn?.preempted) { + throw new Error("a person's turn took the Planner; this job runs again later"); + } + // Watched by a preemption, which lets a write under way finish before the turn stops. + let writing = record(chat, job, produce); + chat.jobWriting = writing; + try { + return await writing; + } finally { + if (chat.jobWriting === writing) chat.jobWriting = undefined; + } +} +async function record( + chat: Chat, + job: Job, + produce: (job: Job) => Promise<{ output: unknown } & Record>, +): Promise { let turn = chat.turn; let id = job.id; let { output, ...reply } = await produce(job); diff --git a/apps/server/src/chat/job-fence.ts b/apps/server/src/chat/job-fence.ts index 67600f340..cc602cc49 100644 --- a/apps/server/src/chat/job-fence.ts +++ b/apps/server/src/chat/job-fence.ts @@ -6,8 +6,11 @@ export function watchJobAbort(chat: Chat, turn: JobTurn, signal: AbortSignal): ( let currentTurn = chat.turn; function abort() { if (chat.job !== turn.job || chat.turn !== currentTurn) return; + // A job that gave way to a person keeps what its own tool wrote, and was not interrupted. + if (turn.preempted) turn.output = chat.jobOutput; chat.job = undefined; chat.jobOutput = undefined; + if (turn.preempted) return; chat.interruption ??= chat.closed ? "The document closed." : signal.reason instanceof Error && signal.reason.name !== "AbortError" diff --git a/apps/server/src/chat/job-queue.test.ts b/apps/server/src/chat/job-queue.test.ts index 97f2e8c73..422cec74b 100644 --- a/apps/server/src/chat/job-queue.test.ts +++ b/apps/server/src/chat/job-queue.test.ts @@ -31,7 +31,7 @@ test.each([false, true])( }, ); -test("a job waits for a human turn, and a person's message while a job runs is refused, without leaking job scope", async () => { +test("a job waits for a human turn, and a person's message while a job runs preempts it, without leaking job scope", async () => { let first = harness(); await Chat.send(first.context, socket(), member("Human first")); await until(() => first.prompts.length === 1); @@ -47,29 +47,25 @@ test("a job waits for a human turn, and a person's message while a job runs is r let second = harness(); let ahead = Chat.job(second.context, JOB, "job first", "session"); await until(() => second.prompts.length === 1); - // A job's session is isolated: nobody talks to it, and nothing waits for it to end. - let refusals: Array> = []; + // A job's session is isolated: nobody talks to it, and a person's message takes its place. + let frames: Array> = []; let sender = { data: { handle: "ana", principalId: "U_ana" }, - send: (frame: string) => refusals.push(JSON.parse(frame)), + send: (frame: string) => frames.push(JSON.parse(frame)), } as unknown as Parameters[1]; await Chat.send(second.context, sender, member("Human second")); - expect(refusals).toEqual([ - expect.objectContaining({ - kind: "session:error", - message: "chopin can't take messages while it works on this machine. Stop it to ask now.", - code: "planner-busy", - }), - ]); + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send" })]); + expect(await ahead).toMatchObject({ status: "preempted" }); + await until(() => second.prompts.length === 2); + // The person's turn has no job scope: the job's tool is refused to it. + expect(second.chat.job).toBeUndefined(); + await expect( + runJobTool(second.chat, "refine_decision", async () => ({ output: { added: 0 } })), + ).rejects.toThrow(); expect(second.chat.waiting).toHaveLength(0); - expect(second.chat.entries).toEqual([]); - await runJobTool(second.chat, "refine_decision", async () => ({ output: { added: 0 } })); + expect(second.steered).toEqual([]); second.emit("session.idle"); - expect(await ahead).toEqual({ status: "done", output: '{"added":0}' }); await until(() => !second.chat.busy); - expect(second.chat.job).toBeUndefined(); - expect(second.prompts).toEqual(["job first"]); - expect(second.steered).toEqual([]); }); test("archiving cancels a job queued behind a human turn without dropping human work", async () => { diff --git a/apps/server/src/chat/job-queue.ts b/apps/server/src/chat/job-queue.ts index 370ffb260..b2b548d05 100644 --- a/apps/server/src/chat/job-queue.ts +++ b/apps/server/src/chat/job-queue.ts @@ -11,6 +11,10 @@ export type JobTurn = { prompt: string; done: (outcome: JobOutcome) => void; settled?: boolean; + /** A person's message cut its turn short; it runs again unless its own tool already wrote. */ + preempted?: true; + /** What its own tool recorded before its turn was cut short. */ + output?: string; }; export function jobReason(error: unknown): string { diff --git a/apps/server/src/chat/preempt.test.ts b/apps/server/src/chat/preempt.test.ts new file mode 100644 index 000000000..5f85036fc --- /dev/null +++ b/apps/server/src/chat/preempt.test.ts @@ -0,0 +1,213 @@ +/** + * A person's message to the Planner is never refused because a background job is running: it + * preempts the job's turn, which goes back to wait and runs again once the person's turn is over. + */ +import { expect, test } from "bun:test"; + +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, installJobCleanup, JOB, member, until } from "./job.test-fixtures"; + +import type { Socket } from "../wire"; + +installJobCleanup(); + +type Frame = { kind: string; [key: string]: unknown }; + +function speaking(handle: string, frames: Frame[] = []): Socket { + return { + data: { handle, principalId: `U_${handle}` }, + send: (raw: string) => frames.push(JSON.parse(raw) as Frame), + } as unknown as Socket; +} + +test("a message to the Planner while a background job runs starts the person's turn at once, and the job waits to run again", async () => { + let h = harness(); + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 1); + // A job's turn is quiet: Chat draws no turn for it and offers no Stop. + expect(h.chat.turn?.quiet).toBe(true); + + let frames: Frame[] = []; + let ask = member("@chopin what is the plan?"); + await Chat.send(h.context, speaking("bo", frames), ask); + + // Accepted, never refused, and the job's turn gives way to the person's. + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send", id: ask.requestId })]); + expect(await job).toEqual({ status: "preempted", reason: "A person asked the Planner." }); + await until(() => h.prompts.length === 2); + expect(h.prompts[1]).toContain("what is the plan?"); + expect(h.chat.job).toBeUndefined(); + expect(h.chat.turn?.handle).toBe("bo"); + // The person's turn is one people watch, and can talk to. + expect(h.chat.turn?.quiet).toBeUndefined(); + expect(h.chat.steering).toBeDefined(); + let shown = h.frames.filter(frame => frame.kind === "chat:state").at(-1); + expect(shown).toMatchObject({ busy: true, turn: { handle: "bo" } }); + expect(shown?.turn).not.toHaveProperty("quiet"); + h.emit("session.idle"); + await until(() => !h.chat.busy); +}); + +test("a job whose own tool already wrote when a person asks is done, not run again", async () => { + let h = harness(); + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 1); + await runJobTool(h.chat, "refine_decision", async () => ({ output: { added: 1 } })); + + await Chat.send(h.context, speaking("bo"), member("@chopin now?")); + expect(await job).toEqual({ status: "done", output: '{"added":1}' }); + await until(() => h.prompts.length === 2); + h.emit("session.idle"); + await until(() => !h.chat.busy); +}); + +test("a write the job's tool has under way finishes whole and is its result; no write starts after", async () => { + let h = harness(); + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 1); + let write = Promise.withResolvers(); + let writing = runJobTool(h.chat, "refine_decision", async () => { + await write.promise; + return { output: { added: 2 } }; + }); + + await Chat.send(h.context, speaking("bo"), member("@chopin now?")); + // The job's turn is still there, waiting for its write: nothing else may write meanwhile. + await Bun.sleep(5); + expect(h.prompts).toHaveLength(1); + expect(h.aborts()).toBe(0); + await expect( + runJobTool(h.chat, "refine_decision", async () => ({ output: { added: 3 } })), + ).rejects.toThrow("a person's turn took the Planner"); + + write.resolve(); + expect(await writing).toMatchObject({ ok: true, added: 2 }); + expect(await job).toEqual({ status: "done", output: '{"added":2}' }); + await until(() => h.prompts.length === 2); + expect(h.prompts[1]).toContain("now?"); + h.emit("session.idle"); + await until(() => !h.chat.busy); +}); + +test("a job that keeps meeting messages gives way three times, then finishes with the person's turn right behind it", async () => { + let h = harness(); + for (let round = 1; round <= 3; round++) { + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 2 * round - 1); + await Chat.send(h.context, speaking("bo"), member(`@chopin question ${round}`)); + expect(await job).toMatchObject({ status: "preempted" }); + await until(() => h.prompts.length === 2 * round); + h.emit("session.idle"); + await until(() => !h.chat.busy); + } + + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 7); + // Run again after giving way, the job's turn is as quiet as ever. + expect(h.chat.turn?.quiet).toBe(true); + let frames: Frame[] = []; + let ask = member("@chopin question 4"); + await Chat.send(h.context, speaking("bo", frames), ask); + // Still accepted at once; the job is let finish this time. + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send", id: ask.requestId })]); + await Bun.sleep(5); + expect(h.prompts).toHaveLength(7); + expect(h.chat.job).toBe(JOB); + await runJobTool(h.chat, "refine_decision", async () => ({ output: { added: 0 } })); + h.emit("session.idle"); + expect(await job).toEqual({ status: "done", output: '{"added":0}' }); + await until(() => h.prompts.length === 8); + expect(h.prompts[7]).toContain("question 4"); + h.emit("session.idle"); + await until(() => !h.chat.busy); + + // Finished, it starts over: the next run gives way again. + let next = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 9); + await Chat.send(h.context, speaking("bo"), member("@chopin question 5")); + expect(await next).toMatchObject({ status: "preempted" }); + await until(() => h.prompts.length === 10); + h.emit("session.idle"); + await until(() => !h.chat.busy); +}); + +test("a job being handed the Chat when a person asks waits behind their turn, then runs", async () => { + let h = harness(); + await Chat.send(h.context, speaking("ana"), member("@chopin first")); + await until(() => h.prompts.length === 1); + let job = Chat.job(h.context, JOB, "job prompt", "session"); + expect(h.chat.waiting).toHaveLength(1); + + // The save that hands the job the Chat is slow. + let persist = h.context.persist; + let handoff = Promise.withResolvers(); + let armed = true; + h.context.persist = async chat => { + if (armed && h.chat.starting) { + armed = false; + await handoff.promise; + } + await persist(chat); + }; + h.emit("session.idle"); + await until(() => !!h.chat.handoffJob); + + let frames: Frame[] = []; + let ask = member("@chopin second"); + await Chat.send(h.context, speaking("bo", frames), ask); + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send", id: ask.requestId })]); + handoff.resolve(); + await until(() => h.prompts.length === 2); + expect(h.prompts[1]).toContain("second"); + expect(h.chat.waiting.map(item => item.job?.job)).toEqual([JOB]); + + h.emit("session.idle"); + await until(() => h.prompts.length === 3); + expect(h.prompts[2]).toBe("job prompt"); + await runJobTool(h.chat, "refine_decision", async () => ({ output: { added: 0 } })); + h.emit("session.idle"); + expect(await job).toEqual({ status: "done", output: '{"added":0}' }); + await until(() => !h.chat.busy); +}); + +test("a harness that takes no message mid-run still starts the person's turn in place of a job", async () => { + let h = harness(); + h.context.steers = undefined; + h.context.config.harness = "copilot-sdk"; + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 1); + let frames: Frame[] = []; + let ask = member("@chopin now?"); + await Chat.send(h.context, speaking("bo", frames), ask); + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send", id: ask.requestId })]); + expect(await job).toMatchObject({ status: "preempted" }); + await until(() => h.prompts.length === 2); + h.emit("session.idle"); + await until(() => !h.chat.busy); +}); + +test("an MCP caller's instruction preempts a background job too, and is settled when its turn ends", async () => { + let h = harness(); + let job = Chat.job(h.context, JOB, "job prompt", "session"); + await until(() => h.prompts.length === 1); + let settled = false; + expect( + await Chat.invoke( + h.context, + { id: "U_bo", login: "bo" }, + "Plan this.", + undefined, + undefined, + () => { + settled = true; + }, + ), + ).toBeUndefined(); + expect(await job).toMatchObject({ status: "preempted" }); + await until(() => h.prompts.length === 2); + expect(h.prompts[1]).toContain("Plan this."); + expect(settled).toBe(false); + h.emit("session.idle"); + await until(() => settled); +}); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index c726abf09..bb62a6da6 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -351,6 +351,17 @@ export type Chat = { jobToolNames?: ReadonlySet; /** The job taken from the waiting turns while its handoff save is pending. */ handoffJob?: JobTurn; + /** The background job whose turn holds the Chat, until the next turn starts or the Chat is idle. */ + backgroundTurn?: JobTurn; + /** The running job's own tool write, which a preemption lets finish so it is recorded whole. */ + jobWriting?: Promise; + /** + * A person's turn that preempted a background job, started as soon as the job's turn has wound + * down, ahead of anything waiting. + */ + preempting?: Parameters; + /** How often each job (by kind and target) has given way since it last finished, so it ends. */ + preemptions?: Map; /** Reads reactions to the Planner's messages and starts the turns that answer them. */ reactions?: import("./reaction-reply").ReactionResponder; /** The id of the running turn when it is answering a reaction. */ @@ -948,10 +959,53 @@ function canSteer(context: Room): boolean { /** Says in the log why a message was refused while a turn runs, naming the harness that takes none. */ function refusedWhileRunning(context: Room): void { - let why = context.chat.job - ? "the running turn is a background job's" - : `the ${context.config.harness} harness takes no input while a turn runs`; - console.warn(`[chat] refused a message while a turn runs: ${why}`); + console.warn( + `[chat] refused a message while a turn runs: the ${context.config.harness} harness takes no input while a turn runs`, + ); +} + +/** + * How many runs in a row of one job (its kind and target) a person's message may cut short. The + * next run is let finish, with the person's turn starting the moment it ends, so a job that keeps + * meeting messages still completes. + */ +const MAX_PREEMPTIONS = 3; + +/** What a job that gave way to a person's turn reports: it goes back to wait, unchanged. */ +const PREEMPTED: JobOutcome = { status: "preempted", reason: "A person asked the Planner." }; + +/** + * Whether what holds the Chat is a background job, running or being handed the Chat, which a + * person's message to the Planner goes ahead of. A turn a person started, or one Chopin started + * for something a person did (a Build, a comment, a follow-up), is never one. + */ +function preemptible(chat: Chat): boolean { + if (chat.closed || chat.preempting || startsHeld) return false; + if (chat.starting) return !!chat.handoffJob; + return chat.busy && !!chat.backgroundTurn; +} + +/** + * Starts a person's turn ahead of the background job holding the Chat. The job's turn is stopped + * and starts this one as it ends; a job being handed the Chat waits behind it instead. A write the + * job's own tool has under way finishes first, so it is recorded whole and not made again. + */ +function preempt(context: Room, turn: Parameters): void { + let { chat } = context; + if (chat.closed) return; + // The job ended while the message was saved: nothing is in the way. + if (!chat.busy && !chat.starting) return startRun(...turn); + chat.preempting = turn; + let job = chat.backgroundTurn; + let controller = chat.turnController; + if (chat.starting || !job || job.preempted || !controller || controller.signal.aborted) return; + let key = `${job.job.kind}:${job.job.target}`; + let preemptions = chat.preemptions ??= new Map(); + let count = preemptions.get(key) ?? 0; + if (count >= MAX_PREEMPTIONS) return; + preemptions.set(key, count + 1); + job.preempted = true; + void Promise.resolve(chat.jobWriting).catch(() => {}).then(() => controller.abort()); } /** A message posted to the transcript for the Planner turn that is running to read. */ @@ -1102,7 +1156,9 @@ export async function invoke( }); return undefined; } - let running = occupied(chat); + // A background job gives way to a person's instruction, as to their message. + let preempts = preemptible(chat); + let running = !preempts && occupied(chat); if (running && !canSteer(context)) { refusedWhileRunning(context); return "planner-busy" as const; @@ -1136,7 +1192,7 @@ export async function invoke( }); if (settled) void given.finally(settled).catch(() => {}); } else { - startRun( + let asked: Parameters = [ { ...context, invokedBy: user.id }, user.login, text, @@ -1144,8 +1200,12 @@ export async function invoke( context.claimantSessionId, false, { entryId: entry.id, userId: user.id, ...(focus ? { focus } : {}) }, - ); - // Started here, so this promise is this turn's own (and the waiting turns it starts). + [], + ]; + if (preempts) preempt(context, asked); + else startRun(...asked); + // Started here, or by the job's turn it preempted as that ends, so this promise holds this + // turn (and the waiting turns it starts). let turn = chat.running; if (settled) { if (turn) void turn.finally(settled).catch(() => {}); @@ -1424,12 +1484,52 @@ async function processSend( if (chat.closed) return refuseClosed(ws, msg.rid); chat.initiative?.cancel(); + if (preemptible(chat)) { + // A background job gives way: the person's turn starts once the job's turn has wound down. + let bindFailure = await bindAttachments?.(); + if (bindFailure) return fail(ws, msg.rid, bindFailure.refusal, bindFailure.code); + let entry: MemberEntry = { + id: requestId, + author: { kind: "member", handle }, + text, + ts: now(), + ...(references?.length ? { references } : {}), + ...withAttachments, + delivery: savedDelivery, + }; + chat.entries.push(entry); + try { + await context.persist(); + } catch { + chat.entries = chat.entries.filter(value => value.id !== entry.id); + return fail(ws, msg.rid, "could not save message", PASSING); + } + reply(ws, msg.rid, { kind: "chat:send", ts: 0, id: entry.id }); + if (chat.closed) return; + announce(server, room, entry); + preempt(context, [ + context, + handle, + text, + undefined, + context.claimantSessionId, + false, + { entryId: entry.id, userId: ws.data.principalId, ...(focus ? { focus } : {}) }, + references ?? [], + ]); + context.memberMessagePersisted?.(entry, context.claimantSessionId); + return; + } if (chat.busy || chat.starting) { if (!canSteer(context)) { - // A turn that is only beginning or finishing has no session to give a message to just now: - // the browser sends it again in a moment, to a Planner that is then idle or working. A - // background job's session, or a harness that takes no input mid-run, never will. - if (!chat.job && !chat.closed && context.steers?.()) { + // A stopping process starts no turn in place of a job, and says nothing: the browser sends + // the message again to the next one. + if (startsHeld && chat.backgroundTurn) return refuseClosed(ws, msg.rid); + // A turn that is only beginning or finishing, or a person's turn about to start in place of + // a job, has no session to give a message to just now: the browser sends it again in a + // moment, to a Planner that is then idle or working. A harness that takes no input mid-run + // never will. + if (!chat.closed && context.steers?.()) { return fail(ws, msg.rid, `${PLANNER_NAME} is between replies`, PASSING); } refusedWhileRunning(context); @@ -2989,6 +3089,7 @@ async function run( let skipped = false; let reader = !!about?.reader; let isolated = reader || !!jobTurn; + chat.backgroundTurn = jobTurn; if (!reserved) { chat.busy = true; @@ -3222,7 +3323,8 @@ async function run( : { status: "done", output: chat.jobOutput }; } } catch (err) { - console.error("[chat] turn failed:", err); + // A job that gave way to a person's turn did not fail: it runs again later. + if (!jobTurn?.preempted) console.error("[chat] turn failed:", err); // A stop the room asked for is not a failure; an interruption is. failed = !turnController.signal.aborted || !!chat.interruption; // A reply the check refused ended no workflow run and answered no question. @@ -3360,6 +3462,14 @@ async function run( } else if (chat.interruption) { outcome = { status: "failed", reason: jobReason(chat.interruption) }; } + if (jobTurn.preempted && !chat.closed && !persistenceError) { + // What its own tool wrote before it gave way is its result; with none, it runs again. + let output = jobTurn.output ?? (outcome?.status === "done" ? outcome.output : undefined); + outcome = output === undefined ? PREEMPTED : { status: "done", output }; + } + if (outcome?.status !== "preempted") { + chat.preemptions?.delete(`${jobTurn.job.kind}:${jobTurn.job.target}`); + } } /* @@ -3445,6 +3555,15 @@ async function startNext( ended?: { job: boolean; outcome: JobOutcome | undefined }, ): Promise { let { chat, room, server } = context; + // A person's turn that preempted a job goes first. + let preempting = chat.preempting; + chat.preempting = undefined; + if (preempting && !startsHeld) { + chat.starting = false; + if (ended) await run(...preempting); + else startRun(...preempting); + return; + } let next = startsHeld ? undefined : pending(chat); if (next) { if (next.job) chat.handoffJob = next.job; @@ -3466,6 +3585,11 @@ async function startNext( // Settled while its handoff save was pending: it never runs, and the next one is tried. return startNext(context, ended); } + if (chat.preempting) { + // A person asked while it was handed the Chat: it waits behind their turn. + chat.waiting.unshift(next); + return startNext(context, ended); + } let turnContext = { ...context }; let turn = [ turnContext, @@ -3490,6 +3614,7 @@ async function startNext( // Called with nothing running, there is nothing to settle. if (!ended) return; chat.busy = false; + chat.backgroundTurn = undefined; chat.turn = undefined; chat.acting = undefined; chat.actingChannel = undefined; @@ -4153,6 +4278,7 @@ export async function close(chat: Chat): Promise { chat.handoffJob = undefined; } chat.waiting = []; + chat.preempting = undefined; chat.turnController?.abort(); chat.referenceCache.clear(); chat.bootstrapEntries = undefined; diff --git a/apps/server/src/chat/steering.test.ts b/apps/server/src/chat/steering.test.ts index 131ee5d0c..b28b5410f 100644 --- a/apps/server/src/chat/steering.test.ts +++ b/apps/server/src/chat/steering.test.ts @@ -199,19 +199,19 @@ test("a harness that takes no input mid-run refuses the message, naming how to a expect(h.prompts).toHaveLength(1); }); -test("a background job's turn takes no message: it is refused, and Stop is how to ask now", async () => { +test("a background job's turn takes no message: the person's own turn starts in its place", async () => { let h = harness(); let job = Chat.job(h.context, JOB, "job prompt", "session"); await until(() => h.prompts.length === 1); let frames: Frame[] = []; - await Chat.send(h.context, speaking("bo", frames), member("@chopin also this")); - expect(frames).toEqual([ - expect.objectContaining({ kind: "session:error", message: CANNOT, code: "planner-busy" }), - ]); - expect(h.chat.entries).toEqual([]); + let ask = member("@chopin also this"); + await Chat.send(h.context, speaking("bo", frames), ask); + expect(frames).toEqual([expect.objectContaining({ kind: "chat:send", id: ask.requestId })]); + expect(await job).toMatchObject({ status: "preempted" }); + await until(() => h.prompts.length === 2); + // Nothing was given to the job's session: the message began a turn of its own. expect(h.steered).toEqual([]); h.emit("session.idle"); - await job; await until(() => !h.chat.busy); }); diff --git a/apps/server/src/conversation-plan/jobs.ts b/apps/server/src/conversation-plan/jobs.ts index c1e4b83d0..2fcceda0a 100644 --- a/apps/server/src/conversation-plan/jobs.ts +++ b/apps/server/src/conversation-plan/jobs.ts @@ -5,9 +5,13 @@ import type { ConversationPlan } from "@chopin/protocol"; import type { JobIntent } from "./effects"; export type Job = ConversationPlan.Job; +/** + * How a run ended. `deferred` waits for the Planner to be available again; `preempted` gave way to + * a person's turn. Both leave the job pending, its attempts unspent. + */ export type JobOutcome = | { status: "done"; output: string } - | { status: "failed" | "skipped" | "deferred"; reason: string }; + | { status: "failed" | "skipped" | "deferred" | "preempted"; reason: string }; export const MAX_JOBS = 128; const MAX_TARGET = 200; @@ -113,13 +117,28 @@ export function settle(jobs: Job[], id: string, outcome: JobOutcome, at: string) || outcome.status === "deferred" && running.kind !== "decision-followup" || (outcome.status === "done" ? !jsonOutput(outcome.output) - : !["failed", "skipped", "deferred"].includes(outcome.status) + : !["failed", "skipped", "deferred", "preempted"].includes(outcome.status) || !bounded(outcome.reason, MAX_REASON)) ) invalid(); + // A newer request for the same thing already waits: it replaces the one that gave way. + let superseded = outcome.status === "preempted" && jobs.some(job => + job.status === "pending" && ( + running.kind === "heading" + ? job.kind === "heading" + : (running.kind === "refine" || running.kind === "suggest") + && (job.kind === "refine" || job.kind === "suggest") && job.target === running.target + ) + ); return jobs.map(job => { if (job.id !== id) return job; let { reason: _reason, output: _output, ...rest } = job; - if (outcome.status === "deferred") return { ...rest, status: "pending", at }; + if (outcome.status === "deferred" || outcome.status === "preempted" && !superseded) { + return { ...rest, status: "pending", at }; + } + if (outcome.status === "preempted") { + let reason = "A newer request replaced it."; + return { ...rest, status: "skipped", attempts: job.attempts + 1, reason, at }; + } return outcome.status === "done" ? { ...rest, status: "done", attempts: job.attempts + 1, output: outcome.output, at } : { ...rest, status: outcome.status, attempts: job.attempts + 1, reason: outcome.reason, at }; diff --git a/apps/server/src/conversation-plan/planner-job-support.ts b/apps/server/src/conversation-plan/planner-job-support.ts index 68967f45c..65ab3d35b 100644 --- a/apps/server/src/conversation-plan/planner-job-support.ts +++ b/apps/server/src/conversation-plan/planner-job-support.ts @@ -22,7 +22,10 @@ export function outcome(value: JobOutcome): JobOutcome { } return { status: "failed", reason: "Invalid Planner job output." }; } - if (value?.status === "failed" || value?.status === "skipped" || value?.status === "deferred") { + if ( + value?.status === "failed" || value?.status === "skipped" || value?.status === "deferred" + || value?.status === "preempted" + ) { return { status: value.status, reason: reason(value.reason, "Planner job failed.") }; } return { status: "failed", reason: "Invalid Planner job outcome." }; diff --git a/apps/server/src/conversation-plan/planner-jobs-preemption.test.ts b/apps/server/src/conversation-plan/planner-jobs-preemption.test.ts new file mode 100644 index 000000000..baad53db6 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-preemption.test.ts @@ -0,0 +1,47 @@ +/** A job that gave way to a person's turn waits, unspent, and runs again when it is woken. */ +import { expect, test } from "bun:test"; +import * as Jobs from "./jobs"; +import { AT, harness } from "./planner-jobs.test-fixtures"; + +test("a preempted job goes back to pending with its attempts unspent, then completes when woken", async () => { + let runs = 0; + let h = harness(async () => + ++runs === 1 + ? { status: "preempted", reason: "A person asked the Planner." } + : { status: "done", output: "{}" } + ); + await h.service.enqueue({ kind: "heading", target: "document", trigger: "m1" }); + await h.service.idle(); + expect(runs).toBe(1); + expect(h.service.jobs()).toMatchObject([{ status: "pending", attempts: 0 }]); + expect(h.service.jobs()[0]).not.toHaveProperty("reason"); + expect(h.published.at(-1)).toEqual(["heading:document:m1:pending"]); + + // The end of the person's turn wakes the queue. + h.service.wake(); + await h.service.idle(); + expect(runs).toBe(2); + expect(h.service.jobs()).toMatchObject([{ status: "done", attempts: 1 }]); +}); + +test("a preempted job a newer request already replaces is skipped, so one waits for each thing", () => { + let running = Jobs.start( + Jobs.enqueue([], { kind: "heading", target: "document", trigger: "m1" }, AT), + "heading:document:m1", + AT, + ); + let both = Jobs.enqueue(running, { kind: "heading", target: "document", trigger: "m2" }, AT); + let settled = Jobs.settle( + both, + "heading:document:m1", + { status: "preempted", reason: "A person asked the Planner." }, + AT, + ); + expect(settled.map(job => [job.id, job.status])).toEqual([ + ["heading:document:m1", "skipped"], + ["heading:document:m2", "pending"], + ]); + expect(settled[0]).toMatchObject({ reason: "A newer request replaced it.", attempts: 1 }); + // What is kept is still a valid queue. + expect(Jobs.restore(settled)).toEqual(settled); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs.ts b/apps/server/src/conversation-plan/planner-jobs.ts index 9644c7cd1..db4f5c37f 100644 --- a/apps/server/src/conversation-plan/planner-jobs.ts +++ b/apps/server/src/conversation-plan/planner-jobs.ts @@ -197,8 +197,9 @@ export function createPlannerJobs(deps: PlannerJobDeps) { ); if (!settled) continue; announce(settled.find(job => job.id === running.id)); - // A later owner or Chat capacity notification retries this pending generation. - if (finalResult.status === "deferred") return; + // A later owner or Chat capacity notification retries this pending generation; one that + // gave way to a person's turn runs again when that turn's end wakes the queue. + if (finalResult.status === "deferred" || finalResult.status === "preempted") return; if (!stopped && (finalResult.status === "done" || finalResult.status === "skipped")) { try { deps.onCapacityAvailable?.(); diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index d8335f51f..3eb35b61b 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -201,7 +201,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Menus and popovers:** Every menu follows shadcn's Nova menu. The panel has 4px padding, the large radius, and a hairline ring with the raised shadow. Items are 28px: 4px by 6px padding, a 6px icon gap, the medium radius and the small text role. Group labels use the extra-small text role in medium weight and tertiary ink with the item's inset. Dividers are 1px, run through the panel's padding to both edges and keep 4px above and below. A selected item is tinted with the selected surface, never marked with a check. A setting that turns on and off (the document's _Full width_) is a checkbox row with a small switch at its end, petrol when on and the control edge when off; the row is not tinted, and choosing it leaves the menu open so the change is seen behind it. Secondary detail sits right-aligned in tertiary ink on the same line. - **Composer menus and assistant-ui:** The `@` people and the `/` commands are assistant-ui's Composer menu, copied unmodified into `src/components` (`ComposerMenu`, its Mentions row `ComposerPersonItem` and its Slash commands row `ComposerCommandItem`): a 288px floating panel with 16px corners and 6px padding, rows of a round badge, a name and a tiny mono tag. A copy keeps its own classes, sizes and radii (one generated layer gives its roots Tailwind's scale, never Chopin's control, pill or menu tokens) and takes only Chopin's colours, through the shadcn colour variables in `src/assistant-ui.css`; [Adding an assistant-ui element](../../docs/assistant-ui.md) says how, and a copy is never restyled here. A recorded patch adds an extension point and changes no class or size: a person's own picture in the Mentions badge, with a tag that can be any word. Chopin's own state drives the menus through props (`mentions.ts`, `commands.ts`), not the element's `useMentionMatches` and `applyMention`, because the draft keeps its caret and its references. The `#` menu of the document's parts and the other documents is still Chopin's own, in the shared menu recipe, until its element is copied. `THIRD_PARTY_NOTICES.md` names the source, its revision and the MIT notice, and nothing of assistant-ui's runtime is used. - **Commands and mentions:** Typing `/` as the whole draft opens assistant-ui's Slash commands list over the composer: Chat's own command (_/research_) and the commands the document's agent reports (a skill, a prompt template, a workflow, any other kind the agent named), in the order reported, one row each: the icon of its kind, `/name`, a line of description that gives way to a narrow screen, and `↵` on the row Enter runs. It filters as you type, by the name or by what follows a skill's prefix, and says nothing when nothing is left. Enter or Tab chooses; an agent's command becomes one chip that opens the draft, and the words typed after it are what it is run with. `@` is for people, as in Slack: assistant-ui's Mentions list offers `chopin` first, tagged `agent`, with its mark in a circle, then the people who can read the document, tagged `human`, each with their GitHub picture in the badge, a rounded square as a person is everywhere else: those here first, then the others the server lists (the repository's collaborators who can read it, or, where GitHub will not list them, the document's creator and authors), then those who have spoken in Chat, filtered by the login typed, and nothing else. `#` and the Mention docs button (which puts a `#` at the caret) are for the document: its sections (tagged with their level), Decisions, research cards and its repository's files, each with an icon and a tag that says what it is, above the other documents, filtered by the words typed, which may go on past a space or a slash; once they do, the menu shows only while something matches, so a `#12` in a sentence holds no empty menu open. The document's parts arrive a moment after the other documents and a stale answer is never offered. No heading divides either list, since each row's tag says what it is. In all three menus Enter or Tab chooses the highlighted row and the draft keeps the focus; Shift+Tab, and Tab with no menu open, move the focus on. Choosing chopin or a person writes `@login`, drawn as a mention, and every `@login` that names chopin or someone who can read the document is drawn as one wherever it is, in the draft as it is typed and in the sent message alike (`message-mentions.ts`), while one that names no one stays words; choosing a part or a document is a chip, the same chip and the same reference whichever way it was reached. A chip is the one treatment for every reference the draft can hold except a document's link: the transcript's tint (`--color-brand-wash`, brand ink, the small radius, medium weight, no underline), whole in every edit (deleting at its edge or inside it takes all of it, a selection that reaches into one takes the rest, a caret placed inside one moves to its end before typing), and drawn the same, as a chip and never a link, once the message is sent. Both lists are the shared composer menu with its tinted active row, their region scrolls and is reachable by keyboard, and on a phone they meet both edges of the composer like the model picker. Neither says anything of the machine: a Planner that reports no commands simply offers Chat's own. -- **Composer box and send and stop:** The composer is a message box in the shape of Slack's: a rounded rectangle (the large radius, a 1px control boundary that darkens to tertiary ink on focus and turns destructive when a send fails, the page's surface) holding, top to bottom, a formatting toolbar, Chopin's own field and a row of controls. Every size is Chopin's control size (28px ghost buttons with Slack's 18px glyphs, a 44px target that grows only on touch) and every colour a token. The toolbar sits on the inset ground under a hairline: bold, italic and strikethrough, a link, a numbered and a bulleted list, code and a code block, each formatting the selection in place, as Slack's does, and shown pressed while the caret is in its format, none taking the draft's focus or selection; each names its key in its tooltip. The draft renders what it holds as it will read (bold, italic and struck words, links, lists, inline code on the inset ground and code blocks in a hairline box), never the marks: it is Lexical, the editor the documents use, loaded beside the page's code rather than in it. Slack's keys format too (⌘ or Ctrl with B, I, Shift+X, Shift+C, Shift+7, Shift+8 and Alt+Shift+C; ⌘K links chosen words and is search otherwise; ⌘U is refused, Markdown having no underline), and so do Slack's typed marks as they close (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, and `-`, `1.` or ``` at the start of a line). A link's address is asked in one row over the box (_Link_, Save, and Remove for a link already there), with `https://` put before a bare host. Enter sends; Shift+Enter breaks the line, makes the next item in a list (and on an empty item leaves it) and the next line in a code block (and on an empty last line leaves it). A rich paste keeps its formatting and a plain one is words. What is sent is the Markdown the formatting means (Chat messages are Markdown), the same Markdown a person typing its marks would send, and a draft read back from Markdown (a reload, a refused send) is drawn the same way; Markdown the draft would write differently (a quote, a table, `*` emphasis) stays as the words it was written in. It gives way on a phone and where the pane is narrow, and the Aa button in the row turns it off and on; the choice is kept for each person in their browser. The row has `+` (attach, where the server takes files), Mention docs, Aa, an @ that starts a mention at the caret, the sparkle button that asks Chopin, the microphone (where speech can be turned into words) and the model pill on the left, and Resume and the send control on the right. The send control is one primary rounded-square button with a paper plane, driven by Chopin's state (`send-stop.ts`): while the Planner is idle it is Send, disabled while the draft is empty; while the Planner runs with an empty draft it is Stop (a square) and stops it (a turn the server marks quiet is not running for this: a background job such as keeping the title, or a reply whose words are all out while its check runs, draws no working row and offers Stop only after a message was refused because of it), under the permission rules Stop has always had, so someone who may not stop it sees a disabled Send, never a Stop they cannot press; while it runs and the draft has words it is Send, which posts the message exactly as Enter does (a message sent meanwhile is posted at once, the composer says nothing of waiting, and one that mentions chopin steers the turn that is running), and it is Stop again once the draft is sent or cleared. The accessible name and tooltip always say what the icon shows (_Send message_, _Stop generating_), and one polite live region says once when it changes, never for the tokens of a reply. There is no chevron beside it, because Chopin has no send options to put there. The emoji button has no state to draw yet and is not drawn, and there is no context-window ring, in this composer or a thread's, even where a harness reports token usage; where no files can be taken or no speech recognized, neither `+` nor the microphone is. Resume is its own ghost button beside Send. +- **Composer box and send and stop:** The composer is a message box in the shape of Slack's: a rounded rectangle (the large radius, a 1px control boundary that darkens to tertiary ink on focus and turns destructive when a send fails, the page's surface) holding, top to bottom, a formatting toolbar, Chopin's own field and a row of controls. Every size is Chopin's control size (28px ghost buttons with Slack's 18px glyphs, a 44px target that grows only on touch) and every colour a token. The toolbar sits on the inset ground under a hairline: bold, italic and strikethrough, a link, a numbered and a bulleted list, code and a code block, each formatting the selection in place, as Slack's does, and shown pressed while the caret is in its format, none taking the draft's focus or selection; each names its key in its tooltip. The draft renders what it holds as it will read (bold, italic and struck words, links, lists, inline code on the inset ground and code blocks in a hairline box), never the marks: it is Lexical, the editor the documents use, loaded beside the page's code rather than in it. Slack's keys format too (⌘ or Ctrl with B, I, Shift+X, Shift+C, Shift+7, Shift+8 and Alt+Shift+C; ⌘K links chosen words and is search otherwise; ⌘U is refused, Markdown having no underline), and so do Slack's typed marks as they close (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, and `-`, `1.` or ``` at the start of a line). A link's address is asked in one row over the box (_Link_, Save, and Remove for a link already there), with `https://` put before a bare host. Enter sends; Shift+Enter breaks the line, makes the next item in a list (and on an empty item leaves it) and the next line in a code block (and on an empty last line leaves it). A rich paste keeps its formatting and a plain one is words. What is sent is the Markdown the formatting means (Chat messages are Markdown), the same Markdown a person typing its marks would send, and a draft read back from Markdown (a reload, a refused send) is drawn the same way; Markdown the draft would write differently (a quote, a table, `*` emphasis) stays as the words it was written in. It gives way on a phone and where the pane is narrow, and the Aa button in the row turns it off and on; the choice is kept for each person in their browser. The row has `+` (attach, where the server takes files), Mention docs, Aa, an @ that starts a mention at the caret, the sparkle button that asks Chopin, the microphone (where speech can be turned into words) and the model pill on the left, and Resume and the send control on the right. The send control is one primary rounded-square button with a paper plane, driven by Chopin's state (`send-stop.ts`): while the Planner is idle it is Send, disabled while the draft is empty; while the Planner runs with an empty draft it is Stop (a square) and stops it (a turn the server marks quiet is not running for this: a background job such as keeping the title, or a reply whose words are all out while its check runs, draws no working row and offers Stop only after a message was refused because of it; a message to chopin during a background job is never refused, since the job gives way and the person's own turn starts in its place), under the permission rules Stop has always had, so someone who may not stop it sees a disabled Send, never a Stop they cannot press; while it runs and the draft has words it is Send, which posts the message exactly as Enter does (a message sent meanwhile is posted at once, the composer says nothing of waiting, and one that mentions chopin steers the turn that is running), and it is Stop again once the draft is sent or cleared. The accessible name and tooltip always say what the icon shows (_Send message_, _Stop generating_), and one polite live region says once when it changes, never for the tokens of a reply. There is no chevron beside it, because Chopin has no send options to put there. The emoji button has no state to draw yet and is not drawn, and there is no context-window ring, in this composer or a thread's, even where a harness reports token usage; where no files can be taken or no speech recognized, neither `+` nor the microphone is. Resume is its own ghost button beside Send. - **Asking chopin:** Who a message goes to is in its words, as in Slack: a message that mentions `@chopin` (in any case, anywhere outside a reference) asks chopin, and one without goes to the team, except for a person alone in the document (below). The composer draws no other state for it: no chip, no mode, and no placeholder but the two below. The sparkle button in the row (_Ask chopin_, its tooltip naming the chord) and Command+J or Control+J (listed in the shortcuts sheet, from the one registry of chords) write `@chopin` and a space into the draft, the same mention the `@` menu writes and the words a person could type: at the caret when the field has it (the button keeps the field's focus and caret), otherwise at the start, set apart by a space on each side, with the caret after it. A draft that already asks chopin gets no second mention; the button only puts the caret after the first one (or at the end, for a command). The button is never spent and has no pressed state, since the mention in the words already says who the message is for. The mention is words: Backspace takes it out like any others, and nothing stays on after a message is sent. A draft kept from before, whose prepared send asked chopin without naming it, comes back with the mention it went with. The empty draft says _Message the team, or @chopin to ask_. A person alone in the document is talking to chopin, as in a direct message with a Slack app, and the server takes their every message as one to chopin, so their empty draft says _Message chopin_ instead, and says the team's words again the moment a second person opens the document (it is the server's count, the same one that routes the message). Where the document has no Planner there is no button, and the chord does nothing. - **Model and reasoning control:** In the composer's toolbar, left of the other controls, assistant-ui's own model pill (`ComposerModelTrigger`, a 32px ghost pill with a chevron and no icon, copied unmodified) names the model that will run and, in tertiary ink, the reasoning level it will run at: the ones chosen for the document, or the agent's own model and level where nobody chose (_Choose a model_ only where the agent names no model of its own). No word of it says _Default_. It opens assistant-ui's Model selector, also copied, over the composer: a search field, the models grouped by provider with a one-line description under each name as the agent reports it, a check at the one that will run, and, only for a model that has levels, the _Thinking_ row: the levels exactly as the harness reports them, `xhigh` as `xhigh` and `High` beside `high` as two (a level the agent calls `Default` is one of them), the one that will run checked, the reported string being what a turn carries; the interface-copy rule's sentence case applies to what Chopin writes, not to a value a harness reports. A pick keeps both halves for the document: choosing a model keeps the level that runs now where the model offers it (else the agent's own level where it does), and choosing a level keeps the model that runs now, so neither follows the agent's own default when it moves. The model under the pointer, or the keyboard's active model (in search results too), lies on one shade darker than the popover (`--color-selected`, as an active row of Chopin's menus), and every other row on the popover; a level under the pointer takes the same shade, and the chosen level the petrol wash inside a petrol edge, as the emoji picker's chosen skin tone, so a level only pointed at never reads as chosen. A level is a setting, so choosing one leaves the popover open, while choosing a model closes it and returns focus to the pill; Escape, Tab out or a pointer elsewhere closes it, and the keys are the element's own. The popover is never taller than the screen has room for beside the pill: the model list gives up its height and scrolls, so the search field and the _Thinking_ row stay on screen. Levels wider than the row are one lane that scrolls sideways under a wheel, a finger or the arrow keys, with a fade on the side that still hides levels, as a wide table's. The list and the lane each draw a quiet scrollbar whenever they overflow, without a pointer over them, so a person sees there is more: Chat's thumb (6px, round, the strongest scrollbar ink, 3:1 or more on the popover), sized and placed by how much is in view, darkening under the pointer and draggable by a mouse, and none where everything fits; the chosen level is in view whenever the popover opens or the choice changes, sliding there unless motion is reduced. The pill gives way to an ellipsis rather than push the other controls, on a phone as on a desktop, and the level stays in its accessible name and the open list. Nothing is drawn where the document's harness offers no choice or the person cannot write. Where a client withdrew its models, or an older one took its place, the picker is gone but a choice somebody saved has its words in the composer's notice until Chat records the fallback; while no client is attached, or one that offered its models has yet to report them, the notice says nothing of it until a turn has really fallen back, and a document nobody chose a model for never has one. A choice the harness no longer offers says what runs instead, in plain words with none of Chopin's own terms, in the trigger's name, in the popover and, before a turn records it in Chat, in the composer's notice. A turn that could not read the choice at all, the catalog or the stored choice, never runs the default silently: Chat says once, and every composer's notice says, _This document's model choice couldn't be read, so chopin used its default model._, until a turn can read it again. These words are the composer's own notice, not a banner: a cause of their own, drawn beneath the connection banner when the machine's banner is drawn (the machine went after the turn), and giving way, as the composer's other notices do, to the room's own connection banner, a refused send and a failed reply, and back with the connection. - **Files and dictation in the composer:** Images and text files and speech are the composer's own state, in hooks (`useFiles`, `useDictation`) that the surfaces take by props, so nothing that draws a chip or the microphone keeps state of its own. They fill the box's slots from assistant-ui's own elements, copied and recorded: the `+` at the start of the row (_Add files_, one of the 28px ghost buttons, with a hidden picker) attaches; the files wait above the text, under the toolbar, as assistant-ui's staged chips (`ComposerAttachments` and `ComposerAttachmentChip` of its Composer) drawn the way Slack's composer draws them, from outside (`chat/media/media.css`), 8px apart on the page's surface with a 1px hairline and 12px corners: an image is a 64px square of its picture, with Slack's ring of how far it has uploaded over it (the petrol arc on a hairline circle) and its name and percent kept for a screen reader; any other file is a card 64px tall, as tall as an image's square, of its type's 40px coloured icon (see _Chat files_), its name in bold and its size, or the percent with a 2px bar along its foot while it uploads; each has its remove button, a 20px ink circle, on its top-end corner, shown on hover or focus and always on a touch screen; a file that cannot go keeps the card's layout, whatever it is, so its reason has room. An image too large to send whole (over `CHAT_ATTACHMENT_MAX_BYTES`, or more than 40 million pixels) is made smaller in the browser before it uploads, at most 4096 pixels on its longest side, then narrower until it fits (a JPEG stays a JPEG and anything else becomes a WebP), so it is never refused for its size, and the viewer says so; the microphone is assistant-ui's own round voice button (`ComposerVoiceButton`, 32px, ink and a square while it listens) in the row after the sparkle, and while it listens the element's voice (`ComposerVoice`: the pulsing dot, the waveform, the time so far, _Transcribing_ while a recording is worked on) sits above the text where the files do; a sent message draws any image attachments through Chat's image elements (see _Chat images_) and its other files as _Chat files_. A paste into the field and a drop anywhere on the box add files too, the box's edge taking the accent and saying _Drop files to add them_ while a drag is over it; where the server takes none, there is no `+`, and the composer is as it was. Three things the chips have no place for are recorded extension points: the id of the file (two pasted screenshots are both _image.png_), the picture of an image in the box of the icon, and what sits beside the status, which is Chopin's own icon buttons to try an upload again and to remove a file that did not go. The chip is the one place a reason is said: a file the browser or the server refuses (a type, a size, a count or a total) is the chip in its error state, its reason in the danger text, with no way to try again and the send held back until it is removed, and a screen reader hears it once; a file the document's model cannot read is shown the same way while the message is for Chopin and is fine again when it is for the team, and `+` says so in its own name and description and offers text in its picker. Images go only to a model that reads them. Mention docs, which `+` used to be, is the document glyph beside it. A message sent before its files have uploaded says _Sending once your files have uploaded_ and goes by itself; if one fails it stays in the composer with its text; a message of only files is sent by the round button, which stops Chopin only when there is nothing to send. Dictation exists only where the browser recognizes speech on the device or the deployment set up transcription, and never takes the field's focus; what is being said is drawn in the tertiary ink beside the caret and is never part of the draft, and what is final is written at the caret as typing would. Where motion is reduced the dot and the shimmer hold still. Why it stopped is the composer's own strip, an alert with a Dismiss, and the polite live region says only that it started and stopped. Words about either use none of Chopin's own terms, and `composer.test.tsx` keeps it so. diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 278933a89..e79dd557e 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -1156,8 +1156,19 @@ Nothing a person sends to the Planner is held for later. A message is never "wai does: `chat:send` is checked before it reaches Chat, so a reader cannot steer, and two people can steer one turn, each message read once in the order it was sent. A message with references carries them, and one sent from a child carries the child as focus. +- **A background job is running** (keeping the title, writing up a decision): the message is + posted and acknowledged at once, and the job gives way. Its turn is stopped and the person's + turn starts as soon as it has ended, ahead of anything waiting. A write the job's own tool has + under way finishes first and is the job's result, so nothing is half written or written twice; + with none, the job goes back to pending with its attempts unspent and runs again when the + person's turn ends. A job about to be handed the Chat waits behind the person's turn instead. One + job (its kind and target) gives way at most three times in a row: the next run is let finish, + with the person's turn starting the moment it ends, so a job that keeps meeting messages still + completes. A newer request for the same thing that is already waiting replaces one that gave way. + An `invoke_planner` message does the same. A turn a person started, and one Chopin started for + something a person did (a Build, a comment), are never preempted. - **It cannot be taken:** it is refused, saves nothing and says so. With a harness that takes - no input mid-run (the Copilot SDK), or a running turn that is a background job's, the code is + no input mid-run (the Copilot SDK), the code is `planner-busy` and the words are "chopin can't take messages while it works on this machine. Stop it to ask now."; the server log names the harness. With no machine attached, or one that is away, the code is `planner-not-attached` with the connection's own words, mid-turn or not. For a @@ -1186,7 +1197,8 @@ note sent to Chopin in a child document is admitted by the root's Chat in the sa What still waits for the running turn, in memory, is Chopin's own scheduling, which nobody typed and which is neither saved nor shown (its turn is sent as `quiet`, which Chat draws as no turn at -all, as it does a reply whose words are all out while its after-answer check runs): background jobs (such as keeping a title) and the turns the +all, as it does a reply whose words are all out while its after-answer check runs): background jobs +(such as keeping a title), which also wait behind a person's turn they gave way to, and the turns the server writes for itself (answers to open decisions, relinking after a build, settling a prototyped passage, an ended workflow run, a readiness check, an initiative proposal). They start in the order they were asked once the running turn ends, a job that is no longer worth running is dropped diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index 184c8f1e0..5b2fd5e00 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -202,7 +202,8 @@ Refusals return an object with `code`, and `planner-not-attached` also a - `planner-unavailable`: the Planner is disabled or the room is closing. - `planner-busy`: a turn is running and cannot be given the instruction. A message-like call is given to the running turn when its harness takes input mid-run, and refused with this code - when it does not (the Copilot SDK) or the running turn is a background job's; an `as: "system"` + when it does not (the Copilot SDK); a background job's turn gives way to it instead, and its + turn starts once the job's has ended. An `as: "system"` call needs a turn of its own and is always refused while one runs. Nothing is held for later: call again once the turn has ended. - `system-turn-forbidden`: `as: "system"` came from someone other than the diff --git a/e2e/steering.e2e.ts b/e2e/steering.e2e.ts index 59ae5caed..c98a75a96 100644 --- a/e2e/steering.e2e.ts +++ b/e2e/steering.e2e.ts @@ -183,20 +183,38 @@ function speak(context: Parameters[0], id: string, text: * and `steered` what its running turns were given. Without `steers` it offers no way to take a * message mid-run. `release` lets a turn that was given nothing end; a Stop ends it as ever. */ -async function machine(room: string, options: { steers?: boolean } = { steers: true }) { +async function machine( + room: string, + options: { steers?: boolean; holdJob?: boolean } = { steers: true }, +) { let prompts: string[] = []; + /** When each prompt in `prompts` reached the machine. */ + let received: number[] = []; let steered: string[] = []; let aborted = 0; + let jobs = 0; + let jobsStopped = 0; + let jobRunning = Promise.withResolvers(); let arrived = Promise.withResolvers(); let released = Promise.withResolvers(); let handlers: AgentHandlers = { async turn(context) { // Chopin's own upkeep of the document runs through the same Planner; it is not a message. + // With `holdJob` the first one works until it is stopped, as a slow title would. if (context.message.prompt.startsWith("[Background job")) { context.emit({ type: "stream-start", modelId: "scripted" }); + if (options.holdJob && ++jobs === 1) { + jobRunning.resolve(); + await new Promise(resolve => + context.signal.addEventListener("abort", () => resolve(), { once: true }) + ); + jobsStopped++; + return; + } return finishTurn(context); } prompts.push(context.message.prompt); + received.push(Date.now()); context.emit({ type: "stream-start", modelId: "scripted" }); speak(context, "first", "Reading the plan."); let stopped = new Promise(resolve => @@ -230,8 +248,12 @@ async function machine(room: string, options: { steers?: boolean } = { steers: t return { client, prompts, + received, steered, aborted: () => aborted, + jobs: () => jobs, + jobsStopped: () => jobsStopped, + jobRunning: jobRunning.promise, release: () => released.resolve(), }; } @@ -331,6 +353,45 @@ test("Stop with an empty draft ends the running turn, and the draft being empty } }); +test("a message sent while Chopin writes the title starts its turn at once: the title waits and is written after", async ({ page }) => { + test.setTimeout(90_000); + let room = await open(page); + let planner = await machine(room, { steers: true, holdJob: true }); + let input = chatInput(page); + try { + // The first message's turn ends, and the title job it asked for begins and keeps working. + planner.release(); + await say(page, "outline the plan"); + await expect.poll(() => planner.prompts.length).toBe(1); + await planner.jobRunning; + // The job's turn is quiet: no working row and no Stop while it keeps the title. + await expect(chat(page).getByRole("button", { name: "Stop generating" })).toHaveCount(0); + await expect(chat(page).getByText("Getting oriented")).toHaveCount(0); + + let sent = Date.now(); + await say(page, "what about the rollback?"); + await expect.poll(() => planner.prompts.length).toBe(2); + let waited = planner.received[1]! - sent; + test.info().annotations.push({ type: "send-to-turn-ms", description: String(waited) }); + console.log(`send to the person's turn while a title job runs: ${waited} ms`); + expect(planner.prompts[1]).toContain("what about the rollback?"); + expect(planner.jobsStopped()).toBe(1); + await expect(chat(page).locator("[data-quiet-note]")).toHaveCount(0); + await expect(message(page, "what about the rollback?").locator("[data-chat-failed]")) + .toHaveCount(0); + await expectChatValue(input, ""); + + // Once the person's turn is over, the title job runs again, and this time finishes, quietly. + await expect.poll(planner.jobs).toBeGreaterThanOrEqual(2); + expect(planner.prompts).toHaveLength(2); + await expect(chat(page).getByRole("button", { name: "Stop generating" })).toHaveCount(0); + await expect(chat(page).getByText("Getting oriented")).toHaveCount(0); + await expect(chat(page).locator("[data-quiet-note]")).toHaveCount(0); + } finally { + await planner.client.detach(); + } +}); + test("a message to a machine that is away is refused with the draft kept, nothing is saved, and sending again when the machine is back answers once", async ({ page }) => { test.setTimeout(90_000); let room = await open(page);