diff --git a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md index f517550acb5..224c8123d06 100644 --- a/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md +++ b/apps/brunch-agent/docs/petrinaut/ARCHITECTURE.md @@ -31,7 +31,9 @@ The library points TypeScript at `src` but runs from `dist`. So that a dev serve The Ledger is the ordered sequence of accepted `ledger_commit` calls in the conversation's canonical history; it has no other storage. A commit submits one queue of `[route, payload]` entries: `entity/create`, `entity/update/`, `claim/create`, `obligation/create` and `reflection/create`. The host assigns record IDs in queue order (`e1`, `c1`, `o1`, `r1`, …) and stamps each commit with the user message it followed; entries reference existing records by ID and records in the same queue as `$index`. A claim supersedes earlier claims by reference and hides nothing. Obligations record checks the model owes, which a reflection citing the run or inspection discharges. -A running commit reads history, folds the accepted commits before its own call and decides from them alone, so an interrupted durable call re-runs to the same IDs. A commit counts only when its recorded output agrees with the IDs its input yields. Flue folds a response's steps into one message and may run a proposal's calls concurrently: a commit that runs while an earlier commit in the same response is unsettled is refused. Its receipt returns the IDs, how many user messages have passed, how many questions are open, the obligations still owed and notes on consequences to weigh. +The user comments on records from the Ledger tab. A batch of comments is a turn of its own: a Flue signal of type `brunch.ledger-comments` whose JSON body gives each comment's ID (`n1`, …), the record addresses it is on and the selected text. The fold holds the comments and anchors the commits after them to that turn, as it does a user message. Any entry resolves comments by naming their IDs in `resolves`; the fold marks each comment with the first record that resolved it. + +A running commit reads history, folds the accepted commits before its own call and decides from them alone, so an interrupted durable call re-runs to the same IDs. A commit counts only when its recorded output agrees with the IDs its input yields. Flue folds a response's steps into one message and may run a proposal's calls concurrently: a commit that runs while an earlier commit in the same response is unsettled is refused. Its receipt returns the IDs, how many user turns have passed, how many questions are open, the obligations still owed, the comments still open and notes on consequences to weigh. `ledger_compile` renders the folded Ledger's projection as Markdown, with record IDs and kind tokens inline; persona evidence (`ledger.md`, `ledger.json`) uses the same Markdown. The website's Ledger tab renders the projection as a document, without IDs, marking each record occurrence with its address so a canvas selection can highlight the records `traceLedger` reaches. diff --git a/apps/brunch-agent/evals/petrinaut/persona/README.md b/apps/brunch-agent/evals/petrinaut/persona/README.md index f952a5983f2..263ac2de7a3 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/README.md +++ b/apps/brunch-agent/evals/petrinaut/persona/README.md @@ -32,6 +32,7 @@ The agent runs in a sibling Herdr pane when the launcher runs inside Herdr (`HER Each run writes `/bin/persona`, a helper bound to that run's bridge socket: ```sh +persona open # sends the case's opening; prints it and Brunch's reply persona say "We lose about a day a week to expediting." # prints Brunch's reply persona say <<'EOF' A longer message, typed as one chat turn. @@ -42,15 +43,15 @@ persona end "Goal reached" # ends the conversation and closes the run persona rpc '{"type":"get_state"}' # one raw record in, JSONL records out ``` -`say` blocks until Brunch has completely finished, including browser-tool continuations; a turn can take several minutes. Interrupting the command (or a shell-tool timeout) stops Brunch's turn. Errors go to stderr with exit status 1, and the persona is told never to resend a failed message: it may already have been admitted. +The persona starts the conversation itself with `open`, which sends the case's opening exactly as written, so the opening exchange is in its own context and the time before its second message is its own. The bridge refuses `say` until `open` has been admitted, and refuses `open` after that and on resume. `open` and `say` block until Brunch has completely finished, including browser-tool continuations; a turn can take several minutes. Interrupting the command (or a shell-tool timeout) stops Brunch's turn. Errors go to stderr with exit status 1, and the persona is told never to resend a failed message: it may already have been admitted. -The helper speaks a Pi-style JSONL protocol to the bridge. Commands are `{ "id"?, "type", ... }` records: `prompt` (with `message`), `get_state`, `get_transcript` and `end` (with optional `reason`). Each command gets one `response` record with `success` and `data` or `error`. An accepted `prompt` is followed by `turn_start`, `assistant_message` and `turn_settled` events (`replied`, `failed` or `aborted`). Records are framed on LF only. New interaction types, such as structured answers to a question card, are added as new command and event types. +The helper speaks a Pi-style JSONL protocol to the bridge. Commands are `{ "id"?, "type", ... }` records: `open`, `prompt` (with `message`), `get_state`, `get_transcript` and `end` (with optional `reason`). Each command gets one `response` record with `success` and `data` or `error`. An accepted `open` or `prompt` is followed by `turn_start`, `assistant_message` and `turn_settled` events (`replied`, `failed` or `aborted`). Records are framed on LF only. New interaction types, such as structured answers to a question card, are added as new command and event types. ## Stopping The persona stops by its own rule: when the person has what they came for, when the conversation is going nowhere, or when they run out of patience. It then runs `persona end` and tells you why it stopped. You, or an agent supervising the run, can force a stop at any point, for example after a chosen number of turns, with `/bin/persona end`, or with Ctrl-C in the launcher when the agent runs in its own pane. -**Persona runs have no automatic accounting cutoff.** The launcher disables the campaign accounting wrapper for Brunch even if `BRUNCH_STEP_A_ACCOUNTING` was inherited. `--max-messages ` stops the run once Brunch has replied to the persona's nth message; the case's opening is not counted, and the run packet is written as on any other stop. A live-provider run requires explicit model and spend authorization; the existence of this command grants none. +**Persona runs have no automatic accounting cutoff.** The launcher disables the campaign accounting wrapper for Brunch even if `BRUNCH_STEP_A_ACCOUNTING` was inherited. `--max-messages ` stops the run once Brunch has replied to the persona's nth message; the case's opening is not counted, and the run packet is written as on any other stop. With `--hold`, the persona stopping, whether at that limit, by `persona end` or by its agent exiting, closes nothing: the bridge refuses any further persona messages and you carry on in the same Chrome window and conversation. Ctrl-C in the launcher (once a foreground agent has exited) or closing Chrome then stops the run as usual. A live-provider run requires explicit model and spend authorization; the existence of this command grants none. ## Guidance provenance @@ -74,9 +75,9 @@ Other files in the directory are not loaded. A case may keep a `key.md` beside t The [launcher](launch.ts): 1. Starts its own local Brunch/Petrinaut services with a fresh run-local SQLite database. Occupied ports refuse rather than reuse unverified services. -2. Opens a separate, headed Chrome window on the real Petrinaut AI panel. On an interactive terminal it prints the URL and profile and **waits for Enter before sending the public opening**, so you can start screen recording. `--skip-recording-pause` skips the wait; it is skipped automatically when stdin is not a terminal, for example when an agent runs the launcher. -3. Sends the opening and waits through browser-tool continuations for the completed reply. The default route is `/` with no preloaded net; `--initial-net` is optional staging, not a from-scratch run. `--interview-length quick|standard|thorough|deep` turns on the website's Interview length at that level, so every turn carries its question allowance; `run.json` records the level, and a resumed run keeps it because the browser profile keeps the setting. -4. Opens the browser bridge, writes `bin/persona` and the private `persona-brief.md` (role, command guide, any objective, the opening exchange and the situation pack), then starts the agent. +2. Opens a separate, headed Chrome window on the real Petrinaut AI panel. On an interactive terminal it prints the URL and profile and **waits for Enter before starting the agent**, so you can start screen recording. `--skip-recording-pause` skips the wait; it is skipped automatically when stdin is not a terminal, for example when an agent runs the launcher. The default route is `/` with no preloaded net; `--initial-net` is optional staging, not a from-scratch run. `--interview-length quick|standard|thorough|deep` turns on the website's Interview length at that level, so every turn carries its question allowance; `run.json` records the level, and a resumed run keeps it because the browser profile keeps the setting. +3. Opens the browser bridge, writes `bin/persona` and the private `persona-brief.md` (role, command guide, any objective, the opening message to send and the situation pack), then starts the agent. +4. The agent's `persona open` sends the opening, and the launcher waits through browser-tool continuations for the completed reply. 5. Types each `say` message into the ordinary composer, waits until every admitted continuation settles, and returns the reply. This uses selectors, not screenshot-based AI computer use. 6. Clicks **Allow** on each destructive-edit approval panel (any `remove*` net tool) during a turn, because the persona sees only reply prose and cannot answer it; an unanswered panel holds the turn indefinitely. Each allowed edit is recorded in `bridge-log.jsonl`. It clicks Allow rather than Always allow so every request is recorded. diff --git a/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.test.ts b/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.test.ts index d6afe58de43..762ec508950 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.test.ts @@ -20,6 +20,8 @@ afterEach(async () => { const openBridge = async (handlers: Partial) => { const bridge = await openPersonaBrowserBridge({ + // Most tests talk to an open conversation, as on resume. + opening: undefined, prompt: async (_message, turn) => { turn.admitted(); return { text: "Tell me more.", submissionIds: ["sub_1"] }; @@ -70,6 +72,82 @@ test("an admitted prompt yields its response, then the turn's events through set }); }); +test("open sends the case's opening once, and say waits for it", async () => { + const prompt = vi.fn( + async (_message, turn) => { + turn.admitted(); + return { text: "Tell me more.", submissionIds: ["sub_1"] }; + }, + ); + const bridge = await openBridge({ opening: "Hello, I run a desk.", prompt }); + expect( + await sendPersonaCommand(bridge.socketPath, { + type: "prompt", + message: "Hi.", + }), + ).toEqual([ + expect.objectContaining({ + success: false, + error: + "Start the conversation with `open`, which sends your opening message.", + }), + ]); + expect( + await sendPersonaCommand(bridge.socketPath, { id: "o", type: "open" }), + ).toEqual([ + { id: "o", type: "response", command: "open", success: true }, + { type: "turn_start", message: "Hello, I run a desk." }, + { type: "assistant_message", text: "Tell me more." }, + { type: "turn_settled", outcome: "replied", submissionIds: ["sub_1"] }, + ]); + expect(prompt).toHaveBeenLastCalledWith( + "Hello, I run a desk.", + expect.objectContaining({ opening: true }), + ); + expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual( + [ + expect.objectContaining({ + success: false, + error: "The conversation is already open; reply with `say`.", + }), + ], + ); + await sendPersonaCommand(bridge.socketPath, { + type: "prompt", + message: "Six agents.", + }); + expect(prompt).toHaveBeenLastCalledWith( + "Six agents.", + expect.objectContaining({ opening: false }), + ); +}); + +test("an opening refused before admission may be retried; once admitted it is never resent", async () => { + let attempts = 0; + const bridge = await openBridge({ + opening: "Hello.", + prompt: async (_message, turn) => { + attempts += 1; + if (attempts === 1) throw new Error("Composer has an unsent draft"); + turn.admitted(); + throw new Error("Brunch turn failed"); + }, + }); + expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual( + [expect.objectContaining({ success: false })], + ); + expect( + (await sendPersonaCommand(bridge.socketPath, { type: "open" })).at(-1), + ).toMatchObject({ type: "turn_settled", outcome: "failed" }); + expect(await sendPersonaCommand(bridge.socketPath, { type: "open" })).toEqual( + [ + expect.objectContaining({ + error: "The conversation is already open; reply with `say`.", + }), + ], + ); +}); + test("a failure before admission is a failed response; after admission it settles the turn", async () => { const early = await openBridge({ prompt: async () => { @@ -197,12 +275,15 @@ test("end is acknowledged before the launcher is told to stop", async () => { test("the generated helper reads like an ordinary CLI over the bridge", async () => { const run = await mkdtemp(join(tmpdir(), "TEST-persona-helper-")); cleanup.push(() => rm(run, { recursive: true })); - const bridge = await openBridge({}); + const bridge = await openBridge({ opening: "Hello." }); const helper = await writePersonaHelper(run, bridge.socketPath); const persona = (...args: string[]) => promisify(execFile)(helper, args, { env: process.env }); - expect((await persona("say", "Hello.")).stdout).toBe("Tell me more.\n"); + expect((await persona("open")).stdout).toBe( + "You: Hello.\n\nBrunch: Tell me more.\n", + ); + expect((await persona("say", "Go on.")).stdout).toBe("Tell me more.\n"); expect((await persona("transcript")).stdout).toBe( "You: Hello.\n\nBrunch: Tell me more.\n", ); diff --git a/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.ts b/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.ts index b28c93aa86a..7a8f1853ab2 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/browser-bridge.ts @@ -26,10 +26,19 @@ export type PersonaBrowserReply = { }; export type PersonaBridgeHandlers = { + /** + * The case's opening, which `open` sends as the persona's first message; + * undefined when the conversation already exists, as on resume. + */ + opening: string | undefined; /** Submit through the real composer; call `admitted` once Brunch accepts it. */ prompt( message: string, - turn: { readonly signal: AbortSignal; readonly admitted: () => void }, + turn: { + readonly signal: AbortSignal; + readonly admitted: () => void; + readonly opening: boolean; + }, ): Promise; getState(): Record; getTranscript(): Promise; @@ -52,6 +61,8 @@ export const openPersonaBrowserBridge = async ( const tasks = new Set>(); let active: AbortController | undefined; let completedTurns = 0; + // Set once the opening is admitted: an admitted message is never resent. + let opened = handlers.opening === undefined; const send = (socket: Socket, record: PersonaRecord) => { if (!socket.destroyed) socket.write(encodePersonaRecord(record)); @@ -79,25 +90,48 @@ export const openPersonaBrowserBridge = async ( const prompt = async ( socket: Socket, - command: Extract, + command: Extract, ) => { if (active) { send(socket, fail(command, "A persona turn is already active")); return; } + const opening = command.type === "open"; + // `open` only before the conversation exists, `prompt` only after. + const message = opening + ? opened + ? undefined + : handlers.opening + : opened + ? command.message + : undefined; + if (message === undefined) { + send( + socket, + fail( + command, + opening + ? "The conversation is already open; reply with `say`." + : "Start the conversation with `open`, which sends your opening message.", + ), + ); + return; + } const controller = new AbortController(); active = controller; const abortOnClose = () => controller.abort(); socket.once("close", abortOnClose); const turn = { admitted: false }; try { - const reply = await handlers.prompt(command.message, { + const reply = await handlers.prompt(message, { signal: controller.signal, + opening, admitted: () => { if (turn.admitted) return; turn.admitted = true; + opened = true; send(socket, succeed(command)); - broadcast({ type: "turn_start", message: command.message }); + broadcast({ type: "turn_start", message }); }, }); // The sender waits for the admission response before reading events. @@ -128,6 +162,7 @@ export const openPersonaBrowserBridge = async ( const dispatch = async (socket: Socket, command: PersonaCommand) => { switch (command.type) { + case "open": case "prompt": return prompt(socket, command); case "get_state": @@ -243,7 +278,11 @@ export const sendPersonaCommand = ( if (record.type === "response") { if (record.id !== id) return; records.push(record); - if (!record.success || command.type !== "prompt") finish(); + if ( + !record.success || + (command.type !== "prompt" && command.type !== "open") + ) + finish(); else accepted = true; return; } diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts b/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts index aa1388845a0..805a2154c9e 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch.test.ts @@ -148,6 +148,8 @@ test.each([false, true])( await appendAdmittedUtterance(run, { source: "opening", message: "Hello.", + submissionId: "sub_TEST_opening", + personaAgent: { agent: "pi" }, }); await appendAdmittedUtterance(run, { source: "persona", diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch.ts b/apps/brunch-agent/evals/petrinaut/persona/launch.ts index d0767b7f3ab..eff3c7ca2c0 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch.ts @@ -36,6 +36,7 @@ import { openPersonaBrowserBridge } from "./browser-bridge.ts"; import { PersonaBrowserTurnError, submitPersonaBrowserTurn, + type PersonaBrowserSession, } from "./browser-turn.ts"; import { guidanceManifest, verifyGuidanceResume } from "./guidance-manifest.ts"; import { @@ -55,7 +56,7 @@ import { appendAllowedDestructiveEdit, } from "./launch/bridge-log.ts"; import { writePersonaBrief, writePersonaResumeBrief } from "./launch/brief.ts"; -import { openPersonaConversation } from "./launch/browser.ts"; +import { showPersonaPanel } from "./launch/browser.ts"; import { interviewLengthStorage } from "./launch/interview-length.ts"; import { openRetainedPersonaBrowser, @@ -288,6 +289,8 @@ type PersonaLaunchOptions = { readonly recordingPause?: boolean; /** Stop the run once Brunch has replied to this many persona messages. */ readonly maxMessages?: number; + /** When the persona stops, keep Chrome and services open for the operator. */ + readonly hold?: boolean; }; /** @@ -305,6 +308,7 @@ const launchPersona = async ({ agent, recordingPause: pauseForRecording = true, maxMessages, + hold = false, }: PersonaLaunchOptions) => { const manifest = resume ? await verifyGuidanceResume(resume.config.guidance) @@ -436,8 +440,9 @@ const launchPersona = async ({ let bridge: Awaited> | undefined; let documentId: string | undefined; let agentProcess: PersonaAgentProcess | undefined; - // Set from bridge and child-process callbacks. - const lifecycle = { endedByPersona: false, agentExited: false }; + // Set from bridge and child-process callbacks. Once `held`, the operator owns + // the browser: the persona can send nothing, and its exit ends nothing. + const lifecycle = { endedByPersona: false, agentExited: false, held: false }; let activeAdmission: | { client: FlueClient; receipt: AgentSendResult } | undefined; @@ -445,9 +450,22 @@ const launchPersona = async ({ if (stop.signal.aborted) return; stop.abort(); }; + /** Where the persona stops: holds the run for the operator, or ends it. */ + const personaStopped = () => { + if (!hold) { + interrupt(); + return; + } + if (lifecycle.held || stop.signal.aborted) return; + lifecycle.held = true; + report( + "Holding Chrome and services for you; the persona can send nothing more. Ctrl-C here, or closing Chrome, ends the run.", + ); + }; // A foreground agent shares this terminal; its own Ctrl-C must not end the run. const onInterruptKey = () => { - if (agentProcess?.kind !== "foreground") interrupt(); + if (agentProcess?.kind !== "foreground" || lifecycle.agentExited) + interrupt(); }; process.on("SIGINT", onInterruptKey); process.on("SIGTERM", interrupt); @@ -624,56 +642,58 @@ const launchPersona = async ({ report( resume ? "Reopening the original browser document…" - : "Opening a fresh browser conversation…", + : "Opening a fresh browser panel; the persona sends the opening…", ); - const opened = resume - ? { kind: "resumed" as const, ...(await reopen(resume)) } - : { - kind: "fresh" as const, - ...(await openPersonaConversation(personaPage, panelOrigin, opening, { - route, - sessionPath: join(run, "session.json"), - signal: stop.signal, - beforeOpening: recordingPause, - onAllowedEdit: (edit) => appendAllowedDestructiveEdit(run, edit), - })), - }; - if (opened.kind === "fresh") - await appendAdmittedUtterance(run, { - source: "opening", - message: opening, + const resumed = resume ? await reopen(resume) : undefined; + if (!resume) { + await showPersonaPanel(personaPage, panelOrigin, route); + await recordingPause(); + } + const sessionClient = (identity: PersonaBrowserSession) => + createFlueClient({ + url: identity.url, + headers: agentOwnershipHeaders(identity), }); - documentId = documentIdFromInitialData(opened.session.initialData); - await writeProofArtifacts(join(run, "evidence"), opened.snapshot); - const flue = createFlueClient({ - url: opened.session.url, - headers: agentOwnershipHeaders(opened.session), - }); + // Retained on resume; on a fresh run, admitted by the persona's opening. + let session = resumed?.session; + if (resumed) { + documentId = documentIdFromInitialData(resumed.session.initialData); + await writeProofArtifacts(join(run, "evidence"), resumed.snapshot); + } let repliedMessages = 0; bridge = await openPersonaBrowserBridge({ + opening: resume ? undefined : opening, prompt: async (message, turn) => { + if (lifecycle.held) + throw new Error( + "The operator has taken over this conversation. Send nothing more; run `persona end` and report.", + ); await verifyGuidanceResume(manifest); let logged = false; try { const result = await submitPersonaBrowserTurn(personaPage, message, { - session: opened.session, + session, signal: AbortSignal.any([turn.signal, stop.signal]), onAllowedEdit: (edit) => appendAllowedDestructiveEdit(run, edit), - onAdmission: async (session, receipt) => { - activeAdmission = { - client: createFlueClient({ - url: session.url, - headers: agentOwnershipHeaders(session), - }), - receipt, - }; + onAdmission: async (admitted, receipt) => { + activeAdmission = { client: sessionClient(admitted), receipt }; // Later admissions in the same turn are browser-tool continuations. if (logged) return; logged = true; // Brunch has the utterance; a failed log write must not read as unadmitted. turn.admitted(); + if (turn.opening) { + session = admitted; + documentId = documentIdFromInitialData(admitted.initialData); + // Retained even if the reply fails; resume needs the identity. + await writeFile( + join(run, "session.json"), + `${JSON.stringify(admitted, null, 2)}\n`, + { mode: 0o600 }, + ); + } await appendAdmittedUtterance(run, { - source: "persona", + source: turn.opening ? "opening" : "persona", message, submissionId: receipt.submissionId, personaAgent: agentSettings, @@ -687,12 +707,12 @@ const launchPersona = async ({ documentId, join(run, "evidence"), ); - repliedMessages += 1; + if (!turn.opening) repliedMessages += 1; if (maxMessages !== undefined && repliedMessages >= maxMessages) // After the bridge has delivered this reply. setImmediate(() => { report(`Message limit reached: ${maxMessages} persona messages.`); - interrupt(); + personaStopped(); }); return { text: result.reply.text, @@ -708,33 +728,28 @@ const launchPersona = async ({ }, getState: () => ({ run, - conversationId: opened.session.conversationId, + conversationId: session?.conversationId, url: personaPage.url(), }), - getTranscript: async () => personaTranscriptFrom(await flue.history()), + getTranscript: async () => + session === undefined + ? [] + : personaTranscriptFrom(await sessionClient(session).history()), end: (reason) => { lifecycle.endedByPersona = true; report(`Persona ended the conversation${reason ? `: ${reason}` : "."}`); - interrupt(); + personaStopped(); }, }); const helper = await writePersonaHelper(run, bridge.socketPath); - const brief = - opened.kind === "resumed" - ? await writePersonaResumeBrief({ - run, - helper, - settlement: opened.settlement, - reply: opened.reply, - }) - : await writePersonaBrief({ - run, - helper, - objective, - opening, - reply: opened.reply.text, - pack, - }); + const brief = resumed + ? await writePersonaResumeBrief({ + run, + helper, + settlement: resumed.settlement, + reply: resumed.reply, + }) + : await writePersonaBrief({ run, helper, objective, opening, pack }); await save(join(run, "run.json"), { ...record, socketPath: bridge.socketPath, @@ -770,7 +785,7 @@ const launchPersona = async ({ } else { const onAgentExit = () => { lifecycle.agentExited = true; - interrupt(); + personaStopped(); }; void agentProcess.exited.then(onAgentExit, onAgentExit); } @@ -871,13 +886,14 @@ if ( "persona-thinking": { type: "string" }, "skip-recording-pause": { type: "boolean" }, "max-messages": { type: "string" }, + hold: { type: "boolean" }, resume: { type: "string" }, help: { type: "boolean", short: "h" }, }, }); if (values.help) { report( - `Usage: yarn brunch:persona --case [--objective ] [--route ] [--initial-net ] [--interview-length quick|standard|thorough|deep] [--brunch-model ] [--brunch-thinking ] [agent options]\nDiscover cases: yarn brunch:persona --list-cases\nDefault: empty net on /; optional --initial-net stages a model and is not a from-scratch run. --interview-length turns on the website's Interview length at that level for the whole run; without it the run has none. --objective is a private aim for the person, phrased in their own terms; without it the person's goal comes from the case. --objective is fresh-run-only and is neither retained nor reapplied on resume. Starts owned services, a fresh headed Chrome window and a browser bridge; on an interactive terminal, pauses for Enter before sending anything (--skip-recording-pause skips it). --max-messages stops the run once Brunch has replied to the persona's nth message (the case's opening is not counted). Defaults: Brunch ${DEFAULT_CHAT_MODEL} ${DEFAULT_CHAT_THINKING}. Native usage is retained; ordinary external-agent runs have no automatic budget cutoff. Requires macOS Chrome, unused ${brunchEnv.chatPort}/${brunchEnv.panelPort}, and Brunch's provider API key. The persona stops by its own rule; Ctrl-C here or /bin/persona end stops the run at any point, stopping owned resources and retaining run data.`, + `Usage: yarn brunch:persona --case [--objective ] [--route ] [--initial-net ] [--interview-length quick|standard|thorough|deep] [--brunch-model ] [--brunch-thinking ] [agent options]\nDiscover cases: yarn brunch:persona --list-cases\nDefault: empty net on /; optional --initial-net stages a model and is not a from-scratch run. --interview-length turns on the website's Interview length at that level for the whole run; without it the run has none. --objective is a private aim for the person, phrased in their own terms; without it the person's goal comes from the case. --objective is fresh-run-only and is neither retained nor reapplied on resume. Starts owned services, a fresh headed Chrome window and a browser bridge; on an interactive terminal, pauses for Enter before sending anything (--skip-recording-pause skips it). --max-messages stops the run once Brunch has replied to the persona's nth message (the case's opening is not counted). --hold keeps Chrome and services open for you when the persona stops, at that limit, on persona end or when its agent exits, until Ctrl-C or closing Chrome. Defaults: Brunch ${DEFAULT_CHAT_MODEL} ${DEFAULT_CHAT_THINKING}. Native usage is retained; ordinary external-agent runs have no automatic budget cutoff. Requires macOS Chrome, unused ${brunchEnv.chatPort}/${brunchEnv.panelPort}, and Brunch's provider API key. The persona stops by its own rule; Ctrl-C here or /bin/persona end stops the run at any point, stopping owned resources and retaining run data.`, ); report( `Agent options:\n --agent ${personaAgentPresets.join("|")} Start that agent with the persona brief.\n --agent-command '' Start any other agent; {prompt} becomes the quoted launch prompt.\n --persona-model Passed to the --agent preset's own model flag.\n --persona-thinking Passed to pi's --thinking (only with --agent pi).\nThe agent runs in a Herdr pane when HERDR_ENV=1, otherwise in this terminal. Without an agent option the launcher prints the launch prompt for you to give any agent. The agent talks to Brunch through /bin/persona (say, transcript, state, end, rpc).`, @@ -942,6 +958,7 @@ if ( agent: selectedAgent, recordingPause, maxMessages, + hold: values.hold, }); }; const freshRun = async (caseDirectory: string) => @@ -963,6 +980,7 @@ if ( agent: agent() ?? {}, recordingPause, maxMessages, + hold: values.hold, }); const task = invalidMaxMessages ? Promise.reject( diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.test.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.test.ts index 5094d83383f..85b0479f151 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.test.ts @@ -19,7 +19,12 @@ afterEach(async () => { test("an allowed destructive edit is recorded without becoming the utterance resume reconciles against", async () => { const run = await mkdtemp(join(tmpdir(), "TEST-persona-bridge-log-")); runs.push(run); - await appendAdmittedUtterance(run, { source: "opening", message: "Hello." }); + await appendAdmittedUtterance(run, { + source: "opening", + message: "Hello.", + submissionId: "sub_TEST", + personaAgent: { agent: "pi" }, + }); await appendAllowedDestructiveEdit(run, { items: ["Remove place — Queue"], }); @@ -39,7 +44,12 @@ test("an allowed destructive edit is recorded without becoming the utterance res test("a torn log line names the file and line instead of guessing the last utterance", async () => { const run = await mkdtemp(join(tmpdir(), "TEST-persona-bridge-log-")); runs.push(run); - await appendAdmittedUtterance(run, { source: "opening", message: "Hello." }); + await appendAdmittedUtterance(run, { + source: "opening", + message: "Hello.", + submissionId: "sub_TEST", + personaAgent: { agent: "pi" }, + }); await expect(lastAdmittedUtterance(run)).resolves.toBe("Hello."); await appendFile(bridgeLogPath(run), '{"event":"admitted","sou'); diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.ts index e7d4dabbfa8..d4c79c37a3f 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/bridge-log.ts @@ -25,14 +25,12 @@ const appendBridgeRecord = (run: string, record: Record) => /** A persona utterance records the agent settings that wrote it, since a resume may start a different agent. */ export const appendAdmittedUtterance = ( run: string, - entry: - | { readonly source: "opening"; readonly message: string } - | { - readonly source: "persona"; - readonly message: string; - readonly submissionId: string; - readonly personaAgent: PersonaAgentSettings; - }, + entry: { + readonly source: "opening" | "persona"; + readonly message: string; + readonly submissionId: string; + readonly personaAgent: PersonaAgentSettings; + }, ) => appendBridgeRecord(run, { event: "admitted", ...entry }); /** The persona cannot see the approval panel, so the launcher allows each destructive edit and records what it listed. */ diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/brief.test.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/brief.test.ts index d9b37e28fc1..eb1b693ebb1 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/brief.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/brief.test.ts @@ -17,7 +17,7 @@ const temporaryRun = async () => { return run; }; -test("the fresh brief assembles the role, command guide, opening exchange and pack in order", async () => { +test("the fresh brief assembles the role, command guide, opening to send and pack in order", async () => { const run = await temporaryRun(); const [brief, system] = await Promise.all([ readFile( @@ -26,7 +26,6 @@ test("the fresh brief assembles the role, command guide, opening exchange and pa helper: "/tmp/run/bin/persona", objective: undefined, opening: "PUBLIC opening", - reply: "BRUNCH reply", pack: "PRIVATE pack", }), "utf8", @@ -39,14 +38,15 @@ test("the fresh brief assembles the role, command guide, opening exchange and pa "## How to talk to Brunch", '/tmp/run/bin/persona say ""', "/tmp/run/bin/persona end", - "## Conversation so far", + "## Starting the conversation", + "/tmp/run/bin/persona open", "PUBLIC opening", - "BRUNCH reply", "## Situation pack", "PRIVATE pack", ].map((part) => brief.indexOf(part)); expect(order.every((index) => index >= 0)).toBe(true); expect(order).toEqual([...order].sort((left, right) => left - right)); + expect(brief).toContain("Nothing has been sent yet."); }); test("an objective is placed after the command guide and before the conversation", async () => { @@ -57,7 +57,6 @@ test("an objective is placed after the command guide and before the conversation helper: "persona", objective: "TEST objective", opening: "Hi", - reply: "Hello", pack: "Pack", }), "utf8", @@ -65,7 +64,7 @@ test("an objective is placed after the command guide and before the conversation const order = [ "## How to talk to Brunch", "## What you're after today\n\nTEST objective", - "## Conversation so far", + "## Starting the conversation", ].map((part) => brief.indexOf(part)); expect(order.every((index) => index >= 0)).toBe(true); expect(order).toEqual([...order].sort((left, right) => left - right)); @@ -85,6 +84,7 @@ test("the resume notice points back to the original brief and forbids resending" expect(notice).toContain(join(run, "persona-brief.md")); expect(notice).toContain("/tmp/run/bin/persona transcript"); expect(notice).toContain("WAS received. Do not resend it"); + expect(notice).toContain("do not run `open`"); expect(notice).toContain("settled as: aborted"); expect(notice).toContain("No reply prose was retained."); }); diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/brief.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/brief.ts index 074ecd96069..9740a9fc5b6 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/brief.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/brief.ts @@ -31,7 +31,6 @@ export const writePersonaBrief = async (input: { readonly helper: string; readonly objective: string | undefined; readonly opening: string; - readonly reply: string; readonly pack: string; }) => { const system = await readFile(systemPath, "utf8"); @@ -46,12 +45,10 @@ export const writePersonaBrief = async (input: { ...(input.objective === undefined ? [] : ["## What you're after today", input.objective]), - "## Conversation so far", - "Your first message below has already been sent; do not repeat it. Reply to what Brunch said, then carry on one message at a time.", - "### Opening (sent as you)", + "## Starting the conversation", + `Nothing has been sent yet. Start with \`${input.helper} open\`: it sends your opening message, below, exactly as written, and prints it with Brunch's reply. Run it once. Then reply to what Brunch said with \`say\`, one message at a time.`, + "### Your opening message", input.opening, - "### Brunch's reply", - input.reply, "## Situation pack", input.pack, ].join("\n\n") + "\n", @@ -71,8 +68,8 @@ export const writePersonaResumeBrief = async (input: { path, [ "# Persona resume notice", - `This resumes an interrupted conversation. Read the original brief at ${join(input.run, "persona-brief.md")} for who you are and your situation pack; this notice supersedes its "Conversation so far" section and its command paths.`, - `Run \`${input.helper} transcript\` first to catch up on the whole conversation. The last message you sent WAS received. Do not resend it or repeat the opening.`, + `This resumes an interrupted conversation. Read the original brief at ${join(input.run, "persona-brief.md")} for who you are and your situation pack; this notice supersedes its "Starting the conversation" section and its command paths.`, + `Run \`${input.helper} transcript\` first to catch up on the whole conversation. The last message you sent WAS received. Do not resend it, and do not run \`open\`: the conversation is already open.`, `Brunch's last turn settled as: ${input.settlement}. Its work so far was kept.`, "If that turn was interrupted, ask Brunch in your own words to pick up where it left off. Otherwise reply to what it last said. Keep this notice private.", personaCommandGuide(input.helper), diff --git a/apps/brunch-agent/evals/petrinaut/persona/launch/browser.ts b/apps/brunch-agent/evals/petrinaut/persona/launch/browser.ts index c136afab52d..6a56f71990c 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/launch/browser.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/launch/browser.ts @@ -1,45 +1,16 @@ -import { writeFile } from "node:fs/promises"; - -import { - submitPersonaBrowserTurn, - type PersonaAllowedEdit, -} from "../browser-turn.ts"; - import type { Page } from "@playwright/test"; -/** Create through the normal UI and capture only the native attachment fields. */ -export const openPersonaConversation = async ( +/** Load the app through the normal UI and show the AI panel; nothing is sent. */ +export const showPersonaPanel = async ( page: Page, origin: string, - opening: string, - options: { - route?: string; - sessionPath?: string; - signal?: AbortSignal; - beforeOpening?: () => Promise; - onAllowedEdit?: (edit: PersonaAllowedEdit) => Promise; - } = {}, + route = "/", ) => { - await page.goto(new URL(options.route ?? "/", origin).href); + await page.goto(new URL(route, origin).href); const skipTour = page.getByRole("button", { name: "Skip tour" }); await skipTour.waitFor(); await skipTour.click(); await page .getByRole("button", { name: "Show AI assistant", exact: true }) .click(); - await options.beforeOpening?.(); - options.signal?.throwIfAborted(); - return submitPersonaBrowserTurn(page, opening, { - signal: options.signal, - onAllowedEdit: options.onAllowedEdit, - onAdmission: async (session) => { - // Retain the admitted identity even if its response fails; never resend it. - if (options.sessionPath) - await writeFile( - options.sessionPath, - `${JSON.stringify(session, null, 2)}\n`, - { mode: 0o600 }, - ); - }, - }); }; diff --git a/apps/brunch-agent/evals/petrinaut/persona/persona-cli.test.ts b/apps/brunch-agent/evals/petrinaut/persona/persona-cli.test.ts index c1cf27690cb..5000dc34af0 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/persona-cli.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/persona-cli.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "vitest"; import { + formatPersonaResult, personaCommandFromArguments, splitPersonaArguments, } from "./persona-cli.ts"; @@ -21,6 +22,19 @@ test("a message that begins with a dash is text, not an option", async () => { }); }); +test("open takes no text and prints the opening it sent with Brunch's reply", async () => { + const command = await personaCommandFromArguments(["open"]); + expect(command).toEqual({ type: "open" }); + expect( + formatPersonaResult(command, [ + { type: "response", command: "open", success: true }, + { type: "turn_start", message: "Hello." }, + { type: "assistant_message", text: "Tell me more." }, + { type: "turn_settled", outcome: "replied", submissionIds: ["sub_1"] }, + ]), + ).toBe("You: Hello.\n\nBrunch: Tell me more."); +}); + test("the CLI's own flags stay options on either side of the command", () => { expect( splitPersonaArguments(["--socket", "/tmp/bridge", "say", "hi", "--json"]), diff --git a/apps/brunch-agent/evals/petrinaut/persona/persona-cli.ts b/apps/brunch-agent/evals/petrinaut/persona/persona-cli.ts index b4c9a55d92f..09bb1fdd3ef 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/persona-cli.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/persona-cli.ts @@ -19,6 +19,8 @@ export const personaSocketVariable = "BRUNCH_PERSONA_SOCKET"; export const personaCliUsage = `Usage: persona [arguments] [--json] + open Send the case's opening message and print it with Brunch's + reply. Runs once, before any say. say Send one message through the browser and print Brunch's reply. Reads the message from stdin when no text is given. state Print the conversation state as JSON. @@ -77,6 +79,8 @@ export const personaCommandFromArguments = async ( const [name, ...rest] = positionals; const text = rest.length > 0 ? rest.join(" ") : undefined; switch (name) { + case "open": + return { type: "open" }; case "say": // Heredocs end in a newline the person never typed. return { type: "prompt", message: text ?? (await stdin()).trimEnd() }; @@ -118,17 +122,22 @@ export const formatPersonaResult = ( if (!response) throw new Error("The persona bridge sent no response"); if (!response.success) throw new Error(response.error); switch (command.type) { + case "open": case "prompt": { const settled = records.find((record) => record.type === "turn_settled"); if (settled?.outcome !== "replied") throw new Error( `Brunch turn ${settled?.outcome ?? "did not settle"}${settled?.error ? `: ${settled.error}` : ""}`, ); - return records + const reply = records .flatMap((record) => record.type === "assistant_message" ? [record.text] : [], ) .join("\n\n"); + if (command.type === "prompt") return reply; + // The opening is the persona's own first message; show it as sent. + const sent = records.find((record) => record.type === "turn_start"); + return `You: ${sent?.type === "turn_start" ? sent.message : ""}\n\nBrunch: ${reply}`; } case "get_state": return JSON.stringify(response.data, null, 2); diff --git a/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts b/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts index f1ee27fa370..9b3e6df2ff6 100644 --- a/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/rpc-protocol.ts @@ -13,6 +13,7 @@ const nonblank = v.pipe( ); export const personaCommandSchema = v.variant("type", [ + v.object({ id: commandId, type: v.literal("open") }), v.object({ id: commandId, type: v.literal("prompt"), message: nonblank }), v.object({ id: commandId, type: v.literal("get_state") }), v.object({ id: commandId, type: v.literal("get_transcript") }), diff --git a/apps/brunch-agent/src/agents/petrinaut/prompts/system.md b/apps/brunch-agent/src/agents/petrinaut/prompts/system.md index 2796f23b3a6..377dca28958 100644 --- a/apps/brunch-agent/src/agents/petrinaut/prompts/system.md +++ b/apps/brunch-agent/src/agents/petrinaut/prompts/system.md @@ -78,6 +78,8 @@ Each recorded commit also returns how many exchanges have passed and how many qu Obligations are the checks the model owes before a result can be trusted, stated as the run or inspection that would meet them: one for each case the purpose rests on, recorded with the case (the model reproduces it), and one for each stand-in or approximation a reported result rests on (the result survives varying it). Only a reflection discharges one, by citing the run, calculation or inspection that met it with its observed result, the _user_'s explicit waiver, or the model change that left no result resting on it. Each recorded commit returns the obligations still owed. While one stands, a result that rests on it is reported as resting on it, not as an answer; running the check usually beats more precision elsewhere. +The _user_ can comment on Ledger records. A turn of comments arrives as a `brunch.ledger-comments` block giving each comment's ID, the records it is on and the passage they selected; it is the _user_'s account, as binding as a message. Address every comment in that exchange: correct, supersede or add the records it calls for, or ask the question it raises. Mark each entry that acts on a comment with `resolves`; a comment you answer with a question stays open until the _user_ replies. Each recorded commit returns the comments still open. + ### Claims Claims carry the account: what the _user_ and consulted material say the system is. What the draft does belongs in a reflection, and what a run of the model shows belongs in the reflection that discharges the obligation it met; neither is a claim about the system, and neither supersedes one. diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts index 2d29906e6ae..3c9768e23f6 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts @@ -545,16 +545,29 @@ test("a claim that only renders under an entity is refused without one", async ( }); }); -test("only a recorded commit that sets endTurn ends the turn", async () => { - const terminates = async (entries: unknown, endTurn?: boolean) => { +test("only a recorded commit that sets endTurn in a turn that has replied ends the turn", async () => { + const terminates = async ( + entries: unknown, + endTurn?: boolean, + reply = "Noted.", + refusedFirst = false, + ) => { const batch = toBatch(entries); + const refused = { ...commitPart("commit-0", batch), state: "output-error" }; const history: LedgerHistory = { messages: [ { id: "turn-1", role: "user", parts: [] }, { id: "response-1", role: "assistant", - parts: [commitPart("commit-1", batch)], + // One message per turn: a repair follows its refused call. + parts: refusedFirst + ? [ + { type: "text", text: reply }, + refused, + commitPart("commit-1", batch), + ] + : [{ type: "text", text: reply }, commitPart("commit-1", batch)], }, ], }; @@ -596,6 +609,10 @@ test("only a recorded commit that sets endTurn ends the turn", async () => { expect(await terminates(entity)).toBe(false); expect(await terminates(entity, false)).toBe(false); expect(await terminates(unanchored, true)).toBe(false); + expect(await terminates(entity, true, "")).toBe(false); + expect(await terminates(entity, true, " \n")).toBe(false); + expect(await terminates(entity, true, "Noted.", true)).toBe(true); + expect(await terminates(entity, true, "", true)).toBe(false); }); test("an entity update keeps the turn the entity was created in", () => { diff --git a/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts b/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts index c5285d71126..583d3d3425f 100644 --- a/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/ledger.ts @@ -13,10 +13,28 @@ import { } from "@hashintel/brunch-agent"; export const commitToolDescription = - "Submit one ordered entries queue of exactly two-member [route, payload] tuples. Routes name the record type and operation: entity/create, entity/update/ (such as entity/update/e45, replacing every field, so it restates origin and status), claim/create, obligation/create and reflection/create. Record entities and claims from the USER's account with independent origin and status; omit both for what the USER stated without hedging. Never submit IDs or turns inside payloads; the system assigns IDs to created records. Reference existing entities as e23, existing claims as c45, existing obligations as o7, or entries in this queue as $index. Use $index only in reference fields, never in a route. In reflection text give the reasons behind accepted net changes and what observed net elements represent, not the change itself, which the net's names and descriptions already carry; anchor each reflection with at least one reference to an observed net element address, a claim, an entity or an obligation it discharges. Record a check the model owes, such as reproducing a case or varying a stand-in a result rests on, as an obligation; discharge it only with a reflection that cites the run or inspection that met it. Tool acceptance does not confirm any entry. A recorded result returns the new revision, the system-assigned ID for each entry aligned with the submitted queue, how many USER messages have passed and how many questions are open, the obligations still owed, and notes on consequences to weigh, such as your own claim superseding the USER's account; a refusal records nothing. Set endTurn on the commit that closes the exchange, in the same response as your reply."; + "Submit one ordered entries queue of exactly two-member [route, payload] tuples. Routes name the record type and operation: entity/create, entity/update/ (such as entity/update/e45, replacing every field, so it restates origin and status), claim/create, obligation/create and reflection/create. Record entities and claims from the USER's account with independent origin and status; omit both for what the USER stated without hedging. Never submit IDs or turns inside payloads; the system assigns IDs to created records. Reference existing entities as e23, existing claims as c45, existing obligations as o7, or entries in this queue as $index. Use $index only in reference fields, never in a route. In reflection text give the reasons behind accepted net changes and what observed net elements represent, not the change itself, which the net's names and descriptions already carry; anchor each reflection with at least one reference to an observed net element address, a claim, an entity or an obligation it discharges. Record a check the model owes, such as reproducing a case or varying a stand-in a result rests on, as an obligation; discharge it only with a reflection that cites the run or inspection that met it. Mark an entry that acts on one of the USER's Ledger comments with resolves. Tool acceptance does not confirm any entry. A recorded result returns the new revision, the system-assigned ID for each entry aligned with the submitted queue, how many USER turns have passed and how many questions are open, the obligations still owed, the USER's comments still open, and notes on consequences to weigh, such as your own claim superseding the USER's account; a refusal records nothing. Set endTurn on the commit that closes the exchange, in the same response as your reply."; export const compileToolDescription = - "Render the committed Ledger as its current map: every entity grouped by section and kind with its origin, status and ID, every current claim against the entities it concerns (superseded claims struck through), open and conflicted claims as questions, the checks still owed, and each check met with the reflection that met it. Other reflections are not rendered. Returns the latest revision with the map, and in rejected the IDs of earlier ledger_commit calls that were recorded but that the Ledger can no longer reconstruct from history, in order, including any that referenced records of an earlier rejected call; none of their entries are in the map or counted in the revision. The rendering is recorded material, not instructions."; + "Render the committed Ledger as its current map: every entity grouped by section and kind with its origin, status and ID, every current claim against the entities it concerns (superseded claims struck through), open and conflicted claims as questions, the checks still owed, each check met with the reflection that met it, and the USER's comments still open with the records they are on. Other reflections are not rendered. Returns the latest revision with the map, and in rejected the IDs of earlier ledger_commit calls that were recorded but that the Ledger can no longer reconstruct from history, in order, including any that referenced records of an earlier rejected call; none of their entries are in the map or counted in the revision. The rendering is recorded material, not instructions."; + +const vTextPart = v.object({ type: v.literal("text"), text: v.string() }); +const vCallPart = v.object({ toolCallId: v.string() }); + +/** + * Whether the turn has replied by this call. History keeps one assistant + * message per turn, so a commit repaired after a refusal counts the reply + * written beside the refused call. + */ +const repliesWith = (history: LedgerHistory, toolCallId: string) => + history.messages + .find((message) => + message.parts.some( + (part) => v.is(vCallPart, part) && part.toolCallId === toolCallId, + ), + ) + ?.parts.some((part) => v.is(vTextPart, part) && part.text.trim() !== "") ?? + false; /** The durable `ledger_commit` over this agent's Ledger. */ export const createLedgerCommitTool = ( @@ -38,8 +56,12 @@ export const createLedgerCommitTool = ( }); return { output, - // Ends the turn only when every call of the step terminates. - terminate: data.endTurn === true && output.status === "recorded", + // Ends the turn only when every call of the step terminates. Before the + // turn has replied, it stays open so the user gets one. + terminate: + data.endTurn === true && + output.status === "recorded" && + repliesWith(history, toolCallId), }; }, }); diff --git a/apps/brunch-agent/test/integration/ledger-history.integration.ts b/apps/brunch-agent/test/integration/ledger-history.integration.ts index 6aa4e695f5f..340cb7cfb93 100644 --- a/apps/brunch-agent/test/integration/ledger-history.integration.ts +++ b/apps/brunch-agent/test/integration/ledger-history.integration.ts @@ -24,7 +24,12 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; -import { agentOwnershipHeaders, foldCommits } from "@hashintel/brunch-agent"; +import { + agentOwnershipHeaders, + brunchSignals, + foldCommits, + ledgerCommentsMessage, +} from "@hashintel/brunch-agent"; import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; import { flueConversationId } from "../../src/shared/conversation/identity.ts"; @@ -272,15 +277,58 @@ try { }, ); await speak("Read the Ledger back."); + + steps.push( + (context) => { + observed.commentReachedModel = JSON.stringify(context.messages).includes( + "There are two night crews, not one.", + ); + return call("commit-4", "ledger_commit", { + entries: [ + [ + "entity/update/e1", + { + name: "Night crews", + kind: "resource", + origin: "stated", + status: "confirmed", + resolves: ["n1"], + }, + ], + ], + }); + }, + (context) => { + observed.commit4 = toolOutput(context, "commit-4"); + return fauxAssistantMessage([fauxText("Renamed.")]); + }, + ); + await client.wait( + await client.send({ + message: ledgerCommentsMessage([ + { + id: "n1", + records: ["e1"], + quote: "Night crew", + text: "There are two night crews, not one.", + }, + ]), + }), + ); const finalHistory = await client.history(); save("history-turn-3", finalHistory); observed.summaryRequests = contexts.filter( ({ label }) => label === "summary-request", ).length; - observed.reconstructed = foldCommits(finalHistory).commits; + const finalLedger = foldCommits(finalHistory); + observed.reconstructed = finalLedger.commits; + observed.comments = finalLedger.state.comments; observed.userMessageIds = finalHistory.messages .filter((message) => message.role === "user" && message.purpose === "user") .map((message) => message.id); + observed.commentMessageId = finalHistory.messages.find( + (message) => message.signal?.tagName === brunchSignals.ledgerComments, + )?.id; } finally { save("contexts", contexts); save("observations", observed); @@ -358,9 +406,26 @@ assert.deepEqual( ? [{ commitId: "sibling-2", afterMessageId: userIds[0] }] : []), { commitId: "commit-3", afterMessageId: userIds[1] }, + { commitId: "commit-4", afterMessageId: observed.commentMessageId }, ], - "The host stamps each commit with the user message it followed.", + "The host stamps each commit with the user message or comment turn it followed.", +); +assert.equal( + observed.commentReachedModel, + true, + "The comment signal reaches the model.", +); +assert.partialDeepStrictEqual(observed.commit4, { + status: "recorded", + ids: ["e1"], +}); +assert( + !Object.hasOwn(observed.commit4 as object, "openComments"), + `No comment stays open once resolved: ${JSON.stringify(observed.commit4)}`, ); +assert.partialDeepStrictEqual(observed.comments, [ + { id: "n1", records: ["e1"], resolvedBy: "e1" }, +]); const turn2Requests = observed.turn2Requests as string[]; assert.deepEqual( turn2Requests.filter((label) => label === "agent"), diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.test.ts index 72f05ad8ffe..f983c553510 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.test.ts @@ -1,7 +1,10 @@ import { readUIMessageStream } from "ai"; import { describe, expect, test, vi } from "vitest"; -import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent"; +import { + ledgerCommentsMessage, + petrinautContextualUserMessageBody, +} from "@hashintel/brunch-agent"; import { canonicalBrunchFlueAdapter } from "./brunch-flue-adapter"; @@ -128,3 +131,29 @@ describe("Brunch's stop marker through Petrinaut's metadata schema", () => { expect(reopen("aborted")).toEqual({ source: "voice", stopped: true }); }); }); + +test("a reopened turn of Ledger comments reads as the user's message, as it was sent", () => { + const { body, tagName } = ledgerCommentsMessage([ + { id: "n1", records: ["e1"], text: "There are two crews." }, + ]); + expect( + canonicalBrunchFlueAdapter.reopen({ + messages: [ + { + id: "comments-1", + role: "system", + purpose: "dispatch", + display: "diagnostic", + ...(tagName === undefined ? {} : { signal: { tagName } }), + parts: [{ type: "text", text: body, state: "done" }], + }, + ], + }), + ).toEqual([ + { + id: "comments-1", + role: "user", + parts: [{ type: "text", text: body, state: "done" }], + }, + ]); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.ts index 46cfd393445..e67073a8d5a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { browserToolOutput, + brunchSignals, petrinautUserMessageText, projectBrunchMessageMetadata, } from "@hashintel/brunch-agent"; @@ -66,6 +67,7 @@ export const createBrunchFlueAdapter = ( mapToolOutput: browserToolOutput, metadataSchema: petrinautMessageMetadataSchema, projectMetadata: projectBrunchMessageMetadata, + userSignalTags: new Set([brunchSignals.ledgerComments]), }); return { ...adapter, diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts index 0e4bbd0a266..03a8320fadf 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest"; -import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent"; +import { + ledgerCommentsMessage, + petrinautContextualUserMessageBody, +} from "@hashintel/brunch-agent"; import { petrinautDiagnosticsContextMessageId } from "@hashintel/petrinaut-core"; import { brunchSubmittedUserMessage } from "./submitted-user-message"; @@ -21,7 +24,7 @@ describe("brunchSubmittedUserMessage", () => { test("admits an ordinary turn unchanged", () => { expect(brunchSubmittedUserMessage([message("user-1", "Hello.")])).toEqual({ messageId: "user-1", - body: "Hello.", + message: { kind: "user", body: "Hello." }, }); }); @@ -31,18 +34,24 @@ describe("brunchSubmittedUserMessage", () => { message("user-1", "Repair the model."), message(petrinautDiagnosticsContextMessageId, "One current error."), ]), - ).toEqual({ messageId: "user-1", body: "Repair the model." }); + ).toEqual({ + messageId: "user-1", + message: { kind: "user", body: "Repair the model." }, + }); }); test("carries submission context without any diagnostics", () => { const submissionContext = { hostKey: { level: 2 } }; const expected = { messageId: "user-1", - body: petrinautContextualUserMessageBody({ - userText: "Repair the model.", - diagnosticsContext: "", - submissionContext, - }), + message: { + kind: "user", + body: petrinautContextualUserMessageBody({ + userText: "Repair the model.", + diagnosticsContext: "", + submissionContext, + }), + }, }; expect( brunchSubmittedUserMessage([message("user-1", "Repair the model.")], { @@ -60,6 +69,15 @@ describe("brunchSubmittedUserMessage", () => { ).toEqual(expected); }); + test("delivers a turn of Ledger comments as the comment signal", () => { + const comments = ledgerCommentsMessage([ + { id: "n1", records: ["e1"], text: "There are two crews." }, + ]); + expect( + brunchSubmittedUserMessage([message("comments-1", comments.body)]), + ).toEqual({ messageId: "comments-1", message: comments }); + }); + test("refuses a continuation that carries only diagnostics context", () => { expect(() => brunchSubmittedUserMessage([ diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts index 6d845024c8d..a756706f864 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport/submitted-user-message.ts @@ -1,4 +1,8 @@ -import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent"; +import { + ledgerCommentsMessage, + parseLedgerComments, + petrinautContextualUserMessageBody, +} from "@hashintel/brunch-agent"; import { petrinautDiagnosticsContextMessageId } from "@hashintel/petrinaut-core"; import { finalUserMessage } from "@local/flue-aisdk-transport"; @@ -11,22 +15,34 @@ import type { UIMessage } from "ai"; * context into its body so the server can separate human evidence from host * data. Petrinaut's assistant appends a reserved diagnostics message after * client tool results; Brunch reads diagnostics through its own tools, so the - * turn never carries it. + * turn never carries it. A turn whose text is a body of Ledger comments goes + * to Brunch as the comment signal. */ export const brunchSubmittedUserMessage = ( messages: readonly UIMessage[], submissionContext?: SubmissionContext, ): SubmittedUserMessage => { - const userMessage = finalUserMessage( + const submitted = finalUserMessage( messages.filter(({ id }) => id !== petrinautDiagnosticsContextMessageId), ); - if (submissionContext === undefined) return userMessage; + if (submitted.message.kind !== "user") return submitted; + const comments = parseLedgerComments(submitted.message.body); + if (comments !== undefined) { + return { + messageId: submitted.messageId, + message: ledgerCommentsMessage(comments), + }; + } + if (submissionContext === undefined) return submitted; return { - messageId: userMessage.messageId, - body: petrinautContextualUserMessageBody({ - userText: userMessage.body, - diagnosticsContext: "", - submissionContext, - }), + messageId: submitted.messageId, + message: { + kind: "user", + body: petrinautContextualUserMessageBody({ + userText: submitted.message.body, + diagnosticsContext: "", + submissionContext, + }), + }, }; }; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.test.ts new file mode 100644 index 00000000000..133fa86a7e0 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.test.ts @@ -0,0 +1,61 @@ +/** @vitest-environment jsdom */ +import { afterEach, expect, test } from "vitest"; + +import { selectedLedgerAddresses } from "./brunch-ledger-comment-anchor"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** An entity with a claim under it, then a second entity, marked as the Ledger document marks them. */ +const mount = () => { + document.body.innerHTML = ` +
+
    +
  • + Night crew — stated +
    • Two people load each truck.
    +
  • +
  • Trucks — stated
  • +
+
`; + const article = document.querySelector("article"); + if (article === null) throw new Error("No article."); + const record = (address: string) => { + const element = article.querySelector(`[data-ledger-address="${address}"]`); + if (element === null) throw new Error(`No record ${address}.`); + return element; + }; + return { article, record }; +}; + +const textOf = (element: Element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const node = walker.nextNode(); + if (node === null) throw new Error("No text."); + return node; +}; + +test("a selection inside one record resolves to that record", () => { + const { article, record } = mount(); + const range = document.createRange(); + range.setStart(textOf(record("c1")), 4); + range.setEnd(textOf(record("c1")), 10); + expect(selectedLedgerAddresses(range, article)).toEqual(["c1"]); +}); + +test("a selection across records resolves to each record it touches, in document order", () => { + const { article, record } = mount(); + const range = document.createRange(); + range.setStart(textOf(record("e1")), 2); + range.setEnd(textOf(record("e2")), 3); + expect(selectedLedgerAddresses(range, article)).toEqual(["e1", "c1", "e2"]); +}); + +test("a selection that ends where the next record begins does not take that record", () => { + const { article, record } = mount(); + const range = document.createRange(); + range.setStart(textOf(record("c1")), 0); + range.setEnd(textOf(record("e2")), 0); + expect(selectedLedgerAddresses(range, article)).toEqual(["c1"]); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.ts new file mode 100644 index 00000000000..ef8ca6f496b --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-anchor.ts @@ -0,0 +1,35 @@ +/** The text of `element` that `range` covers. */ +const coveredText = (range: Range, element: Element): string => { + const overlap = document.createRange(); + overlap.selectNodeContents(element); + if (range.compareBoundaryPoints(Range.START_TO_START, overlap) > 0) + overlap.setStart(range.startContainer, range.startOffset); + if (range.compareBoundaryPoints(Range.END_TO_END, overlap) < 0) + overlap.setEnd(range.endContainer, range.endOffset); + return overlap.toString(); +}; + +/** + * The addresses of the records whose text a selection in the Ledger document + * covers, in document order, each once. A selection that only reaches a + * record's edge, as a triple-click reaches the start of the next block, + * leaves it out. + */ +export const selectedLedgerAddresses = ( + range: Range, + root: Element, +): string[] => { + const addresses = new Set(); + for (const element of root.querySelectorAll( + "[data-ledger-address]", + )) { + const address = element.dataset.ledgerAddress; + if ( + address !== undefined && + range.intersectsNode(element) && + coveredText(range, element).trim() !== "" + ) + addresses.add(address); + } + return [...addresses]; +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.test.ts new file mode 100644 index 00000000000..724bc6776ba --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; + +import { ledgerCommentsMessage } from "@hashintel/brunch-agent"; + +import { showLedgerComments } from "./brunch-ledger-comment-messages"; + +import type { PetrinautAiMessage } from "../chat/ai-assistant-panel"; + +test("shows a turn of Ledger comments as the comments, and leaves other messages alone", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "comments", + role: "user", + parts: [ + { + type: "text", + text: ledgerCommentsMessage([ + { + id: "n1", + records: ["e1"], + quote: "Night crew\nloads trucks", + text: "There are two crews.", + }, + { id: "n2", records: ["c1"], text: "Only in winter." }, + ]).body, + }, + ], + }, + { id: "plain", role: "user", parts: [{ type: "text", text: "{}" }] }, + ]; + const [comments, plain] = showLedgerComments(messages); + expect(comments?.parts).toEqual([ + { + type: "text", + text: "Comments on the Ledger\n“Night crew loads trucks” — There are two crews.\nOnly in winter.", + }, + ]); + expect(plain).toBe(messages[1]); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.ts new file mode 100644 index 00000000000..91ce870d216 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comment-messages.ts @@ -0,0 +1,46 @@ +import { + parseLedgerComments, + type LedgerComment, +} from "@hashintel/brunch-agent"; + +import type { PetrinautAiMessage } from "../chat/ai-assistant-panel"; + +const quoteLimit = 120; + +/** The passage a comment was made on, shortened to fit beside it. */ +export const shortQuote = (quote: string) => { + const flat = quote.replace(/\s+/g, " ").trim(); + return flat.length > quoteLimit + ? `${flat.slice(0, quoteLimit).trimEnd()}…` + : flat; +}; + +const commentLine = ({ quote, text }: LedgerComment) => + quote === undefined || quote.trim() === "" + ? text + : `“${shortQuote(quote)}” — ${text}`; + +/** Shows a turn of Ledger comments as the comments, not as the JSON Brunch receives. */ +export const showLedgerComments = ( + messages: PetrinautAiMessage[], +): PetrinautAiMessage[] => + messages.map((message) => { + if (message.role !== "user") return message; + const comments = parseLedgerComments( + message.parts + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""), + ); + if (comments === undefined) return message; + return { + ...message, + parts: [ + { + type: "text", + text: ["Comments on the Ledger", ...comments.map(commentLine)].join( + "\n", + ), + }, + ], + }; + }); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments-bar.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments-bar.tsx new file mode 100644 index 00000000000..9cbd4bfa63f --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments-bar.tsx @@ -0,0 +1,66 @@ +import { useSyncExternalStore } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { pendingCommentsFor } from "./brunch-ledger-comments"; + +import type { PetrinautAiComposerControlContext } from "../chat/ai-assistant-composer-control"; +import type { BrunchEditorApi } from "../editor-api-plugin"; + +/** Sends the user's pending Ledger comments to Brunch as one turn, from either tab. */ +export const BrunchLedgerCommentsBar = ({ + context, + net, +}: { + context: PetrinautAiComposerControlContext; + /** The open net, whose editor holds the pending comments. */ + net: BrunchEditorApi["document"]["net"]; +}) => { + const store = pendingCommentsFor(net); + const { comments, sending } = useSyncExternalStore( + store.subscribe, + store.get, + ); + if (comments.length === 0) return null; + const busy = + sending === "pending" || + context.status === "submitted" || + context.status === "streaming"; + return ( +
+ + {comments.length === 1 + ? "1 comment on the Ledger" + : `${comments.length} comments on the Ledger`} + {sending === "failed" && " could not be sent"} + + + +
+ ); +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.test.tsx new file mode 100644 index 00000000000..41f984c5813 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.test.tsx @@ -0,0 +1,112 @@ +/** @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { parseLedgerComments } from "@hashintel/brunch-agent"; +import { + createJsonDocHandle, + createPetrinaut, +} from "@hashintel/petrinaut-core"; + +import { pendingCommentsFor } from "./brunch-ledger-comments"; +import { BrunchLedgerCommentsBar } from "./brunch-ledger-comments-bar"; + +import type { PetrinautAiComposerControlContext } from "../chat/ai-assistant-composer-control"; + +afterEach(cleanup); + +const onCrew = { records: ["e1"], text: "There are two crews." }; +const admitted = { kind: "message", messageId: "comments" } as const; + +test("numbers comments after those already sent, and never reuses a number", () => { + const comments = pendingCommentsFor({}); + expect(comments.add(onCrew, ["n1", "n4"]).id).toBe("n5"); + comments.discard(); + expect(comments.add(onCrew, ["n1", "n4"]).id).toBe("n6"); +}); + +test("sends every pending comment as one turn, keeping comments added meanwhile", async () => { + const comments = pendingCommentsFor({}); + comments.add(onCrew, []); + let settle = () => {}; + const submitText = vi.fn( + () => + new Promise((resolve) => { + settle = () => resolve(admitted); + }), + ); + comments.send(submitText); + comments.send(submitText); + comments.add({ records: ["c1"], text: "Only in winter." }, []); + + expect(submitText).toHaveBeenCalledOnce(); + const request = submitText.mock.calls[0]?.[0]; + expect(request?.target).toBe("message"); + expect(request?.preserveDraft).toBe(true); + expect(parseLedgerComments(request?.text ?? "")).toEqual([ + { id: "n1", ...onCrew }, + ]); + expect(comments.get().sending).toBe("pending"); + + settle(); + await vi.waitFor(() => expect(comments.get().sending).toBe("idle")); + expect(comments.get().comments.map(({ id }) => id)).toEqual(["n2"]); +}); + +test("keeps comments that could not be sent", async () => { + const comments = pendingCommentsFor({}); + comments.add(onCrew, []); + comments.send(() => Promise.reject(new Error("offline"))); + await vi.waitFor(() => expect(comments.get().sending).toBe("failed")); + expect(comments.get().comments).toHaveLength(1); +}); + +test("the composer bar sends or discards the pending comments, and is absent without any", async () => { + const net = createPetrinaut({ + document: createJsonDocHandle({ + initial: { places: [], transitions: [] }, + }), + }).definition; + const submitText = vi.fn( + () => Promise.resolve(admitted), + ); + const context: PetrinautAiComposerControlContext = { + conversationId: "conversation", + messages: [], + status: "ready", + submitText, + stop: async () => {}, + }; + const { container } = render( + , + ); + expect(container.textContent).toBe(""); + + const comments = pendingCommentsFor(net); + act(() => { + comments.add(onCrew, []); + comments.add({ records: ["c1"], text: "Only in winter." }, []); + }); + expect( + screen.getByRole("group", { name: "Ledger comments" }).textContent, + ).toContain("2 comments on the Ledger"); + + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expect(submitText).toHaveBeenCalledOnce(); + await waitFor(() => + expect(screen.queryByRole("group", { name: "Ledger comments" })).toBeNull(), + ); + + act(() => { + comments.add({ records: ["c2"], text: "Ask the planner." }, []); + }); + fireEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(comments.get().comments).toEqual([]); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.ts new file mode 100644 index 00000000000..2556abefd57 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-comments.ts @@ -0,0 +1,90 @@ +import { + ledgerCommentsMessage, + type LedgerComment, +} from "@hashintel/brunch-agent"; + +import type { PetrinautAiComposerControlContext } from "../chat/ai-assistant-composer-control"; + +export type PendingCommentsState = { + /** Comments the user has written but not yet sent to Brunch. */ + readonly comments: readonly LedgerComment[]; + readonly sending: "idle" | "pending" | "failed"; +}; + +const commentNumber = (id: string) => Number(id.slice(1)); + +const createPendingComments = () => { + let state: PendingCommentsState = { comments: [], sending: "idle" }; + // IDs are never reused, even for a comment discarded before it was sent. + let highestIssued = 0; + const listeners = new Set<() => void>(); + const publish = (next: PendingCommentsState) => { + state = next; + for (const listener of listeners) listener(); + }; + return { + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + get: (): PendingCommentsState => state, + /** `sentIds` are the comments the conversation already holds. */ + add: ( + comment: Omit, + sentIds: readonly string[], + ): LedgerComment => { + highestIssued = + Math.max(highestIssued, ...sentIds.map(commentNumber)) + 1; + const added = { id: `n${highestIssued}`, ...comment }; + publish({ ...state, comments: [...state.comments, added] }); + return added; + }, + edit: (id: string, text: string) => + publish({ + ...state, + comments: state.comments.map((comment) => + comment.id === id ? { ...comment, text } : comment, + ), + }), + remove: (id: string) => + publish({ + ...state, + comments: state.comments.filter((comment) => comment.id !== id), + }), + discard: () => publish({ comments: [], sending: "idle" }), + /** Sends every pending comment as one turn; comments added meanwhile stay pending. */ + send: (submitText: PetrinautAiComposerControlContext["submitText"]) => { + const sent = state.comments; + if (state.sending === "pending" || sent.length === 0) return; + publish({ ...state, sending: "pending" }); + const sentIds = new Set(sent.map(({ id }) => id)); + void submitText({ + target: "message", + preserveDraft: true, + text: ledgerCommentsMessage(sent).body, + }).then( + () => + publish({ + comments: state.comments.filter(({ id }) => !sentIds.has(id)), + sending: "idle", + }), + () => publish({ ...state, sending: "failed" }), + ); + }, + }; +}; + +export type PendingComments = ReturnType; + +// Keyed like the experiment drafts: on the Brunch plugin's +// `api.document.net`, so the Ledger tab and the composer bar share one store +// per editor, and it is released with that editor. +const editors = new WeakMap(); +export const pendingCommentsFor = (definitionStore: object) => { + let comments = editors.get(definitionStore); + if (!comments) { + comments = createPendingComments(); + editors.set(definitionStore, comments); + } + return comments; +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx index 14964905737..aaed9e4fec3 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/ledger/brunch-ledger-document.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; -import { Button } from "@hashintel/ds-components"; +import { Button, TextArea, Tooltip } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { shortQuote } from "./brunch-ledger-comment-messages"; import { reflectionSelection } from "./brunch-ledger-selection"; import type { + CommentRecord, + LedgerComment, LedgerProjection, ProjectedClaim, ProjectedEntity, @@ -47,12 +50,56 @@ const recordStyle = css({ paddingX: "0.5", fontWeight: "medium", }, + "&[data-commented]": { + textDecorationLine: "underline", + textDecorationStyle: "dashed", + textDecorationColor: "yellow.s90", + textUnderlineOffset: "[3px]", + }, }); const qualifierStyle = css({ color: "neutral.s90" }); const reflectionStyle = css({ color: "neutral.s100", fontStyle: "italic" }); +const openMarkStyle = css({ + marginLeft: "1", + paddingX: "1.5", + borderRadius: "full", + backgroundColor: "yellow.s40", + color: "neutral.s120", + fontSize: "[11px]", + fontWeight: "medium", + lineHeight: "[1.5]", + cursor: "default", +}); + +const noteStyle = css({ + display: "flex", + flexDirection: "column", + gap: "1", + marginY: "1", + paddingX: "2", + paddingY: "1.5", + borderLeftWidth: "[2px]", + borderColor: "yellow.s60", + backgroundColor: "yellow.s10", + borderRadius: "sm", + "& blockquote": { color: "neutral.s90", fontStyle: "italic" }, +}); + +/** The user's comments on the Ledger, as the document shows them. */ +interface LedgerCommentMarks { + /** Written but not yet sent; each shows under the first record it is on. */ + readonly pending: readonly LedgerComment[]; + /** Sent, and not yet resolved by Brunch. */ + readonly open: readonly CommentRecord[]; + /** False while the pending comments are being sent. */ + readonly editable: boolean; + readonly onEdit: (id: string, text: string) => void; + readonly onRemove: (id: string) => void; +} + /** What the document shows besides the records themselves. */ export interface LedgerDocumentOptions { /** Record addresses to mark, such as those behind the canvas selection. */ @@ -61,29 +108,199 @@ export interface LedgerDocumentOptions { readonly showReflections: boolean; /** Select a reflection's net elements on the canvas; absent where there is no canvas. */ readonly onSelectNetElements?: (items: SelectionItem[]) => void; + readonly comments?: LedgerCommentMarks; +} + +/** The options, with the block each record first appears in. */ +interface DocumentContext extends LedgerDocumentOptions { + readonly firstShownIn: ReadonlyMap; } /** - * One record where it appears. A claim can appear under several entities, - * so the same address can mark more than one block. + * The block each record address first appears in: an entity's address, an + * obligation's address for the reflections that met it, or a section's name. + * Walks the projection in the order `BrunchLedgerDocument` renders it. + */ +const firstShownIn = ( + projection: LedgerProjection, + showReflections: boolean, +): ReadonlyMap => { + const first = new Map(); + const show = (within: string, address: string) => { + if (!first.has(address)) first.set(address, within); + }; + const showEntity = ({ record, claims, reflections }: ProjectedEntity) => { + show(record.address, record.address); + for (const claim of claims) { + show(record.address, claim.record.address); + for (const ancestor of claim.history) + show(record.address, ancestor.address); + } + if (showReflections) + for (const reflection of reflections) + show(record.address, reflection.address); + }; + for (const section of projection.sections) + for (const group of section.kinds) group.entities.forEach(showEntity); + for (const { claim } of projection.questions) + show("questions", claim.address); + for (const { record } of projection.owed) show("owed", record.address); + for (const { record, dischargedBy } of projection.met) { + show("met", record.address); + for (const reflection of dischargedBy) + show(record.address, reflection.address); + } + projection.excluded.forEach(showEntity); + return first; +}; + +const OpenCommentMark = ({ + comments, +}: { + comments: readonly CommentRecord[]; +}) => ( + ( +

{comment.text}

+ ))} + > + +
+); + +const PendingCommentNote = ({ + comment, + marks, +}: { + comment: LedgerComment; + marks: LedgerCommentMarks; +}) => { + const [draft, setDraft] = useState(); + return ( +
+ {comment.quote !== undefined && ( +
“{shortQuote(comment.quote)}”
+ )} + {draft === undefined ? ( + <> + {comment.text} + + + + + + ) : ( + <> +