From a53e1a60787cf889388385021edbbf94509bad4c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 27 Aug 2026 14:28:28 +0200 Subject: [PATCH 1/6] Replace the elicitation path with a plain Petrinaut-to-Flue chat. Mission 1 needs a durable conversation through the real panel before capture lands, so /api/chat now mounts one Flue agent with a server tool, a read-only client-tool resume, and Flue history as the log. Co-authored-by: Cursor --- apps/brunch-agent/README.md | 35 +- apps/brunch-agent/index.html | 2 +- apps/brunch-agent/package.json | 12 +- apps/brunch-agent/src/agents/chat-agent.ts | 53 ++ .../src/agents/gherkin-elicitor.ts | 81 --- .../brunch-agent/src/agents/sdcpn-elicitor.ts | 66 --- apps/brunch-agent/src/app.ts | 43 +- .../brunch-agent/src/conversation-identity.ts | 13 + apps/brunch-agent/src/db.ts | 11 +- apps/brunch-agent/src/elicitation-session.ts | 75 --- apps/brunch-agent/src/flue-transcript.ts | 219 ++++++++ apps/brunch-agent/src/flue-ui-stream.ts | 160 ++++++ apps/brunch-agent/src/petrinaut-chat.ts | 162 +++--- apps/brunch-agent/src/routes.ts | 15 +- apps/brunch-agent/src/target-document-path.ts | 17 - apps/brunch-agent/src/transcript-cli.ts | 46 ++ apps/brunch-agent/src/ui/chat.tsx | 77 +-- apps/brunch-agent/src/ui/main.tsx | 13 +- apps/brunch-agent/src/ui/styles.css | 33 +- .../test/baseline-harness.test.ts | 292 ---------- .../test/conversation-identity.test.ts | 17 + apps/brunch-agent/test/db-path.test.ts | 27 +- .../test/elicitation-session.test.ts | 25 - .../fixtures/baseline-harness-interviewer.ts | 137 ----- .../brunch-agent/test/petrinaut-ask-result.ts | 19 - .../test/petrinaut-ask.integration.ts | 150 ----- apps/brunch-agent/test/petrinaut-ask.test.ts | 83 --- .../test/petrinaut-chat-result.ts | 28 +- .../test/petrinaut-chat.integration.ts | 339 +++++++++--- apps/brunch-agent/test/petrinaut-chat.test.ts | 225 ++++---- .../test/transport-aisdk-server.test.ts | 450 --------------- .../test/walking-skeleton.integration.ts | 374 ------------- .../test/walking-skeleton.test.ts | 42 -- apps/brunch-agent/turbo.json | 14 - .../brunch-ask-interactive-tool.tsx | 148 ----- .../brunch-conversation-id.test.ts | 23 + .../brunch-conversation-id.ts | 30 + .../brunch-panel-transport.test.ts | 255 +-------- .../brunch-panel-transport.ts | 203 +------ .../local-storage-demo-app.tsx | 49 +- .../use-flue-chat-history.ts | 58 ++ .../core/test/architecture/boundaries.test.ts | 14 +- .../core/test/architecture/open-gaps.ts | 8 - .../packages/transport-aisdk/package.json | 7 +- .../transport-aisdk/src/client-tools.ts | 23 - .../packages/transport-aisdk/src/index.ts | 518 ++++++------------ .../transport-aisdk/test/ask-reply.test.ts | 395 ------------- .../transport-aisdk/test/chat-handler.test.ts | 246 +++++++++ .../fixtures/elicitor-initial.normalized.json | 36 -- .../test/fixtures/panel-client-tool.post.json | 26 + .../test/fixtures/panel-follow-up.sse | 15 - .../test/fixtures/panel-initial.sse | 31 -- .../fixtures/panel-tool-results.post.json | 118 ---- .../transport-aisdk/test/golden.test.ts | 141 +---- .../packages/transport-aisdk/vite.config.ts | 5 +- yarn.lock | 13 +- 56 files changed, 1727 insertions(+), 3990 deletions(-) create mode 100644 apps/brunch-agent/src/agents/chat-agent.ts delete mode 100644 apps/brunch-agent/src/agents/gherkin-elicitor.ts delete mode 100644 apps/brunch-agent/src/agents/sdcpn-elicitor.ts create mode 100644 apps/brunch-agent/src/conversation-identity.ts delete mode 100644 apps/brunch-agent/src/elicitation-session.ts create mode 100644 apps/brunch-agent/src/flue-transcript.ts create mode 100644 apps/brunch-agent/src/flue-ui-stream.ts delete mode 100644 apps/brunch-agent/src/target-document-path.ts create mode 100644 apps/brunch-agent/src/transcript-cli.ts delete mode 100644 apps/brunch-agent/test/baseline-harness.test.ts create mode 100644 apps/brunch-agent/test/conversation-identity.test.ts delete mode 100644 apps/brunch-agent/test/elicitation-session.test.ts delete mode 100644 apps/brunch-agent/test/fixtures/baseline-harness-interviewer.ts delete mode 100644 apps/brunch-agent/test/petrinaut-ask-result.ts delete mode 100644 apps/brunch-agent/test/petrinaut-ask.integration.ts delete mode 100644 apps/brunch-agent/test/petrinaut-ask.test.ts delete mode 100644 apps/brunch-agent/test/transport-aisdk-server.test.ts delete mode 100644 apps/brunch-agent/test/walking-skeleton.integration.ts delete mode 100644 apps/brunch-agent/test/walking-skeleton.test.ts delete mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.test.ts create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.ts create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.ts delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/src/client-tools.ts delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-handler.test.ts delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/elicitor-initial.normalized.json create mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/panel-client-tool.post.json delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/panel-follow-up.sse delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/panel-initial.sse delete mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/panel-tool-results.post.json diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md index e2ca70e74cb..b450c41a963 100644 --- a/apps/brunch-agent/README.md +++ b/apps/brunch-agent/README.md @@ -1,6 +1,6 @@ # Brunch agent application -## Run the process-model panel locally +## Run the Petrinaut panel locally From the repository root, make `ANTHROPIC_API_KEY` available in the environment and run: @@ -9,9 +9,32 @@ yarn dev:brunch ``` The command starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut website at -`http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch, where the panel runs the SDCPN -process-model elicitor. +`http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch. The panel talks to one plain +Flue chat agent: streamed text and reasoning, one server `ping` tool, and the existing Petrinaut +`readPetrinautDoc` client tool. There is no elicitation, capture, or `brunch_ask` on this path. -Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. Owned target documents -persist as per-document JSON files under `apps/brunch-agent/.data-wipe-me/target-documents/`. -`BRUNCH_DEV_DB_PATH` and `BRUNCH_DEV_TARGET_DOCUMENT_DIR` override those local paths. +Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. `BRUNCH_DEV_DB_PATH` +overrides that local path. Flue history is the conversation log; the browser may cache messages +but reload hydrates from `GET /api/chat?id=`. + +Print a human-readable transcript of one conversation from that same Flue history (server already +running): + +```sh +yarn workspace @apps/brunch-agent transcript -- --principal --id +``` + +## Voice dock + +A second input modality joins the same chat door. It is not a voice route and does not own +provider audio or session state. + +| | | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| URL | `POST /api/chat` (and `GET /api/chat?id=` to hydrate) | +| Identity | `x-brunch-principal` plus body `id` (the conversation id). The server hashes those into the Flue instance id. | +| Initial turn | JSON `{ id, trigger: "submit-message", messages }` whose last user text part is the utterance. | +| Client-tool follow-up | Same POST, with `messageId` of the assistant message and completed client-tool parts (`providerExecuted` not true). Correlated by `toolCallId`. | +| Response | AI SDK UI-message stream (SSE). | + +`OPTIONS /api/chat` is the CORS preflight for that same contract. diff --git a/apps/brunch-agent/index.html b/apps/brunch-agent/index.html index 0b84eae229a..9dc347b8be8 100644 --- a/apps/brunch-agent/index.html +++ b/apps/brunch-agent/index.html @@ -3,7 +3,7 @@ - Elicitation harness — dev app + Brunch Flue chat
diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index f81915b9b43..0874ed3af99 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -2,28 +2,24 @@ "name": "@apps/brunch-agent", "version": "0.0.0-private", "private": true, - "description": "Remote Brunch server, local development loop, target gallery, and diagnostic probe surface.", + "description": "Remote Brunch server, local development loop, and Petrinaut /api/chat door.", "license": "AGPL-3.0", "type": "module", "scripts": { - "baseline:harness": "node --experimental-strip-types ../../libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/harness-run.ts", "build": "vite build && vite build --config vite.client.config.ts", "dev": "vite dev", "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "petrinaut:dev": "vite dev --config petrinaut-local.vite.config.ts", - "test:unit": "vitest run --config vitest.config.ts" + "test:unit": "vitest run --config vitest.config.ts", + "transcript": "node --experimental-strip-types src/transcript-cli.ts" }, "dependencies": { "@flue/opentelemetry": "2.0.3", "@flue/react": "2.0.3", "@flue/runtime": "2.0.3", "@flue/sdk": "2.0.3", - "@hashintel/brunch-agent": "workspace:*", - "@hashintel/brunch-agent-binding-flue": "workspace:*", - "@hashintel/brunch-agent-plugin-gherkin": "workspace:*", - "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*", "@hashintel/brunch-agent-transport-aisdk": "workspace:*", "@opentelemetry/api": "1.9.1", "hono": "4.13.2", @@ -34,8 +30,6 @@ "devDependencies": { "@earendil-works/pi-ai": "0.83.0", "@flue/vite": "2.0.3", - "@opentelemetry/sdk-trace-base": "2.9.0", - "@opentelemetry/sdk-trace-node": "2.9.0", "@types/node": "22.18.13", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", diff --git a/apps/brunch-agent/src/agents/chat-agent.ts b/apps/brunch-agent/src/agents/chat-agent.ts new file mode 100644 index 00000000000..9f5d9b06247 --- /dev/null +++ b/apps/brunch-agent/src/agents/chat-agent.ts @@ -0,0 +1,53 @@ +"use agent"; +/** + * One plain Flue chat agent for the Petrinaut panel throughline. + * + * No elicitation, capture, or plugin. The model can call a server-side ping + * and a browser-executed Petrinaut doc reader; Flue history is the session log. + */ + +import { useModel, useTool } from "@flue/runtime"; +import * as v from "valibot"; + +export const CHAT_MODEL_ID = + process.env["BRUNCH_CHAT_MODEL"] || "claude-haiku-4-5"; + +export const PING_TOOL_NAME = "ping"; +export const READ_PETRINAUT_DOC_TOOL_NAME = "readPetrinautDoc"; + +export function ChatAgent() { + useModel(`anthropic/${CHAT_MODEL_ID}`); + useTool({ + name: PING_TOOL_NAME, + description: + "Return a short server-side acknowledgement. Call this when you need to confirm the server is in the loop.", + input: v.object({ + note: v.optional(v.pipe(v.string(), v.nonEmpty())), + }), + run({ data }) { + return { output: { ok: true as const, note: data.note ?? "pong" } }; + }, + }); + useTool({ + name: READ_PETRINAUT_DOC_TOOL_NAME, + description: + "Read one page of the Petrinaut user guide. The browser executes this tool. After you call it, wait for a client-tool-result signal carrying the page text, then continue from that text.", + input: v.object({ + doc: v.pipe(v.string(), v.nonEmpty()), + }), + run() { + return { output: { awaiting: "client" as const }, terminate: true }; + }, + }); + return [ + "You are a concise assistant inside the Petrinaut editor.", + "Call ping when you need to confirm the server tool path.", + "When the user asks how Petrinaut's UI works, call readPetrinautDoc.", + "A client-tool-result signal is JSON [{ toolCallId, toolName, output }]. Treat output as the browser's result for that call and continue helping the user.", + ].join("\n"); +} + +/** + * Pinned, and never to be edited: conversation storage keys on this literal. + */ +ChatAgent.agentName = "brunch-chat-agent"; diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts deleted file mode 100644 index d61da9094df..00000000000 --- a/apps/brunch-agent/src/agents/gherkin-elicitor.ts +++ /dev/null @@ -1,81 +0,0 @@ -"use agent"; -/** - * The gherkin elicitor (spec §12.5: one agent per target). - * - * Named as a noun — the thing, not the act — and read target-first, so the - * family sorts together as targets multiply: `gherkin-elicitor`, - * `assurance-elicitor`. - * - * The product is the harness library in a thin host-authored agent — Flue's - * build-time scan makes the alternative structurally unavailable, since a - * library cannot ship a pre-registered agent (spec §12.1). So this module is - * deliberately thin: it mounts harness capability and holds no elicitation - * semantics of its own. - * - * Three recorded Flue constraints are honoured here by construction (spec §10): - * the `'use agent'` directive is the file's first statement; `agentName` is a - * pinned string literal, because conversation storage keys on it; and the tool - * set is static, because prompt-cache economics forbid per-question tool - * swapping. - */ - -import { useInitialData, useModel, type AgentProps } from "@flue/runtime"; -import * as v from "valibot"; - -import { useElicitation } from "@hashintel/brunch-agent-binding-flue"; -import { gherkin } from "@hashintel/brunch-agent-plugin-gherkin"; - -import { createGherkinElicitationSession } from "../elicitation-session.ts"; - -/** - * One definition for the agent and the faux provider alike: the two must name - * the same model id, and drift fails at resolution only if both sides resolve - * the same string (Flue patterns audit, 2026-08-17). - */ -export const GHERKIN_MODEL_ID = "claude-haiku-4-5"; - -const gherkinElicitorInitialData = v.object({ - ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())), - targetDocumentId: v.pipe(v.string(), v.nonEmpty()), -}); - -export function GherkinElicitor(props: AgentProps) { - useModel(`anthropic/${GHERKIN_MODEL_ID}`); - const initialData = - useInitialData>(); - return useElicitation( - gherkin, - createGherkinElicitationSession( - props.id, - initialData.targetDocumentId, - initialData.ownerKey, - ), - ); -} - -/** - * Pinned, and never to be edited: conversation storage keys on this literal, - * so changing it orphans every existing conversation. Flue requires a string - * literal here because build targets derive durable identifiers from it before - * any user code runs. - * - * Product-prefixed on purpose, and this is the one place the prefix is not - * cosmetic. Agent identities are global per application, and the September - * demo shell is chartered to mount this library alongside the Petrinaut - * libraries — a bare `gherkin-elicitor` could collide with another library's - * agent, and the collision would land on durable conversation storage. - * - * The exported symbol stays the shorter `GherkinElicitor` because it reads - * better at the mount site; `agentName` exists precisely to let durable - * identity and source-level name differ. - */ -GherkinElicitor.agentName = "brunch-gherkin-elicitor"; - -/** - * Session→document binding (spec §9.1, adjudication L4): a new session's - * `initialData` carries the target-document id, validated once at creation and - * immutable thereafter — Flue's own lane for a target descriptor. Dispatching - * to an existing conversation id resumes that session against the current state - * of its target-document. - */ -GherkinElicitor.initialData = gherkinElicitorInitialData; diff --git a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts deleted file mode 100644 index fadcbe0cc07..00000000000 --- a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts +++ /dev/null @@ -1,66 +0,0 @@ -"use agent"; -/** - * The SDCPN elicitor (spec §12.5: one agent per target). - * - * The second entry in the target gallery, and the first whose plugin is a - * file: `@hashintel/brunch-agent-plugin-sdcpn` loads `plugin.yaml` and the - * harness reads its cells (ADR-0006, ADR-0007). This module is as thin as the - * gherkin one — it mounts harness capability and holds no elicitation - * semantics of its own; what the interviewer asks, demands, and treats as - * complete all comes from the plugin file through the binding. - * - * The same three recorded Flue constraints hold here by construction - * (spec §10): `'use agent'` is the file's first statement; `agentName` is a - * pinned string literal; the tool set is static. - */ - -import { useInitialData, useModel, type AgentProps } from "@flue/runtime"; -import * as v from "valibot"; - -import { useElicitation } from "@hashintel/brunch-agent-binding-flue"; -import { sdcpn } from "@hashintel/brunch-agent-plugin-sdcpn"; - -import { createSdcpnElicitationSession } from "../elicitation-session.ts"; - -/** - * One definition for the agent and any faux provider alike (see the gherkin - * elicitor). `BRUNCH_SDCPN_MODEL` overrides the default so an evaluation - * runner can drive this same agent with a stronger model without a second - * agent definition; the override is read once, at module load, like the rest - * of the agent's static configuration. - */ -export const SDCPN_MODEL_ID = - process.env["BRUNCH_SDCPN_MODEL"] || "claude-haiku-4-5"; - -const sdcpnElicitorInitialData = v.object({ - ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())), - targetDocumentId: v.pipe(v.string(), v.nonEmpty()), -}); - -export function SdcpnElicitor(props: AgentProps) { - useModel(`anthropic/${SDCPN_MODEL_ID}`); - const initialData = - useInitialData>(); - return useElicitation( - sdcpn, - createSdcpnElicitationSession( - props.id, - initialData.targetDocumentId, - initialData.ownerKey, - ), - ); -} - -/** - * Pinned, and never to be edited: conversation storage keys on this literal, - * so changing it orphans every existing conversation. Product-prefixed for the - * same reason as the gherkin elicitor — agent identities are global per - * application and the demo shell mounts this library beside others. - */ -SdcpnElicitor.agentName = "brunch-sdcpn-elicitor"; - -/** - * Session→document binding (spec §9.1): `initialData` carries the - * target-document id, validated once at creation and immutable thereafter. - */ -SdcpnElicitor.initialData = sdcpnElicitorInitialData; diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 308dbe9389c..aef45894af9 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -1,10 +1,9 @@ /** - * The dev app's route map — the "mount" half of the thin host (spec §12.1). + * The app's route map — one plain Flue chat agent plus Petrinaut's /api/chat door. * - * The dev app is chartered with three roles, none of them "the product" - * (spec §12.5): the local dev loop against every plugin, the colleague-facing - * target-gallery demo, and the diagnostic probe surface. Milestone one keeps - * affordance renderers here rather than in a ui package. + * `/api/chat` requires `x-brunch-principal` and hashes principal + conversation + * id into the Flue instance id, so `/agents/chat/:id` is unguessable without + * both. The stock Flue UI at `/` uses a random UUID on the same router. */ import { readFile } from "node:fs/promises"; @@ -14,45 +13,21 @@ import { instrument } from "@flue/runtime"; import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; -import { GherkinElicitor } from "./agents/gherkin-elicitor.ts"; -import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; +import { ChatAgent } from "./agents/chat-agent.ts"; import { assetHandler } from "./assets.ts"; import { petrinautChatHandler } from "./petrinaut-chat.ts"; -import { - GHERKIN_AGENT_ROUTE, - PETRINAUT_CHAT_ROUTE, - SDCPN_AGENT_ROUTE, -} from "./routes.ts"; +import { CHAT_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./routes.ts"; instrument(createOpenTelemetryInstrumentation({ content: false })); const app = new Hono(); -// One route per target agent. The gallery grows an entry per plugin; gherkin -// is the tracer that wires end-to-end first (spec §13). The browser and mount -// share the route constant; Flue still keys storage on the agent's independent, -// pinned identity. -app.route(`/agents/${GHERKIN_AGENT_ROUTE}`, createAgentRouter(GherkinElicitor)); -// The SDCPN elicitor is the process-model target (ADR-0006): the plugin file -// is code the harness loads, and this mount is what FE-1404's run talks to. -app.route(`/agents/${SDCPN_AGENT_ROUTE}`, createAgentRouter(SdcpnElicitor)); +app.route(`/agents/${CHAT_AGENT_ROUTE}`, createAgentRouter(ChatAgent)); -// The application owns the HTTP mount; transport-aisdk owns only request validation -// and AI SDK stream encoding. No parallel conversation renderer is introduced. -app.on(["POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => +app.on(["GET", "POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => petrinautChatHandler(c.req.raw), ); -// The flue dev controller owns the whole request space — no fall-through to -// vite's html serving — so the ui is app-served, in dev and in production -// alike (spec §10, recorded facts). -// -// Two different files, because two different builds produce them: in dev, the -// source `index.html` whose script tag vite resolves live; in production, the -// client build's emitted `index.html`, whose script tag points at a real -// bundled asset. `@flue/vite` emits the server environment only, so that -// client build is a second, plain vite build — without it the ui tree would -// have no build coverage at all. const uiRoot = new URL( // oxlint-disable-next-line typescript/no-unnecessary-condition -- import.meta.env is absent when Node executes this module directly. import.meta.env?.DEV === false ? "./client/" : "../", @@ -63,8 +38,6 @@ app.get("/", async (c) => c.html(await readFile(new URL("index.html", uiRoot), "utf8")), ); -// Production only: in dev, vite serves the module graph under /src. A -// wildcard, not `:file` — bundlers may emit nested asset paths. app.get("/assets/*", assetHandler(uiRoot)); export default app; diff --git a/apps/brunch-agent/src/conversation-identity.ts b/apps/brunch-agent/src/conversation-identity.ts new file mode 100644 index 00000000000..8da87fef759 --- /dev/null +++ b/apps/brunch-agent/src/conversation-identity.ts @@ -0,0 +1,13 @@ +/** Stable Flue instance id for one principal + panel conversation. */ + +import { createHash } from "node:crypto"; + +export const flueConversationId = ( + principalKey: string, + conversationId: string, +): string => + createHash("sha256") + .update(principalKey) + .update("\0") + .update(conversationId) + .digest("hex"); diff --git a/apps/brunch-agent/src/db.ts b/apps/brunch-agent/src/db.ts index a63b9680437..3750ec79bda 100644 --- a/apps/brunch-agent/src/db.ts +++ b/apps/brunch-agent/src/db.ts @@ -1,15 +1,8 @@ /** * The substrate's conversation storage — host-authored because Flue requires - * it of the consuming app (spec §9.6, adjudication C1). + * it of the consuming app. * - * Not to be confused with the capture store: that is the harness's storage - * port, harness-defined and implemented in `@hashintel/brunch-agent-binding-flue`, and plugins are - * blind to both. This file holds only the live transport copy of conversations. - * The provenance record is the target-document's own session-log archive. - * - * Without this file conversations are process-memory and a restart loses them - * (recorded Flue fact, spec §10). Restart durability of the full stack is an - * open verification item (spec §14.5) that this file exists to make testable. + * Without this file conversations are process-memory and a restart loses them. */ import { sqlite } from "@flue/runtime/node"; diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts deleted file mode 100644 index f99c159f325..00000000000 --- a/apps/brunch-agent/src/elicitation-session.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** Host-owned wiring for the local Flue binding's history transport and store. */ -import { createHash } from "node:crypto"; - -import { - createFlueHistoryReader, - createLocalCaptureStore, - type ElicitationSession, - type FlueHistoryReaderOptions, -} from "@hashintel/brunch-agent-binding-flue"; - -import { AGENT_ROUTES, type AgentTarget } from "./routes.ts"; -import { targetDocumentPath } from "./target-document-path.ts"; - -export const resolvePetrinautSessionIdentity = ( - principalKey: string, - conversationId: string, -) => { - const sessionDigest = createHash("sha256") - .update(JSON.stringify([principalKey, conversationId])) - .digest("hex"); - return { - ownerKey: principalKey, - sessionId: `petrinaut-local:${sessionDigest}`, - targetDocumentId: `petrinaut-local:${principalKey}`, - } as const; -}; - -const appTransport: FlueHistoryReaderOptions["transport"] = async ( - input, - init, -) => { - const { default: app } = await import("./app.ts"); - return app.fetch(input instanceof Request ? input : new Request(input, init)); -}; - -/** - * One session factory per target agent. The history reader resolves - * conversations through the agent's own route, so each target gets a - * named creator rather than a shared one that guesses the route. - */ -const createElicitationSession = ( - target: AgentTarget, - sessionId: string, - targetDocumentId: string, - ownerKey?: string, -): ElicitationSession => { - const captureStore = createLocalCaptureStore( - targetDocumentPath(targetDocumentId), - ownerKey === undefined ? {} : { ownerKey }, - ); - return { - sessionId, - captureStore, - historyReader: createFlueHistoryReader({ - resolveConversationUrl: (id) => - `http://brunch.local/agents/${AGENT_ROUTES[target]}/${id}`, - transport: appTransport, - archive: captureStore, - }), - }; -}; - -export const createGherkinElicitationSession = ( - sessionId: string, - targetDocumentId: string, - ownerKey?: string, -): ElicitationSession => - createElicitationSession("gherkin", sessionId, targetDocumentId, ownerKey); - -export const createSdcpnElicitationSession = ( - sessionId: string, - targetDocumentId: string, - ownerKey?: string, -): ElicitationSession => - createElicitationSession("sdcpn", sessionId, targetDocumentId, ownerKey); diff --git a/apps/brunch-agent/src/flue-transcript.ts b/apps/brunch-agent/src/flue-transcript.ts new file mode 100644 index 00000000000..1ab577d1618 --- /dev/null +++ b/apps/brunch-agent/src/flue-transcript.ts @@ -0,0 +1,219 @@ +/** Human-readable and UI-message projections of Flue's public conversation snapshot. */ + +import { + type FlueConversationMessage, + type FlueConversationPart, + type FlueConversationSnapshot, +} from "@flue/sdk"; + +const CLIENT_TOOL_RESULT_SIGNAL = "client-tool-result"; +const AWAITING_CLIENT = "awaiting"; + +type UiMessagePart = + | { readonly type: "text"; readonly text: string; readonly state: "done" } + | { + readonly type: "reasoning"; + readonly text: string; + readonly state: "done"; + } + | { + readonly type: `tool-${string}`; + readonly toolCallId: string; + readonly state: "output-available" | "output-error" | "input-available"; + readonly input: unknown; + readonly output?: unknown; + readonly errorText?: string; + readonly providerExecuted?: boolean; + }; + +export interface UiHistoryMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly parts: readonly UiMessagePart[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const clientToolResultsFrom = ( + snapshot: FlueConversationSnapshot, +): ReadonlyMap => { + const outputsByCallId = new Map(); + for (const message of snapshot.messages) { + if (message.purpose !== "dispatch") continue; + if (message.signal?.tagName !== CLIENT_TOOL_RESULT_SIGNAL) continue; + const parsed: unknown = (() => { + try { + return JSON.parse( + message.parts + .filter( + (part): part is Extract => + part.type === "text", + ) + .map((part) => part.text) + .join(""), + ) as unknown; + } catch { + return undefined; + } + })(); + const results = Array.isArray(parsed) ? parsed : []; + for (const result of results) { + if ( + !isRecord(result) || + typeof result.toolCallId !== "string" || + !("output" in result) + ) { + continue; + } + outputsByCallId.set(result.toolCallId, result.output); + } + } + return outputsByCallId; +}; + +const isAwaitingClient = (output: unknown): boolean => + isRecord(output) && output[AWAITING_CLIENT] === "client"; + +const toolPartFrom = ( + part: Extract, + clientOutputs: ReadonlyMap, +): UiMessagePart => { + const clientOutput = clientOutputs.get(part.toolCallId); + const output = + part.state === "output-available" && isAwaitingClient(part.output) + ? (clientOutput ?? part.output) + : part.state === "output-available" + ? part.output + : clientOutput; + const providerExecuted = + part.state === "output-available" && !isAwaitingClient(part.output) + ? true + : undefined; + if (part.state === "output-error") { + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "output-error", + input: part.input, + errorText: part.errorText, + ...(providerExecuted === undefined ? {} : { providerExecuted }), + }; + } + if (output !== undefined) { + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "output-available", + input: part.input, + output, + ...(providerExecuted === undefined ? {} : { providerExecuted }), + }; + } + return { + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "input-available", + input: part.input, + }; +}; + +const partsFrom = ( + message: FlueConversationMessage, + clientOutputs: ReadonlyMap, +): UiMessagePart[] => { + const parts: UiMessagePart[] = []; + for (const part of message.parts) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text, state: "done" }); + continue; + } + if (part.type === "reasoning") { + parts.push({ type: "reasoning", text: part.text, state: "done" }); + continue; + } + if (part.type === "dynamic-tool") { + parts.push(toolPartFrom(part, clientOutputs)); + } + } + return parts; +}; + +export const snapshotToUiMessages = ( + snapshot: FlueConversationSnapshot, +): UiHistoryMessage[] => { + const clientOutputs = clientToolResultsFrom(snapshot); + const messages: UiHistoryMessage[] = []; + for (const message of snapshot.messages) { + if (message.display !== "visible") continue; + if (message.purpose !== "user" && message.purpose !== "assistant") continue; + if (message.role !== "user" && message.role !== "assistant") continue; + const parts = partsFrom(message, clientOutputs); + if (parts.length === 0) continue; + messages.push({ id: message.id, role: message.role, parts }); + } + return messages; +}; + +const textOf = (message: FlueConversationMessage): string => + message.parts + .filter( + (part): part is Extract => + part.type === "text", + ) + .map((part) => part.text) + .join(""); + +const formatToolPart = ( + part: Extract, + clientOutputs: ReadonlyMap, +): string => { + const output = + part.state === "output-available" && isAwaitingClient(part.output) + ? (clientOutputs.get(part.toolCallId) ?? part.output) + : part.state === "output-available" + ? part.output + : clientOutputs.get(part.toolCallId); + const result = + part.state === "output-error" + ? `error: ${part.errorText}` + : output === undefined + ? "pending" + : JSON.stringify(output); + return `- tool ${part.toolName} (${part.toolCallId}): ${result}`; +}; + +/** + * Built-in Flue `history()` snapshot → human-readable transcript. + * User text, assistant text, and tool interactions, including client-tool + * results delivered as signals. + */ +export const formatFlueTranscript = ( + snapshot: FlueConversationSnapshot, +): string => { + const clientOutputs = clientToolResultsFrom(snapshot); + const sections: string[] = []; + for (const message of snapshot.messages) { + if (message.purpose === "dispatch") { + if (message.signal?.tagName !== CLIENT_TOOL_RESULT_SIGNAL) continue; + const body = textOf(message); + if (body.length === 0) continue; + sections.push(`Signal ${CLIENT_TOOL_RESULT_SIGNAL}: ${body}`); + continue; + } + if (message.display !== "visible") continue; + if (message.purpose !== "user" && message.purpose !== "assistant") continue; + const speaker = message.purpose === "user" ? "User" : "Assistant"; + const lines: string[] = []; + const text = textOf(message); + if (text.length > 0) lines.push(text); + for (const part of message.parts) { + if (part.type === "dynamic-tool") { + lines.push(formatToolPart(part, clientOutputs)); + } + } + if (lines.length === 0) continue; + sections.push(`## ${speaker}\n${lines.join("\n")}`); + } + return sections.join("\n\n"); +}; diff --git a/apps/brunch-agent/src/flue-ui-stream.ts b/apps/brunch-agent/src/flue-ui-stream.ts new file mode 100644 index 00000000000..db133e2dd78 --- /dev/null +++ b/apps/brunch-agent/src/flue-ui-stream.ts @@ -0,0 +1,160 @@ +/** Project Flue live conversation chunks into AI SDK UI-message-stream chunks. */ + +import { type ConversationStreamChunk } from "@flue/sdk"; + +import type { UIMessageChunk } from "ai"; + +export interface FlueUiStreamOptions { + readonly submissionId: string; + readonly clientToolNames: ReadonlySet; + readonly write: (chunk: UIMessageChunk) => void; +} + +type StreamingPart = { + readonly kind: "text" | "reasoning"; + readonly partId: string; +}; + +export const createFlueUiStream = ( + options: FlueUiStreamOptions, +): { accept: (chunk: ConversationStreamChunk) => void } => { + let accepting = false; + let messageId: string | undefined; + let turnId: string | undefined; + let partOrdinal = 0; + let streamingPart: StreamingPart | undefined; + const pendingClientToolCallIds = new Set(); + + const finishPart = (): void => { + if (!streamingPart) return; + options.write({ + type: `${streamingPart.kind}-end`, + id: streamingPart.partId, + }); + streamingPart = undefined; + }; + + const finishTurn = (): void => { + finishPart(); + if (!turnId) return; + options.write({ type: "finish-step" }); + turnId = undefined; + }; + + const startPart = (kind: StreamingPart["kind"]): StreamingPart => { + finishPart(); + partOrdinal += 1; + const part = { + kind, + partId: `${messageId}:${kind}:${partOrdinal}`, + } as const; + options.write({ type: `${kind}-start`, id: part.partId }); + streamingPart = part; + return part; + }; + + return { + accept(chunk) { + if (chunk.type === "message-started") { + accepting = chunk.submissionId === options.submissionId; + if (!accepting) return; + + if (messageId === undefined) { + messageId = chunk.messageId; + options.write({ type: "start", messageId }); + } + finishTurn(); + turnId = chunk.turnId ?? `${messageId}:turn`; + options.write({ type: "start-step" }); + return; + } + + if (chunk.type === "submission-settled") { + if (chunk.submissionId !== options.submissionId) return; + finishTurn(); + switch (chunk.outcome) { + case "completed": + options.write({ + type: "finish", + finishReason: + pendingClientToolCallIds.size > 0 ? "tool-calls" : "stop", + }); + break; + case "failed": + options.write({ + type: "error", + errorText: "The chat turn failed.", + }); + break; + case "aborted": + options.write({ type: "abort", reason: "The chat turn aborted." }); + break; + } + accepting = false; + return; + } + + if (!accepting || messageId === undefined) return; + + switch (chunk.type) { + case "message-delta": { + if (chunk.messageId !== messageId) return; + const part = + streamingPart?.kind === chunk.kind + ? streamingPart + : startPart(chunk.kind); + options.write({ + type: `${part.kind}-delta`, + id: part.partId, + delta: chunk.delta, + }); + return; + } + case "tool-input": { + if (chunk.messageId !== messageId) return; + finishPart(); + const isClientTool = options.clientToolNames.has(chunk.toolName); + if (isClientTool) pendingClientToolCallIds.add(chunk.toolCallId); + options.write({ + type: "tool-input-available", + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + input: chunk.input, + ...(isClientTool ? {} : { providerExecuted: true }), + }); + return; + } + case "tool-output": { + if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + options.write({ + type: "tool-output-available", + toolCallId: chunk.toolCallId, + output: chunk.output, + providerExecuted: true, + }); + return; + } + case "tool-output-error": { + if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + options.write({ + type: "tool-output-error", + toolCallId: chunk.toolCallId, + errorText: chunk.errorText, + providerExecuted: true, + }); + return; + } + case "message-completed": { + if (chunk.messageId === messageId) finishTurn(); + return; + } + case "conversation-reset": + case "message-appended": + case "message-metadata": + case "data-part": + case "stream-checkpoint": + return; + } + }, + }; +}; diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 498049d22a2..8c6d2345626 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -1,62 +1,114 @@ /** Application composition for Petrinaut's stock AI SDK chat transport. */ import { init } from "@flue/runtime"; +import { createFlueClient, type FlueConversationSnapshot } from "@flue/sdk"; -import { - decideAskReplyAdmission, - pendingAskAffordanceId, -} from "@hashintel/brunch-agent"; -import { - createFlueReplyProjector, - projectFlueHistoryForSweep, -} from "@hashintel/brunch-agent-binding-flue"; import { createAiSdkChatHandler, - type HarnessReplyEvent, + type ChatResumeInput, + type ChatTurnInput, type TransportInspectionEvent, } from "@hashintel/brunch-agent-transport-aisdk"; -import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; import { - createSdcpnElicitationSession, - resolvePetrinautSessionIdentity, -} from "./elicitation-session.ts"; + ChatAgent, + READ_PETRINAUT_DOC_TOOL_NAME, +} from "./agents/chat-agent.ts"; +import { flueConversationId } from "./conversation-identity.ts"; +import { snapshotToUiMessages } from "./flue-transcript.ts"; +import { createFlueUiStream } from "./flue-ui-stream.ts"; import { defaultPanelOrigins } from "./local-dev-origins.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +import type { UIMessageChunk } from "ai"; const inspect = process.env.BRUNCH_TRANSPORT_AISDK_INSPECT === "1" ? (event: TransportInspectionEvent): void => { - // This is an opt-in shell diagnostic stream. It is never dispatched - // into Flue and therefore cannot become elicitation evidence. process.stdout.write(`TRANSPORT_AISDK ${JSON.stringify(event)}\n`); } : undefined; -const streamElicitorTurn = async ( - principalKey: string, - conversationId: string, - dispatch: { readonly message: string; readonly idempotencyKey: string }, - emit: (event: HarnessReplyEvent) => void, -): Promise => { - const identity = resolvePetrinautSessionIdentity( - principalKey, - conversationId, - ); - const agent = init(SdcpnElicitor, { id: identity.sessionId }); - const receipt = await agent.dispatch({ - ...dispatch, - initialData: { - ownerKey: identity.ownerKey, - targetDocumentId: identity.targetDocumentId, - }, +const clientToolNames = new Set([READ_PETRINAUT_DOC_TOOL_NAME]); + +const appTransport: typeof fetch = async (input, init) => { + const { default: app } = await import("./app.ts"); + return app.fetch(input instanceof Request ? input : new Request(input, init)); +}; + +const conversationUrl = (instanceId: string): string => + `http://brunch.local/agents/${CHAT_AGENT_ROUTE}/${instanceId}`; + +const historyClient = (instanceId: string) => + createFlueClient({ + url: conversationUrl(instanceId), + fetch: appTransport, }); - const projector = createFlueReplyProjector({ + +const streamTurn = async ( + instanceId: string, + dispatch: Parameters["dispatch"]>[0], + write: (chunk: UIMessageChunk) => void, +): Promise => { + const agent = init(ChatAgent, { id: instanceId }); + const receipt = await agent.dispatch(dispatch); + const projector = createFlueUiStream({ submissionId: receipt.submissionId, - emit, + clientToolNames, + write, }); await agent.read(receipt, { onEvent: (chunk) => projector.accept(chunk) }); }; +const runUserTurn = ( + input: ChatTurnInput, + write: (chunk: UIMessageChunk) => void, +): Promise => + streamTurn( + flueConversationId(input.principalKey, input.conversationId), + { message: input.userMessage.text }, + write, + ); + +const runClientToolResume = ( + input: ChatResumeInput, + write: (chunk: UIMessageChunk) => void, +): Promise => + streamTurn( + flueConversationId(input.principalKey, input.conversationId), + { + message: { + kind: "signal", + type: "client-tool-result", + tagName: "client-tool-result", + body: JSON.stringify(input.toolResults), + attributes: { + toolCallIds: input.toolResults + .map((result) => result.toolCallId) + .join(","), + }, + }, + }, + write, + ); + +const loadHistory = async (input: { + readonly conversationId: string; + readonly principalKey: string; +}): Promise<{ readonly messages: readonly unknown[] }> => { + const instanceId = flueConversationId( + input.principalKey, + input.conversationId, + ); + let snapshot: FlueConversationSnapshot; + try { + snapshot = await historyClient(instanceId).history(); + } catch { + return { messages: [] }; + } + return { messages: snapshotToUiMessages(snapshot) }; +}; + export const petrinautChatHandler = createAiSdkChatHandler({ allowedOrigins: ( process.env.BRUNCH_PETRINAUT_ORIGINS ?? defaultPanelOrigins.join(",") @@ -65,43 +117,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({ .map((origin) => origin.trim()) .filter((origin) => origin.length > 0), inspect, - runTurn: (input, emit) => - streamElicitorTurn( - input.principalKey, - input.conversationId, - { message: input.userMessage.text, idempotencyKey: input.idempotencyKey }, - emit, - ), - askReply: { - // Admission consults durable Flue history, not request-shaped claims: the - // submission resumes the conversation only when its tool-call id - // correlates with the one ask still awaiting a reply. - async admit(input) { - const identity = resolvePetrinautSessionIdentity( - input.principalKey, - input.conversationId, - ); - const session = createSdcpnElicitationSession( - identity.sessionId, - identity.targetDocumentId, - identity.ownerKey, - ); - const entries = projectFlueHistoryForSweep( - await session.historyReader.peek(identity.sessionId), - ); - return decideAskReplyAdmission( - pendingAskAffordanceId(entries), - input.ask.toolCallId, - ); - }, - // The admitted answer is a fresh user dispatch (spec §7.4); the binding - // binds it to the pending affordance, making it the user-affordance reply. - run: (input, emit) => - streamElicitorTurn( - input.principalKey, - input.conversationId, - { message: input.ask.answer, idempotencyKey: input.idempotencyKey }, - emit, - ), - }, + runTurn: runUserTurn, + resumeTurn: runClientToolResume, + loadHistory, }); diff --git a/apps/brunch-agent/src/routes.ts b/apps/brunch-agent/src/routes.ts index 40aaa363945..5100c2cb669 100644 --- a/apps/brunch-agent/src/routes.ts +++ b/apps/brunch-agent/src/routes.ts @@ -1,17 +1,6 @@ -/** Browser-facing route segments; conversation identity remains each agent's pinned `agentName`. */ -export const GHERKIN_AGENT_ROUTE = "gherkin"; -export const SDCPN_AGENT_ROUTE = "sdcpn"; +/** Browser-facing route segments; conversation identity remains the agent's pinned `agentName`. */ -/** One route per target agent; the gallery grows an entry per plugin (spec §13). */ -export const AGENT_ROUTES = { - gherkin: GHERKIN_AGENT_ROUTE, - sdcpn: SDCPN_AGENT_ROUTE, -} as const; - -export type AgentTarget = keyof typeof AGENT_ROUTES; - -export const isAgentTarget = (value: string | null): value is AgentTarget => - value !== null && Object.hasOwn(AGENT_ROUTES, value); +export const CHAT_AGENT_ROUTE = "chat"; /** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */ export const PETRINAUT_CHAT_ROUTE = "/api/chat"; diff --git a/apps/brunch-agent/src/target-document-path.ts b/apps/brunch-agent/src/target-document-path.ts deleted file mode 100644 index a2939f7ee7b..00000000000 --- a/apps/brunch-agent/src/target-document-path.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Resolve one target-document's local binding store without trusting its id as a path. */ - -import { createHash } from "node:crypto"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const defaultDirectory = (): string => - fileURLToPath(new URL("../.data-wipe-me/target-documents/", import.meta.url)); - -export function targetDocumentPath(targetDocumentId: string): string { - if (targetDocumentId.length === 0) - throw new TypeError("A target-document id cannot be empty."); - const directory = - process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR || defaultDirectory(); - const identity = createHash("sha256").update(targetDocumentId).digest("hex"); - return join(directory, `${identity}.json`); -} diff --git a/apps/brunch-agent/src/transcript-cli.ts b/apps/brunch-agent/src/transcript-cli.ts new file mode 100644 index 00000000000..61a54ead036 --- /dev/null +++ b/apps/brunch-agent/src/transcript-cli.ts @@ -0,0 +1,46 @@ +/** + * Print a human-readable transcript from Flue `history()` for one conversation. + * + * Usage, with the Brunch server already running (`yarn dev:brunch`): + * + * yarn workspace @apps/brunch-agent transcript -- --principal --id + * + * Identity matches POST /api/chat: principal + conversation id hash to the + * Flue instance. This is a read of canonical Flue history, not a second log. + */ + +import { createFlueClient } from "@flue/sdk"; + +import { flueConversationId } from "./conversation-identity.ts"; +import { formatFlueTranscript } from "./flue-transcript.ts"; +import { defaultChatOrigin } from "./local-dev-origins.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +const readFlag = ( + argv: readonly string[], + name: string, +): string | undefined => { + const index = argv.indexOf(name); + if (index < 0) return undefined; + const value = argv[index + 1]; + return value === undefined || value.length === 0 ? undefined : value; +}; + +const argv = process.argv.slice(2); +const principalKey = readFlag(argv, "--principal"); +const conversationId = readFlag(argv, "--id"); +const origin = readFlag(argv, "--origin") ?? defaultChatOrigin; + +if (principalKey === undefined || conversationId === undefined) { + process.stderr.write( + "usage: transcript -- --principal --id [--origin ]\n", + ); + process.exit(1); +} + +const instanceId = flueConversationId(principalKey, conversationId); +const snapshot = await createFlueClient({ + url: `${origin}/agents/${CHAT_AGENT_ROUTE}/${instanceId}`, +}).history(); + +process.stdout.write(`${formatFlueTranscript(snapshot)}\n`); diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx index df13a33924b..62d3e613f0e 100644 --- a/apps/brunch-agent/src/ui/chat.tsx +++ b/apps/brunch-agent/src/ui/chat.tsx @@ -1,22 +1,11 @@ import { useFlueAgent } from "@flue/react"; import { createFlueClient, type FlueConversationMessage } from "@flue/sdk"; -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; -import * as v from "valibot"; +import { useMemo, useState, type FormEvent } from "react"; -import { FreeTextAffordance } from "@hashintel/brunch-agent"; - -import { AGENT_ROUTES, isAgentTarget } from "../routes.ts"; +import { CHAT_AGENT_ROUTE } from "../routes.ts"; const conversationId = crypto.randomUUID(); -// `?target=sdcpn` selects the agent; the gallery is one route per plugin and -// gherkin remains the default tracer. -const requestedTarget = new URLSearchParams(window.location.search).get( - "target", -); -const agentRoute = - AGENT_ROUTES[isAgentTarget(requestedTarget) ? requestedTarget : "gherkin"]; - function VisibleMessage({ message }: { message: FlueConversationMessage }) { if ( message.display !== "visible" || @@ -28,30 +17,17 @@ function VisibleMessage({ message }: { message: FlueConversationMessage }) { return (

- {message.role === "user" ? "You" : "Interviewer"} + {message.role === "user" ? "You" : "Assistant"}

- {message.parts.map((part, index) => { + {message.parts.map((part, partIndex) => { if (part.type === "text") { return ( // oxlint-disable-next-line react/no-array-index-key -- Flue text parts expose no stable identifier. -

+

{part.text}

); } - if (part.type === "data-affordance") { - const affordance = v.safeParse(FreeTextAffordance, part.data); - if (!affordance.success) return null; - return ( -
- Question -

{affordance.output.markdown}

- - Reply in your own words below. - -
- ); - } return null; })}
@@ -60,45 +36,16 @@ function VisibleMessage({ message }: { message: FlueConversationMessage }) { export function Chat() { const [input, setInput] = useState(""); - const [bootstrapping, setBootstrapping] = useState(true); - const [startupError, setStartupError] = useState(); - const started = useRef(false); const client = useMemo( () => createFlueClient({ - url: `/agents/${agentRoute}/${conversationId}`, + url: `/agents/${CHAT_AGENT_ROUTE}/${conversationId}`, }), [], ); const agent = useFlueAgent({ client }); - useEffect(() => { - if (!agent.historyReady || agent.messages.length > 0 || started.current) - return; - started.current = true; - - void (async () => { - try { - const admission = await client.send({ - message: { kind: "user", body: "Begin the interview." }, - initialData: { targetDocumentId: `dev-${conversationId}` }, - }); - await client.wait(admission); - agent.refresh(); - } catch (error: unknown) { - setStartupError( - error instanceof Error - ? error.message - : "The interview could not start.", - ); - } finally { - setBootstrapping(false); - } - })(); - }, [agent, client]); - const busy = - bootstrapping || agent.status === "connecting" || agent.status === "submitted" || agent.status === "streaming"; @@ -115,8 +62,8 @@ export function Chat() {
-

Brunch / elicitation field notes

-

Tell me how it should behave.

+

Brunch / Flue chat

+

Plain Flue conversation

{agent.status}
@@ -125,21 +72,17 @@ export function Chat() { {agent.messages.map((message) => ( ))} - {agent.messages.length === 0 && !startupError ? ( -

Opening a fresh interview…

- ) : null} - {startupError ?

{startupError}

: null} {agent.error ?

{agent.error.message}

: null}
- +