diff --git a/.changeset/ag-ui-adapter.md b/.changeset/ag-ui-adapter.md new file mode 100644 index 00000000..a905ea10 --- /dev/null +++ b/.changeset/ag-ui-adapter.md @@ -0,0 +1,22 @@ +--- +"@dawn-ai/ag-ui": patch +"@dawn-ai/cli": patch +"@dawn-ai/langchain": patch +--- + +Consolidate the existing `@dawn-ai/ag-ui` package as Dawn's pure canonical AG-UI +adapter. Its root API now maps standard `RunAgentInput` requests and Dawn stream +chunks, including standard interrupt outcomes and addressed resume decisions, +while the focused `@dawn-ai/ag-ui/sse` subpath provides event-stream encoding +without taking ownership of a server or runtime transport. + +The CLI AG-UI endpoint now uses the canonical adapter, applies the same request +projection as other runtime middleware, and emits canonical events without the +former custom state event shapes. Pending checkpoint interrupts are resolved +through the standard resume contract. + +The langchain adapter surfaces each tool invocation's `run_id` on its +`tool_call` and `tool_result` chunks, and the CLI preserves those IDs through +Dawn and AG-UI streams for reliable `toolCallId` correlation. Local in-process +`dawn run` also assigns agent routes a one-shot thread ID so the default SQLite +checkpointer can execute the same route shape supported by `dawn dev`. diff --git a/apps/web/content/docs/ag-ui.mdx b/apps/web/content/docs/ag-ui.mdx index 75130ebe..465a1b59 100644 --- a/apps/web/content/docs/ag-ui.mdx +++ b/apps/web/content/docs/ag-ui.mdx @@ -1,10 +1,13 @@ # AG-UI & Web Clients -`dawn dev` (and the `node` build target in production) serves a second protocol alongside Agent Protocol: [AG-UI](https://github.com/ag-ui-protocol/ag-ui), a wire format built for streaming a run to a browser. `@dawn-ai/ag-ui` is the translation layer between the two — it maps Dawn's internal stream chunks to AG-UI events, and maps an AG-UI `RunAgentInput` back to a Dawn route input. This page covers what that translation guarantees; for the endpoint's request/response shape, see [Dev Server](/docs/dev-server#ag-ui-endpoint). +Dawn exposes [AG-UI](https://github.com/ag-ui-protocol/ag-ui) for browser clients +alongside Agent Protocol. `@dawn-ai/ag-ui` is the pure translation library: it +maps Dawn stream chunks to AG-UI events and maps `RunAgentInput` back to a +Dawn-shaped run input. The package owns no server, transport, or agent runtime. -## Why a second protocol - -Agent Protocol (`/threads/:id/runs/wait`, `/runs/stream`) is the durable, thread-oriented interface Dawn harnesses and the CLI use. AG-UI is what UI component libraries — CopilotKit chief among them — expect: a single `POST` that opens an SSE stream of typed UI events (message deltas, tool call lifecycle, state snapshots). Both sit on top of the same route runtime and the same thread; a request through either protocol reads and writes the same checkpointed state in `.dawn/threads.sqlite` and `.dawn/checkpoints.sqlite`. AG-UI is not a separate agent runtime — it's a client-facing view onto the one Dawn already runs. +`dawn dev` and the production runtime started by `dawn start` use that adapter to +serve an SSE endpoint. For its complete request and middleware behavior, see +[Dev Server](/docs/dev-server#ag-ui-endpoint). ## The endpoint @@ -14,70 +17,115 @@ content-type: application/json accept: text/event-stream ``` -The URL segment is the URL-encoded `#` assistant id (`/chat#agent` → `/agui/%2Fchat%23agent`). The request body is an AG-UI `RunAgentInput`; Dawn creates the thread when the incoming `threadId` is new, maps the newest user message to the route's input, and streams the translated event sequence back. Full request/response details, including how HITL resume rides on `forwardedProps.command.resume`, live on [Dev Server](/docs/dev-server#ag-ui-endpoint) — this page does not repeat that reference. +The URL segment is the URL-encoded `#` assistant id. For example, +`/chat#agent` becomes `/agui/%2Fchat%23agent`. The request body is an AG-UI +`RunAgentInput`. Dawn creates an unknown thread, invokes the same route runtime +used by Agent Protocol, and streams translated events back as SSE. ## Consuming it from a web UI -`examples/chat/web` is the canonical reference client: a CopilotKit v2 app (`@copilotkit/react-core/v2` + `@copilotkit/runtime`) whose runtime route registers an `HttpAgent` pointed at Dawn's `/agui/{routeId}` endpoint. The browser never talks to Dawn directly — CopilotKit's Next.js runtime route proxies to it, and only the Dawn server holds model credentials. +`examples/chat/web` is the canonical reference client. Its CopilotKit runtime +registers an `HttpAgent` pointed at Dawn's encoded route URL. The browser talks +to the Next.js runtime route, while only the Dawn server holds model credentials. ```text browser - -> CopilotKit runtime (Next.js route, no API key) - -> HttpAgent -> POST /agui/%2Fchat%23agent (Dawn dev server, holds the model API key) - -> the live route (workspace tools, planning, HITL permissions) - -> AG-UI event stream back to the browser + -> CopilotKit runtime + -> HttpAgent -> POST /agui/%2Fchat%23agent + -> Dawn route runtime + -> AG-UI event stream ``` -CopilotKit's hooks (`useAgent`, `useInterrupt`, `CopilotSidebar`) fall back to the literal agent id `"default"` when none is given — the example registers the Dawn route under that key so the components bind without per-component wiring. See `examples/chat/web/README.md` for the full architecture and a live smoke checklist. +CopilotKit's sidebar uses the literal agent id `default` when none is supplied, +so the example registers the Dawn route under that key. See +`examples/chat/web/README.md` for the complete setup and smoke checklist. -## Event contract +## Adapter API -`createAgUiTranslator({ threadId, runId })` from `@dawn-ai/ag-ui` (verified against `packages/ag-ui/src/translate.ts`) emits these AG-UI event types, one Dawn stream chunk at a time: +The root package exports the transport-independent mapping surface: -| Dawn chunk | AG-UI event(s) | -|---|---| -| (stream start) | `RUN_STARTED` | -| `token` / `chunk` | `TEXT_MESSAGE_START` (once) → `TEXT_MESSAGE_CONTENT` per delta | -| `tool_call` | `TEXT_MESSAGE_END` (if a message was open) → `TOOL_CALL_START` → `TOOL_CALL_ARGS` → `TOOL_CALL_END` | -| `tool_result` | `TEXT_MESSAGE_END` (if open) → `TOOL_CALL_RESULT` | -| `plan_update` | `STATE_SNAPSHOT` (merges into a running state object the translator accumulates for the run) | -| `interrupt` | `CUSTOM` with `name: "on_interrupt"` | -| `subagent.*` | `CUSTOM` with `name: "dawn."` | -| any other object-shaped chunk | `STATE_SNAPSHOT` (merged into the same running state) | -| `done` (success) | `RUN_FINISHED` with `{ threadId, runId, result }` | -| `done` (chunk output has an `error` field) | `RUN_ERROR` | -| stream ends without a `done` chunk | `RUN_FINISHED` (no result) | -| stream throws | `RUN_ERROR` | +```ts +import { fromRunAgentInput, toAguiEvents } from "@dawn-ai/ag-ui" -A text message is always closed (`TEXT_MESSAGE_END`) before a tool call, tool result, state snapshot, or the terminal event is emitted — the translator never interleaves an open text message with another event type. +const dawnInput = fromRunAgentInput(runAgentInput) -### Threading +for await (const event of toAguiEvents(dawnChunks, { threadId, runId })) { + // Send `event` through the transport chosen by the application. +} +``` -AG-UI's `threadId` on `RunAgentInput` **is** the Dawn thread id — there's no separate mapping table. `mapRunInput` (in `packages/ag-ui/src/run-input.ts`) takes the newest `user`-role message from `RunAgentInput.messages` as the turn's input; Dawn keeps the rest of the conversation in its own checkpoint keyed by that thread id, so only the newest turn is forwarded on the wire. +Transport helpers are isolated behind subpaths. An SSE server can encode an +event without expanding the root API: + +```ts +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" + +response.write(encodeAgUiSse(event, request.headers.accept)) +``` -### Human-in-the-loop resume +### Outbound events -A `kind: "on_interrupt"` `CUSTOM` event carries Dawn's permission or memory interrupt payload (the same shapes documented in [Permissions](/docs/permissions)). The client resumes by sending `forwardedProps.command.resume` on the next `RunAgentInput` — either a bare decision string or `{ decision, interruptId }`: +`toAguiEvents(chunks, context)` is a stateful async generator. It frames Dawn's +implicit assistant text and preserves upstream tool-call ids for result +correlation. -```json -{ "forwardedProps": { "command": { "resume": { "decision": "once", "interruptId": "perm-abc123" } } } } +| Dawn chunk | AG-UI event(s) | +|---|---| +| stream start | `RUN_STARTED` | +| `token` | `TEXT_MESSAGE_START` once, then `TEXT_MESSAGE_CONTENT` per delta | +| `tool_call` | close open text, then `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END` | +| `tool_result` | close open text, then `TOOL_CALL_RESULT` | +| `interrupt` | terminal `RUN_FINISHED` with `outcome.type: "interrupt"` | +| `done` | terminal `RUN_FINISHED` with `outcome.type: "success"` | +| upstream error | terminal `RUN_ERROR` | + +Planning updates, subagent capability events, and unknown chunk types have no v1 +mapping and are ignored. The adapter does not invent application state or +protocol-specific custom events. + +### Inbound input + +`fromRunAgentInput(input)` translates every AG-UI message and preserves the +original request as `raw`, where consumers can inspect tools, state, and context. +The CLI endpoint forwards only the newest user message because Dawn owns the +checkpointed conversation for the thread. + +The standard top-level `RunAgentInput.resume` array is preserved as +vocabulary-neutral Dawn resume requests: + +```ts +resume?: Array<{ + interruptId: string + status: "resolved" | "cancelled" + payload?: unknown +}> ``` -### Disconnect and reconnect +For a permission prompt, a resolved payload can carry a Dawn decision such as +`"once"` or `"always"`; cancelling the interrupt maps to denial at the runtime +boundary. Every answer must address one currently pending interrupt. - - The AG-UI handler (`packages/cli/src/lib/dev/agui-handler.ts`) opens one SSE response per `POST` and writes translated events to it as the route streams. There is no server-side buffer, no last-event-id tracking, and no replay mechanism — if the connection drops mid-run, the events already written are gone and the handler has no way to resume that specific stream. This is not a documented guarantee Dawn makes; do not build reconnect-and-catch-up logic that assumes one exists. The route's underlying execution and checkpoint are unaffected by a dropped SSE connection (the run keeps going and the thread state still lands in `.dawn/checkpoints.sqlite`), but the *client* has no protocol-level way to reattach to that in-flight stream — a fresh request starts a new run against the same thread, picking up from the last checkpoint rather than the interrupted stream. - +## Threading -## Relation to Agent Protocol +AG-UI's `threadId` is the Dawn thread id; there is no separate mapping table. +AG-UI and Agent Protocol requests execute the same route code and use the same +thread store and checkpointer. A caller can inspect state through Agent Protocol +while a UI drives later turns through AG-UI. -AG-UI and Agent Protocol are two protocols in front of the same runtime, not two runtimes. A route invoked via `/agui/{routeId}` and one invoked via `/threads/:id/runs/stream` execute the same route code, share the same thread store and checkpointer, and can be mixed against the same `threadId` — an operator could inspect a thread's state with `GET /threads/:id/state` (Agent Protocol) while a web client drives it over AG-UI. Choose Agent Protocol for harnesses, CLIs, and server-to-server calls; choose AG-UI when the caller is a UI component library that already speaks it. +## Disconnect and reconnect + + + Each POST owns one SSE response. Dawn does not buffer emitted AG-UI events or + support last-event-id replay. If the connection drops, the route can finish + and checkpoint its state, but the client cannot reattach to that in-flight + event stream. A later request against the same thread starts a new run from + the latest checkpoint. + ## Related diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 1f5797bc..479ebe3a 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -705,87 +705,93 @@ export type { ThreadsStore } from "@dawn-ai/sqlite-storage" ## @dawn-ai/ag-ui -AG-UI translation utilities used by `dawn dev` to serve `POST /agui/{routeId}` for CopilotKit and other AG-UI clients. The `{routeId}` URL segment is the URL-encoded Dawn assistant id, such as `%2Fchat%23agent` for `/chat#agent`. Most apps use the endpoint through the CLI, not by importing this package directly. +Pure, transport-agnostic AG-UI translation utilities. The CLI uses the same adapter when `dawn dev` or `dawn start` serves `POST /agui/{routeId}`; generated production entrypoints invoke the exported `serveRuntime()` function directly. The `{routeId}` URL segment is the URL-encoded Dawn assistant id, such as `%2Fchat%23agent` for `/chat#agent`. ```ts import { - createAgUiTranslator, - encodeAgUiSse, - mapRunInput, - type AgUiEvent, - type DawnStreamChunk, - type MappedRunInput, - type ResumeDecision, - type TranslatorOptions, + createCounterIdFactory, + createDefaultIdFactory, + fromRunAgentInput, + toAguiEvents, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnInterruptEnvelope, + type DawnMessage, + type DawnResumeRequest, + type DawnRunInput, + type IdFactory, + type RunContext, + type ToAguiOptions, } from "@dawn-ai/ag-ui" ``` See [Dev Server](/docs/dev-server#ag-ui-endpoint). -### `mapRunInput(input)` +### ID factories + +`toAguiEvents` uses `createDefaultIdFactory()` to generate prefixed UUID-based message, tool-call, and tool-result ids when it must synthesize them. `createCounterIdFactory()` produces deterministic counters for tests. Inject either factory, or a custom `IdFactory`, through `options.idFactory`: ```ts -export function mapRunInput(input: RunAgentInput): MappedRunInput +const events = toAguiEvents(chunks, context, { + idFactory: createCounterIdFactory(), +}) ``` -Maps an AG-UI `RunAgentInput` to Dawn route input. The newest user message becomes Dawn's `{ messages: [...] }` input because Dawn stores conversation history in the checkpoint keyed by `threadId`. Human-in-the-loop resume decisions are read from `forwardedProps.command.resume`. - ```ts -export type ResumeDecision = "once" | "always" | "deny" +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string -export interface MappedRunInput { - readonly dawnInput: { readonly messages: ReadonlyArray<{ role: string; content: string }> } - readonly resumeDecision?: ResumeDecision - readonly interruptId?: string +export interface ToAguiOptions { + readonly idFactory?: IdFactory } ``` -### `createAgUiTranslator(options)` +### `toAguiEvents(chunks, context)` ```ts -export function createAgUiTranslator(options: TranslatorOptions): AgUiTranslator +export function toAguiEvents( + chunks: AsyncIterable, + context: RunContext, + options?: ToAguiOptions, +): AsyncGenerator ``` -Creates a per-run translator. Call `begin()` once, pass each Dawn stream chunk to `translate(chunk)`, and call `end()` if the stream completes without a Dawn `done` chunk. +Maps Dawn `token`, `tool_call`, `tool_result`, `interrupt`, and `done` chunks to canonical AG-UI lifecycle, text, and tool events. Upstream tool invocation ids are preserved. Interrupts produce a standard `RUN_FINISHED` interrupt outcome, successful runs produce a success outcome, and upstream failures produce one `RUN_ERROR`. -```ts -export interface TranslatorOptions { - readonly threadId: string - readonly runId: string -} +Planning updates, subagent capability events, and other unknown chunk types have no v1 mapping and are ignored. -export interface AgUiTranslator { - begin(): AgUiEvent[] - translate(chunk: DawnStreamChunk): AgUiEvent[] - end(): AgUiEvent[] -} -``` +### `fromRunAgentInput(input)` -The translator maps Dawn token/chunk events to AG-UI text events, tool calls/results to AG-UI tool events, `plan_update` chunks to `STATE_SNAPSHOT`, interrupts to `CUSTOM{name:"on_interrupt"}`, and `subagent.*` chunks to `CUSTOM{name:"dawn."}`. +```ts +export function fromRunAgentInput(input: RunAgentInput): DawnRunInput +``` -### `encodeAgUiSse(event, accept?)` +Maps every AG-UI message structurally and preserves the original input as `raw`. A non-empty top-level `RunAgentInput.resume` array becomes `DawnRunInput.resume` with the same `interruptId`, `status`, and optional `payload` fields: ```ts -export function encodeAgUiSse(event: AgUiEvent, accept?: string): string +export interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: Array<{ + readonly interruptId: string + readonly status: "resolved" | "cancelled" + readonly payload?: unknown + }> + readonly raw: RunAgentInput +} ``` -Encodes one AG-UI event as an SSE frame using `@ag-ui/encoder`. +The adapter leaves AG-UI `tools`, `state`, and `context` uninterpreted in v1; consumers can read them from `raw`. -### Types +### SSE subpath: `encodeAgUiSse(event, accept?)` ```ts -export type AgUiEvent = BaseEvent +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" +``` -export interface DawnStreamChunk { - readonly type: string - readonly data?: unknown - readonly name?: string - readonly input?: unknown - readonly output?: unknown -} +```ts +export function encodeAgUiSse(event: BaseEvent, accept?: string): string ``` -`DawnStreamChunk` is structural so `@dawn-ai/ag-ui` does not depend on `@dawn-ai/cli`. +Encodes one AG-UI event as an SSE frame using `@ag-ui/encoder`. This transport helper is available only from `@dawn-ai/ag-ui/sse`; the root package remains transport-agnostic. --- diff --git a/apps/web/content/docs/deployment.mdx b/apps/web/content/docs/deployment.mdx index 5c2f1e6b..3b651dfc 100644 --- a/apps/web/content/docs/deployment.mdx +++ b/apps/web/content/docs/deployment.mdx @@ -35,6 +35,8 @@ dawn start --host 127.0.0.1 --port 3000 Because it runs the actual Dawn runtime, this is the only deployment path that engages the [execution sandbox](/docs/sandbox), [tool scoping](/docs/tools), and [permissions](/docs/permissions) exactly as configured in `dawn.config.ts`. The `langsmith` target does not run the Dawn runtime and does not engage any of the three. +The built runtime and `dawn start` serve the same Agent Protocol and AG-UI routes as `dawn dev`; generated server entrypoints call the exported `serveRuntime()` function directly. In both modes, `src/middleware.ts` gates Agent Protocol run, wait, and resume execution plus AG-UI route execution, but not thread create, read, delete, or state endpoints. Allowed middleware context reaches tools as `ctx.middleware`. + Select targets explicitly in `dawn.config.ts` if you only want one: ```ts title="dawn.config.ts" diff --git a/apps/web/content/docs/dev-server.mdx b/apps/web/content/docs/dev-server.mdx index e027dd47..63e30cec 100644 --- a/apps/web/content/docs/dev-server.mdx +++ b/apps/web/content/docs/dev-server.mdx @@ -199,17 +199,21 @@ Dawn route key: /chat#agent AG-UI URL: /agui/%2Fchat%23agent ``` -The endpoint emits `RUN_STARTED`, assistant text events, tool call/result events, `STATE_SNAPSHOT` updates for planning state, `CUSTOM{name:"on_interrupt"}` for HITL permission or memory interrupts, and `RUN_FINISHED` or `RUN_ERROR`. +The endpoint emits `RUN_STARTED`, assistant text events, tool call/result events, and one terminal `RUN_FINISHED` or `RUN_ERROR`. A parked run uses the standard AG-UI interrupt outcome on `RUN_FINISHED`; successful runs use the success outcome. Planning and subagent capability events have no v1 AG-UI mapping and are ignored. -Human-in-the-loop resume decisions ride on AG-UI `forwardedProps.command.resume`: +Human-in-the-loop answers use the top-level AG-UI `RunAgentInput.resume` array. Every answer is addressed to one pending interrupt: ```json { - "forwardedProps": { - "command": { - "resume": { "decision": "once", "interruptId": "perm-abc123" } - } - } + "threadId": "thread-1", + "runId": "run-2", + "messages": [], + "tools": [], + "context": [], + "state": {}, + "resume": [ + { "interruptId": "perm-abc123", "status": "resolved", "payload": "once" } + ] } ``` @@ -221,7 +225,7 @@ If `LANGSMITH_API_KEY` is present in the loaded environment and `LANGCHAIN_TRACI ## Middleware -`src/middleware.ts` (default-exporting a function returned by `defineMiddleware`) runs before every local `/threads/:thread_id/runs/wait` and `/threads/:thread_id/runs/stream` request handled by `dawn dev`. It can short-circuit a request with `reject(status, body?)`, or continue with `allow(context?)` — the optional `context` flows to every tool as `ctx.middleware` (a `Readonly>`). The AG-UI endpoint is a client-protocol bridge over the same route runtime, but middleware is documented for the Agent Protocol thread endpoints. +`src/middleware.ts` (default-exporting a function returned by `defineMiddleware`) gates Agent Protocol `/runs/stream`, `/runs/wait`, and `/resume` execution plus AG-UI route execution under both `dawn dev` and the built runtime served by `dawn start`. Thread create, read, delete, and state endpoints do not invoke middleware. It can short-circuit execution with `reject(status, body?)`, or continue with `allow(context?)` — the optional `context` flows to every tool as `ctx.middleware` (a `Readonly>`). `MiddlewareRequest` shape: `{ assistantId, headers, method, params, routeId, url }`. See [Middleware](/docs/middleware) for the full reference. diff --git a/apps/web/content/docs/faq.mdx b/apps/web/content/docs/faq.mdx index 17bd36fe..1bfedbe6 100644 --- a/apps/web/content/docs/faq.mdx +++ b/apps/web/content/docs/faq.mdx @@ -17,7 +17,7 @@ Yes. The built-in `agent()` route materializes to a LangChain chat model. Dawn i They solve different problems. - **Vercel AI SDK** is a client-and-server toolkit for chat UIs and provider-agnostic LLM calls. Dawn does not compete with it — it sits one layer up, organizing whole agent projects, and you can use the AI SDK inside a Dawn route. -- **CopilotKit** is a frontend-first framework for embedding copilots in existing apps. Dawn is backend-first; the deliverable is a deployable agent runtime, not in-app UI. They compose through Dawn's AG-UI endpoint: `dawn dev` serves `POST /agui/{routeId}`, and the chat example wires a CopilotKit `HttpAgent` to it. +- **CopilotKit** is a frontend-first framework for embedding copilots in existing apps. Dawn is backend-first; the deliverable is a deployable agent runtime, not in-app UI. They compose through Dawn's AG-UI endpoint: both `dawn dev` and `dawn start` serve `POST /agui/{routeId}` with the same route-execution middleware behavior, and the chat example wires a CopilotKit `HttpAgent` to it. - **Mastra** is a general-purpose agent framework with its own runtime and abstractions. Dawn is narrower: it does not replace LangGraph, it deletes the boilerplate around it. If you are already on LangGraph, Dawn fits. If you are not, pick the framework whose runtime you want to live in. diff --git a/apps/web/content/docs/recipes/research-web-ui.mdx b/apps/web/content/docs/recipes/research-web-ui.mdx index ee4df404..16026706 100644 --- a/apps/web/content/docs/recipes/research-web-ui.mdx +++ b/apps/web/content/docs/recipes/research-web-ui.mdx @@ -1,34 +1,36 @@ # Research assistant web UI -Wire a [CopilotKit](https://docs.copilotkit.ai) web client to Dawn's flagship -research demo over [AG-UI](/docs/ag-ui). You get streaming chat and a cited -report, a live plan, subagent activity, human-in-the-loop approvals, and a -memory-candidate review panel — all driven by the `/research` agent. +Wire a [CopilotKit](https://docs.copilotkit.ai) web client to Dawn's research +demo over [AG-UI](/docs/ag-ui). The example streams chat and a cited report from +the `/research` agent and provides a separate review panel for durable-memory +candidates. -This recipe is the concrete, task-oriented walkthrough. For the protocol itself — -the `/agui` endpoint, the event contract, threading, and HITL resume — see -[AG-UI & Web Clients](/docs/ag-ui). +This recipe focuses on application wiring. For the endpoint, event contract, +threading, and standard interrupt outcome, see [AG-UI & Web Clients](/docs/ag-ui). ## What you'll build The demo lives in `examples/research`: a Dawn server (`server/`) and a Next.js -CopilotKit client (`web/`). The client surfaces the coordinator's work as it runs: +CopilotKit client (`web/`). The client provides: -- **Chat + report** — the streamed answer, cited with `[corpus/…]`, in a `CopilotSidebar`. -- **Plan** — the coordinator's todos, checked off as steps complete. -- **Subagents** — each `researcher` dispatch and its corpus tool calls. -- **Permissions** — an approve/deny card when the agent runs a non-allowlisted command. -- **Memory candidates** — durable facts the agent proposes, approved from the UI. +- **Chat + report** - streamed output and citations in a `CopilotSidebar`. +- **Memory candidates** - durable facts proposed by the agent and reviewed from + the web UI. + +The AG-UI v1 adapter does not map planning or subagent capability events, so the +web client does not present live plan or subagent panels. It also does not add a +client-specific interrupt compatibility layer; interrupt outcomes and answers +use the standard AG-UI fields documented on the protocol page. ## Run it - + ```bash cd examples/research/server -cp .env.example .env # set OPENAI_API_KEY here — on the server, not the web app +cp .env.example .env # set OPENAI_API_KEY here, not in the web app ``` @@ -47,17 +49,21 @@ Open [http://localhost:3010](http://localhost:3010) and ask a research question. -## Connect the client to `/agui/research` +## Connect the client to Dawn -The Next.js runtime route registers an AG-UI `HttpAgent` pointed at the research -route. Register it under CopilotKit's **`default`** agent id so every hook and -component binds to it with no per-component wiring: +The Next.js runtime route registers an AG-UI `HttpAgent` pointed at the encoded +Dawn assistant id. Register it under CopilotKit's `default` agent id so the +sidebar and other consumers bind without per-component wiring: ```ts title="examples/research/web/app/api/copilotkit/route.ts" -import { CopilotRuntime, ExperimentalEmptyAdapter, copilotRuntimeNextJSAppRouterEndpoint } from "@copilotkit/runtime" import { HttpAgent } from "@ag-ui/client" +import { + CopilotRuntime, + ExperimentalEmptyAdapter, + copilotRuntimeNextJSAppRouterEndpoint, +} from "@copilotkit/runtime" import type { NextRequest } from "next/server" const dawnUrl = process.env.DAWN_SERVER_URL ?? "http://127.0.0.1:3002" @@ -80,23 +86,18 @@ export const POST = async (req: NextRequest): Promise => { ```tsx title="examples/research/web/app/page.tsx" "use client" import { CopilotKit, CopilotSidebar } from "@copilotkit/react-core/v2" -import { PermissionInterrupt } from "./components/PermissionInterrupt" -import { PlanPanel } from "./components/PlanPanel" -import { SubagentActivity } from "./components/SubagentActivity" import { MemoryCandidates } from "./components/MemoryCandidates" export default function Home() { return ( -
-
- - - -
+
- +
@@ -106,111 +107,38 @@ export default function Home() {
- -Setting `agentId` on the sidebar prop alone does **not** reach the chat -component's internal `useAgent`, which falls back to the id `default`. Register -your agent under `default` (or set `agentId` on *every* consumer) — otherwise you -get `Agent 'default' not found`. + + CopilotKit's sidebar falls back to the id `default`. Register the Dawn agent + under that id, or set the same explicit id on every consumer. -## Surface the plan and subagents from agent state - -Dawn's planning updates arrive as AG-UI `STATE_SNAPSHOT`s (read them off -`agent.state`), and subagent activity arrives as `CUSTOM` events named -`dawn.subagent.*`. Subscribe to the raw event stream for those — and skip the -per-token `message` events, which arrive one-per-token and would flood the panel: - - - -```tsx title="examples/research/web/app/components/PlanPanel.tsx" -import { UseAgentUpdate, useAgent } from "@copilotkit/react-core/v2" - -export function PlanPanel() { - const { agent } = useAgent({ updates: [UseAgentUpdate.OnStateChanged] }) - const todos = (agent.state?.todos ?? []) as { content?: string; status?: string }[] - if (todos.length === 0) return null - return ( -
    - {todos.map((t, i) => ( -
  • {t.status === "completed" ? "☑" : "☐"} {t.content}
  • - ))} -
- ) -} -``` - -```tsx title="examples/research/web/app/components/SubagentActivity.tsx" -import { useAgent } from "@copilotkit/react-core/v2" -import { useEffect, useState } from "react" - -export function SubagentActivity() { - const { agent } = useAgent() - const [events, setEvents] = useState<{ name: string; subagent?: string }[]>([]) - useEffect(() => { - const sub = agent.subscribe({ - onCustomEvent: ({ event }: { event: { name?: string; value?: unknown } }) => { - if (typeof event.name !== "string" || !event.name.startsWith("dawn.subagent.")) return - // Skip the per-token message stream — show only start/tool/end lifecycle. - const suffix = event.name.replace("dawn.subagent.", "") - if (suffix === "message" || suffix === "token" || suffix === "chunk") return - const v = (event.value ?? {}) as { subagent?: string } - setEvents((prev) => [...prev, { name: event.name as string, ...v }]) - }, - }) - return () => sub.unsubscribe() - }, [agent]) - // …render events… -} -``` +## Approve memory candidates -
+When the coordinator calls `remember()`, Dawn stores a durable-memory candidate. +The runtime exposes candidates over HTTP: -## Human-in-the-loop approvals - -The research agent gates its external fetch behind a permission prompt. Dawn -emits it as a `CUSTOM{on_interrupt}` event; CopilotKit's `useInterrupt` renders -it and `resolve(...)` resumes the run. Resolve with a Dawn decision shape: - -```tsx title="examples/research/web/app/components/PermissionInterrupt.tsx" -useInterrupt({ - render: ({ event, resolve }) => { - const { interruptId } = event.value ?? {} - const decide = (decision: "once" | "always" | "deny") => - resolve(interruptId ? { decision, interruptId } : { decision }) - return ( -
- - -
- ) - }, -}) +```text +GET /memory/candidates +POST /memory/candidates/:id/approve +POST /memory/candidates/:id/reject ``` -See [AG-UI § Human-in-the-loop resume](/docs/ag-ui) for how `resolve` maps onto -`forwardedProps.command.resume`. - -## Approve memory candidates - -When the coordinator calls `remember()`, Dawn stores a durable-memory *candidate*. -The dev server exposes them over HTTP — `GET /memory/candidates`, -`POST /memory/candidates/:id/approve`, `POST /memory/candidates/:id/reject` — so -the UI can review them instead of the `dawn memory approve` CLI. The panel -refetches after each run (`onRunFinishedEvent`), since that's when new candidates -land: +The example's same-origin Next.js proxy forwards these requests to Dawn, which +avoids browser CORS configuration. The panel refetches after each completed run, +when any newly proposed candidates are available: ```tsx title="examples/research/web/app/components/MemoryCandidates.tsx" const refetch = useCallback(() => { fetch("/api/memory/candidates") - .then((r) => r.json()) - .then((d) => setCandidates(d.candidates ?? [])) + .then((response) => response.json()) + .then((data) => setCandidates(data.candidates ?? [])) .catch(() => setCandidates([])) }, []) useEffect(() => { - const sub = agent.subscribe({ onRunFinishedEvent: () => refetch() }) + const subscription = agent.subscribe({ onRunFinishedEvent: refetch }) refetch() - return () => sub.unsubscribe() + return () => subscription.unsubscribe() }, [agent, refetch]) const approve = async (id: string) => { @@ -219,22 +147,19 @@ const approve = async (id: string) => { } ``` -A same-origin Next proxy (`app/api/memory/[...path]/route.ts`) forwards to the -Dawn server so the browser avoids CORS. - -The coordinator plans, dispatches a subagent per sub-question, and makes many -tool calls — enough to exceed LangGraph's default 25-step ceiling. The research -route sets `recursionLimit: 100` in `agent({ … })` so a full run completes. + The coordinator plans, dispatches subagents, and makes many tool calls. The + research route sets `recursionLimit: 100` so a full run can exceed LangGraph's + default step ceiling. ## Related diff --git a/docs/superpowers/plans/2026-07-07-ag-ui-adapter.md b/docs/superpowers/plans/2026-07-07-ag-ui-adapter.md new file mode 100644 index 00000000..d3e7558f --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-ag-ui-adapter.md @@ -0,0 +1,1402 @@ +# `@dawn-ai/ag-ui` Transport-Agnostic Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a new leaf package `@dawn-ai/ag-ui` that maps Dawn agent stream events ↔ AG-UI protocol events in both directions, as a pure library with no transport/server commitment. + +**Architecture:** A stateful async-generator (`toAguiEvents`) turns a Dawn `AgentStreamChunk` stream (`token | tool_call | tool_result | interrupt | done`) into AG-UI `BaseEvent`s, framing text/tool messages that Dawn emits implicitly. A pure function (`fromRunAgentInput`) maps AG-UI input (messages + interrupt-resume) back to a Dawn run input. A shared `interrupts.ts` module owns the Dawn-interrupt ↔ AG-UI-`Interrupt`/resume translation so the round-trip is lossless. One small additive core change surfaces the upstream tool-call id (LangGraph's per-invocation `run_id`) on the `tool_call`/`tool_result` chunks so AG-UI `toolCallId` correlation is faithful. + +**Tech Stack:** TypeScript (ESM, NodeNext), `@ag-ui/core@^0.0.57` (event types + `EventType` enum), vitest, biome. Follows the existing `packages/permissions` leaf-package shape. + +--- + +## Design decisions locked in (read before starting) + +These were verified against the installed `@ag-ui/core@0.0.57` `.d.ts` and Dawn's source. Do not re-derive; do not deviate without flagging. + +- **The mapper consumes the langchain `AgentStreamChunk` shape** — `{ type: string; data: unknown }` — NOT the flat CLI `StreamChunk`. `token` data is a `string`; `tool_call` data is `{ id?, name, input }`; `tool_result` data is `{ id?, name, output }`; `interrupt` data is the capability envelope `{ interruptId, kind, ... }`; `done` data is the final output. The package declares this shape structurally in `types.ts` and does **not** import `@dawn-ai/langchain`. +- **AG-UI event field shapes** (exact, from `@ag-ui/core`): + - `RUN_STARTED`: `{ type, threadId, runId }` + - `RUN_FINISHED`: `{ type, threadId, runId, outcome? }` — **threadId+runId are required**; `outcome` is `{ type: "success" }` or `{ type: "interrupt", interrupts: Interrupt[] }` + - `RUN_ERROR`: `{ type, message, code? }` — no threadId/runId + - `TEXT_MESSAGE_START`: `{ type, messageId, role }` (role default exists; we set `"assistant"`) + - `TEXT_MESSAGE_CONTENT`: `{ type, messageId, delta }` + - `TEXT_MESSAGE_END`: `{ type, messageId }` + - `TOOL_CALL_START`: `{ type, toolCallId, toolCallName, parentMessageId? }` + - `TOOL_CALL_ARGS`: `{ type, toolCallId, delta }` + - `TOOL_CALL_END`: `{ type, toolCallId }` + - `TOOL_CALL_RESULT`: `{ type, messageId, toolCallId, content, role? }` + - `Interrupt`: `{ id, reason, message?, toolCallId?, responseSchema?, expiresAt?, metadata? }` + - `RunAgentInput`: `{ threadId, runId, parentRunId?, state?, messages: Message[], tools?, context?, resume?, forwardedProps? }` + - `RunAgentInput.resume`: `{ interruptId, status: "resolved" | "cancelled", payload? }[]` + - `Message` (discriminated on `role`): user/assistant/system/developer carry `{ id, content }` (+ optional `name`, assistant `toolCalls?`); tool carries `{ id, role:"tool", content, toolCallId }`. +- **`EventType` is a runtime enum** — imported as a value (small runtime dep on `@ag-ui/core`; its transitive `zod@^3` is package-manager-isolated from Dawn's `zod@4` and only used for `@ag-ui/core`'s own schemas, which we never invoke). +- **`IdFactory` has three kinds:** `"message" | "toolCall" | "toolResult"` (the design spec listed two; a third `"toolCall"` kind is needed for the fallback id when a `tool_call` chunk arrives without an upstream id, and `"toolResult"` doubles for the result-message id). `runId`/`threadId` come from `RunContext`, never the factory; `toolCallId` comes from the upstream chunk `id`, falling back to `idFactory("toolCall")`. +- **Inbound resume is vocabulary-agnostic passthrough.** Dawn resumes per-interrupt via a `{ interrupt_id, decision }` POST to `/threads/:id/resume`; there is no single graph-level `Command({resume})` payload at the `RunAgentInput` layer. So `fromRunAgentInput` returns `resume?: DawnResumeRequest[]` preserving `interruptId` (+ `status`, `payload`); translating an entry to Dawn's `decision` vocabulary and hitting the endpoint is the **consumer's** job. The round-trip guarantee we test is: `interruptId` survives outbound→inbound losslessly. +- **Tests live in `test/**/*.test.ts`** (repo convention; the spec's "co-located" note is superseded). +- **No `dawn dev` wiring, no server, no STATE/CUSTOM mapping in v1** (out of scope per spec). + +## File Structure + +``` +packages/ag-ui/ + package.json # @dawn-ai/ag-ui, mirrors packages/permissions; dep on @ag-ui/core + tsconfig.json # extends ../config-typescript/node.json + vitest.config.ts # include test/**/*.test.ts + src/ + index.ts # public barrel + types.ts # RunContext, RawChunk, Dawn*Data, asToolCallData/asToolResultData guards + ids.ts # IdFactory, createCounterIdFactory (deterministic), createDefaultIdFactory + interrupts.ts # toAguiInterrupt, fromAguiResume, DawnInterruptEnvelope, DawnResumeRequest + outbound.ts # toAguiEvents (state machine) + stringify helpers + AguiOutboundEvent union + inbound.ts # fromRunAgentInput, DawnMessage, DawnRunInput, toDawnMessage + test/ + ids.test.ts + interrupts.test.ts + outbound.test.ts + inbound.test.ts + round-trip.test.ts +``` + +Plus one modified file outside the package: +- `packages/langchain/src/agent-adapter.ts` — add `id: event.run_id` to `tool_call`/`tool_result` chunk data. +- `packages/langchain/test/agent-adapter-toolcall-id.test.ts` — new test for the above. + +--- + +### Task 1: Scaffold the `@dawn-ai/ag-ui` package + +**Files:** +- Create: `packages/ag-ui/package.json` +- Create: `packages/ag-ui/tsconfig.json` +- Create: `packages/ag-ui/vitest.config.ts` +- Create: `packages/ag-ui/src/index.ts` + +- [ ] **Step 1: Create `package.json`** + +```json +{ + "name": "@dawn-ai/ag-ui", + "version": "0.8.8", + "private": false, + "type": "module", + "license": "MIT", + "homepage": "https://github.com/cacheplane/dawnai/tree/main/packages/ag-ui#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/cacheplane/dawnai.git", + "directory": "packages/ag-ui" + }, + "bugs": { + "url": "https://github.com/cacheplane/dawnai/issues" + }, + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "lint": "biome check --config-path ../config-biome/biome.json package.json src tsconfig.json vitest.config.ts", + "test": "vitest --run --config vitest.config.ts --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ag-ui/core": "^0.0.57" + }, + "devDependencies": { + "@dawn-ai/config-typescript": "workspace:*", + "@types/node": "26.1.0" + } +} +``` + +- [ ] **Step 2: Create `tsconfig.json`** + +```json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../config-typescript/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} +``` + +- [ ] **Step 3: Create `vitest.config.ts`** + +```ts +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + passWithNoTests: true, + }, +}) +``` + +- [ ] **Step 4: Create a placeholder `src/index.ts`** (replaced in Task 7) + +```ts +export {} +``` + +- [ ] **Step 5: Install the new dependency and wire the workspace** + +Run: `pnpm install` +Expected: pnpm adds `@dawn-ai/ag-ui` to the workspace (auto-matched by the `packages/*` glob in `pnpm-workspace.yaml`) and installs `@ag-ui/core@0.0.57`. No error. + +- [ ] **Step 6: Verify it builds and typechecks** + +Run: `pnpm --filter @dawn-ai/ag-ui build && pnpm --filter @dawn-ai/ag-ui typecheck` +Expected: both succeed with no output errors (empty package compiles). + +- [ ] **Step 7: Commit** + +```bash +git add packages/ag-ui pnpm-lock.yaml +git commit -m "feat(ag-ui): scaffold @dawn-ai/ag-ui package" +``` + +--- + +### Task 2: `ids.ts` — deterministic and default id factories + +**Files:** +- Create: `packages/ag-ui/src/ids.ts` +- Test: `packages/ag-ui/test/ids.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "vitest" +import { createCounterIdFactory, createDefaultIdFactory } from "../src/ids.js" + +describe("createCounterIdFactory", () => { + test("produces deterministic, kind-prefixed, monotonically increasing ids", () => { + const id = createCounterIdFactory() + expect(id("message")).toBe("msg-1") + expect(id("message")).toBe("msg-2") + expect(id("toolCall")).toBe("tc-1") + expect(id("toolResult")).toBe("tr-1") + expect(id("toolResult")).toBe("tr-2") + }) +}) + +describe("createDefaultIdFactory", () => { + test("produces unique kind-prefixed ids", () => { + const id = createDefaultIdFactory() + const a = id("message") + const b = id("message") + expect(a).not.toBe(b) + expect(a.startsWith("msg-")).toBe(true) + expect(id("toolCall").startsWith("tc-")).toBe(true) + expect(id("toolResult").startsWith("tr-")).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `Cannot find module '../src/ids.js'`. + +- [ ] **Step 3: Write the implementation** + +```ts +/** + * Generates ids for AG-UI events. `runId`/`threadId` never flow through here + * (the consumer owns run identity via RunContext); a tool call's `toolCallId` + * normally comes from the upstream chunk and only falls back to `"toolCall"`. + */ +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string + +const PREFIX: Record<"message" | "toolCall" | "toolResult", string> = { + message: "msg", + toolCall: "tc", + toolResult: "tr", +} + +/** + * Deterministic, monotonically-increasing factory for tests: `msg-1`, `tc-1`, + * `tr-1`, … Each kind has an independent counter. + */ +export function createCounterIdFactory(): IdFactory { + const counters = { message: 0, toolCall: 0, toolResult: 0 } + return (kind) => { + counters[kind] += 1 + return `${PREFIX[kind]}-${counters[kind]}` + } +} + +/** + * Default production factory: collision-resistant, non-deterministic ids using + * the platform crypto UUID. + */ +export function createDefaultIdFactory(): IdFactory { + return (kind) => `${PREFIX[kind]}-${crypto.randomUUID()}` +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: PASS (both describe blocks green). + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/ids.ts packages/ag-ui/test/ids.test.ts +git commit -m "feat(ag-ui): id factories (deterministic + default)" +``` + +--- + +### Task 3: `types.ts` — Dawn chunk input shape and guards + +**Files:** +- Create: `packages/ag-ui/src/types.ts` +- Test: covered indirectly by later tasks; add a focused guard test here. +- Test: `packages/ag-ui/test/types.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "vitest" +import { asToolCallData, asToolResultData } from "../src/types.js" + +describe("asToolCallData", () => { + test("extracts id/name/input when shape is valid", () => { + expect(asToolCallData({ id: "run-1", name: "greet", input: { x: 1 } })).toEqual({ + id: "run-1", + name: "greet", + input: { x: 1 }, + }) + }) + + test("returns undefined id when absent", () => { + expect(asToolCallData({ name: "greet", input: {} })).toEqual({ + id: undefined, + name: "greet", + input: {}, + }) + }) + + test("returns null when name missing or data not an object", () => { + expect(asToolCallData({ input: {} })).toBeNull() + expect(asToolCallData("nope")).toBeNull() + expect(asToolCallData(null)).toBeNull() + }) +}) + +describe("asToolResultData", () => { + test("extracts id/name/output", () => { + expect(asToolResultData({ id: "run-1", name: "greet", output: "hi" })).toEqual({ + id: "run-1", + name: "greet", + output: "hi", + }) + }) + + test("returns null when name missing", () => { + expect(asToolResultData({ output: "hi" })).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `Cannot find module '../src/types.js'`. + +- [ ] **Step 3: Write the implementation** + +```ts +/** Run identity the consumer supplies; never synthesized by the mapper. */ +export interface RunContext { + readonly threadId: string + readonly runId: string +} + +/** + * The minimal Dawn stream-chunk shape the mapper consumes. Structurally + * compatible with `AgentStreamChunk` from `@dawn-ai/langchain` (which is + * `{ type: string; data: unknown }`), declared here so this package takes no + * dependency on the langchain package. + */ +export interface RawChunk { + readonly type: string + readonly data?: unknown +} + +export interface DawnToolCallData { + readonly id?: string + readonly name: string + readonly input: unknown +} + +export interface DawnToolResultData { + readonly id?: string + readonly name: string + readonly output: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +/** Validates and narrows a `tool_call` chunk's `data`. Returns null if malformed. */ +export function asToolCallData(data: unknown): DawnToolCallData | null { + if (!isRecord(data) || typeof data.name !== "string") return null + return { + id: typeof data.id === "string" ? data.id : undefined, + name: data.name, + input: data.input, + } +} + +/** Validates and narrows a `tool_result` chunk's `data`. Returns null if malformed. */ +export function asToolResultData(data: unknown): DawnToolResultData | null { + if (!isRecord(data) || typeof data.name !== "string") return null + return { + id: typeof data.id === "string" ? data.id : undefined, + name: data.name, + output: data.output, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/types.ts packages/ag-ui/test/types.test.ts +git commit -m "feat(ag-ui): Dawn chunk input types + guards" +``` + +--- + +### Task 4: `interrupts.ts` — interrupt/resume translation + +**Files:** +- Create: `packages/ag-ui/src/interrupts.ts` +- Test: `packages/ag-ui/test/interrupts.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "vitest" +import { fromAguiResume, toAguiInterrupt } from "../src/interrupts.js" + +describe("toAguiInterrupt", () => { + test("maps a Dawn interrupt envelope to an AG-UI Interrupt, preserving the envelope as metadata", () => { + const envelope = { + interruptId: "perm-1", + kind: "command", + type: "permission-request", + detail: { command: "ls" }, + } + expect(toAguiInterrupt(envelope)).toEqual({ + id: "perm-1", + reason: "command", + metadata: envelope, + }) + }) + + test("carries an optional human message and toolCallId when present", () => { + const envelope = { interruptId: "perm-2", kind: "tool", message: "Approve?", toolCallId: "tc-9" } + expect(toAguiInterrupt(envelope)).toEqual({ + id: "perm-2", + reason: "tool", + message: "Approve?", + toolCallId: "tc-9", + metadata: envelope, + }) + }) + + test("falls back to empty id and 'interrupt' reason for a malformed envelope", () => { + expect(toAguiInterrupt(null)).toEqual({ id: "", reason: "interrupt", metadata: {} }) + }) +}) + +describe("fromAguiResume", () => { + test("maps AG-UI resume entries to Dawn resume requests, preserving interruptId", () => { + expect( + fromAguiResume([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ]), + ).toEqual([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `Cannot find module '../src/interrupts.js'`. + +- [ ] **Step 3: Write the implementation** + +```ts +import type { Interrupt } from "@ag-ui/core" + +/** + * The interrupt envelope Dawn's capabilities emit inside an `interrupt` chunk + * (`entry.value` from LangGraph). Always carries `interruptId`; other keys are + * capability-specific and preserved verbatim. + */ +export interface DawnInterruptEnvelope { + readonly interruptId: string + readonly kind?: string + readonly message?: string + readonly toolCallId?: string + readonly [key: string]: unknown +} + +/** A resume instruction addressed to one open Dawn interrupt. */ +export interface DawnResumeRequest { + readonly interruptId: string + readonly status: "resolved" | "cancelled" + readonly payload?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +/** + * Map a Dawn interrupt envelope to an AG-UI `Interrupt`. The full envelope is + * preserved under `metadata` so no capability-specific information is lost on + * the way to the client. + */ +export function toAguiInterrupt(data: unknown): Interrupt { + const env = isRecord(data) ? data : {} + const interruptId = typeof env.interruptId === "string" ? env.interruptId : "" + const reason = typeof env.kind === "string" ? env.kind : "interrupt" + return { + id: interruptId, + reason, + ...(typeof env.message === "string" ? { message: env.message } : {}), + ...(typeof env.toolCallId === "string" ? { toolCallId: env.toolCallId } : {}), + metadata: env, + } +} + +/** + * Map AG-UI resume entries to Dawn resume requests. Vocabulary-agnostic: the + * consumer decides how a `{ status, payload }` becomes Dawn's per-interrupt + * decision. We only guarantee `interruptId` survives. + */ +export function fromAguiResume( + resume: ReadonlyArray<{ interruptId: string; status: "resolved" | "cancelled"; payload?: unknown }>, +): DawnResumeRequest[] { + return resume.map((entry) => ({ + interruptId: entry.interruptId, + status: entry.status, + ...(entry.payload !== undefined ? { payload: entry.payload } : {}), + })) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/interrupts.ts packages/ag-ui/test/interrupts.test.ts +git commit -m "feat(ag-ui): interrupt/resume translation" +``` + +--- + +### Task 5: `outbound.ts` — `toAguiEvents` state machine + +**Files:** +- Create: `packages/ag-ui/src/outbound.ts` +- Test: `packages/ag-ui/test/outbound.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { EventType } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { createCounterIdFactory } from "../src/ids.js" +import type { RawChunk } from "../src/types.js" +import { toAguiEvents } from "../src/outbound.js" + +const CTX = { threadId: "th-1", runId: "rn-1" } + +async function collect(chunks: RawChunk[]) { + const out = [] + for await (const ev of toAguiEvents(toAsync(chunks), CTX, { + idFactory: createCounterIdFactory(), + })) { + out.push(ev) + } + return out +} + +async function* toAsync(items: RawChunk[]) { + for (const item of items) yield item +} + +describe("toAguiEvents", () => { + test("text-only stream: run start, framed message, run finished success", async () => { + const events = await collect([ + { type: "token", data: "Hel" }, + { type: "token", data: "lo" }, + { type: "done", data: {} }, + ]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.TEXT_MESSAGE_START, messageId: "msg-1", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg-1", delta: "Hel" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg-1", delta: "lo" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "msg-1" }, + { type: EventType.RUN_FINISHED, threadId: "th-1", runId: "rn-1", outcome: { type: "success" } }, + ]) + }) + + test("tool call + result: correlated by upstream id, single args frame", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-abc", name: "greet", input: { name: "World" } } }, + { type: "tool_result", data: { id: "run-abc", name: "greet", output: { greeting: "hi" } } }, + { type: "done", data: {} }, + ]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.TOOL_CALL_START, toolCallId: "run-abc", toolCallName: "greet" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "run-abc", delta: '{"name":"World"}' }, + { type: EventType.TOOL_CALL_END, toolCallId: "run-abc" }, + { + type: EventType.TOOL_CALL_RESULT, + messageId: "tr-1", + toolCallId: "run-abc", + content: '{"greeting":"hi"}', + }, + { type: EventType.RUN_FINISHED, threadId: "th-1", runId: "rn-1", outcome: { type: "success" } }, + ]) + }) + + test("interleaved text then tool: open message is flushed before the tool call", async () => { + const events = await collect([ + { type: "token", data: "thinking" }, + { type: "tool_call", data: { id: "run-x", name: "noop", input: {} } }, + { type: "done", data: {} }, + ]) + const types = events.map((e) => e.type) + expect(types).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.RUN_FINISHED, + ]) + }) + + test("repeated calls to the same tool get distinct toolCallIds from their upstream ids", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-1", name: "t", input: {} } }, + { type: "tool_call", data: { id: "run-2", name: "t", input: {} } }, + { type: "done", data: {} }, + ]) + const starts = events.filter((e) => e.type === EventType.TOOL_CALL_START) + expect(starts.map((e) => (e as { toolCallId: string }).toolCallId)).toEqual(["run-1", "run-2"]) + }) + + test("interrupt: emits RUN_FINISHED with an interrupt outcome and stops", async () => { + const events = await collect([ + { type: "token", data: "hi" }, + { type: "interrupt", data: { interruptId: "perm-1", kind: "command" } }, + { type: "done", data: {} }, // must be ignored after interrupt + ]) + expect(events.at(-1)).toEqual({ + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + outcome: { type: "interrupt", interrupts: [{ id: "perm-1", reason: "command", metadata: { interruptId: "perm-1", kind: "command" } }] }, + }) + // exactly one RUN_FINISHED (done after interrupt was ignored) + expect(events.filter((e) => e.type === EventType.RUN_FINISHED)).toHaveLength(1) + }) + + test("empty stream (done only): run start then success", async () => { + const events = await collect([{ type: "done", data: {} }]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.RUN_FINISHED, threadId: "th-1", runId: "rn-1", outcome: { type: "success" } }, + ]) + }) + + test("stream that ends without a done chunk still flushes and finishes success", async () => { + const events = await collect([{ type: "token", data: "x" }]) + expect(events.map((e) => e.type)).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.RUN_FINISHED, + ]) + }) + + test("tool_result with a missing upstream id still emits a result with a synthesized toolCallId", async () => { + const events = await collect([ + { type: "tool_result", data: { name: "greet", output: "hi" } }, + { type: "done", data: {} }, + ]) + const result = events.find((e) => e.type === EventType.TOOL_CALL_RESULT) as { + toolCallId: string + messageId: string + content: string + } + expect(result.content).toBe("hi") + expect(result.toolCallId).toBe("tc-1") // fallback id + expect(result.messageId).toBe("tr-1") + }) + + test("upstream throw is emitted as RUN_ERROR, not thrown to the consumer", async () => { + async function* boom(): AsyncGenerator { + yield { type: "token", data: "hi" } + throw new Error("kaboom") + } + const out = [] + for await (const ev of toAguiEvents(boom(), CTX, { idFactory: createCounterIdFactory() })) { + out.push(ev) + } + expect(out.at(-1)).toEqual({ type: EventType.RUN_ERROR, message: "kaboom" }) + // the open text message was flushed before the error + expect(out.some((e) => e.type === EventType.TEXT_MESSAGE_END)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `Cannot find module '../src/outbound.js'`. + +- [ ] **Step 3: Write the implementation** + +```ts +import { EventType } from "@ag-ui/core" +import type { + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +} from "@ag-ui/core" +import { createDefaultIdFactory, type IdFactory } from "./ids.js" +import { toAguiInterrupt } from "./interrupts.js" +import { asToolCallData, asToolResultData, type RawChunk, type RunContext } from "./types.js" + +/** The AG-UI events this mapper can emit. */ +export type AguiOutboundEvent = + | RunStartedEvent + | RunFinishedEvent + | RunErrorEvent + | TextMessageStartEvent + | TextMessageContentEvent + | TextMessageEndEvent + | ToolCallStartEvent + | ToolCallArgsEvent + | ToolCallEndEvent + | ToolCallResultEvent + +export interface ToAguiOptions { + readonly idFactory?: IdFactory +} + +function stringifyArgs(input: unknown): string { + if (input === undefined || input === null) return "{}" + if (typeof input === "string") return input + try { + return JSON.stringify(input) + } catch { + return "{}" + } +} + +function stringifyContent(output: unknown): string { + if (typeof output === "string") return output + if (output === undefined || output === null) return "" + try { + return JSON.stringify(output) + } catch { + return String(output) + } +} + +/** + * Map a Dawn agent stream (`token | tool_call | tool_result | interrupt | + * done`) to an AG-UI event stream. Stateful: it frames assistant text and tool + * calls that Dawn emits implicitly, and it never throws into the consumer — an + * upstream error becomes a `RUN_ERROR` event and a clean return. + */ +export async function* toAguiEvents( + chunks: AsyncIterable, + ctx: RunContext, + options: ToAguiOptions = {}, +): AsyncGenerator { + const nextId = options.idFactory ?? createDefaultIdFactory() + let openMessageId: string | null = null + + function* flushText(): Generator { + if (openMessageId !== null) { + yield { type: EventType.TEXT_MESSAGE_END, messageId: openMessageId } + openMessageId = null + } + } + + yield { type: EventType.RUN_STARTED, threadId: ctx.threadId, runId: ctx.runId } + + try { + for await (const chunk of chunks) { + switch (chunk.type) { + case "token": { + const delta = typeof chunk.data === "string" ? chunk.data : "" + if (delta.length === 0) break + if (openMessageId === null) { + openMessageId = nextId("message") + yield { type: EventType.TEXT_MESSAGE_START, messageId: openMessageId, role: "assistant" } + } + yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId: openMessageId, delta } + break + } + case "tool_call": { + yield* flushText() + const tc = asToolCallData(chunk.data) + if (!tc) break + const toolCallId = tc.id ?? nextId("toolCall") + yield { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: tc.name } + yield { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: stringifyArgs(tc.input) } + yield { type: EventType.TOOL_CALL_END, toolCallId } + break + } + case "tool_result": { + yield* flushText() + const tr = asToolResultData(chunk.data) + if (!tr) break + const toolCallId = tr.id ?? nextId("toolCall") + const messageId = nextId("toolResult") + yield { + type: EventType.TOOL_CALL_RESULT, + messageId, + toolCallId, + content: stringifyContent(tr.output), + } + break + } + case "interrupt": { + yield* flushText() + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "interrupt", interrupts: [toAguiInterrupt(chunk.data)] }, + } + return + } + case "done": { + yield* flushText() + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "success" }, + } + return + } + default: + // Unknown/capability chunk types (e.g. plan_update) have no v1 + // AG-UI mapping — ignore them. + break + } + } + // Stream ended without an explicit done/interrupt: flush and finish. + yield* flushText() + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "success" }, + } + } catch (err) { + yield* flushText() + yield { type: EventType.RUN_ERROR, message: err instanceof Error ? err.message : String(err) } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: PASS (all 9 cases green). + +> If TypeScript complains that an object literal is not assignable to the event +> type (e.g. an excess-property or enum-literal mismatch), it means the +> constructed shape drifted from `@ag-ui/core`. Re-check the field list in the +> "Design decisions" section — do NOT add `as` casts to silence it. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/outbound.ts packages/ag-ui/test/outbound.test.ts +git commit -m "feat(ag-ui): toAguiEvents outbound stream mapper" +``` + +--- + +### Task 6: `inbound.ts` — `fromRunAgentInput` + +**Files:** +- Create: `packages/ag-ui/src/inbound.ts` +- Test: `packages/ag-ui/test/inbound.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import type { RunAgentInput } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { fromRunAgentInput } from "../src/inbound.js" + +function baseInput(overrides: Partial = {}): RunAgentInput { + return { + threadId: "th-1", + runId: "rn-1", + messages: [], + tools: [], + context: [], + ...overrides, + } as RunAgentInput +} + +describe("fromRunAgentInput", () => { + test("maps user and assistant messages to Dawn messages", () => { + const input = baseInput({ + messages: [ + { id: "m1", role: "user", content: "hi" }, + { id: "m2", role: "assistant", content: "hello" }, + ], + } as Partial) + const result = fromRunAgentInput(input) + expect(result.messages).toEqual([ + { role: "user", content: "hi", id: "m1" }, + { role: "assistant", content: "hello", id: "m2" }, + ]) + expect(result.resume).toBeUndefined() + expect(result.raw).toBe(input) + }) + + test("maps a tool message, preserving toolCallId", () => { + const input = baseInput({ + messages: [{ id: "m1", role: "tool", content: "42", toolCallId: "tc-9" }], + } as Partial) + expect(fromRunAgentInput(input).messages).toEqual([ + { role: "tool", content: "42", id: "m1", toolCallId: "tc-9" }, + ]) + }) + + test("stringifies non-string content", () => { + const input = baseInput({ + messages: [{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] }], + } as unknown as Partial) + expect(fromRunAgentInput(input).messages[0]?.content).toBe('[{"type":"text","text":"hi"}]') + }) + + test("maps a resume array to Dawn resume requests", () => { + const input = baseInput({ + resume: [{ interruptId: "perm-1", status: "resolved", payload: "once" }], + } as Partial) + expect(fromRunAgentInput(input).resume).toEqual([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + ]) + }) + + test("raw preserves the original input for tools/state/context access", () => { + const input = baseInput({ state: { a: 1 } } as Partial) + expect(fromRunAgentInput(input).raw).toBe(input) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `Cannot find module '../src/inbound.js'`. + +- [ ] **Step 3: Write the implementation** + +```ts +import type { Message, RunAgentInput } from "@ag-ui/core" +import { type DawnResumeRequest, fromAguiResume } from "./interrupts.js" + +export interface DawnMessage { + readonly role: "user" | "assistant" | "system" | "developer" | "tool" + readonly content: string + readonly id?: string + readonly toolCallId?: string +} + +export interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: DawnResumeRequest[] + /** The untouched AG-UI input, so a consumer can reach tools/state/context. */ + readonly raw: RunAgentInput +} + +function coerceContent(content: unknown): string { + if (typeof content === "string") return content + if (content === undefined || content === null) return "" + try { + return JSON.stringify(content) + } catch { + return String(content) + } +} + +function toDawnMessage(message: Message): DawnMessage { + const content = coerceContent((message as { content?: unknown }).content) + if (message.role === "tool") { + return { + role: "tool", + content, + id: message.id, + toolCallId: (message as { toolCallId: string }).toolCallId, + } + } + return { role: message.role, content, id: message.id } +} + +/** + * Map an AG-UI `RunAgentInput` to a Dawn run input. Messages are translated + * structurally; a `resume` array becomes vocabulary-agnostic Dawn resume + * requests (see `fromAguiResume`). `tools`/`state`/`context` are not + * interpreted in v1 — reach them via `raw`. + */ +export function fromRunAgentInput(input: RunAgentInput): DawnRunInput { + const messages = input.messages.map(toDawnMessage) + const resume = + input.resume && input.resume.length > 0 ? fromAguiResume(input.resume) : undefined + return { messages, ...(resume ? { resume } : {}), raw: input } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/inbound.ts packages/ag-ui/test/inbound.test.ts +git commit -m "feat(ag-ui): fromRunAgentInput inbound mapper" +``` + +--- + +### Task 7: `index.ts` barrel + interrupt round-trip test + +**Files:** +- Modify: `packages/ag-ui/src/index.ts` +- Test: `packages/ag-ui/test/round-trip.test.ts` + +- [ ] **Step 1: Write the failing round-trip test** + +```ts +import { EventType, type RunAgentInput } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { createCounterIdFactory, fromRunAgentInput, toAguiEvents } from "../src/index.js" +import type { RawChunk } from "../src/index.js" + +async function* one(chunk: RawChunk) { + yield chunk + yield { type: "done", data: {} } as RawChunk +} + +describe("interrupt round-trip", () => { + test("a Dawn interrupt's id survives outbound -> AG-UI -> resume input -> Dawn resume", async () => { + // 1. Dawn emits an interrupt; map it outbound. + const events = [] + for await (const ev of toAguiEvents(one({ type: "interrupt", data: { interruptId: "perm-42", kind: "command" } }), { threadId: "th", runId: "rn" }, { idFactory: createCounterIdFactory() })) { + events.push(ev) + } + const finished = events.find((e) => e.type === EventType.RUN_FINISHED) as { + outcome: { type: string; interrupts: Array<{ id: string }> } + } + expect(finished.outcome.type).toBe("interrupt") + const interruptId = finished.outcome.interrupts[0]?.id + expect(interruptId).toBe("perm-42") + + // 2. The client answers with a resume RunAgentInput addressing that id. + const resumeInput = { + threadId: "th", + runId: "rn-2", + messages: [], + tools: [], + context: [], + resume: [{ interruptId, status: "resolved", payload: "once" }], + } as unknown as RunAgentInput + + // 3. Map it back to Dawn — the interruptId must survive. + const dawn = fromRunAgentInput(resumeInput) + expect(dawn.resume).toEqual([{ interruptId: "perm-42", status: "resolved", payload: "once" }]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/ag-ui test` +Expected: FAIL — `../src/index.js` does not export `toAguiEvents` / `fromRunAgentInput` (barrel still `export {}`). + +- [ ] **Step 3: Write the barrel** + +```ts +export { toAguiEvents, type AguiOutboundEvent, type ToAguiOptions } from "./outbound.js" +export { fromRunAgentInput, type DawnMessage, type DawnRunInput } from "./inbound.js" +export { + toAguiInterrupt, + fromAguiResume, + type DawnInterruptEnvelope, + type DawnResumeRequest, +} from "./interrupts.js" +export { createCounterIdFactory, createDefaultIdFactory, type IdFactory } from "./ids.js" +export { + asToolCallData, + asToolResultData, + type DawnToolCallData, + type DawnToolResultData, + type RawChunk, + type RunContext, +} from "./types.js" +``` + +- [ ] **Step 4: Run test + full package suite + typecheck** + +Run: `pnpm --filter @dawn-ai/ag-ui test && pnpm --filter @dawn-ai/ag-ui typecheck` +Expected: all tests PASS; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/src/index.ts packages/ag-ui/test/round-trip.test.ts +git commit -m "feat(ag-ui): public barrel + interrupt round-trip test" +``` + +--- + +### Task 8: Core change — surface the tool-call id on stream chunks + +**Files:** +- Modify: `packages/langchain/src/agent-adapter.ts` (the `on_tool_start` and `on_tool_end` emit sites, ~lines 613–629) +- Test: `packages/langchain/test/agent-adapter-toolcall-id.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { MemorySaver } from "@langchain/langgraph" +import { describe, expect, test } from "vitest" +import { streamAgent } from "../src/agent-adapter.js" + +describe("streamAgent — tool-call id correlation", () => { + test("tool_call and tool_result chunks carry the invocation run_id as data.id", async () => { + const mockRunnable = { + invoke: async () => ({}), + streamEvents: async function* (_input: unknown, _options: Record) { + yield { + event: "on_tool_start", + name: "greet", + run_id: "run-xyz", + data: { input: { name: "World" } }, + } + yield { + event: "on_tool_end", + name: "greet", + run_id: "run-xyz", + data: { output: { greeting: "Hello, World!" } }, + } + yield { event: "on_chain_end", name: "LangGraph", data: { output: { messages: [] } } } + }, + } + + const chunks: Array<{ type: string; data: unknown }> = [] + for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), + entry: mockRunnable, + input: { messages: [{ role: "user", content: "greet" }] }, + routeParamNames: [], + signal: new AbortController().signal, + tools: [], + })) { + chunks.push({ type: chunk.type, data: chunk.data }) + } + + const call = chunks.find((c) => c.type === "tool_call") + const result = chunks.find((c) => c.type === "tool_result") + expect((call?.data as { id?: string }).id).toBe("run-xyz") + expect((result?.data as { id?: string }).id).toBe("run-xyz") + // start and end of the same invocation share the id + expect((call?.data as { id?: string }).id).toBe((result?.data as { id?: string }).id) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dawn-ai/langchain test -- agent-adapter-toolcall-id` +Expected: FAIL — `expected undefined to be "run-xyz"` (the chunk data has no `id` yet). + +- [ ] **Step 3: Edit the `on_tool_start` emit site** + +In `packages/langchain/src/agent-adapter.ts`, change the `on_tool_start` case from: + +```ts + case "on_tool_start": { + hasYielded = true + yield { + type: "tool_call" as const, + data: { + name: event.name, + input: event.data.input ?? event.data.chunk ?? event.data.output, + }, + } + break + } +``` + +to: + +```ts + case "on_tool_start": { + hasYielded = true + yield { + type: "tool_call" as const, + data: { + // LangGraph assigns the same run_id to on_tool_start and + // on_tool_end of one invocation — a stable correlator that + // survives repeated calls to the same tool. + id: event.run_id, + name: event.name, + input: event.data.input ?? event.data.chunk ?? event.data.output, + }, + } + break + } +``` + +- [ ] **Step 4: Edit the `on_tool_end` emit site** + +Change the `on_tool_end` case's yield from: + +```ts + case "on_tool_end": { + hasYielded = true + yield { + type: "tool_result" as const, + data: { name: event.name, output: event.data.output }, + } +``` + +to: + +```ts + case "on_tool_end": { + hasYielded = true + yield { + type: "tool_result" as const, + data: { id: event.run_id, name: event.name, output: event.data.output }, + } +``` + +(Leave the `streamTransformers` loop that follows this yield unchanged.) + +- [ ] **Step 5: Run the new test and the existing adapter suite** + +Run: `pnpm --filter @dawn-ai/langchain test -- agent-adapter` +Expected: the new `agent-adapter-toolcall-id` test PASSES, and `agent-adapter.test.ts` / `agent-adapter-interrupt.test.ts` / `agent-adapter-retry.test.ts` remain green (the added optional `id` field does not break existing assertions, which use `toEqual` on `type` only or on interrupt/`data` payloads that don't touch tool_call/tool_result data shape). + +> If an existing test asserts a `tool_call`/`tool_result` chunk's `data` with an +> exact `toEqual({ name, input })`, update that single assertion to include +> `id: expect.any(String)` — the field is now always present on these chunks. + +- [ ] **Step 6: Commit** + +```bash +git add packages/langchain/src/agent-adapter.ts packages/langchain/test/agent-adapter-toolcall-id.test.ts +git commit -m "feat(langchain): surface tool invocation run_id on tool_call/tool_result chunks" +``` + +--- + +### Task 9: Changeset + package README + +**Files:** +- Create: `.changeset/ag-ui-adapter.md` +- Create: `packages/ag-ui/README.md` + +- [ ] **Step 1: Write the changeset** + +Create `.changeset/ag-ui-adapter.md`: + +```markdown +--- +"@dawn-ai/ag-ui": minor +"@dawn-ai/langchain": patch +--- + +Add `@dawn-ai/ag-ui`, a transport-agnostic adapter that maps Dawn agent stream +events to and from the AG-UI protocol. `toAguiEvents` turns a Dawn stream +(`token`/`tool_call`/`tool_result`/`interrupt`/`done`) into AG-UI events; +`fromRunAgentInput` maps AG-UI input (messages + interrupt resume) back to a Dawn +run input. No server or transport is bundled — consumers own the transport. + +The langchain adapter now surfaces each tool invocation's `run_id` as `id` on its +`tool_call`/`tool_result` stream chunks, so AG-UI `toolCallId` correlation is +faithful even when a tool is called more than once. +``` + +> **Fixed-group 0.x gotcha (from the release memo):** the repo's changesets are a +> fixed group on 0.x, where a `minor` on any package bumps the whole group to +> `1.0.0`. If the intent is to stay on 0.8.x, change `"@dawn-ai/ag-ui": minor` to +> `patch` before versioning. Confirm the desired bump with the maintainer at +> release time; default to `patch` to hold the 0.x line. + +- [ ] **Step 2: Write a short README** + +Create `packages/ag-ui/README.md`: + +```markdown +# @dawn-ai/ag-ui + +Transport-agnostic adapter between Dawn agent stream events and the +[AG-UI protocol](https://docs.ag-ui.com). Pure functions — no HTTP server, no +transport, no LangGraph dependency. + +## Outbound: Dawn stream → AG-UI events + +```ts +import { toAguiEvents } from "@dawn-ai/ag-ui" + +for await (const event of toAguiEvents(dawnChunks, { threadId, runId })) { + // event is an AG-UI BaseEvent (RUN_STARTED, TEXT_MESSAGE_CONTENT, + // TOOL_CALL_START, RUN_FINISHED, ...). Serialize it to your transport. +} +``` + +`dawnChunks` is any `AsyncIterable<{ type, data }>` shaped like the langchain +adapter's `AgentStreamChunk`. + +## Inbound: AG-UI input → Dawn run input + +```ts +import { fromRunAgentInput } from "@dawn-ai/ag-ui" + +const { messages, resume, raw } = fromRunAgentInput(runAgentInput) +``` + +`resume` (when present) carries AG-UI interrupt answers keyed by `interruptId`; +translate them to your runtime's resume call. `raw` exposes the untouched AG-UI +input for `tools`/`state`/`context`. + +## Interrupts + +Dawn interrupts map to AG-UI `RUN_FINISHED { outcome: { type: "interrupt" } }`, +and AG-UI resume input maps back — the `interruptId` round-trips losslessly. +``` + +- [ ] **Step 3: Commit** + +```bash +git add .changeset/ag-ui-adapter.md packages/ag-ui/README.md +git commit -m "docs(ag-ui): changeset + package README" +``` + +--- + +### Task 10: Full workspace verification + final review + +**Files:** none (verification only) + +- [ ] **Step 1: Build the new package and the modified one** + +Run: `pnpm --filter @dawn-ai/ag-ui --filter @dawn-ai/langchain build` +Expected: both build clean. + +- [ ] **Step 2: Typecheck both** + +Run: `pnpm --filter @dawn-ai/ag-ui --filter @dawn-ai/langchain typecheck` +Expected: no type errors. + +- [ ] **Step 3: Lint the new package** + +Run: `pnpm --filter @dawn-ai/ag-ui lint` +Expected: biome reports no errors. Fix any formatting/lint findings and re-run. + +- [ ] **Step 4: Run both test suites** + +Run: `pnpm --filter @dawn-ai/ag-ui --filter @dawn-ai/langchain test` +Expected: all green, including the new `agent-adapter-toolcall-id` test and all pre-existing langchain tests. + +- [ ] **Step 5: Sanity-check the whole build graph is not broken** + +Run: `pnpm -w build` +Expected: the full workspace build succeeds (confirms the new package didn't break turbo/tsc wiring and nothing that imports langchain regressed). + +- [ ] **Step 6: Final review against the spec** + +Read `docs/superpowers/specs/2026-07-07-ag-ui-adapter-design.md` and confirm each in-scope item is implemented: outbound mapper (all chunk types + framing + error), inbound mapper (messages + resume + raw), interrupt round-trip, deterministic id injection, the core `run_id` change, graceful degradation (missing id, malformed chunk, upstream throw). Confirm out-of-scope items were NOT built (no server, no dawn dev wiring, no STATE/CUSTOM). Note anything deferred. + +- [ ] **Step 7: Open the PR (only when the maintainer asks)** + +Do not push or open a PR until the maintainer confirms. When asked: + +```bash +git push -u origin blove/ag-ui-adapter +gh pr create --title "feat(ag-ui): transport-agnostic AG-UI adapter" --body "..." +``` + +--- + +## Self-review notes (author) + +- **Spec coverage:** package/boundary (Task 1), outbound state machine incl. all chunk types + framing + ids + error (Task 5), core `run_id` change (Task 8), inbound incl. resume + raw (Task 6), interrupts module + round-trip (Tasks 4, 7), determinism via injected id factory (Task 2, used throughout), testing matrix (Tasks 3–7), changeset/README (Task 9), verification (Task 10). All spec sections map to a task. +- **Refinements over the spec, applied deliberately:** (1) `IdFactory` has three kinds (`message`/`toolCall`/`toolResult`) not two — needed for the missing-id fallback and the result-message id; (2) inbound `resume` is a vocabulary-agnostic `DawnResumeRequest[]` passthrough rather than a fabricated single `Command({resume})` payload, because Dawn resumes per-interrupt via `{interrupt_id, decision}` and no graph-level resume payload exists at the `RunAgentInput` layer; (3) the core change touches only `agent-adapter.ts` (the `AgentStreamChunk` the mapper actually consumes), not the CLI `stream-types.ts` — the CLI-serializer id is deferred to the future `dawn dev` wiring, which is out of scope; (4) tests live in `test/**/*.test.ts` per repo convention. +- **Type consistency:** `RawChunk`, `RunContext`, `DawnToolCallData`, `DawnToolResultData`, `IdFactory`, `AguiOutboundEvent`, `DawnMessage`, `DawnRunInput`, `DawnResumeRequest`, `DawnInterruptEnvelope`, `toAguiEvents`, `fromRunAgentInput`, `toAguiInterrupt`, `fromAguiResume`, `asToolCallData`, `asToolResultData`, `createCounterIdFactory`, `createDefaultIdFactory` — names are identical across every task and the barrel. +``` diff --git a/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md b/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md new file mode 100644 index 00000000..2c05aa6b --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md @@ -0,0 +1,718 @@ +# AG-UI Canonical Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove Dawn's legacy AG-UI translator API and make `toAguiEvents` / `fromRunAgentInput` the only mapping layer used by library consumers, `dawn dev`, and `dawn start`. + +**Architecture:** `@dawn-ai/ag-ui` owns protocol translation and exposes optional SSE encoding through `@dawn-ai/ag-ui/sse`; it has no CLI, LangGraph, or server dependency. `@dawn-ai/cli` owns checkpoint-aware message selection, exact-set interrupt resume validation, middleware, cancellation, and flat-stream normalization. The shared runtime handler serves development and production. + +**Tech Stack:** TypeScript 6 ESM/NodeNext, `@ag-ui/core@0.0.57`, `@ag-ui/encoder@0.0.57`, LangGraph 1.4.7, Node HTTP, SQLite checkpointer, Vitest 4, Biome, pnpm, Turbo. + +**Spec:** `docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md` + +**Branch/worktree:** `blove/ag-ui-adapter` at `/Users/blove/repos/dawn/.claude/worktrees/wonderful-tu-a40261` + +--- + +## Locked Decisions + +- No backward compatibility. Delete `createAgUiTranslator`, `mapRunInput`, their types, the root SSE export, legacy tests, `CUSTOM on_interrupt`, capability `STATE_SNAPSHOT`, and `forwardedProps.command.resume`. Add no aliases, warnings, flags, or dual modes. +- Root `@dawn-ai/ag-ui` is transport-agnostic. SSE is only `@dawn-ai/ag-ui/sse`. +- Standard `RunAgentInput.resume` is the only AG-UI resume path. +- All open checkpoint interrupts must be addressed together through LangGraph's interrupt-namespace-keyed resume map. New turns are rejected while interrupts remain pending. +- `done.data` is a successful `RUN_FINISHED.result`; only thrown failures become `RUN_ERROR`. +- Unknown capability chunks are ignored and only act as text framing boundaries. +- Middleware runs before thread/checkpoint mutation under both `dawn dev` and `dawn start`. +- Commit after every task. Do not push or open a PR. + +## File Structure + +- `packages/ag-ui/src/types.ts`: canonical structural stream union and internal guards. +- `packages/ag-ui/src/outbound.ts`: only outbound state machine. +- `packages/ag-ui/src/inbound.ts`: only inbound projection. +- `packages/ag-ui/src/sse.ts`: optional transport subpath. +- `packages/ag-ui/src/index.ts`: exact pure root API. +- `packages/cli/src/lib/dev/pending-interrupts.ts`: checkpoint parsing and exact resume-set resolution. +- `packages/cli/src/lib/dev/request-context.ts`: shared middleware request projection. +- `packages/cli/src/lib/dev/agui-handler.ts`: HTTP orchestration only. +- `packages/cli/src/lib/runtime/execute-route.ts`: thrown preparation failures and scalar/task-map resume support. + +--- + +### Task 1: Complete the Canonical Outbound State Machine + +**Files:** +- Modify: `packages/ag-ui/src/types.ts` +- Modify: `packages/ag-ui/src/outbound.ts` +- Modify: `packages/ag-ui/src/interrupts.ts` +- Modify: `packages/ag-ui/test/types.test.ts` +- Modify: `packages/ag-ui/test/outbound.test.ts` +- Modify: `packages/ag-ui/test/interrupts.test.ts` + +- [ ] **Step 1: Write failing result and interrupt tests** + +Replace `RawChunk` test references with `DawnAgentStreamChunk`. Add: + +```ts +test("preserves done data as the successful result", async () => { + const result = { final: true } + expect((await collect([{ type: "done", data: result }])).at(-1)).toEqual({ + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result, + outcome: { type: "success" }, + }) +}) + +test("collects consecutive interrupts", async () => { + const events = await collect([ + { type: "interrupt", data: { interruptId: "perm-1", kind: "command" } }, + { type: "interrupt", data: { interruptId: "perm-2", kind: "path" } }, + { type: "done", data: {} }, + ]) + expect(events.filter((event) => event.type === EventType.RUN_FINISHED)).toHaveLength(1) + expect(events.at(-1)).toMatchObject({ + outcome: { type: "interrupt", interrupts: [{ id: "perm-1" }, { id: "perm-2" }] }, + }) +}) + +test("rejects an unaddressable interrupt", async () => { + expect((await collect([{ type: "interrupt", data: { kind: "command" } }])).at(-1)).toEqual({ + type: EventType.RUN_ERROR, + message: "Malformed Dawn interrupt: missing interruptId", + }) +}) +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui test -- outbound.test.ts types.test.ts interrupts.test.ts +``` + +Expected: failures because interrupts terminate immediately, results are discarded, and the canonical type is missing. + +- [ ] **Step 3: Define the structural stream type** + +Keep translator-only types temporarily so `translate.ts` remains buildable. Add the canonical type alongside them; Task 6 removes the legacy types and modules atomically with the CLI cutover: + +```ts +export type DawnAgentStreamChunk = + | { readonly type: "token"; readonly data: string } + | { readonly type: "tool_call"; readonly data: DawnToolCallData } + | { readonly type: "tool_result"; readonly data: DawnToolResultData } + | { readonly type: "interrupt"; readonly data: unknown } + | { readonly type: "done"; readonly data?: unknown } + | { readonly type: string; readonly data?: unknown } +``` + +- [ ] **Step 4: Implement terminal state handling** + +Change `toAguiEvents` to accept `AsyncIterable`. Accumulate validated interrupts until `done`/natural end, then emit one interrupt outcome. If a malformed interrupt lacks a non-empty ID, emit `RUN_ERROR` and return. On successful `done`, include `result` iff `data !== undefined`. Never infer failure from `{ error: ... }`. + +- [ ] **Step 5: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui test -- outbound.test.ts types.test.ts interrupts.test.ts +corepack pnpm --filter @dawn-ai/ag-ui typecheck +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/ag-ui/src packages/ag-ui/test/outbound.test.ts packages/ag-ui/test/types.test.ts packages/ag-ui/test/interrupts.test.ts +git commit -m "feat(ag-ui): complete canonical outbound mapping" +``` + +--- + +### Task 2: Add the SSE Subpath and Canonical Conformance Path + +**Files:** +- Create: `packages/ag-ui/src/sse.ts` +- Create: `packages/ag-ui/test/sse.test.ts` +- Modify: `packages/ag-ui/package.json` +- Modify: `packages/ag-ui/test/conformance.test.ts` + +- [ ] **Step 1: Write the failing SSE module test** + +```ts +import { EventType } from "@ag-ui/core" +import { expect, test } from "vitest" +import { encodeAgUiSse } from "../src/sse.js" + +test("encodes events from the focused SSE module", () => { + expect(encodeAgUiSse({ type: EventType.RUN_STARTED, threadId: "t", runId: "r" })) + .toContain('"type":"RUN_STARTED"') +}) +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui test -- sse.test.ts +``` + +- [ ] **Step 3: Create the subpath without cutting over consumers** + +Copy the focused encoder implementation into `sse.ts`. Add package export `./sse` -> `dist/sse.js` / `dist/sse.d.ts`. Keep `encode.ts`, its root export, translator modules, and legacy tests until Task 6 so the CLI remains buildable. This is temporary task sequencing, not a final compatibility surface. + +- [ ] **Step 4: Rewire conformance** + +Use `toAguiEvents` plus `encodeAgUiSse` from `src/sse.ts` in the canned HTTP server. Convert fixture chunks to nested `data`. Keep `@ag-ui/client` `verifyEvents(false)`. Remove `STATE_SNAPSHOT`/`CUSTOM` expectations and assert those event types are absent. + +- [ ] **Step 5: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui build +corepack pnpm --filter @dawn-ai/ag-ui typecheck +corepack pnpm --filter @dawn-ai/ag-ui lint +corepack pnpm --filter @dawn-ai/ag-ui test +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/ag-ui +git commit -m "feat(ag-ui): add focused SSE encoder subpath" +``` + +--- + +### Task 3: Extract Checkpoint Interrupt Resolution + +**Files:** +- Create: `packages/cli/src/lib/dev/pending-interrupts.ts` +- Create: `packages/cli/test/pending-interrupts.test.ts` +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` +- Modify: `packages/cli/test/resume-endpoint.test.ts` + +- [ ] **Step 1: Write failing table-driven tests** + +Cover no pending/no resume -> turn; pending/no resume -> 409; no pending/resume -> 409; resolved -> decision; cancelled -> deny; two entries -> two outer resume keys; missing/unknown/duplicate IDs -> 409; invalid resolved payload -> 400; and malformed or duplicate checkpoint addresses -> 409. + +```ts +expect(resolveAgUiResume([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, +], { + interrupts: [ + { + interruptId: "perm-1", + resumeKey: "11111111111111111111111111111111", + aliases: ["perm-1", "11111111111111111111111111111111"], + }, + { + interruptId: "perm-2", + resumeKey: "22222222222222222222222222222222", + aliases: ["perm-2", "22222222222222222222222222222222"], + }, + ], + malformed: false, +})).toEqual({ + ok: true, + mode: "resume", + resume: { + "11111111111111111111111111111111": "once", + "22222222222222222222222222222222": "deny", + }, +}) +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- pending-interrupts.test.ts +``` + +- [ ] **Step 3: Implement the helper** + +```ts +export type PermissionDecision = "once" | "always" | "deny" +export interface PendingInterrupt { + readonly interruptId: string + readonly resumeKey: string | null + readonly aliases: readonly string[] +} +export interface PendingInterruptSnapshot { + readonly interrupts: readonly PendingInterrupt[] + readonly malformed: boolean +} +export async function readPendingInterrupts( + checkpointer: BaseCheckpointSaver, + threadId: string, +): Promise +export function resolveAgUiResume( + resume: readonly DawnResumeRequest[] | undefined, + pending: PendingInterruptSnapshot, +): ResumeResolution +``` + +Read every `__interrupt__` pending write. Use inner `value.value.interruptId` as the client-facing ID with outer `value.id` as fallback, retain both as AP aliases, and use outer `value.id` as the AG-UI resume key only when it is 32 lowercase hexadecimal characters. Tuple element 0 is a hyphenated task UUID, not the resume-map key. Mark malformed or duplicate checkpoint addresses and reject them with a structured 409; never accept resume keys from HTTP input. Return exact 400/409 result objects rather than throwing protocol errors. + +- [ ] **Step 4: Reuse it in AP resume** + +Replace the inline checkpoint scan in `runtime-server.ts`; preserve AP status behavior and scalar decision semantics. + +- [ ] **Step 5: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- pending-interrupts.test.ts resume-endpoint.test.ts +corepack pnpm --filter @dawn-ai/cli typecheck +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/lib/dev/pending-interrupts.ts packages/cli/src/lib/dev/runtime-server.ts packages/cli/test/pending-interrupts.test.ts packages/cli/test/resume-endpoint.test.ts +git commit -m "refactor(cli): centralize pending interrupt resolution" +``` + +--- + +### Task 4: Make Route Streaming Preserve Failures and Resume Maps + +**Files:** +- Modify: `packages/cli/src/lib/runtime/execute-route.ts` +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` +- Modify: `packages/cli/test/run-command.test.ts` +- Modify: `packages/cli/test/stream-types.test.ts` +- Verify: `packages/langchain/test/agent-adapter-toolcall-id.test.ts` + +- [ ] **Step 1: Write a failing preparation-error stream test** + +Use an invalid route file/options and consume `streamResolvedRoute`: + +```ts +await expect(async () => { + for await (const _chunk of streamResolvedRoute(invalidOptions)) { + // consume + } +}).rejects.toThrow(/route|load|resolve/i) +``` + +Add an internal-module test for a focused `toAgentInput(input, resume?)` helper. Pass a two-task map and assert the returned value is a LangGraph `Command` whose `.resume` is that exact map. Retain tool-ID assertions for flat call/result chunks. + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- run-command.test.ts stream-types.test.ts +``` + +Expected: preparation currently yields a successful `done`; resume accepts only a scalar. + +- [ ] **Step 3: Implement the stream contract** + +Define: + +```ts +export type RouteResumePayload = + | "once" + | "always" + | "deny" + | Readonly> +``` + +Temporarily broaden the existing internal `resumeDecision` option to `RouteResumePayload`. Extract `toAgentInput(input, resumeDecision)` in `execute-route.ts`; it returns `new Command({ resume })` when provided and the original input otherwise. This keeps the legacy handler buildable and gives the task-map behavior a direct test. Task 6 atomically renames the option to `resume` with the handler cutover. Throw `new Error(prepared.message)` on preparation failure. Do not inspect successful results for an `error` key. Preserve optional tool IDs through the existing langchain -> flat CLI path. + +- [ ] **Step 4: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- run-command.test.ts stream-types.test.ts resume-endpoint.test.ts +corepack pnpm --filter @dawn-ai/langchain test -- agent-adapter-toolcall-id.test.ts +corepack pnpm --filter @dawn-ai/cli typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/lib/runtime/execute-route.ts packages/cli/src/lib/dev/runtime-server.ts packages/cli/test/run-command.test.ts packages/cli/test/stream-types.test.ts +git commit -m "fix(cli): support addressed resume maps in route streams" +``` + +--- + +### Task 5: Extract Shared Middleware Request Projection + +**Files:** +- Create: `packages/cli/src/lib/dev/request-context.ts` +- Create: `packages/cli/test/request-context.test.ts` +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` + +- [ ] **Step 1: Write failing extraction tests** + +Cover string/array headers, omitted undefined headers, and route params: + +```ts +expect(extractRouteParams("/users/[userId]", { userId: 42 })).toEqual({ userId: "42" }) +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- request-context.test.ts +``` + +- [ ] **Step 3: Move, do not duplicate, helpers** + +Move `parseHeaders` and `extractRouteParams` unchanged from `runtime-server.ts` into the new module. Import them back into `runtime-server.ts`. Do not export them from the CLI package. + +- [ ] **Step 4: Verify AP behavior** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- request-context.test.ts middleware.test.ts runtime-request-listener.test.ts +corepack pnpm --filter @dawn-ai/cli typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/lib/dev/request-context.ts packages/cli/src/lib/dev/runtime-server.ts packages/cli/test/request-context.test.ts +git commit -m "refactor(cli): share middleware request projection" +``` + +--- + +### Task 6: Rewrite the AG-UI Handler Around the Canonical Adapter + +**Files:** +- Modify: `packages/cli/src/lib/dev/agui-handler.ts` +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` +- Modify: `packages/cli/src/lib/runtime/execute-route.ts` +- Modify: `packages/cli/test/agui-endpoint.test.ts` +- Modify: `packages/ag-ui/src/index.ts` +- Modify: `packages/ag-ui/src/types.ts` +- Create: `packages/ag-ui/test/public-api.test.ts` +- Delete: `packages/ag-ui/src/encode.ts` +- Delete: `packages/ag-ui/src/translate.ts` +- Delete: `packages/ag-ui/src/run-input.ts` +- Delete: `packages/ag-ui/test/translate-text-tools.test.ts` +- Delete: `packages/ag-ui/test/translate-capabilities.test.ts` +- Delete: `packages/ag-ui/test/run-input.test.ts` + +- [ ] **Step 1: Expand endpoint tests for the canonical contract** + +Parse SSE `data:` frames as JSON. Assert lifecycle ordering, successful `result`, absence of `STATE_SNAPSHOT`/`CUSTOM`, and only the latest user message on a second request using the same thread. Add an assertion that tool IDs reach AG-UI unchanged when a fixture produces tool events. Add `public-api.test.ts` asserting the root runtime keys are exactly the two mappers and two ID factories, with no translator, run-input, or SSE function. + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui build +corepack pnpm --filter @dawn-ai/cli test -- agui-endpoint.test.ts +``` + +Expected: endpoint still emits legacy events and the root public-API assertion fails. + +- [ ] **Step 3: Add the internal flat-to-canonical generator** + +```ts +async function* normalizeDawnStream( + chunks: AsyncIterable, +): AsyncGenerator { + for await (const chunk of chunks) { + switch (chunk.type) { + case "chunk": + yield { type: "token", data: typeof chunk.data === "string" ? chunk.data : String(chunk.data ?? "") } + break + case "tool_call": + yield { type: "tool_call", data: { ...(chunk.id ? { id: chunk.id } : {}), name: chunk.name, input: chunk.input } } + break + case "tool_result": + yield { type: "tool_result", data: { ...(chunk.id ? { id: chunk.id } : {}), name: chunk.name, output: chunk.output } } + break + case "done": + yield { type: "done", data: chunk.output } + break + default: + yield { type: chunk.type, data: chunk.data } + } + } +} +``` + +- [ ] **Step 4: Implement canonical input/resume policy** + +Pass `checkpointer` and `middleware` from the route table. Validate `RunAgentInputSchema`, resolve the route, and call `fromRunAgentInput`. Construct and run middleware immediately after that pure projection and before `readPendingInterrupts`, resume validation, thread reads/writes, or status changes. Only after middleware allows the request, select the newest user message, read pending interrupts, and call `resolveAgUiResume`. Return its 400/409 response before thread mutation. Never inspect `forwardedProps`. + +- [ ] **Step 5: Implement middleware and event iteration** + +Return middleware rejection unchanged and pass allowed context as `middlewareContext`. Rename `streamResolvedRoute`'s broadened option from `resumeDecision` to `resume` and update AP and AG-UI callers in this same commit. Then create/update the thread, persist route metadata, mark busy, and iterate: + +```ts +for await (const event of toAguiEvents(normalizeDawnStream(streamResolvedRoute(routeOptions)), { + threadId, + runId: input.runId, +})) { + response.write(encodeAgUiSse(event, request.headers.accept)) +} +``` + +Import SSE from `@dawn-ai/ag-ui/sse`. Restore idle in `finally`. The adapter emits `RUN_ERROR`; do not emit a duplicate handler error. + +- [ ] **Step 6: Delete the superseded package surface** + +Now that the CLI imports only canonical functions, delete `translate.ts`, `run-input.ts`, `encode.ts`, and their legacy tests. Make root `index.ts` export exactly the approved canonical functions/types; remove translator-only types from `types.ts`. Add no aliases or deprecated wrappers. + +- [ ] **Step 7: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui build +corepack pnpm --filter @dawn-ai/ag-ui typecheck +corepack pnpm --filter @dawn-ai/ag-ui test +corepack pnpm --filter @dawn-ai/cli test -- agui-endpoint.test.ts middleware.test.ts +corepack pnpm --filter @dawn-ai/cli typecheck +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/ag-ui packages/cli/src/lib/dev/agui-handler.ts packages/cli/src/lib/dev/runtime-server.ts packages/cli/src/lib/runtime/execute-route.ts packages/cli/test/agui-endpoint.test.ts +git commit -m "refactor(cli): route AG-UI through canonical adapter" +``` + +--- + +### Task 7: Verify Middleware, Resume, and Cancellation End to End + +**Files:** +- Create: `packages/cli/src/lib/dev/abortable-iterable.ts` +- Create: `packages/cli/test/abortable-iterable.test.ts` +- Modify: `packages/cli/src/lib/dev/agui-handler.ts` +- Modify: `packages/cli/test/agui-endpoint.test.ts` +- Modify: `packages/cli/test/serve-runtime.test.ts` + +- [ ] **Step 1: Add failing middleware tests** + +Fixture middleware rejects without `x-api-key` and allows with context `{ tenant: "acme" }`. Assert rejection occurs before thread creation. For allowed requests, return `ctx.middleware` from a route and assert it appears in `RUN_FINISHED.result`. + +- [ ] **Step 2: Add failing resume tests** + +Make `AgUiRequestOptions` accept an optional CLI-internal `streamRoute` dependency whose type is `typeof streamResolvedRoute`, defaulting to the real function. The runtime route table never overrides it. In tests, run `handleAgUiRequest` behind a small Node server with: (a) a synthetic checkpointer returning captured-shape `__interrupt__` pending-write tuples whose first elements are hyphenated task UUIDs and whose outer `value.id` fields are 32-hex interrupt namespace/resume keys, and (b) an injected stream function that records its `resume` option and yields `{ type: "done", output: { resumed: true } }`. + +Assert no resume while pending -> 409; incomplete/unknown/duplicate set -> 409; malformed checkpoint addresses -> 409; invalid resolved payload -> 400; resume with no pending interrupts -> 409. For one and two valid entries, assert HTTP success **and** that the captured `resume` is the exact outer interrupt-namespace-keyed map, never a tuple task-UUID-keyed map. Task 4 separately proves that `streamResolvedRoute` turns this option into `Command.resume`, so the two tests cover the complete boundary without a model call. Do not add a `forwardedProps` test. + +- [ ] **Step 3: Add a failing disconnect test** + +First unit-test `abortableAsyncIterable`: abort while `next()` is pending, expect rejection, and assert the source iterator's `return()`/`finally` ran. Then use a slow chain fixture, abort fetch after streaming begins, poll `GET /threads/:id` until `idle`, and assert the route observed the abort. + +- [ ] **Step 4: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- agui-endpoint.test.ts serve-runtime.test.ts +``` + +- [ ] **Step 5: Wire request-scoped cancellation** + +Implement `abortableAsyncIterable(source, signal)` with an explicit iterator, race each `next()` against an abort promise, and call/await `iterator.return?.()` in `finally`. In the handler, abort on `request.aborted` or premature `response.close`, combine with shutdown using `AbortSignal.any`, wrap the route stream with `abortableAsyncIterable`, remove listeners in `finally`, and pass the combined signal to route execution. Do not abort after normal response completion. + +- [ ] **Step 6: Prove production assembly parity** + +Boot `serveRuntime({ host: "127.0.0.1", port: 0 })`, POST to `/agui/%2Fchat%23agent`, and assert canonical events. No Docker build is needed because generated production entrypoints call the same `serveRuntime`. + +- [ ] **Step 7: Run GREEN** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- abortable-iterable.test.ts agui-endpoint.test.ts serve-runtime.test.ts resume-endpoint.test.ts +corepack pnpm --filter @dawn-ai/cli typecheck +corepack pnpm --filter @dawn-ai/cli lint +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/lib/dev/abortable-iterable.ts packages/cli/src/lib/dev/agui-handler.ts packages/cli/test/abortable-iterable.test.ts packages/cli/test/agui-endpoint.test.ts packages/cli/test/serve-runtime.test.ts +git commit -m "fix(cli): enforce AG-UI runtime boundaries" +``` + +--- + +### Task 8: Remove Legacy Documentation and Example UI + +**Files:** +- Modify: `packages/ag-ui/README.md` +- Modify: `packages/cli/docs/dev-server.md` +- Modify: `apps/web/content/docs/dev-server.mdx` +- Modify: `apps/web/content/docs/api.mdx` +- Modify: `apps/web/content/docs/deployment.mdx` +- Modify: `packages/cli/docs/faq.md` +- Modify: `apps/web/content/docs/faq.mdx` +- Modify: `examples/chat/web/README.md` +- Modify: `examples/chat/web/app/api/copilotkit/route.ts` +- Modify: `examples/chat/web/app/page.tsx` +- Modify: `packages/cli/test/docs-bundle.test.ts` +- Delete: `examples/chat/web/app/components/PermissionInterrupt.tsx` +- Delete: `examples/chat/web/app/components/TodosPanel.tsx` + +- [ ] **Step 1: Add failing stale-reference checks** + +Extend the docs test to scan current docs/examples, excluding changelogs and `docs/superpowers`, and reject: + +```ts +const removed = [ + "createAgUiTranslator", + "mapRunInput", + 'CUSTOM{name:"on_interrupt"}', + "forwardedProps.command.resume", + "STATE_SNAPSHOT", + "dawn.subagent", + "useInterrupt", + "PermissionInterrupt", + "TodosPanel", +] +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm --filter @dawn-ai/cli test -- docs-bundle.test.ts +``` + +- [ ] **Step 3: Rewrite current documentation** + +Document root `toAguiEvents` / `fromRunAgentInput`, `@dawn-ai/ag-ui/sse`, standard interrupt outcomes, exact `RunAgentInput.resume`, middleware parity, and both runtime commands. State that v1 ignores planning/subagent capability events. Remove legacy `useInterrupt` comments from the CopilotKit route as well as the removed components/page imports. + +Delete the legacy interrupt and todos panels and remove them from `page.tsx`. Keep the basic CopilotKit `HttpAgent` chat. Do not add compatibility UI. + +- [ ] **Step 4: Regenerate API docs and verify** + +```bash +corepack pnpm --filter @dawn-ai/cli build +corepack pnpm --filter @dawn-ai/cli test -- docs-bundle.test.ts +corepack pnpm --filter @dawn-example/chat-web build +rg -n "createAgUiTranslator|mapRunInput|on_interrupt|forwardedProps\.command\.resume|STATE_SNAPSHOT|dawn\.subagent|useInterrupt|PermissionInterrupt|TodosPanel" packages/ag-ui/README.md packages/cli/docs apps/web/content/docs examples/chat/web +``` + +Expected: commands pass and `rg` prints no matches. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ag-ui/README.md packages/cli/docs apps/web/content/docs examples/chat/web packages/cli/test/docs-bundle.test.ts +git commit -m "docs(ag-ui): document canonical adapter API" +``` + +--- + +### Task 9: Verify Packed and Published API Surfaces + +**Files:** +- Modify: `scripts/lib/pack-check.mjs` +- Modify: `scripts/pack-check.test.mjs` +- Modify: `scripts/lib/published-artifacts.mjs` +- Modify: `scripts/published-artifact-smoke.mjs` +- Modify: `scripts/published-artifacts.test.mjs` + +- [ ] **Step 1: Write failing package-set tests** + +```js +assert.deepEqual(packageSets["ag-ui"], ["@dawn-ai/ag-ui"]) +assert.deepEqual(expectedFilesForPackage("@dawn-ai/ag-ui"), [ + "dist/index.js", + "dist/index.d.ts", + "dist/sse.js", + "dist/sse.d.ts", + "README.md", + "package.json", +]) +``` + +- [ ] **Step 2: Run RED** + +```bash +corepack pnpm test:published-artifacts +``` + +- [ ] **Step 3: Extend the new all-public-package pack manifest** + +`origin/main` already lists every public package in `scripts/lib/pack-check.mjs`. Update the existing `packages/ag-ui` entry to include `dist/sse.js` and `dist/sse.d.ts`. Add a focused assertion in `scripts/pack-check.test.mjs` that AG-UI expects both files and requires `exports`/`types`. In the pack runner, validate that `exports["./sse"]` targets files present in the extracted tarball; implement this as a generic export-target check rather than an AG-UI-only branch. + +- [ ] **Step 4: Add release-time installed probes** + +Add the `ag-ui` set and file expectations. When selected, `published-artifact-smoke.mjs` must: + +1. run an ESM script importing root and `@dawn-ai/ag-ui/sse`; +2. assert canonical functions exist and removed functions do not; +3. encode a `RUN_STARTED` event; +4. install `typescript@6.0.2` in the temporary consumer; +5. compile a small consumer with `module` and `moduleResolution` `NodeNext`, importing root types and SSE. + +Unit-test generated probe sources/commands. The registry smoke itself is release-time only. + +- [ ] **Step 5: Run GREEN** + +```bash +corepack pnpm test:published-artifacts +corepack pnpm test:pack-check +corepack pnpm pack:check +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/lib/pack-check.mjs scripts/pack-check.mjs scripts/pack-check.test.mjs scripts/lib/published-artifacts.mjs scripts/published-artifact-smoke.mjs scripts/published-artifacts.test.mjs +git commit -m "test(ag-ui): verify published adapter entrypoints" +``` + +--- + +### Task 10: Changeset, Full Verification, and Manual Smoke + +**Files:** +- Modify: `.changeset/ag-ui-adapter.md` +- Modify only if exact-shape assertions require it: existing CLI/langchain tests + +- [ ] **Step 1: Rewrite the changeset** + +Keep patch bumps for `@dawn-ai/ag-ui`, `@dawn-ai/cli`, and `@dawn-ai/langchain`. Describe consolidation of the existing package, standard interrupts/resume, middleware parity, SSE subpath, tool IDs, and the `dawn run` thread fix. Remove language claiming the package is newly created or unused by a runtime endpoint. + +- [ ] **Step 2: Run package verification** + +```bash +corepack pnpm --filter @dawn-ai/ag-ui build +corepack pnpm --filter @dawn-ai/ag-ui typecheck +corepack pnpm --filter @dawn-ai/ag-ui lint +corepack pnpm --filter @dawn-ai/ag-ui test +corepack pnpm --filter @dawn-ai/langchain build +corepack pnpm --filter @dawn-ai/langchain typecheck +corepack pnpm --filter @dawn-ai/langchain test +corepack pnpm --filter @dawn-ai/cli build +corepack pnpm --filter @dawn-ai/cli typecheck +corepack pnpm --filter @dawn-ai/cli lint +corepack pnpm --filter @dawn-ai/cli test +``` + +Expected: every command exits 0. Pre-existing CLI lint warnings may print but must not become errors. + +- [ ] **Step 3: Run artifact/workspace verification** + +```bash +corepack pnpm test:published-artifacts +corepack pnpm test:pack-check +corepack pnpm pack:check +corepack pnpm -w build +corepack pnpm changeset status --since=origin/main +git diff --check origin/main...HEAD +``` + +Expected: all pass and changesets report patch-only bumps. + +- [ ] **Step 4: Run manual smoke in `~/tmp/dawn-app`** + +Use the same local workspace package linking/install method already used on this branch. Verify `dawn run /chat#agent` succeeds, then start `dawn dev --port 3001`. POST a standard `RunAgentInput` to `/agui/%2Fchat%23agent`; verify canonical events and no custom/state events. Exercise standard `resume` if the app has a permission-gated tool. Stop dev, run `dawn start --host 127.0.0.1 --port 3002`, and repeat one AG-UI request. Stop every server before continuing. + +- [ ] **Step 5: Commit** + +```bash +git add .changeset/ag-ui-adapter.md +git commit -m "chore(ag-ui): finalize canonical adapter release" +``` + +- [ ] **Step 6: Final audit** + +```bash +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Expected: clean branch, no push, no PR. diff --git a/docs/superpowers/specs/2026-07-07-ag-ui-adapter-design.md b/docs/superpowers/specs/2026-07-07-ag-ui-adapter-design.md new file mode 100644 index 00000000..99d97a47 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-ag-ui-adapter-design.md @@ -0,0 +1,293 @@ +# `@dawn-ai/ag-ui` — Transport-Agnostic AG-UI Adapter — Design + +**Status:** Approved (design), pending implementation plan +**Date:** 2026-07-07 +**Author:** Brian Love (with Claude) + +## Summary + +A new leaf package, `@dawn-ai/ag-ui`, that maps between Dawn's agent stream +events and the [AG-UI protocol](https://docs.ag-ui.com) event schema — in both +directions — as a **pure library with no transport commitment**. It imports no +HTTP server, no SSE plumbing, and no LangGraph. Consumers own the transport and +pipe data through the mapper: `dawn dev` can adopt it now, and any future +production transport can reuse it unchanged. + +This package deliberately does **not** decide "where the agent runs." That keeps +Dawn aligned with its hard roadmap constraint — *own local lifecycle only, not +production runtime* — while giving Dawn a portable, spec-compliant AG-UI surface +that is not coupled to the LangGraph Agent Protocol. + +## Motivation + +Dawn already streams agent execution as a sequence of chunks +(`token | tool_call | tool_result | interrupt | done`) and serializes them to +SSE/ndjson in `dawn dev`. AG-UI is an emerging client↔agent-UI transport +(CopilotKit / ag-ui-protocol) whose event schema maps almost 1:1 onto Dawn's +chunks. Building a reusable mapper — rather than a bespoke server — gives us: + +- **Portability:** the same mapping works behind `dawn dev` today and behind any + production transport later. Auth/transport decisions stay with the consumer. +- **Spec compliance without drift:** we depend on `@ag-ui/core` for the canonical + event types and enums instead of hand-rolling them. +- **A clean interrupt story:** AG-UI models human-in-the-loop as + `RUN_FINISHED{outcome:{type:"interrupt"}}` + a resume run — a native fit for + Dawn's `interrupt` chunk and `Command({ resume })`. + +## Scope + +### In scope (v1) + +1. **Outbound mapping:** `toAguiEvents(chunks, ctx)` — a stateful async-generator + transform from Dawn `StreamChunk`s to AG-UI `BaseEvent`s. +2. **Inbound mapping:** `fromRunAgentInput(input)` — AG-UI `RunAgentInput` to a + Dawn run input `{ messages, resume? }`, including the interrupt-resume path. +3. **A small, additive core change:** surface the upstream tool-call id on the + `tool_call` / `tool_result` stream chunks so AG-UI `toolCallId` correlation is + faithful (rather than name-matched). + +### Out of scope (v1) + +- No HTTP server, endpoint, or transport wiring. The package exports functions + only. +- No `dawn dev` integration. Wiring the mapper into the dev runtime is a + **separate follow-up** with its own spec/plan. +- No `STATE_SNAPSHOT` / `STATE_DELTA` emission. Dawn has no incremental + state-diff stream today; adding one is future work. +- No interpretation of `RunAgentInput.tools` / `state` / `context`. They are + preserved on a typed `raw` escape hatch but not acted upon (YAGNI). +- No production auth model. Auth belongs to whatever transport a consumer builds; + see "Relationship to auth/middleware" below. + +## Package layout + +``` +packages/ag-ui/ + package.json # @dawn-ai/ag-ui, leaf package, mirrors packages/permissions shape + tsconfig.json + vitest.config.ts + src/ + index.ts # public exports + outbound.ts # toAguiEvents (state machine) + inbound.ts # fromRunAgentInput + interrupts.ts # Dawn interrupt <-> AG-UI Interrupt mapping (shared by both directions) + ids.ts # IdFactory type + default factory + types.ts # Dawn-facing types this package consumes (StreamChunk shape, run ctx) + src/*.test.ts # co-located table-driven golden tests +``` + +Dependencies: `@ag-ui/core` (event types/enums). No dependency on +`@dawn-ai/langchain` or any server. The Dawn `StreamChunk` shape the mapper +consumes is declared structurally in `types.ts` (a minimal local interface), so +the package does not take a runtime dependency on the CLI/langchain packages — +it only needs the shape. + +## Outbound: `toAguiEvents` + +```ts +export interface RunContext { + readonly threadId: string + readonly runId: string +} + +export function toAguiEvents( + chunks: AsyncIterable, + ctx: RunContext, + options?: { readonly idFactory?: IdFactory }, +): AsyncIterable +``` + +It is a **state machine** because AG-UI requires message/tool framing that Dawn +emits implicitly. State tracked across the stream: + +- `openMessageId: string | null` — the currently-open assistant text message. +- (tool framing is emitted eagerly per `tool_call` chunk; no long-lived tool + state is needed because Dawn delivers a tool call's whole input at once.) + +Mapping rules (in emission order): + +| Dawn chunk | AG-UI events emitted | +|---|---| +| *(stream start)* | `RUN_STARTED { threadId, runId }` | +| `token` (first since last flush) | `TEXT_MESSAGE_START { messageId, role: "assistant" }`, then `TEXT_MESSAGE_CONTENT { messageId, delta }` | +| `token` (subsequent) | `TEXT_MESSAGE_CONTENT { messageId, delta }` | +| any non-`token` chunk while a message is open | first `TEXT_MESSAGE_END { messageId }` (flush), then handle the chunk | +| `tool_call { id, name, input }` | `TOOL_CALL_START { toolCallId, toolCallName }`, `TOOL_CALL_ARGS { toolCallId, delta: JSON.stringify(input) }`, `TOOL_CALL_END { toolCallId }` | +| `tool_result { id, name, output }` | `TOOL_CALL_RESULT { messageId, toolCallId, content: stringify(output) }` | +| `interrupt` | `RUN_FINISHED { outcome: { type: "interrupt", interrupts: [...] } }` | +| `done` | `RUN_FINISHED { outcome: { type: "success" } }` | +| *(upstream throws)* | `RUN_ERROR { message, code? }`, then the generator returns | + +Notes: + +- **Single args frame.** Dawn has the full tool input at once, so `TOOL_CALL_ARGS` + is emitted once with the complete JSON. This is spec-valid (deltas need only + concatenate to valid JSON). +- **`TOOL_CALL_RESULT.messageId`.** AG-UI requires a `messageId` on the result + event (the id of the tool-result message). It is generated via `idFactory`, + independent of the assistant text `messageId`. +- **Flush-before-finish.** `done` / `interrupt` / `tool_call` first flush any open + text message with `TEXT_MESSAGE_END`. + +### IDs and determinism + +```ts +export type IdFactory = (kind: "message" | "toolResult") => string +``` + +`Math.random()` / `Date.now()` are non-deterministic and make golden tests +impossible, so all generated ids flow through an injectable `IdFactory`. The +default factory produces collision-resistant ids (nanoid-style). Two id sources +are **not** the factory's job: `runId`/`threadId` come from `RunContext` (the +consumer owns run identity), and `toolCallId` is the real upstream id carried on +the chunk (see core change). + +### Graceful degradation + +The generator never throws into the consumer mid-stream. Two failure modes: + +- **Upstream throw:** caught, emitted as `RUN_ERROR`, generator returns cleanly. +- **Malformed chunk** (e.g. a `tool_result` with no `id` because it came from an + older producer): synthesize a fallback id via `idFactory` and continue, rather + than abort the whole run. A `tool_result` with no correlating id falls back to + a fresh `toolResult` id (the link is best-effort in that case). + +## Core change: surface the tool-call id on stream chunks + +Today `StreamChunk` and `AgentStreamChunk` carry only `{ name, input }` / +`{ name, output }` for tool events — correlation is by name, which breaks when a +tool is called more than once in a run. AG-UI needs a stable `toolCallId` +linking `TOOL_CALL_START` → `TOOL_CALL_RESULT`. + +**Change (additive, optional):** + +- `packages/cli/src/lib/runtime/stream-types.ts`: + - `tool_call`: add optional `readonly id?: string` + - `tool_result`: add optional `readonly id?: string` +- `packages/langchain/src/agent-adapter.ts` (`on_tool_start` / `on_tool_end` + emit sites, ~lines 613–629): populate `id` from **`event.run_id`** — LangGraph's + `streamEvents` assigns the *same* `run_id` to the `on_tool_start` and + `on_tool_end` of a single tool invocation, so it is a deterministic + start↔end correlator (no name-matching, correct even for repeated calls). +- `toSseEvent` / `toNdjsonLine`: no change needed — they serialize the whole + chunk, so `id` passes through automatically. + +The field is optional, so every existing consumer and serialized shape is +unaffected. The mapper reads `id` when present and falls back gracefully when +absent. + +> Rationale for `event.run_id` over the model's OpenAI `tool_call_id`: `run_id` +> is guaranteed present on both the start and end events and uniquely identifies +> the invocation. The OpenAI `tool_call_id` is only reliably reachable at +> tool-execution time (via `config`, as `extractToolCallId` does) and is not +> needed for AG-UI correlation. If a future consumer needs the model-facing id, +> that is a separate additive field. + +## Inbound: `fromRunAgentInput` + +```ts +export function fromRunAgentInput(input: RunAgentInput): DawnRunInput + +export interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: unknown // Command({ resume }) payload, when resuming an interrupt + readonly raw: RunAgentInput // typed escape hatch: tools/state/context preserved, uninterpreted +} +``` + +- **Messages:** AG-UI messages (`{ id, role, content }`, roles `user` / `assistant` + / `tool` / `system`) map to Dawn's message input shape. Tool-call messages on + input are passed through structurally (Dawn's run input already accepts prior + tool messages). +- **Resume (interrupt round-trip):** when `RunAgentInput` carries a `resume` + array (AG-UI's mechanism for answering open interrupts), it is mapped to the + payload Dawn expects inside `Command({ resume })`. The `interrupts.ts` module + owns the shape translation so outbound (`interrupt` → AG-UI `Interrupt`) and + inbound (AG-UI resume → Dawn resume) stay symmetric and are tested together. +- **`raw`:** the untouched `RunAgentInput` so a consumer can reach + `tools`/`state`/`context`/`forwardedProps` if it needs them. v1 does not + interpret these. + +## Interrupt mapping (`interrupts.ts`) + +The one place both directions meet. Dawn's `interrupt` chunk carries a payload +describing why the run paused (e.g. tool approval, path/command gate). This maps +to an AG-UI `Interrupt` entry inside `RUN_FINISHED.outcome.interrupts`. The +reverse — an AG-UI `resume` answer — maps back to Dawn's resume payload. Keeping +both in one module with a shared test guarantees the round-trip is lossless for +the interrupt kinds Dawn emits. + +The exact `Interrupt` field shape will be pinned against `@ag-ui/core` during +implementation (the plan verifies the type before writing the mapping). + +## Public API (`index.ts`) + +```ts +export { toAguiEvents, type RunContext } from "./outbound" +export { fromRunAgentInput, type DawnRunInput, type DawnMessage } from "./inbound" +export { type IdFactory } from "./ids" +export { type StreamChunk } from "./types" +``` + +AG-UI event/input types are re-used from `@ag-ui/core`; the package does not +re-export them (consumers import from `@ag-ui/core` directly to avoid a second +source of truth). + +## Testing + +Pure async-generator + pure functions → table-driven golden tests, no network, +no server. A deterministic `IdFactory` (counter-based: `msg-1`, `tr-1`, …) makes +event sequences exact-matchable. + +Outbound cases: + +- text-only stream (`token`×N, `done`) +- single tool call + result (asserts `toolCallId` correlation) +- interleaved text → tool call → text (asserts flush ordering) +- repeated calls to the same tool (asserts distinct `toolCallId`s via `run_id`) +- `interrupt` → `RUN_FINISHED{interrupt}` +- upstream throw → `RUN_ERROR` then clean return +- empty stream (`done` only) → `RUN_STARTED`, `RUN_FINISHED{success}` +- `tool_result` with missing `id` → graceful fallback + +Inbound cases: + +- user + assistant messages → Dawn messages +- resume answer → Dawn `resume` payload +- `raw` preserves `tools`/`state`/`context` + +Round-trip case: + +- Dawn `interrupt` → AG-UI interrupt outcome → AG-UI resume `RunAgentInput` → + Dawn resume payload (asserts the interrupt shape survives both hops). + +Core-change tests: + +- `agent-adapter` emits `id` on `tool_call` / `tool_result` from `event.run_id`; + start and end of one invocation share the id (extend existing adapter tests). + +## Relationship to auth/middleware (context, not scope) + +This package intentionally carries **no auth model**. AG-UI does not define one — +it leaves auth to the host HTTP layer, and the `@ag-ui/langgraph` reference +adapter delegates auth to the underlying deployment. Because any Dawn-owned +transport can be bypassed by hitting the runtime directly, request-level +auth/middleware must ultimately be enforced at the runtime boundary regardless of +AG-UI. The (paused) "middleware survives to production" work is therefore +**orthogonal** to this adapter and unblocked by it: this package makes Dawn's +stream portable; a later transport/runtime decision handles enforcement. + +## Risks and open questions + +- **`@ag-ui/core` API surface.** Exact exported type/enum names and the + `Interrupt` / `RunAgentInput.resume` field shapes must be pinned against the + installed `@ag-ui/core` version during implementation, not assumed. The plan's + first task installs the dep and reads its `.d.ts`. +- **`event.run_id` availability.** Confirmed conceptually (LangGraph assigns a + per-invocation `run_id` present on both tool start/end); the plan verifies it + against the installed `@langchain/*` version with a focused test before relying + on it, and falls back to a synthesized correlator if absent. +- **Version/publishing.** New public package → needs the OIDC new-package + bootstrap before its first release (see the npm-release memo). Not a v1-code + concern but flagged for the release step. +``` diff --git a/docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md b/docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md new file mode 100644 index 00000000..9614118e --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md @@ -0,0 +1,614 @@ +# `@dawn-ai/ag-ui` Canonical Adapter API Design + +**Status:** Approved +**Date:** 2026-07-10 +**Supersedes:** `2026-07-07-ag-ui-adapter-design.md` where this document conflicts + +## Summary + +Make the transport-agnostic adapter the single canonical AG-UI mapping layer in +Dawn. Remove the older synchronous `createAgUiTranslator` and `mapRunInput` +APIs, route Dawn's shared development/production AG-UI endpoint through +`toAguiEvents` and `fromRunAgentInput`, and keep runtime-specific policy inside +the CLI. + +The package root remains protocol focused. Optional SSE encoding moves to an +explicit `@dawn-ai/ag-ui/sse` subpath so importing the canonical adapter does not +imply an HTTP transport. Dawn's existing shared runtime server exposes the +endpoint through both `dawn dev` and the Node/Docker `dawn start` path. This +change modifies that existing endpoint but does not add a server, deployment +runtime, managed hosting service, or transport to the adapter package. + +Backward compatibility with the superseded translator API and its custom event +semantics is not required. + +## Context + +The original transport-agnostic adapter was developed while `main` independently +landed an AG-UI package and a local CLI endpoint. Rebasing produced one package +with two competing implementations: + +- `toAguiEvents` and `fromRunAgentInput`, which use canonical AG-UI run outcomes + and the langchain `AgentStreamChunk` shape; +- `createAgUiTranslator` and `mapRunInput`, which use the flat CLI stream shape, + legacy `CUSTOM` interrupts, and CLI-specific checkpoint policy. + +The implementations disagree about interrupts, terminal errors, state events, +input history, fallback IDs, and public types. Keeping both makes it unclear +which behavior is authoritative and allows the local endpoint to diverge from +library consumers. + +Meanwhile, `main` now includes published-artifact verification. The final API +must therefore be verified from a packed package, not only through workspace +source imports. + +After the initial review of this design, `main` also landed the production Node +target and `dawn start`. `serveRuntime` uses the same runtime listener as +`dawn dev` and exposes `/agui/{routeId}` on its default `0.0.0.0` bind. The +endpoint is therefore a production-facing, user-operated surface. Its current +middleware bypass must be fixed as part of canonicalizing the handler. + +## Goals + +1. Provide one authoritative bidirectional Dawn-to-AG-UI mapping API. +2. Preserve the adapter package's independence from the CLI, LangGraph, and any + HTTP server. +3. Keep checkpoint, resume-decision, route, and thread-status policy in the CLI. +4. Make the existing shared-runtime `/agui/{routeId}` endpoint consume the same + adapter API external users consume in both development and production. +5. Use standard AG-UI interrupt outcomes and resume input, with explicit + validation where Dawn's local runtime has a narrower execution model. +6. Verify protocol conformance, runtime endpoint behavior, and packed-package + exports. +7. Apply the same Dawn middleware gate to AG-UI requests that protects Agent + Protocol runs before the shared runtime is considered production-ready. + +## Non-goals + +- Preserve `createAgUiTranslator`, `mapRunInput`, or their exported types. +- Preserve `STATE_SNAPSHOT` emission for arbitrary Dawn capability chunks. +- Preserve `CUSTOM{name:"dawn.subagent.*"}` events. +- Preserve `CUSTOM{name:"on_interrupt"}` or + `forwardedProps.command.resume` as the interrupt protocol. +- Add state-delta, planning, subagent, or capability extension mappings to the + v1 canonical adapter. +- Add a new production runtime, server, endpoint, managed hosting service, or + authentication framework. The existing Node/Docker runtime remains a + consumer of the adapter and reuses its existing middleware. +- Make the pure adapter understand Dawn checkpoint policy or permission + decisions such as `once`, `always`, and `deny`. + +## Architecture + +The design has three boundaries: + +```text +AG-UI RunAgentInput + | + v +fromRunAgentInput @dawn-ai/ag-ui + | + v +CLI invocation bridge @dawn-ai/cli (runtime policy) + | + v +Dawn route / langchain stream + | + v +CLI stream normalization @dawn-ai/cli (shape only) + | + v +toAguiEvents @dawn-ai/ag-ui + | + v +optional SSE encoding @dawn-ai/ag-ui/sse +``` + +`@dawn-ai/ag-ui` owns protocol translation. `@dawn-ai/cli` owns the decisions +needed to run a Dawn route in Dawn's shared development/production runtime. The +CLI may depend on the adapter; the adapter must not depend on the CLI or +langchain packages. + +## Package API + +### Root export + +The root `@dawn-ai/ag-ui` export contains exactly: + +```ts +export { + toAguiEvents, + fromRunAgentInput, + createCounterIdFactory, + createDefaultIdFactory, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnInterruptEnvelope, + type DawnMessage, + type DawnResumeRequest, + type DawnRunInput, + type IdFactory, + type RunContext, + type ToAguiOptions, +} +``` + +All other package functions and types are implementation details. In particular, +`AgUiEvent`, `DawnToolCallData`, `DawnToolResultData`, `fromAguiResume`, +`toAguiInterrupt`, `asToolCallData`, `asToolResultData`, `RawChunk`, +`DawnStreamChunk`, and `TranslatorOptions` are not public API. Consumers use +AG-UI protocol types directly from `@ag-ui/core`. + +The removed public API is: + +- `createAgUiTranslator` +- `AgUiTranslator` +- `mapRunInput` +- `MappedRunInput` +- `ResumeDecision` +- `TranslatorOptions` +- the flat `DawnStreamChunk` translator type +- `AgUiEvent` +- `DawnToolCallData` +- `DawnToolResultData` +- `fromAguiResume` +- `toAguiInterrupt` +- `asToolCallData` +- `asToolResultData` +- `RawChunk` + +No compatibility aliases or deprecation period are required. + +### SSE subpath + +SSE encoding is reusable but transport-specific. It moves to: + +```ts +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" +``` + +The package declares an `./sse` export pointing at a focused module. The root +entrypoint does not export `encodeAgUiSse` and does not import +`@ag-ui/encoder`. This keeps transport code out of the root module graph while +avoiding duplicate encoder logic in the CLI. + +`@ag-ui/encoder` remains a package runtime dependency because the published SSE +subpath requires it. + +## Canonical Dawn Stream Type + +Replace the loose `{ type: string; data?: unknown }` public type with a +discriminated union that is structurally compatible with the langchain adapter: + +```ts +export type DawnAgentStreamChunk = + | { readonly type: "token"; readonly data: string } + | { + readonly type: "tool_call" + readonly data: { + readonly id?: string + readonly name: string + readonly input: unknown + } + } + | { + readonly type: "tool_result" + readonly data: { + readonly id?: string + readonly name: string + readonly output: unknown + } + } + | { readonly type: "interrupt"; readonly data: unknown } + | { readonly type: "done"; readonly data?: unknown } + | { readonly type: string; readonly data?: unknown } +``` + +The open final member permits capability-contributed chunks without requiring a +dependency on Dawn core. Runtime narrowing still validates tool payloads at the +trust boundary. Internal helper types may use a stricter known-chunk union to +retain useful narrowing despite the open extension member. + +Unknown chunks are ignored in v1. They do not produce state or custom events. +They flush an open text message, preserving the existing rule that every +non-token chunk is a framing boundary. + +## Outbound Mapping + +`toAguiEvents(chunks, context, options)` remains the only outbound mapper. It +emits: + +| Dawn input | AG-UI output | +|---|---| +| stream start | `RUN_STARTED` | +| `token` | text message start/content framing | +| `tool_call` | tool call start/args/end | +| `tool_result` | tool call result | +| one or more interrupts | one `RUN_FINISHED` with interrupt outcome | +| `done` | `RUN_FINISHED` with success outcome and optional result | +| upstream throw | `RUN_ERROR` | + +### Interrupt collection + +Dawn can emit multiple interrupt chunks for one parked run, while AG-UI's +terminal interrupt outcome accepts an array. The mapper collects consecutive +interrupt chunks and emits one terminal event containing all of them. + +Because an async generator cannot know that an interrupt is the last item until +it reads again, the state machine holds pending interrupts. The next event has +the following meaning: + +- another `interrupt`: append it; +- `done` or natural stream end: emit one interrupt outcome and return; +- any other chunk: emit the accumulated interrupt outcome and return without + consuming further run output as part of a completed AG-UI run. + +The expected Dawn stream contract is that interruption terminates execution. +Tests cover multiple interrupts followed by Dawn's current terminal behavior. + +### Terminal errors + +Thrown upstream errors are the canonical failure signal and map to `RUN_ERROR`. +A `done` payload is opaque result data and does not become an error merely +because it contains an `error` property. This prevents protocol translation from +guessing application semantics. Runtime layers that represent failures as +values must throw before passing the stream to the adapter. + +When `done.data` is defined, the mapper preserves it as +`RUN_FINISHED.result`. Natural stream completion has no result. Interrupt +outcomes do not include a result. + +The publicly exported `streamResolvedRoute` currently represents +route-preparation failure as +`done { output: { error } }`. Change that internal generator to throw the +preparation error instead. Its existing Agent Protocol and resume consumers +already catch thrown stream errors and serialize their existing terminal error +shape, so their external behavior remains unchanged. The AG-UI path then +receives an actual upstream throw and emits `RUN_ERROR` without inspecting +application result values. + +This is an intentional behavior change for direct callers of +`streamResolvedRoute`: preparation failure rejects during iteration instead of +yielding a terminal value. The outward Agent Protocol and AG-UI HTTP contracts +remain stable. + +### Tool IDs + +Upstream tool invocation IDs remain authoritative. Missing IDs use the existing +injectable factory and name-based FIFO correlation as graceful degradation. +Internal validation silently ignores malformed tool chunks rather than emitting +invalid AG-UI events. + +An interrupt is different because an AG-UI interrupt without an addressable ID +cannot be resumed. If any interrupt chunk lacks a non-empty `interruptId`, the +mapper emits `RUN_ERROR` with a producer-contract message and returns. It does +not synthesize an interrupt ID. + +## Inbound Mapping + +`fromRunAgentInput(input)` remains a protocol-only structural conversion: + +```ts +interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: DawnResumeRequest[] + readonly raw: RunAgentInput +} +``` + +It converts all supported AG-UI messages and preserves the untouched input as +`raw`. It does not decide which messages a checkpointed runtime should replay. +Assistant `toolCalls`, message names, tool-call IDs, and other protocol fields +that Dawn does not yet execute remain accessible through `raw`; the typed Dawn +message projection documents the fields it intentionally preserves. + +AG-UI's standard `resume` array maps losslessly to addressed Dawn resume +requests. The adapter does not interpret permission-decision vocabulary. + +## Runtime Integration + +The `/agui/{routeId}` endpoint remains in `@dawn-ai/cli`, but it becomes a +consumer of the canonical API. Because `dawn dev` and `dawn start` share +`createRuntimeRequestListener`, one handler implementation serves both. + +### Middleware boundary + +Before creating or mutating a thread, marking it busy, validating checkpoint +resume state, or starting route execution, the AG-UI handler runs the same +loaded `DawnMiddleware` used by Agent Protocol run endpoints. + +The middleware request contains the resolved route's `assistantId` and +`routeId`, the actual HTTP method, URL and headers, and route params extracted +from the mapped Dawn input. A rejection returns the middleware's status and body +before SSE headers are sent. A continuation passes the middleware context to +`streamResolvedRoute` so tools and route execution observe the same +request-scoped context as Agent Protocol runs. + +Shared request parsing/context helpers should be extracted within the CLI where +needed rather than creating a second interpretation in `agui-handler.ts`. +Middleware behavior is runtime policy and is not imported into +`@dawn-ai/ag-ui`. + +### Input bridge + +After validating `RunAgentInputSchema`, the handler calls +`fromRunAgentInput(input)`. + +For a normal checkpointed turn, the CLI selects only the newest projected user +message and passes it as Dawn route input. Replaying the full AG-UI history would +duplicate messages already held by LangGraph's checkpoint. This is explicitly +CLI checkpoint policy, not adapter behavior. + +Before choosing a normal or resume turn, the CLI reads the checkpoint's pending +`__interrupt__` writes and derives an address map from each capability-level +`interruptId` to the LangGraph interrupt namespace/resume key in the write's +outer `value.id`. + +For a resume turn, the CLI requires the AG-UI resume array to address the exact +set of open checkpoint interrupts: + +- `status: "cancelled"` maps to Dawn decision `"deny"`; +- `status: "resolved"` requires payload `"once"`, `"always"`, or `"deny"`; +- every open interrupt must appear exactly once; +- unknown, duplicate, or omitted interrupt IDs return HTTP 409; +- an unsupported or missing resolved payload returns HTTP 400; +- each addressed `interruptId` is validated against the pending checkpoint + before execution, using the same state-based validation as the existing Dawn + resume endpoint. + +The bridge converts the validated answers to LangGraph's supported +interrupt-namespace-keyed resume map and invokes +`Command({ resume: { [resumeKey]: decision } })`. The resume key is the outer +interrupt `value.id` and must be 32 lowercase hexadecimal characters. The first +element of each pending-write tuple is a hyphenated task UUID used for write +association; it is not the keyed-resume address. Resume keys are read only from +the checkpoint and are never accepted from the client. This supports one or +many parallel open interrupts without weakening stale-interrupt protection. + +If `RunAgentInput.resume` is absent or empty while the checkpoint has pending +interrupts, the request returns HTTP 409 instead of starting a new message turn. +If no interrupts are pending, an absent or empty resume array means a normal +turn. Resume entries supplied when no interrupts are pending also return 409. + +The runtime's `streamResolvedRoute` resume option is broadened from a single +permission decision to the internal resume payload accepted by LangGraph. The +existing Agent Protocol resume endpoint may continue passing its scalar +decision; the AG-UI handler passes the validated interrupt-namespace-keyed map. + +The shared checkpoint lookup/validation should be extracted into a CLI-internal +helper rather than duplicated between the two handlers. + +### Output bridge + +`streamResolvedRoute` produces the CLI's flat `StreamChunk`. A CLI-internal async +generator normalizes it to `DawnAgentStreamChunk`: + +| CLI chunk | Adapter chunk | +|---|---| +| `chunk` | `token` with string `data` | +| `tool_call` | `tool_call` with nested `data` | +| `tool_result` | `tool_result` with nested `data` | +| `interrupt` | `interrupt` | +| `done` | `done` with `data: output` | +| any other type | same type and `data` | + +The endpoint iterates `toAguiEvents`, encodes each event with the SSE subpath, +and writes it to the response. It does not implement its own AG-UI state +machine. + +The branch's existing cross-layer tool-ID work is part of this design and must +survive the rebase onto latest `main`: + +- `packages/langchain/src/agent-adapter.ts` puts the shared tool invocation + `event.run_id` on both `tool_call.data.id` and `tool_result.data.id`; +- `packages/cli/src/lib/runtime/stream-types.ts` carries optional IDs on flat + tool chunks; +- `streamResolvedRoute` preserves those IDs when flattening langchain chunks; +- the CLI normalizer nests the same ID back under `data` for `toAguiEvents`. + +This is tested at each boundary and end to end. Name-based fallback remains only +for structurally compatible older or custom chunk producers. + +Before this normalization, `streamResolvedRoute` is changed to throw +route-preparation failures rather than encode them as successful `done` chunks, +as described under Terminal errors. + +### Lifecycle and cancellation + +The thread is marked busy before route execution and returned to idle in a +`finally` path. Request disconnect aborts route execution and closes the source +iterator. A client disconnect must not leave the thread busy or continue an +unobserved model run. + +Once response headers have been sent, execution failures are represented by +the adapter's `RUN_ERROR` event. Request validation and route lookup failures +remain ordinary JSON HTTP errors before streaming starts. + +These lifecycle rules apply identically under `dawn dev`, `dawn start`, and the +generated Node/Docker server because they share the listener and handler. + +## Deliberate Behavior Changes + +After this change, `/agui/{routeId}` no longer emits: + +- `STATE_SNAPSHOT` for `plan_update` or arbitrary object chunks; +- `CUSTOM{name:"dawn.subagent.*"}`; +- `CUSTOM{name:"on_interrupt"}`. + +Interrupts use `RUN_FINISHED { outcome: { type: "interrupt", interrupts } }` and +resume through `RunAgentInput.resume`. + +The CopilotKit chat example's legacy `useInterrupt` component and comments rely +on the removed custom event path. They must be removed or rewritten against the +standard AG-UI interrupt API in the same change. Documentation must not claim +planning or subagent events are supported by the v1 adapter. + +## Files and Ownership + +### Remove + +- `packages/ag-ui/src/translate.ts` +- `packages/ag-ui/src/run-input.ts` +- translator and run-input unit tests + +### Modify + +- `packages/ag-ui/src/index.ts` +- `packages/ag-ui/src/types.ts` +- `packages/ag-ui/src/outbound.ts` +- `packages/ag-ui/src/inbound.ts` as required to align its documented projection +- `packages/ag-ui/package.json` and build configuration for the `./sse` subpath +- `packages/cli/src/lib/dev/agui-handler.ts` +- CLI resume validation internals +- CLI middleware request/context helpers and route-table wiring +- `packages/cli/src/lib/runtime/execute-route.ts` resume payload and tool-ID + propagation +- `packages/cli/src/lib/runtime/stream-types.ts` optional tool IDs +- `packages/langchain/src/agent-adapter.ts` upstream invocation IDs +- AG-UI package, CLI, API, and chat-example documentation +- changeset wording +- published-artifact smoke configuration + +The exact CLI helper filenames are an implementation-plan decision. Helpers +remain internal and are not exported from `@dawn-ai/cli`. + +## Testing + +### Adapter tests + +- exact text and tool framing sequences; +- upstream tool ID preservation and repeated same-name calls; +- missing-ID FIFO fallback; +- malformed known chunks do not emit invalid events; +- malformed interrupts emit `RUN_ERROR` and cannot produce an empty ID; +- multiple interrupts become one terminal outcome; +- standard interrupt/resume ID round trip; +- natural stream completion; +- explicit `done` success; +- successful `done.data` is preserved as `RUN_FINISHED.result`; +- thrown error becomes `RUN_ERROR`; +- unknown capability events do not emit state or custom events; +- all supported inbound message roles and content coercion; +- standard resume arrays remain lossless; +- SSE encoder is reachable only through the `./sse` public subpath. + +### Conformance test + +Feed `toAguiEvents` through a canned SSE server, parse it with +`@ag-ui/client`, and run `verifyEvents`. The fixture includes text, tool calls, +tool results, ignored capability chunks, and a terminal event. It must no longer +depend on the removed translator. + +### CLI endpoint tests + +- normal message turn emits a conforming AG-UI stream; +- a second turn forwards only the newest user message; +- tool-call IDs survive langchain -> CLI -> adapter -> AG-UI; +- one or multiple fully addressed resume decisions continue the parked run; +- cancelled resume entries map to deny; +- stale, unknown, duplicate, or incomplete interrupt sets return 409; +- a normal turn while interrupts remain pending returns 409; +- resume input when no interrupts are pending returns 409; +- unsupported or missing resolved decision payloads return 400; +- interrupt output uses a standard terminal outcome; +- execution errors emit `RUN_ERROR` and restore idle status; +- client disconnect aborts execution and restores idle status; +- middleware rejection prevents thread mutation and route execution; +- middleware continuation passes request-scoped context into route execution. + +Run the endpoint integration once through `createRuntimeRequestListener` for +focused coverage and once through +`serveRuntime({ host: "127.0.0.1", port: 0 })` to prove that the `dawn start` +production assembly exposes the same canonical AG-UI behavior. No Docker build +is needed for this adapter change because the generated server entry calls that +same `serveRuntime` function. + +### Published artifact + +Extend `scripts/pack-check.mjs` with `@dawn-ai/ag-ui`, including +`dist/index.js`, `dist/index.d.ts`, `dist/sse.js`, `dist/sse.d.ts`, the README, +and the two package export paths. This is the local pre-publish tarball check. + +Also extend `scripts/lib/published-artifacts.mjs` with an `ag-ui` package set and +file expectations. The post-publish command +`pnpm published:smoke -- --package-set ag-ui --version ` installs the +registry artifact and verifies: + +- root imports include `toAguiEvents` and `fromRunAgentInput`; +- removed translator APIs are absent; +- `@dawn-ai/ag-ui/sse` resolves and encodes a valid event; +- declarations for both export paths resolve under NodeNext. + +The published smoke's install probe must actually import the root and `./sse` +entrypoints and compile a small consumer project with TypeScript `NodeNext` +module resolution; version and file-presence checks alone are insufficient. + +### Final verification + +Run package build, typecheck, lint, and tests for `@dawn-ai/ag-ui` and +`@dawn-ai/cli`; build, typecheck, and test `@dawn-ai/langchain`; run the relevant +pack-check and published-artifact unit tests; then run the workspace build. The +registry-based `published:smoke` command is a release-time check and cannot test +the unpublished branch version. + +## Documentation and Release Notes + +Update all references to the dual API in: + +- `packages/ag-ui/README.md` +- `packages/cli/docs/dev-server.md` +- production deployment and CLI docs that describe `dawn start` +- generated or source API documentation +- `examples/chat/web` +- the branch changeset + +The changeset remains patch-level because Dawn's fixed 0.x group would otherwise +move to `1.0.0`. Its text should describe consolidation of the existing package, +not creation of a package that is already present on `main`. + +The old July 7 design and plan remain as historical implementation records. This +document governs the cleanup where they conflict. + +## Risks and Mitigations + +**Loss of capability UI events.** Planning, subagent, and legacy interrupt UIs +stop receiving custom events. This is accepted. Documentation and examples are +updated atomically so unsupported behavior is not advertised. + +**Checkpoint history duplication.** The canonical inbound mapper returns all +messages. The CLI explicitly selects the newest user message and tests a second +turn. + +**Resume semantic mismatch.** AG-UI requires all open interrupts to be addressed +together. The CLI validates the exact checkpoint interrupt set, translates each +supported permission decision, and uses LangGraph's +interrupt-namespace-keyed resume map. It rejects new turns while interrupts +remain unresolved. + +**Transport purity ambiguity.** SSE encoding is isolated behind a subpath whose +dependency graph is separate from the root adapter entrypoint. + +**Published API drift.** Packed-artifact tests exercise both root and SSE +subpath exports. + +**Production middleware bypass.** The shared runtime now serves AG-UI on a +public production bind, but the current handler does not call Dawn middleware. +The handler gains middleware parity and tests rejection before any thread or +execution side effect. + +## Success Criteria + +- There is one outbound mapper and one inbound mapper in `@dawn-ai/ag-ui`. +- The package root is transport-agnostic and does not load the SSE encoder. +- The shared development/production runtime endpoint uses the canonical mapper + end to end. +- AG-UI requests pass through Dawn middleware before side effects or streaming. +- Standard AG-UI interrupts and resumes work through the shared endpoint. +- Parallel open interrupts are resumed together through an + interrupt-namespace-keyed LangGraph resume map, and unresolved interrupts + block new turns. +- Unsupported resume shapes fail explicitly and stale interrupt IDs remain + protected. +- Removed custom/state behaviors are absent from tests, examples, and current + documentation. +- Workspace and published-artifact verification pass. diff --git a/examples/chat/README.md b/examples/chat/README.md index 7377c5d6..bf6f40da 100644 --- a/examples/chat/README.md +++ b/examples/chat/README.md @@ -15,24 +15,23 @@ them in `dawn.config.ts` for in-memory storage, remote sandboxes, etc. - `AGENTS.md` memory autoload — Dawn auto-injects `workspace/AGENTS.md` into the system prompt on every turn; the agent updates it via `writeFile` - **Planning** — `plan.md` in the route directory opts the agent into the built-in - `writeTodos` tool, a `todos` state channel, and a `plan_update` SSE event. Open the - smoke client's event log; you'll see `event: plan_update` lines whenever the agent - updates its plan. + `writeTodos` tool, a `todos` state channel, and a `plan_update` Agent Protocol stream + event. AG-UI v1 intentionally ignores planning capability events. - **Skills** — `src/app/chat/skills//SKILL.md` files are auto-listed in the agent's system prompt (name + description). The agent calls `readSkill({ name })` to load a skill's full body on demand. Two example skills ship with the demo: `workspace-conventions` and `recover-from-failure`. - **Subagents** — `/coordinator` dispatches to specialist subagents (`research`, `summarizer`) via an auto-generated `task({ subagent, input })` tool. Subagent runs - bubble `subagent.*` SSE events with `call_id` correlation. Not wired into the web - client yet (see below) — drive it directly against the server's SSE/AG-UI endpoints. + bubble `subagent.*` Agent Protocol stream events with `call_id` correlation. The basic + web client does not expose `/coordinator`; drive it through Agent Protocol instead. - **HITL permissions** — `dawn.config.ts` seeds allow/deny lists for `runBash`. Unknown commands in interactive mode emit an interrupt; resume the thread with `once`, `always`, or `deny` to continue. See [Permissions](../../apps/web/content/docs/permissions.mdx) for the interrupt/resume flow. - End-to-end streaming to a [CopilotKit](https://docs.copilotkit.ai) web client over Dawn's - AG-UI endpoint (`POST /agui/{routeId}`, see `@dawn-ai/ag-ui`) — the `/chat` route only; - `/coordinator` is a fast-follow for the web client. + AG-UI endpoint (`POST /agui/{routeId}`, see `@dawn-ai/ag-ui`) for basic `/chat` messages. + The web example deliberately has no planning, subagent, or permission compatibility UI. ## Model choice @@ -42,7 +41,10 @@ If you swap to a smaller model, expect to do more prompt-engineering work to get ## Quickstart +Run these commands from the repository root: + ```bash +cd examples/chat cp server/.env.example server/.env # add OPENAI_API_KEY cp web/.env.example web/.env.local pnpm install @@ -71,12 +73,9 @@ examples/chat/ │ └── summarizer/index.ts └── web/ # @dawn-example/chat-web (CopilotKit v2 web client) └── app/ - ├── layout.tsx # imports @copilotkit/react-ui styles - ├── page.tsx # CopilotKitProvider + CopilotSidebar + TodosPanel - ├── api/copilotkit/route.ts # CopilotRuntime + HttpAgent → Dawn /agui/%2Fchat%23agent - └── components/ - ├── PermissionInterrupt.tsx # useInterrupt → approve/deny card - └── TodosPanel.tsx # useAgent({agentId:"chat"}) → live plan/todos + ├── layout.tsx # imports @copilotkit/react-core/v2/styles.css + ├── page.tsx # CopilotKit + CopilotSidebar + └── api/copilotkit/route.ts # CopilotRuntime + HttpAgent → Dawn /agui/%2Fchat%23agent ``` ## Security caveats @@ -93,6 +92,7 @@ shell expansion — all possible. Do not point untrusted users at this example. `workspace/` are also permission-gated by the workspace capability. See [Permissions](../../apps/web/content/docs/permissions.mdx) and the [configuration reference](../../apps/web/content/docs/configuration.mdx#permissions). + The basic web client does not render a decision control. - **Tool-output offloading** is supported by the runtime and is active whenever the app root has a `workspace/` directory. Large tool results are written under `workspace/tool-outputs/` and replaced in context with a preview plus a `readFile` diff --git a/examples/chat/web/README.md b/examples/chat/web/README.md index 4887c6f0..1c1bd19c 100644 --- a/examples/chat/web/README.md +++ b/examples/chat/web/README.md @@ -9,10 +9,10 @@ previous hand-rolled SSE smoke client. This app runs **live** against a real model — there is no aimock/demo mode here. The deterministic, no-key proof that the AG-UI wire protocol works is the `/agui` endpoint's -own e2e test suite in `@dawn-ai/ag-ui`. +test suite in `@dawn-ai/cli`. -Scope: the `/chat` route only. `/coordinator` (subagents) is a fast-follow, not covered -by this client yet. +Scope: basic chat with the `/chat` route. The AG-UI v1 adapter intentionally +ignores planning and subagent capability events. ## Architecture @@ -20,7 +20,7 @@ by this client yet. browser -> CopilotKit runtime (app/api/copilotkit/route.ts, this app, no API key) -> HttpAgent -> POST /agui/%2Fchat%23agent (Dawn dev server, holds OPENAI_API_KEY) - -> live /chat agent (workspace tools, planning, HITL permissions) + -> live /chat agent -> AG-UI event stream back to the browser ``` @@ -28,20 +28,18 @@ browser served via `copilotRuntimeNextJSAppRouterEndpoint`. No LLM credentials live here; the Dawn server holds `OPENAI_API_KEY`. - `app/page.tsx` — `CopilotKit` (`runtimeUrl="/api/copilotkit"`) wrapping a - `CopilotSidebar` (chat transcript), plus two thin Dawn-specific wrapper components. -- `app/components/PermissionInterrupt.tsx` — `useInterrupt` renders an approve/deny card - when Dawn's HITL permission gate pauses a run (`CUSTOM{name:"on_interrupt"}`). -- `app/components/TodosPanel.tsx` — `useAgent` (v2's coagent-state hook) renders Dawn's - live plan/todos list as they stream in. + `CopilotSidebar` chat transcript. -CopilotKit's sidebar and hooks fall back to the literal agent id `"default"` when -no `agentId` is provided. This example registers the Dawn `/chat#agent` route -under `default`, so `CopilotSidebar`, `useAgent`, and `useInterrupt` all bind to -the same agent without per-component wiring. +CopilotKit's sidebar falls back to the literal agent id `"default"`. This example +registers the Dawn `/chat#agent` route under that id. ## Running +Run these commands from the repository root. They intentionally enter the parent +`examples/chat` package before using its server/web scripts: + ```bash +cd examples/chat cp server/.env.example server/.env # add OPENAI_API_KEY — the server needs it, not this app pnpm install pnpm dev # server on :3001, web on :3000 @@ -55,21 +53,12 @@ because this client intentionally has no demo/mock mode. ## Live smoke checklist (run manually, with a real `OPENAI_API_KEY`) -1. `cp server/.env.example server/.env` and set `OPENAI_API_KEY`. +1. From `examples/chat`, run `cp server/.env.example server/.env` and set `OPENAI_API_KEY`. 2. `pnpm dev` (server :3001, web :3000). 3. Open http://localhost:3000. Send "list the files in the workspace" — expect a streamed assistant reply in the sidebar. -4. Send a prompt that triggers a non-allowlisted `runBash` (e.g. "run `npm install - left-pad`") — expect the **PermissionInterrupt** card to appear. Click **Allow once** - — expect the run to resume and the command to execute. This is the one path that - isn't proven by typecheck/build: it confirms `useInterrupt` really does carry our - `{decision, interruptId}` payload through `forwardedProps.command.resume` into - `@dawn-ai/ag-ui`'s `mapRunInput`. If the card never appears, the translator's - `CUSTOM{on_interrupt}` event isn't reaching the hook — check that the runtime route's - `agents` map registers `default` and the page components are not pointing at a - different `agentId`. -5. Send a multi-step prompt that makes the agent plan — expect the **TodosPanel** to - populate and check items off as the plan progresses. +4. Confirm a second message in the same thread continues the conversation without + replaying prior user messages to the Dawn route. ## Security caveat diff --git a/examples/chat/web/app/api/copilotkit/route.ts b/examples/chat/web/app/api/copilotkit/route.ts index 942f29b9..1837e675 100644 --- a/examples/chat/web/app/api/copilotkit/route.ts +++ b/examples/chat/web/app/api/copilotkit/route.ts @@ -1,9 +1,9 @@ +import { HttpAgent } from "@ag-ui/client" import { CopilotRuntime, - ExperimentalEmptyAdapter, copilotRuntimeNextJSAppRouterEndpoint, + ExperimentalEmptyAdapter, } from "@copilotkit/runtime" -import { HttpAgent } from "@ag-ui/client" import type { NextRequest } from "next/server" export const runtime = "nodejs" @@ -13,10 +13,7 @@ const dawnUrl = process.env.DAWN_SERVER_URL ?? "http://127.0.0.1:3001" const agUiUrl = `${dawnUrl}/agui/${encodeURIComponent("/chat#agent")}` // Register the Dawn /chat agent under CopilotKit's default agent id ("default"). -// CopilotKit components/hooks that don't specify an agentId resolve "default", so -// registering it there means the sidebar, useAgent, and useInterrupt all bind to -// this agent with no per-component wiring. (When a second agent is added — e.g. -// /coordinator — switch to named ids + explicit agentId on each consumer.) +// CopilotKit's sidebar resolves "default" when no agentId is specified. const copilotRuntime = new CopilotRuntime({ agents: { default: new HttpAgent({ url: agUiUrl }) }, }) diff --git a/examples/chat/web/app/components/PermissionInterrupt.tsx b/examples/chat/web/app/components/PermissionInterrupt.tsx deleted file mode 100644 index 0a37799d..00000000 --- a/examples/chat/web/app/components/PermissionInterrupt.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" -import { useInterrupt } from "@copilotkit/react-core/v2" - -type InterruptValue = { interruptId?: string; kind?: string; detail?: unknown } - -// Dawn emits CUSTOM{ name:"on_interrupt", value } (see @dawn-ai/ag-ui's -// translate.ts) — v2's useInterrupt treats this as a "legacy" interrupt (as -// opposed to the AG-UI-standard RUN_FINISHED{outcome:{type:"interrupt"}} -// flow). For legacy interrupts, `resolve(payload)` forwards `payload` -// *directly* as `forwardedProps.command.resume` (verified against the -// installed @copilotkit/react-core compiled source, src/v2/hooks/use-interrupt.tsx) — -// there is no `{status, payload}` envelope on the wire for this path, so -// `{ decision, interruptId? }` — the exact shape @dawn-ai/ag-ui's -// `mapRunInput` decodes — reaches Dawn unmodified. -// -// With no agentId, useInterrupt binds to CopilotKit's default agent id -// ("default"), which the runtime route registers as our Dawn /chat agent. -export function PermissionInterrupt() { - useInterrupt({ - render: ({ event, resolve }: { event: { value?: InterruptValue }; resolve: (r: unknown) => void }) => { - const value = event?.value ?? {} - const interruptId = value.interruptId - const decide = (decision: "once" | "always" | "deny") => - resolve(interruptId ? { decision, interruptId } : { decision }) - return ( -
-

Permission required

-

- {value.kind ? `${value.kind}: ` : ""} - {typeof value.detail === "string" ? value.detail : JSON.stringify(value.detail ?? {})} -

-
- - - -
-
- ) - }, - }) - return null -} diff --git a/examples/chat/web/app/components/TodosPanel.tsx b/examples/chat/web/app/components/TodosPanel.tsx deleted file mode 100644 index 33751aac..00000000 --- a/examples/chat/web/app/components/TodosPanel.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" -import { UseAgentUpdate, useAgent } from "@copilotkit/react-core/v2" - -type Todo = { content?: string; status?: string } -type ChatState = { todos?: Todo[] } - -// NOTE: v2 has no `useCoAgent` (that's a v1-only hook). The v2 equivalent is -// `useAgent`, which returns the live `AbstractAgent` instance; shared/coagent -// state lives on `agent.state`. With no agentId, it binds to CopilotKit's -// default agent id ("default") — which the runtime route registers as our -// Dawn /chat agent. -export function TodosPanel() { - const { agent } = useAgent({ updates: [UseAgentUpdate.OnStateChanged] }) - const state = (agent.state ?? {}) as ChatState - const todos = state.todos ?? [] - if (todos.length === 0) return null - return ( - - ) -} diff --git a/examples/chat/web/app/page.tsx b/examples/chat/web/app/page.tsx index ff05284a..4651e32d 100644 --- a/examples/chat/web/app/page.tsx +++ b/examples/chat/web/app/page.tsx @@ -1,31 +1,21 @@ "use client" import { CopilotKit, CopilotSidebar } from "@copilotkit/react-core/v2" -import { PermissionInterrupt } from "./components/PermissionInterrupt" -import { TodosPanel } from "./components/TodosPanel" -// Notes (verified against installed @copilotkit/react-core@1.62.2 types): +// Notes (verified against installed @copilotkit/react-core@1.62.3 types): // - Use the `CopilotKit` wrapper (not bare `CopilotKitProvider`) per CopilotKit's own v2 // guidance: it adds the error boundary, toasts, and threads provider around the context. // Its props are a superset of CopilotKitProviderProps (so `runtimeUrl` applies). // - `CopilotSidebar` ships from `@copilotkit/react-core/v2`, not `@copilotkit/react-ui` // (react-ui's root export is the v1 CopilotSidebar, incompatible with the v2 context; // react-ui exposes no `/v2` JS export, only `/v2/styles.css`). -// - Components/hooks that omit agentId resolve CopilotKit's default id ("default"). -// The runtime route (api/copilotkit/route.ts) registers the Dawn /chat agent under -// "default", so the sidebar, useAgent, and useInterrupt all bind to it with no -// per-component agentId. (Setting agentId on the sidebar alone did NOT reach the -// chat component's internal useAgent, which raised "Agent 'default' not found".) +// - The runtime route registers the Dawn /chat agent under CopilotKit's default id. // - `labels` is `Partial`, whose header title field is `modalHeaderTitle`. export default function Home() { return ( - -
- -
- -
-
+
+ +
) } diff --git a/examples/chat/web/package.json b/examples/chat/web/package.json index 79540b44..3e703fda 100644 --- a/examples/chat/web/package.json +++ b/examples/chat/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "next dev -p 3000", - "build": "next build", + "build": "node -e \"require('node:fs').rmSync('.next', { recursive: true, force: true })\" && next build", "start": "next start -p 3000", "typecheck": "tsc -p . --noEmit" }, diff --git a/examples/research/web/README.md b/examples/research/web/README.md index 18b0300d..5a1ff4ba 100644 --- a/examples/research/web/README.md +++ b/examples/research/web/README.md @@ -4,7 +4,7 @@ A [CopilotKit](https://docs.copilotkit.ai) v2 app (`@copilotkit/react-core/v2` + `@copilotkit/runtime`) whose runtime route (`app/api/copilotkit/route.ts`) registers an `HttpAgent` pointed at Dawn's `POST /agui/research` endpoint (see `@dawn-ai/ag-ui`). It mirrors `examples/chat/web` (the canonical AG-UI wiring reference) and adds -research-specific panels: live subagent activity and memory-candidate approval. +memory-candidate approval for the research workflow. This app runs **live** against a real model — there is no aimock/demo mode here. The deterministic, no-key proof that the research route and its AG-UI wire protocol work is @@ -16,7 +16,7 @@ the server's own offline test/eval suite (`examples/research/server`). browser → CopilotKit runtime (app/api/copilotkit/route.ts, this app, no API key) → HttpAgent → POST /agui/research (Dawn dev server, holds OPENAI_API_KEY) - → live /research agent (planning, subagent dispatch, HITL permissions) + → live /research agent → AG-UI event stream back to the browser ``` @@ -24,17 +24,7 @@ browser served via `copilotRuntimeNextJSAppRouterEndpoint`. No LLM credentials live here; the Dawn server holds `OPENAI_API_KEY`. - `app/page.tsx` — `CopilotKit` (`runtimeUrl="/api/copilotkit"`) wrapping a - `CopilotSidebar` (chat transcript + cited report), plus a left column of - Dawn-specific panels. -- `app/components/PermissionInterrupt.tsx` — `useInterrupt` renders an approve/deny card - when Dawn's HITL permission gate pauses a run (`CUSTOM{name:"on_interrupt"}`) — e.g. the - research agent's external fetch tool. -- `app/components/PlanPanel.tsx` — `useAgent` (v2's coagent-state hook) renders Dawn's - live plan/todos list as they stream in. -- `app/components/SubagentActivity.tsx` — subscribes to the agent's raw event stream via - `agent.subscribe({ onCustomEvent })` and lists `dawn.subagent.*` custom events (start, - tool_call, tool_result, message, end) as the researcher subagent runs. This is the one - panel with no chat/web analog. + `CopilotSidebar` (chat transcript + cited report), plus the memory-review panel. - `app/components/MemoryCandidates.tsx` — after a research run proposes durable memory via `remember()`, the candidate (`status:"candidate"`) shows up in this panel with **Approve**/**Reject** buttons, backed by the dev server's @@ -46,6 +36,11 @@ Components/hooks that omit `agentId` resolve CopilotKit's default agent id (`"default"`), which the runtime route registers as the Dawn `/research` agent — same pattern as `examples/chat/web`, no per-component wiring needed. +The AG-UI v1 adapter intentionally ignores planning and subagent capability +events. Interrupts use the standard AG-UI run outcome and top-level resume +array; this example does not add a client-specific compatibility component for +them. + ## Running ```bash @@ -66,16 +61,7 @@ because this client intentionally has no demo/mock mode. 2. `pnpm dev` (server :3001, web :3010). 3. Open http://localhost:3010. Send a research question — expect a streamed, cited report in the sidebar. -4. Expect the **PlanPanel** to populate and check items off as the agent plans and - works through the research task. -5. Expect the **SubagentActivity** panel to populate as the researcher subagent is - dispatched — start/tool_call/tool_result/message/end entries should appear in order. -6. When the agent's external-fetch tool is invoked on a non-allowlisted target, expect - the **PermissionInterrupt** card to appear. Click **Allow once** — expect the run to - resume and the fetch to execute. This confirms `useInterrupt` carries our - `{decision, interruptId}` payload through `forwardedProps.command.resume` into - `@dawn-ai/ag-ui`'s `mapRunInput`, same as the chat client. -7. If the run calls `remember()`, expect the **Memory candidates** panel to populate +4. If the run calls `remember()`, expect the **Memory candidates** panel to populate once the run finishes. Click **Approve** on one — expect it to disappear from the panel (now `status:"active"` in `.dawn/memory.sqlite`); **Reject** deletes it. diff --git a/examples/research/web/app/api/copilotkit/route.ts b/examples/research/web/app/api/copilotkit/route.ts index 486de0ba..1bb721a3 100644 --- a/examples/research/web/app/api/copilotkit/route.ts +++ b/examples/research/web/app/api/copilotkit/route.ts @@ -14,9 +14,9 @@ const agUiUrl = `${dawnUrl}/agui/${encodeURIComponent("/research#agent")}` // Register the Dawn /research agent under CopilotKit's default agent id ("default"). // CopilotKit components/hooks that don't specify an agentId resolve "default", so -// registering it there means the sidebar, useAgent, and useInterrupt all bind to -// this agent with no per-component wiring. (When a second agent is added, switch -// to named ids + explicit agentId on each consumer.) +// registering it there means the sidebar and memory panel bind to this agent with +// no per-component wiring. (When a second agent is added, switch to named ids and +// explicit agentId on each consumer.) const copilotRuntime = new CopilotRuntime({ agents: { default: new HttpAgent({ url: agUiUrl }) }, }) diff --git a/examples/research/web/app/components/PermissionInterrupt.tsx b/examples/research/web/app/components/PermissionInterrupt.tsx deleted file mode 100644 index 0a37799d..00000000 --- a/examples/research/web/app/components/PermissionInterrupt.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" -import { useInterrupt } from "@copilotkit/react-core/v2" - -type InterruptValue = { interruptId?: string; kind?: string; detail?: unknown } - -// Dawn emits CUSTOM{ name:"on_interrupt", value } (see @dawn-ai/ag-ui's -// translate.ts) — v2's useInterrupt treats this as a "legacy" interrupt (as -// opposed to the AG-UI-standard RUN_FINISHED{outcome:{type:"interrupt"}} -// flow). For legacy interrupts, `resolve(payload)` forwards `payload` -// *directly* as `forwardedProps.command.resume` (verified against the -// installed @copilotkit/react-core compiled source, src/v2/hooks/use-interrupt.tsx) — -// there is no `{status, payload}` envelope on the wire for this path, so -// `{ decision, interruptId? }` — the exact shape @dawn-ai/ag-ui's -// `mapRunInput` decodes — reaches Dawn unmodified. -// -// With no agentId, useInterrupt binds to CopilotKit's default agent id -// ("default"), which the runtime route registers as our Dawn /chat agent. -export function PermissionInterrupt() { - useInterrupt({ - render: ({ event, resolve }: { event: { value?: InterruptValue }; resolve: (r: unknown) => void }) => { - const value = event?.value ?? {} - const interruptId = value.interruptId - const decide = (decision: "once" | "always" | "deny") => - resolve(interruptId ? { decision, interruptId } : { decision }) - return ( -
-

Permission required

-

- {value.kind ? `${value.kind}: ` : ""} - {typeof value.detail === "string" ? value.detail : JSON.stringify(value.detail ?? {})} -

-
- - - -
-
- ) - }, - }) - return null -} diff --git a/examples/research/web/app/components/PlanPanel.tsx b/examples/research/web/app/components/PlanPanel.tsx deleted file mode 100644 index e1381819..00000000 --- a/examples/research/web/app/components/PlanPanel.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" -import { UseAgentUpdate, useAgent } from "@copilotkit/react-core/v2" - -type Todo = { content?: string; status?: string } -type ResearchState = { todos?: Todo[] } - -// NOTE: v2 has no `useCoAgent` (that's a v1-only hook). The v2 equivalent is -// `useAgent`, which returns the live `AbstractAgent` instance; shared/coagent -// state lives on `agent.state`. With no agentId, it binds to CopilotKit's -// default agent id ("default") — which the runtime route registers as our -// Dawn /research agent. -export function PlanPanel() { - const { agent } = useAgent({ updates: [UseAgentUpdate.OnStateChanged] }) - const state = (agent.state ?? {}) as ResearchState - const todos = state.todos ?? [] - if (todos.length === 0) return null - return ( - - ) -} diff --git a/examples/research/web/app/components/SubagentActivity.tsx b/examples/research/web/app/components/SubagentActivity.tsx deleted file mode 100644 index 4d453537..00000000 --- a/examples/research/web/app/components/SubagentActivity.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client" -import { useAgent } from "@copilotkit/react-core/v2" -import { useEffect, useState } from "react" - -type SubagentEvent = { name: string; call_id?: string; subagent?: string; depth?: number } - -// Dawn's subagent dispatcher relays child agent activity as CUSTOM -// `dawn.subagent.` events (see @dawn-ai/ag-ui's translate.ts, which maps -// StreamChunks of type "subagent.*" to CUSTOM{name:`dawn.${chunk.type}`, -// value: chunk.data}). This panel subscribes directly to the agent's raw -// event stream (rather than `agent.state`, which only carries plan/report -// snapshots) to surface subagent dispatch/tool-call/completion activity as -// it streams in. -export function SubagentActivity() { - const { agent } = useAgent() - const [events, setEvents] = useState([]) - useEffect(() => { - const sub = agent.subscribe({ - onCustomEvent: ({ event }: { event: { name?: string; value?: unknown } }) => { - if (typeof event.name !== "string" || !event.name.startsWith("dawn.subagent.")) return - // Skip the per-token stream of the child agent's message — those arrive - // one event per token and would flood the panel. Show only the meaningful - // lifecycle: dispatch (start), tool calls/results, and completion (end). - const suffix = event.name.replace("dawn.subagent.", "") - if (suffix === "message" || suffix === "token" || suffix === "chunk") return - const v = (event.value ?? {}) as { call_id?: string; subagent?: string; depth?: number } - setEvents((prev) => [...prev, { name: event.name as string, ...v }]) - }, - }) - return () => sub.unsubscribe() - }, [agent]) - if (events.length === 0) return null - return ( - - ) -} diff --git a/examples/research/web/app/page.tsx b/examples/research/web/app/page.tsx index 72d2223c..23da34c5 100644 --- a/examples/research/web/app/page.tsx +++ b/examples/research/web/app/page.tsx @@ -1,9 +1,6 @@ "use client" import { CopilotKit, CopilotSidebar } from "@copilotkit/react-core/v2" import { MemoryCandidates } from "./components/MemoryCandidates" -import { PermissionInterrupt } from "./components/PermissionInterrupt" -import { PlanPanel } from "./components/PlanPanel" -import { SubagentActivity } from "./components/SubagentActivity" // Notes (verified against installed @copilotkit/react-core@1.62.3 types — see // examples/chat/web/app/page.tsx for the original investigation): @@ -15,17 +12,14 @@ import { SubagentActivity } from "./components/SubagentActivity" // react-ui exposes no `/v2` JS export, only `/v2/styles.css`). // - Components/hooks that omit agentId resolve CopilotKit's default id ("default"). // The runtime route (api/copilotkit/route.ts) registers the Dawn /research route -// under "default", so the sidebar, useAgent, and useInterrupt all bind to it with -// no per-component agentId. +// under "default", so the sidebar and memory panel bind without per-component +// agentId wiring. // - `labels` is `Partial`, whose header title field is `modalHeaderTitle`. export default function Home() { return ( -
-
- - +
diff --git a/packages/ag-ui/README.md b/packages/ag-ui/README.md index 8f45c559..971c88b5 100644 --- a/packages/ag-ui/README.md +++ b/packages/ag-ui/README.md @@ -4,9 +4,9 @@ # @dawn-ai/ag-ui -AG-UI protocol translation for Dawn's local runtime. This package maps Dawn's -runtime stream chunks to AG-UI events and maps AG-UI run input back to Dawn route -input, so CopilotKit and other AG-UI clients can drive Dawn agents. +Pure, transport-agnostic AG-UI protocol translation for Dawn. This package maps +Dawn agent stream chunks to AG-UI events and maps AG-UI run input back to a +Dawn-shaped run input. It does not host an HTTP server or import LangGraph. This is part of [Dawn - the TypeScript meta-framework for LangGraph](https://github.com/cacheplane/dawnai). Conceptual docs: [Dev Server](https://dawnai.org/docs/dev-server) and the @@ -18,8 +18,8 @@ Conceptual docs: [Dev Server](https://dawnai.org/docs/dev-server) and the pnpm add @dawn-ai/ag-ui ``` -Most apps do not import this package directly. `@dawn-ai/cli` uses it to serve -the local dev endpoint: +Most apps do not import this package directly. `@dawn-ai/cli` uses the same +adapter in `dawn dev` and in the production runtime started by `dawn start`: ```text POST /agui/{routeId} @@ -32,80 +32,141 @@ example, the Dawn route `/chat#agent` is exposed to AG-UI clients as: POST http://127.0.0.1:3001/agui/%2Fchat%23agent ``` -## Public API +## Adapter API ```ts import { - createAgUiTranslator, - encodeAgUiSse, - mapRunInput, - type AgUiEvent, - type DawnStreamChunk, - type MappedRunInput, - type ResumeDecision, - type TranslatorOptions, + createCounterIdFactory, + createDefaultIdFactory, + fromRunAgentInput, + toAguiEvents, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnInterruptEnvelope, + type DawnMessage, + type DawnResumeRequest, + type DawnRunInput, + type IdFactory, + type RunContext, + type ToAguiOptions, } from "@dawn-ai/ag-ui" ``` -### `mapRunInput(input)` +The root package is a pure, transport-agnostic adapter. It has no CLI, HTTP, or +LangGraph dependency. -Maps an AG-UI `RunAgentInput` to Dawn's route input: +### ID factories -- The newest user message becomes `{ messages: [{ role: "user", content }] }`. -- Dawn keeps conversation history in the checkpoint keyed by AG-UI `threadId`, - so only the newest turn is forwarded. -- Human-in-the-loop resume decisions are read from - `forwardedProps.command.resume`. +`toAguiEvents` uses `createDefaultIdFactory()` to generate prefixed UUID-based +message, tool-call, and tool-result ids when it must synthesize them. +`createCounterIdFactory()` produces deterministic counters for tests. Inject +either factory, or a custom `IdFactory`, through `options.idFactory`: -Resume accepts either a string decision: +```ts +const events = toAguiEvents(chunks, context, { + idFactory: createCounterIdFactory(), +}) +``` + +```ts +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string -```json -{ "forwardedProps": { "command": { "resume": "once" } } } +export interface ToAguiOptions { + readonly idFactory?: IdFactory +} ``` -or an object carrying both the decision and interrupt id: +### `toAguiEvents(chunks, ctx)` -```json -{ - "forwardedProps": { - "command": { - "resume": { "decision": "once", "interruptId": "perm-abc123" } - } - } +Maps a Dawn agent stream to AG-UI events: + +```ts +import { toAguiEvents } from "@dawn-ai/ag-ui" + +for await (const event of toAguiEvents(dawnChunks, { threadId, runId })) { + // Serialize the AG-UI event to your transport. } ``` -`ResumeDecision` is `"once" | "always" | "deny"`. +Supported chunks are: + +```ts +type DawnAgentStreamChunk = + | { type: "token"; data: string } + | { type: "tool_call"; data: { id?: string; name: string; input: unknown } } + | { type: "tool_result"; data: { id?: string; name: string; output: unknown } } + | { type: "interrupt"; data: unknown } + | { type: "done"; data?: unknown } + | { type: string; data?: unknown } +``` + +`dawnChunks` can be any `AsyncIterable`, including the +LangChain adapter's `AgentStreamChunk` stream. An interrupt maps when its data is +a `DawnInterruptEnvelope` with a non-empty `interruptId`. Tool call ids from +Dawn chunks are preserved as AG-UI `toolCallId`. Capability-contributed and +other unknown chunk types are ignored. + +### `fromRunAgentInput(input)` + +Maps AG-UI `RunAgentInput` to a Dawn-shaped run input: + +```ts +import { fromRunAgentInput } from "@dawn-ai/ag-ui" + +const { messages, resume, raw } = fromRunAgentInput(runAgentInput) +``` + +`messages` contains all translated AG-UI messages. `resume` is omitted when the +top-level AG-UI `RunAgentInput.resume` array is absent or empty. That input field +has this exact shape: -### `createAgUiTranslator(options)` +```ts +resume?: Array<{ + interruptId: string + status: "resolved" | "cancelled" + payload?: unknown +}> +``` -Creates a translator for one AG-UI run: +When present, the adapter preserves those fields in `DawnRunInput.resume`: ```ts -const translator = createAgUiTranslator({ threadId, runId }) +{ + messages: [{ role: "user", content: "Continue", id: "message-1" }], + resume: [ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ], + raw: runAgentInput, +} ``` -Call `begin()` once, feed each Dawn stream chunk to `translate(chunk)`, then call -`end()` if the stream finishes without a Dawn `done` chunk. +The adapter does not interpret AG-UI `tools`, `state`, or `context`; they +remain available through `raw`. + +Interrupt chunks are accumulated and emitted as a standard AG-UI +`RUN_FINISHED` event with `outcome: { type: "interrupt", interrupts: [...] }`. +Each interrupt uses the Dawn `interruptId` as its AG-UI `id`, and the complete +Dawn envelope is retained in `metadata`. Successful runs finish with +`outcome: { type: "success" }`; upstream failures become one `RUN_ERROR`. -The translator emits: +Planning updates, subagent capability events, and other unknown Dawn chunk types +have no v1 mapping and are ignored. -- `RUN_STARTED` / `RUN_FINISHED` -- assistant text message start/content/end events -- tool call start/args/end/result events -- `STATE_SNAPSHOT` for `plan_update` and other object state chunks -- `CUSTOM{name:"on_interrupt"}` for Dawn permission or memory interrupts -- `CUSTOM{name:"dawn."}` for `subagent.*` chunks -- `RUN_ERROR` when Dawn reports an error +## SSE Transport ### `encodeAgUiSse(event, accept?)` Encodes one AG-UI event as an SSE frame using `@ag-ui/encoder`: ```ts +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" + response.write(encodeAgUiSse(event, request.headers.accept)) ``` +The SSE helper is a focused subpath; it is not exported from the root adapter. + ## CopilotKit The canonical example is `examples/chat/web`. It registers a CopilotKit @@ -119,19 +180,16 @@ browser -> Dawn /chat agent ``` -The example also shows how Dawn's `CUSTOM{name:"on_interrupt"}` event flows to a -CopilotKit interrupt card, and how the card sends `{ decision, interruptId }` -back through `forwardedProps.command.resume`. - ## Limitations -- The AG-UI endpoint is a local `dawn dev` integration surface. It is additive; - the Agent Protocol thread endpoints remain unchanged. +- The CLI serves the same AG-UI endpoint through `dawn dev` and the production + runtime started by `dawn start`; generated server entrypoints invoke the + exported `serveRuntime()` function directly. - `POST /agui/{routeId}` expects a URL-encoded Dawn assistant id such as `%2Fchat%23agent` for `/chat#agent`. -- Dawn middleware currently documents and targets the Agent Protocol - `/threads/:thread_id/runs/*` endpoints. Do not rely on middleware behavior as - the AG-UI authorization boundary. +- Dawn middleware gates Agent Protocol run, wait, and resume execution plus + AG-UI route execution. It does not gate thread create, read, delete, or state + endpoints. Allowed middleware context is exposed to tools as `ctx.middleware`. - The package translates protocol events; it does not host a web UI. ## License diff --git a/packages/ag-ui/package.json b/packages/ag-ui/package.json index d290d7ec..85de4119 100644 --- a/packages/ag-ui/package.json +++ b/packages/ag-ui/package.json @@ -24,13 +24,17 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./sse": { + "types": "./dist/sse.d.ts", + "default": "./dist/sse.js" } }, "publishConfig": { "access": "public" }, "scripts": { - "build": "tsc -b tsconfig.json", + "build": "node -e \"const fs = require('node:fs'); const path = require('node:path'); if (fs.existsSync('dist')) for (const entry of fs.readdirSync('dist', { withFileTypes: true })) if (entry.isFile() && /^(?:encode|run-input|translate)\\./.test(entry.name)) fs.rmSync(path.join('dist', entry.name))\" && tsc -b tsconfig.json", "lint": "biome check --config-path ../config-biome/biome.json package.json src tsconfig.json vitest.config.ts", "test": "vitest --run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" diff --git a/packages/ag-ui/src/ids.ts b/packages/ag-ui/src/ids.ts new file mode 100644 index 00000000..ff47d6f7 --- /dev/null +++ b/packages/ag-ui/src/ids.ts @@ -0,0 +1,32 @@ +/** + * Generates ids for AG-UI events. `runId`/`threadId` never flow through here + * (the consumer owns run identity via RunContext); a tool call's `toolCallId` + * normally comes from the upstream chunk and only falls back to `"toolCall"`. + */ +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string + +const PREFIX: Record<"message" | "toolCall" | "toolResult", string> = { + message: "msg", + toolCall: "tc", + toolResult: "tr", +} + +/** + * Deterministic, monotonically-increasing factory for tests: `msg-1`, `tc-1`, + * `tr-1`, ... Each kind has an independent counter. + */ +export function createCounterIdFactory(): IdFactory { + const counters = { message: 0, toolCall: 0, toolResult: 0 } + return (kind) => { + counters[kind] += 1 + return `${PREFIX[kind]}-${counters[kind]}` + } +} + +/** + * Default production factory: collision-resistant, non-deterministic ids using + * the platform crypto UUID. + */ +export function createDefaultIdFactory(): IdFactory { + return (kind) => `${PREFIX[kind]}-${crypto.randomUUID()}` +} diff --git a/packages/ag-ui/src/inbound.ts b/packages/ag-ui/src/inbound.ts new file mode 100644 index 00000000..a7f7dcc4 --- /dev/null +++ b/packages/ag-ui/src/inbound.ts @@ -0,0 +1,66 @@ +import type { Message, RunAgentInput } from "@ag-ui/core" +import { type DawnResumeRequest, fromAguiResume } from "./interrupts.js" + +export interface DawnMessage { + readonly role: "user" | "assistant" | "system" | "developer" | "tool" + readonly content: string + readonly id?: string + readonly toolCallId?: string +} + +export interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: DawnResumeRequest[] + /** The untouched AG-UI input, so a consumer can reach tools/state/context. */ + readonly raw: RunAgentInput +} + +type AguiToolMessage = Extract + +function coerceContent(content: unknown): string { + if (typeof content === "string") return content + if (content === undefined || content === null) return "" + try { + const json = JSON.stringify(content) + return typeof json === "string" ? json : String(content) + } catch { + return String(content) + } +} + +function toDawnToolMessage(message: AguiToolMessage, content: string): DawnMessage { + return { + role: "tool", + content, + id: message.id, + toolCallId: message.toolCallId, + } +} + +function toDawnMessage(message: Message): DawnMessage { + const content = coerceContent(message.content) + switch (message.role) { + case "tool": + return toDawnToolMessage(message, content) + case "user": + case "assistant": + case "system": + case "developer": + return { role: message.role, content, id: message.id } + case "activity": + case "reasoning": + return { role: "assistant", content, id: message.id } + } +} + +/** + * Map an AG-UI `RunAgentInput` to a Dawn run input. Messages are translated + * structurally; a `resume` array becomes vocabulary-agnostic Dawn resume + * requests (see `fromAguiResume`). `tools`/`state`/`context` are not + * interpreted in v1 - reach them via `raw`. + */ +export function fromRunAgentInput(input: RunAgentInput): DawnRunInput { + const messages = input.messages.map(toDawnMessage) + const resume = input.resume && input.resume.length > 0 ? fromAguiResume(input.resume) : undefined + return { messages, ...(resume ? { resume } : {}), raw: input } +} diff --git a/packages/ag-ui/src/index.ts b/packages/ag-ui/src/index.ts index 0c7c5e13..432a5d46 100644 --- a/packages/ag-ui/src/index.ts +++ b/packages/ag-ui/src/index.ts @@ -1,4 +1,5 @@ -export { encodeAgUiSse } from "./encode.js" -export { type MappedRunInput, mapRunInput, type ResumeDecision } from "./run-input.js" -export { type AgUiTranslator, createAgUiTranslator } from "./translate.js" -export type { AgUiEvent, DawnStreamChunk, TranslatorOptions } from "./types.js" +export { createCounterIdFactory, createDefaultIdFactory, type IdFactory } from "./ids.js" +export { type DawnMessage, type DawnRunInput, fromRunAgentInput } from "./inbound.js" +export type { DawnInterruptEnvelope, DawnResumeRequest } from "./interrupts.js" +export { type AguiOutboundEvent, type ToAguiOptions, toAguiEvents } from "./outbound.js" +export type { DawnAgentStreamChunk, RunContext } from "./types.js" diff --git a/packages/ag-ui/src/interrupts.ts b/packages/ag-ui/src/interrupts.ts new file mode 100644 index 00000000..0c420c03 --- /dev/null +++ b/packages/ag-ui/src/interrupts.ts @@ -0,0 +1,70 @@ +import type { Interrupt } from "@ag-ui/core" + +/** + * The interrupt envelope Dawn's capabilities emit inside an `interrupt` chunk + * (`entry.value` from LangGraph). Always carries `interruptId`; other keys are + * capability-specific and preserved verbatim. + */ +export interface DawnInterruptEnvelope { + readonly interruptId: string + readonly kind?: string + readonly message?: string + readonly toolCallId?: string + readonly [key: string]: unknown +} + +/** A resume instruction addressed to one open Dawn interrupt. */ +export interface DawnResumeRequest { + readonly interruptId: string + readonly status: "resolved" | "cancelled" + readonly payload?: unknown +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +/** + * Map a Dawn interrupt envelope to an AG-UI `Interrupt`. The full envelope is + * preserved under `metadata` so no capability-specific information is lost on + * the way to the client. + */ +export function toAguiInterrupt(data: unknown): Interrupt | null { + if (!isPlainRecord(data)) return null + const interruptId = data.interruptId + if (typeof interruptId !== "string" || interruptId.length === 0) { + return null + } + const env = data + const reason = typeof env.kind === "string" ? env.kind : "interrupt" + return { + id: interruptId, + reason, + ...(typeof env.message === "string" ? { message: env.message } : {}), + ...(typeof env.toolCallId === "string" ? { toolCallId: env.toolCallId } : {}), + metadata: env, + } +} + +/** + * Map AG-UI resume entries to Dawn resume requests. Vocabulary-agnostic: the + * consumer decides how a `{ status, payload }` becomes Dawn's per-interrupt + * decision. We only guarantee `interruptId` survives. + */ +export function fromAguiResume( + resume: ReadonlyArray<{ + interruptId: string + status: "resolved" | "cancelled" + payload?: unknown + }>, +): DawnResumeRequest[] { + return resume.map((entry) => ({ + interruptId: entry.interruptId, + status: entry.status, + ...(Object.hasOwn(entry, "payload") ? { payload: entry.payload } : {}), + })) +} diff --git a/packages/ag-ui/src/outbound.ts b/packages/ag-ui/src/outbound.ts new file mode 100644 index 00000000..76b433c2 --- /dev/null +++ b/packages/ag-ui/src/outbound.ts @@ -0,0 +1,200 @@ +import type { + Interrupt, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +} from "@ag-ui/core" +import { EventType } from "@ag-ui/core" +import { createDefaultIdFactory, type IdFactory } from "./ids.js" +import { toAguiInterrupt } from "./interrupts.js" +import { + asToolCallData, + asToolResultData, + type DawnAgentStreamChunk, + type RunContext, +} from "./types.js" + +/** The AG-UI events this mapper can emit. */ +export type AguiOutboundEvent = + | RunStartedEvent + | RunFinishedEvent + | RunErrorEvent + | TextMessageStartEvent + | TextMessageContentEvent + | TextMessageEndEvent + | ToolCallStartEvent + | ToolCallArgsEvent + | ToolCallEndEvent + | ToolCallResultEvent + +export interface ToAguiOptions { + readonly idFactory?: IdFactory +} + +function stringifyArgs(input: unknown): string { + try { + return JSON.stringify(input) ?? "{}" + } catch { + return "{}" + } +} + +function stringifyContent(output: unknown): string { + if (typeof output === "string") return output + if (output === undefined || output === null) return "" + try { + const serialized = JSON.stringify(output) + return typeof serialized === "string" ? serialized : String(output) + } catch { + return String(output) + } +} + +/** + * Map a Dawn agent stream (`token | tool_call | tool_result | interrupt | + * done`) to an AG-UI event stream. Stateful: it frames assistant text and tool + * calls that Dawn emits implicitly, and it never throws into the consumer - an + * upstream error becomes a `RUN_ERROR` event and a clean return. + */ +export async function* toAguiEvents( + chunks: AsyncIterable, + ctx: RunContext, + options: ToAguiOptions = {}, +): AsyncGenerator { + const nextId = options.idFactory ?? createDefaultIdFactory() + let openMessageId: string | null = null + const pendingFallbackToolCallIds = new Map() + const pendingInterrupts: Interrupt[] = [] + + function* flushText(): Generator { + if (openMessageId !== null) { + yield { type: EventType.TEXT_MESSAGE_END, messageId: openMessageId } + openMessageId = null + } + } + + yield { type: EventType.RUN_STARTED, threadId: ctx.threadId, runId: ctx.runId } + + try { + for await (const chunk of chunks) { + if (chunk.type !== "interrupt" && pendingInterrupts.length > 0) { + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "interrupt", interrupts: pendingInterrupts }, + } + return + } + + switch (chunk.type) { + case "token": { + const delta = typeof chunk.data === "string" ? chunk.data : "" + if (delta.length === 0) break + if (openMessageId === null) { + openMessageId = nextId("message") + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: openMessageId, + role: "assistant", + } + } + yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId: openMessageId, delta } + break + } + case "tool_call": { + yield* flushText() + const tc = asToolCallData(chunk.data) + if (!tc) break + const toolCallId = tc.id ?? nextId("toolCall") + if (tc.id === undefined) { + const pending = pendingFallbackToolCallIds.get(tc.name) + if (pending) { + pending.push(toolCallId) + } else { + pendingFallbackToolCallIds.set(tc.name, [toolCallId]) + } + } + yield { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: tc.name } + yield { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: stringifyArgs(tc.input) } + yield { type: EventType.TOOL_CALL_END, toolCallId } + break + } + case "tool_result": { + yield* flushText() + const tr = asToolResultData(chunk.data) + if (!tr) break + const pending = tr.id === undefined ? pendingFallbackToolCallIds.get(tr.name) : undefined + const toolCallId = tr.id ?? pending?.shift() ?? nextId("toolCall") + if (pending?.length === 0) pendingFallbackToolCallIds.delete(tr.name) + const messageId = nextId("toolResult") + yield { + type: EventType.TOOL_CALL_RESULT, + messageId, + toolCallId, + content: stringifyContent(tr.output), + } + break + } + case "interrupt": { + yield* flushText() + const interrupt = toAguiInterrupt(chunk.data) + if (interrupt === null) { + yield { + type: EventType.RUN_ERROR, + message: "Malformed Dawn interrupt: missing interruptId", + } + return + } + pendingInterrupts.push(interrupt) + break + } + case "done": { + yield* flushText() + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + ...(Object.hasOwn(chunk, "data") && chunk.data !== undefined + ? { result: chunk.data } + : {}), + outcome: { type: "success" }, + } + return + } + default: + yield* flushText() + // Unknown/capability chunk types (e.g. plan_update) have no v1 + // AG-UI mapping - ignore them. + break + } + } + // Stream ended without an explicit done/interrupt: flush and finish. + yield* flushText() + if (pendingInterrupts.length > 0) { + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "interrupt", interrupts: pendingInterrupts }, + } + return + } + yield { + type: EventType.RUN_FINISHED, + threadId: ctx.threadId, + runId: ctx.runId, + outcome: { type: "success" }, + } + } catch (err) { + yield* flushText() + yield { type: EventType.RUN_ERROR, message: err instanceof Error ? err.message : String(err) } + } +} diff --git a/packages/ag-ui/src/run-input.ts b/packages/ag-ui/src/run-input.ts deleted file mode 100644 index 67ab0dda..00000000 --- a/packages/ag-ui/src/run-input.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { RunAgentInput } from "@ag-ui/core" - -export type ResumeDecision = "once" | "always" | "deny" - -export interface MappedRunInput { - readonly dawnInput: { readonly messages: ReadonlyArray<{ role: string; content: string }> } - readonly resumeDecision?: ResumeDecision - readonly interruptId?: string -} - -function coerceDecision(value: unknown): ResumeDecision | undefined { - if (value === "once" || value === "always" || value === "deny") return value - return undefined -} - -/** - * Map an AG-UI RunAgentInput onto a Dawn run. HITL resume rides on - * forwardedProps.command.resume (the @ag-ui/langgraph convention); otherwise - * the newest user message becomes the turn's input (Dawn keeps history in its - * checkpoint keyed by threadId). - */ -export function mapRunInput(input: RunAgentInput): MappedRunInput { - const resume = (input.forwardedProps as { command?: { resume?: unknown } } | undefined)?.command - ?.resume - if (resume !== undefined) { - if (typeof resume === "string") { - const decision = coerceDecision(resume) - return decision - ? { dawnInput: { messages: [] }, resumeDecision: decision } - : { dawnInput: { messages: [] } } - } - if (resume && typeof resume === "object") { - const r = resume as { decision?: unknown; interruptId?: unknown } - const decision = coerceDecision(r.decision) - const interruptId = typeof r.interruptId === "string" ? r.interruptId : undefined - return { - dawnInput: { messages: [] }, - ...(decision ? { resumeDecision: decision } : {}), - ...(interruptId ? { interruptId } : {}), - } - } - } - - const lastUser = [...input.messages].reverse().find((m) => m.role === "user") - const content = - lastUser && typeof lastUser.content === "string" ? lastUser.content : lastUser ? "" : undefined - return { - dawnInput: { messages: content === undefined ? [] : [{ role: "user", content }] }, - } -} diff --git a/packages/ag-ui/src/encode.ts b/packages/ag-ui/src/sse.ts similarity index 66% rename from packages/ag-ui/src/encode.ts rename to packages/ag-ui/src/sse.ts index 5bda3e05..6c3be4ed 100644 --- a/packages/ag-ui/src/encode.ts +++ b/packages/ag-ui/src/sse.ts @@ -1,8 +1,8 @@ +import type { BaseEvent } from "@ag-ui/core" import { EventEncoder } from "@ag-ui/encoder" -import type { AgUiEvent } from "./types.js" /** Encode one AG-UI event as an SSE frame (`data: \n\n`). */ -export function encodeAgUiSse(event: AgUiEvent, accept?: string): string { +export function encodeAgUiSse(event: BaseEvent, accept?: string): string { const encoder = new EventEncoder(accept ? { accept } : {}) return encoder.encode(event) } diff --git a/packages/ag-ui/src/translate.ts b/packages/ag-ui/src/translate.ts deleted file mode 100644 index b024f158..00000000 --- a/packages/ag-ui/src/translate.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { EventType } from "@ag-ui/core" -import type { AgUiEvent, DawnStreamChunk, TranslatorOptions } from "./types.js" - -let counter = 0 -/** Deterministic-per-process id (no Math.random/Date — safe for tests). */ -function nextId(prefix: string): string { - counter += 1 - return `${prefix}_${counter}` -} - -export interface AgUiTranslator { - /** Emit RUN_STARTED. Call once before feeding chunks. */ - begin(): AgUiEvent[] - /** Translate one Dawn chunk into zero or more AG-UI events. */ - translate(chunk: DawnStreamChunk): AgUiEvent[] - /** Emit a terminal RUN_FINISHED if the stream ended without a `done` chunk. */ - end(): AgUiEvent[] -} - -export function createAgUiTranslator(options: TranslatorOptions): AgUiTranslator { - const { threadId, runId } = options - let activeTextId: string | null = null - const pendingToolCalls = new Map() - let finished = false - let state: Record = {} - - function flushText(): AgUiEvent[] { - if (activeTextId === null) return [] - const id = activeTextId - activeTextId = null - return [{ type: EventType.TEXT_MESSAGE_END, messageId: id }] - } - - function toText(chunk: DawnStreamChunk): AgUiEvent[] { - const text = typeof chunk.data === "string" ? chunk.data : String(chunk.data ?? "") - if (text.length === 0) return [] - const out: AgUiEvent[] = [] - if (activeTextId === null) { - activeTextId = nextId("msg") - out.push({ - type: EventType.TEXT_MESSAGE_START, - messageId: activeTextId, - role: "assistant", - }) - } - out.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: activeTextId, delta: text }) - return out - } - - function toToolCall(chunk: DawnStreamChunk): AgUiEvent[] { - const name = chunk.name ?? "tool" - const toolCallId = nextId("call") - const queue = pendingToolCalls.get(name) ?? [] - queue.push(toolCallId) - pendingToolCalls.set(name, queue) - return [ - ...flushText(), - { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: name }, - { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: JSON.stringify(chunk.input ?? {}) }, - { type: EventType.TOOL_CALL_END, toolCallId }, - ] - } - - function toToolResult(chunk: DawnStreamChunk): AgUiEvent[] { - const name = chunk.name ?? "tool" - const queue = pendingToolCalls.get(name) ?? [] - const toolCallId = queue.shift() ?? nextId("call") - pendingToolCalls.set(name, queue) - const content = - typeof chunk.output === "string" ? chunk.output : JSON.stringify(chunk.output ?? null) - return [ - ...flushText(), - { - type: EventType.TOOL_CALL_RESULT, - messageId: nextId("msg"), - toolCallId, - content, - role: "tool", - }, - ] - } - - function toDone(chunk: DawnStreamChunk): AgUiEvent[] { - finished = true - const out = flushText() - const output = chunk.output - if (output && typeof output === "object" && "error" in output) { - out.push({ - type: EventType.RUN_ERROR, - message: String((output as { error: unknown }).error), - }) - return out - } - out.push({ type: EventType.RUN_FINISHED, threadId, runId, result: output }) - return out - } - - return { - begin() { - return [{ type: EventType.RUN_STARTED, threadId, runId }] - }, - translate(chunk) { - switch (chunk.type) { - case "token": - case "chunk": - return toText(chunk) - case "tool_call": - return toToolCall(chunk) - case "tool_result": - return toToolResult(chunk) - case "done": - return toDone(chunk) - case "plan_update": { - const flushed = flushText() - const data = (chunk.data ?? {}) as Record - state = { ...state, ...data } - return [...flushed, { type: EventType.STATE_SNAPSHOT, snapshot: state }] - } - case "interrupt": { - return [ - ...flushText(), - { type: EventType.CUSTOM, name: "on_interrupt", value: chunk.data ?? {} }, - ] - } - default: { - const flushed = flushText() - if (chunk.type.startsWith("subagent.")) { - return [ - ...flushed, - { type: EventType.CUSTOM, name: `dawn.${chunk.type}`, value: chunk.data ?? {} }, - ] - } - if (chunk.data && typeof chunk.data === "object") { - state = { ...state, ...(chunk.data as Record) } - return [...flushed, { type: EventType.STATE_SNAPSHOT, snapshot: state }] - } - return flushed - } - } - }, - end() { - if (finished) return [] - finished = true - return [...flushText(), { type: EventType.RUN_FINISHED, threadId, runId }] - }, - } -} diff --git a/packages/ag-ui/src/types.ts b/packages/ag-ui/src/types.ts index e1a82693..9beb2a5c 100644 --- a/packages/ag-ui/src/types.ts +++ b/packages/ag-ui/src/types.ts @@ -1,22 +1,54 @@ -import type { BaseEvent } from "@ag-ui/core" - -/** An AG-UI protocol event. Alias kept local so consumers import one name. */ -export type AgUiEvent = BaseEvent +/** Run identity the consumer supplies; never synthesized by the mapper. */ +export interface RunContext { + readonly threadId: string + readonly runId: string +} /** - * Structural mirror of `@dawn-ai/cli`'s `StreamChunk`. Kept loose (all fields - * optional beyond `type`) so this package has ZERO dependency on the CLI. The - * translator inspects fields at runtime by `type`. + * Structural Dawn agent stream shape consumed by the canonical AG-UI mapper. + * The final member permits capability-contributed chunks without coupling this + * package to Dawn core. */ -export interface DawnStreamChunk { - readonly type: string - readonly data?: unknown - readonly name?: string - readonly input?: unknown - readonly output?: unknown +export type DawnAgentStreamChunk = + | { readonly type: "token"; readonly data: string } + | { readonly type: "tool_call"; readonly data: DawnToolCallData } + | { readonly type: "tool_result"; readonly data: DawnToolResultData } + | { readonly type: "interrupt"; readonly data: unknown } + | { readonly type: "done"; readonly data?: unknown } + | { readonly type: string; readonly data?: unknown } + +export interface DawnToolCallData { + readonly id?: string | undefined + readonly name: string + readonly input: unknown } -export interface TranslatorOptions { - readonly threadId: string - readonly runId: string +export interface DawnToolResultData { + readonly id?: string | undefined + readonly name: string + readonly output: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +/** Validates and narrows a `tool_call` chunk's `data`. Returns null if malformed. */ +export function asToolCallData(data: unknown): DawnToolCallData | null { + if (!isRecord(data) || typeof data.name !== "string") return null + return { + id: typeof data.id === "string" ? data.id : undefined, + name: data.name, + input: data.input, + } +} + +/** Validates and narrows a `tool_result` chunk's `data`. Returns null if malformed. */ +export function asToolResultData(data: unknown): DawnToolResultData | null { + if (!isRecord(data) || typeof data.name !== "string") return null + return { + id: typeof data.id === "string" ? data.id : undefined, + name: data.name, + output: data.output, + } } diff --git a/packages/ag-ui/test/conformance.test.ts b/packages/ag-ui/test/conformance.test.ts index a1c14dfd..ecfac780 100644 --- a/packages/ag-ui/test/conformance.test.ts +++ b/packages/ag-ui/test/conformance.test.ts @@ -1,42 +1,68 @@ import { createServer, type Server } from "node:http" -import type { AddressInfo } from "node:net" import { HttpAgent, verifyEvents } from "@ag-ui/client" -import { EventType } from "@ag-ui/core" +import { EventType, type RunAgentInput } from "@ag-ui/core" import { lastValueFrom, toArray } from "rxjs" import { afterEach, expect, it } from "vitest" -import { encodeAgUiSse } from "../src/encode.js" -import { createAgUiTranslator } from "../src/translate.js" -import type { DawnStreamChunk } from "../src/types.js" +import { createCounterIdFactory } from "../src/ids.js" +import { toAguiEvents } from "../src/outbound.js" +import { encodeAgUiSse } from "../src/sse.js" +import type { DawnAgentStreamChunk } from "../src/types.js" let server: Server | undefined -afterEach(() => server?.close()) +afterEach(async () => { + const currentServer = server + server = undefined + if (!currentServer) return + await new Promise((resolve, reject) => { + currentServer.close((error) => (error ? reject(error) : resolve())) + }) +}) -const CANNED: DawnStreamChunk[] = [ +const CANNED: DawnAgentStreamChunk[] = [ { type: "token", data: "Researching" }, - { type: "tool_call", name: "searchCorpus", input: { query: "agents" } }, - { type: "tool_result", name: "searchCorpus", output: [{ path: "corpus/a.md" }] }, + { type: "tool_call", data: { name: "searchCorpus", input: { query: "agents" } } }, + { + type: "tool_result", + data: { name: "searchCorpus", output: [{ path: "corpus/a.md" }] }, + }, { type: "plan_update", data: { todos: [{ content: "search", status: "completed" }] } }, { type: "subagent.start", data: { call_id: "c1", subagent: "researcher" } }, { type: "token", data: " done. [corpus/a.md]" }, - { type: "done", output: { messages: [] } }, + { type: "done", data: { messages: [] } }, ] +async function* toAsync(items: readonly DawnAgentStreamChunk[]) { + yield* items +} + async function startCannedServer(): Promise { - server = createServer((req, res) => { - req.resume() - res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }) - const t = createAgUiTranslator({ threadId: "t1", runId: "r1" }) - for (const e of t.begin()) res.write(encodeAgUiSse(e)) - for (const chunk of CANNED) for (const e of t.translate(chunk)) res.write(encodeAgUiSse(e)) - for (const e of t.end()) res.write(encodeAgUiSse(e)) - res.end() + const cannedServer = createServer((req, res) => { + void (async () => { + req.resume() + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }) + const events = toAguiEvents( + toAsync(CANNED), + { threadId: "t1", runId: "r1" }, + { idFactory: createCounterIdFactory() }, + ) + for await (const event of events) res.write(encodeAgUiSse(event)) + res.end() + })().catch((error: unknown) => { + res.destroy(error instanceof Error ? error : new Error(String(error))) + }) }) - await new Promise((resolve) => { - // biome-ignore lint/style/noNonNullAssertion: assigned above, listen callback is sync-scheduled - server!.listen(0, "127.0.0.1", resolve) + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error) + cannedServer.once("error", onError) + cannedServer.listen(0, "127.0.0.1", () => { + cannedServer.off("error", onError) + resolve() + }) }) - // biome-ignore lint/style/noNonNullAssertion: assigned above - const { port } = server!.address() as AddressInfo + server = cannedServer + const address = cannedServer.address() + if (!address || typeof address === "string") throw new Error("Canned server has no TCP address") + const { port } = address return `http://127.0.0.1:${port}` } @@ -51,13 +77,13 @@ it("produces an AG-UI stream that @ag-ui/client parses and verifyEvents accepts" tools: [], context: [], forwardedProps: {}, - } - const events = await lastValueFrom(agent.run(input as never).pipe(verifyEvents(false), toArray())) + } satisfies RunAgentInput + const events = await lastValueFrom(agent.run(input).pipe(verifyEvents(false), toArray())) const kinds = events.map((e) => e.type) expect(kinds[0]).toBe(EventType.RUN_STARTED) expect(kinds).toContain(EventType.TOOL_CALL_START) expect(kinds).toContain(EventType.TOOL_CALL_RESULT) - expect(kinds).toContain(EventType.STATE_SNAPSHOT) - expect(kinds).toContain(EventType.CUSTOM) + expect(kinds).not.toContain(EventType.STATE_SNAPSHOT) + expect(kinds).not.toContain(EventType.CUSTOM) expect(kinds[kinds.length - 1]).toBe(EventType.RUN_FINISHED) }) diff --git a/packages/ag-ui/test/ids.test.ts b/packages/ag-ui/test/ids.test.ts new file mode 100644 index 00000000..415db265 --- /dev/null +++ b/packages/ag-ui/test/ids.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest" +import { createCounterIdFactory, createDefaultIdFactory } from "../src/ids.js" + +describe("createCounterIdFactory", () => { + test("produces deterministic, kind-prefixed, monotonically increasing ids", () => { + const id = createCounterIdFactory() + expect(id("message")).toBe("msg-1") + expect(id("message")).toBe("msg-2") + expect(id("toolCall")).toBe("tc-1") + expect(id("toolResult")).toBe("tr-1") + expect(id("toolResult")).toBe("tr-2") + }) +}) + +describe("createDefaultIdFactory", () => { + test("produces unique kind-prefixed ids", () => { + const id = createDefaultIdFactory() + const a = id("message") + const b = id("message") + expect(a).not.toBe(b) + expect(a.startsWith("msg-")).toBe(true) + expect(id("toolCall").startsWith("tc-")).toBe(true) + expect(id("toolResult").startsWith("tr-")).toBe(true) + }) +}) diff --git a/packages/ag-ui/test/inbound.test.ts b/packages/ag-ui/test/inbound.test.ts new file mode 100644 index 00000000..74ad76ba --- /dev/null +++ b/packages/ag-ui/test/inbound.test.ts @@ -0,0 +1,89 @@ +import type { RunAgentInput } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { fromRunAgentInput } from "../src/inbound.js" + +function baseInput(overrides: Partial = {}): RunAgentInput { + return { + threadId: "th-1", + runId: "rn-1", + messages: [], + tools: [], + context: [], + ...overrides, + } as RunAgentInput +} + +describe("fromRunAgentInput", () => { + test("maps user and assistant messages to Dawn messages", () => { + const input = baseInput({ + messages: [ + { id: "m1", role: "user", content: "hi" }, + { id: "m2", role: "assistant", content: "hello" }, + ], + } as Partial) + const result = fromRunAgentInput(input) + expect(result.messages).toEqual([ + { role: "user", content: "hi", id: "m1" }, + { role: "assistant", content: "hello", id: "m2" }, + ]) + expect(result.resume).toBeUndefined() + expect(result.raw).toBe(input) + }) + + test("maps a tool message, preserving toolCallId", () => { + const input = baseInput({ + messages: [{ id: "m1", role: "tool", content: "42", toolCallId: "tc-9" }], + } as Partial) + expect(fromRunAgentInput(input).messages).toEqual([ + { role: "tool", content: "42", id: "m1", toolCallId: "tc-9" }, + ]) + }) + + test("stringifies non-string content", () => { + const input = baseInput({ + messages: [{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] }], + } as unknown as Partial) + expect(fromRunAgentInput(input).messages[0]?.content).toBe('[{"type":"text","text":"hi"}]') + }) + + test("falls back to String() when JSON.stringify returns undefined", () => { + const content = Symbol.for("x") + const message = { id: "m1", role: "user", content } as unknown as RunAgentInput["messages"][number] + const input = baseInput({ messages: [message] }) + expect(fromRunAgentInput(input).messages[0]?.content).toBe(String(content)) + }) + + test("maps a resume array to Dawn resume requests", () => { + const input = baseInput({ + resume: [{ interruptId: "perm-1", status: "resolved", payload: "once" }], + } as Partial) + expect(fromRunAgentInput(input).resume).toEqual([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + ]) + }) + + test("omits the resume property for an empty resume array", () => { + const input = baseInput({ resume: [] } as Partial) + const result = fromRunAgentInput(input) + expect(Object.hasOwn(result, "resume")).toBe(false) + expect(result.resume).toBeUndefined() + }) + + test("maps activity and reasoning messages to Dawn assistant messages", () => { + const input = baseInput({ + messages: [ + { id: "m1", role: "activity", content: { status: "running" }, activityType: "status" }, + { id: "m2", role: "reasoning", content: "thinking" }, + ], + } as Partial) + expect(fromRunAgentInput(input).messages).toEqual([ + { role: "assistant", content: '{"status":"running"}', id: "m1" }, + { role: "assistant", content: "thinking", id: "m2" }, + ]) + }) + + test("raw preserves the original input for tools/state/context access", () => { + const input = baseInput({ state: { a: 1 } } as Partial) + expect(fromRunAgentInput(input).raw).toBe(input) + }) +}) diff --git a/packages/ag-ui/test/interrupts.test.ts b/packages/ag-ui/test/interrupts.test.ts new file mode 100644 index 00000000..bb574210 --- /dev/null +++ b/packages/ag-ui/test/interrupts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest" +import { fromAguiResume, toAguiInterrupt } from "../src/interrupts.js" + +describe("toAguiInterrupt", () => { + test("maps a Dawn interrupt envelope to an AG-UI Interrupt, preserving the envelope as metadata", () => { + const envelope = { + interruptId: "perm-1", + kind: "command", + type: "permission-request", + detail: { command: "ls" }, + } + expect(toAguiInterrupt(envelope)).toEqual({ + id: "perm-1", + reason: "command", + metadata: envelope, + }) + }) + + test("carries an optional human message and toolCallId when present", () => { + const envelope = { interruptId: "perm-2", kind: "tool", message: "Approve?", toolCallId: "tc-9" } + expect(toAguiInterrupt(envelope)).toEqual({ + id: "perm-2", + reason: "tool", + message: "Approve?", + toolCallId: "tc-9", + metadata: envelope, + }) + }) + + test("rejects a malformed envelope instead of synthesizing an interrupt id", () => { + expect(toAguiInterrupt(null)).toBeNull() + }) + + test("treats arrays as malformed envelopes", () => { + expect(toAguiInterrupt([])).toBeNull() + }) + + test("treats non-plain objects as malformed envelopes", () => { + expect(toAguiInterrupt(new Date(0))).toBeNull() + }) + + test("treats plain objects without a non-empty string interruptId as malformed envelopes", () => { + expect(toAguiInterrupt({ foo: "bar" })).toBeNull() + expect(toAguiInterrupt({ interruptId: 123, kind: "command" })).toBeNull() + expect(toAguiInterrupt({ interruptId: "", kind: "command" })).toBeNull() + }) +}) + +describe("fromAguiResume", () => { + test("maps AG-UI resume entries to Dawn resume requests, preserving interruptId", () => { + expect( + fromAguiResume([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ]), + ).toEqual([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ]) + }) + + test("preserves a present payload key with an undefined value", () => { + const [resume] = fromAguiResume([{ interruptId: "perm-1", status: "resolved", payload: undefined }]) + expect(resume).toEqual({ interruptId: "perm-1", status: "resolved", payload: undefined }) + expect(Object.hasOwn(resume, "payload")).toBe(true) + }) +}) diff --git a/packages/ag-ui/test/outbound.test.ts b/packages/ag-ui/test/outbound.test.ts new file mode 100644 index 00000000..df957b63 --- /dev/null +++ b/packages/ag-ui/test/outbound.test.ts @@ -0,0 +1,345 @@ +import { EventType, ToolCallResultEventSchema } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { createCounterIdFactory } from "../src/ids.js" +import { toAguiEvents } from "../src/outbound.js" +import { encodeAgUiSse } from "../src/sse.js" +import type { DawnAgentStreamChunk } from "../src/types.js" + +const CTX = { threadId: "th-1", runId: "rn-1" } + +async function collect(chunks: DawnAgentStreamChunk[]) { + const out = [] + for await (const ev of toAguiEvents(toAsync(chunks), CTX, { + idFactory: createCounterIdFactory(), + })) { + out.push(ev) + } + return out +} + +async function* toAsync(items: DawnAgentStreamChunk[]) { + for (const item of items) yield item +} + +describe("toAguiEvents", () => { + test("text-only stream: run start, framed message, run finished success", async () => { + const events = await collect([ + { type: "token", data: "Hel" }, + { type: "token", data: "lo" }, + { type: "done", data: {} }, + ]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.TEXT_MESSAGE_START, messageId: "msg-1", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg-1", delta: "Hel" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg-1", delta: "lo" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "msg-1" }, + { + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result: {}, + outcome: { type: "success" }, + }, + ]) + }) + + test("tool call + result: correlated by upstream id, single args frame", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-abc", name: "greet", input: { name: "World" } } }, + { type: "tool_result", data: { id: "run-abc", name: "greet", output: { greeting: "hi" } } }, + { type: "done", data: {} }, + ]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.TOOL_CALL_START, toolCallId: "run-abc", toolCallName: "greet" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "run-abc", delta: '{"name":"World"}' }, + { type: EventType.TOOL_CALL_END, toolCallId: "run-abc" }, + { + type: EventType.TOOL_CALL_RESULT, + messageId: "tr-1", + toolCallId: "run-abc", + content: '{"greeting":"hi"}', + }, + { + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result: {}, + outcome: { type: "success" }, + }, + ]) + }) + + test.each([ + ["function", () => undefined], + ["symbol", Symbol("result")], + ])("tool result %s output remains string content through AG-UI SSE", async (_, output) => { + const expected = String(output) + const events = await collect([ + { type: "tool_result", data: { id: "run-special", name: "special", output } }, + { type: "done" }, + ]) + const result = ToolCallResultEventSchema.parse( + events.find((event) => event.type === EventType.TOOL_CALL_RESULT), + ) + + expect(typeof result.content).toBe("string") + expect(result.content).toBe(expected) + + const dataLine = encodeAgUiSse(result) + .split("\n") + .find((line) => line.startsWith("data: ")) + if (dataLine === undefined) throw new Error("SSE frame is missing a data line") + expect(JSON.parse(dataLine.slice("data: ".length))).toMatchObject({ content: expected }) + }) + + test("tool call args JSON-serialize string input", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-string", name: "echo", input: "raw" } }, + { type: "done", data: {} }, + ]) + const args = events.find((e) => e.type === EventType.TOOL_CALL_ARGS) as { delta: string } + expect(args.delta).toBe('"raw"') + }) + + test("tool call args JSON-serialize null input", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-null", name: "echo", input: null } }, + { type: "done", data: {} }, + ]) + const args = events.find((e) => e.type === EventType.TOOL_CALL_ARGS) as { delta: string } + expect(args.delta).toBe("null") + }) + + test("tool call args fall back to a string when JSON serialization returns undefined", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-undefined", name: "echo", input: undefined } }, + { type: "tool_call", data: { id: "run-function", name: "echo", input: () => undefined } }, + { type: "done", data: {} }, + ]) + const args = events.filter((e) => e.type === EventType.TOOL_CALL_ARGS) as Array<{ delta: string }> + expect(args.map((e) => e.delta)).toEqual(["{}", "{}"]) + }) + + test("interleaved text then tool: open message is flushed before the tool call", async () => { + const events = await collect([ + { type: "token", data: "thinking" }, + { type: "tool_call", data: { id: "run-x", name: "noop", input: {} } }, + { type: "done", data: {} }, + ]) + const types = events.map((e) => e.type) + expect(types).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.RUN_FINISHED, + ]) + }) + + test("unknown non-token chunks flush an open text message before being ignored", async () => { + const events = await collect([ + { type: "token", data: "hi" }, + { type: "plan_update", data: {} }, + { type: "token", data: "again" }, + { type: "done", data: {} }, + ]) + expect(events.map((e) => e.type)).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.RUN_FINISHED, + ]) + }) + + test("repeated calls to the same tool get distinct toolCallIds from their upstream ids", async () => { + const events = await collect([ + { type: "tool_call", data: { id: "run-1", name: "t", input: {} } }, + { type: "tool_call", data: { id: "run-2", name: "t", input: {} } }, + { type: "done", data: {} }, + ]) + const starts = events.filter((e) => e.type === EventType.TOOL_CALL_START) + expect(starts.map((e) => (e as { toolCallId: string }).toolCallId)).toEqual(["run-1", "run-2"]) + }) + + test("missing-id tool results reuse pending fallback toolCallIds by tool name in FIFO order", async () => { + const events = await collect([ + { type: "tool_call", data: { name: "greet", input: {} } }, + { type: "tool_call", data: { name: "greet", input: { again: true } } }, + { type: "tool_result", data: { name: "greet", output: "hi" } }, + { type: "tool_result", data: { name: "greet", output: "again" } }, + { type: "done", data: {} }, + ]) + const starts = events.filter((e) => e.type === EventType.TOOL_CALL_START) as Array<{ toolCallId: string }> + const results = events.filter((e) => e.type === EventType.TOOL_CALL_RESULT) as Array<{ + toolCallId: string + messageId: string + }> + expect(starts.map((e) => e.toolCallId)).toEqual(["tc-1", "tc-2"]) + expect(results.map((e) => e.toolCallId)).toEqual(["tc-1", "tc-2"]) + expect(results.map((e) => e.messageId)).toEqual(["tr-1", "tr-2"]) + }) + + test("interrupt: emits RUN_FINISHED with an interrupt outcome and stops", async () => { + const events = await collect([ + { type: "token", data: "hi" }, + { type: "interrupt", data: { interruptId: "perm-1", kind: "command" } }, + { type: "done", data: {} }, // must be ignored after interrupt + ]) + expect(events.at(-1)).toEqual({ + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + outcome: { type: "interrupt", interrupts: [{ id: "perm-1", reason: "command", metadata: { interruptId: "perm-1", kind: "command" } }] }, + }) + // exactly one RUN_FINISHED (done after interrupt was ignored) + expect(events.filter((e) => e.type === EventType.RUN_FINISHED)).toHaveLength(1) + }) + + test("consecutive interrupts are accumulated in order before done", async () => { + const events = await collect([ + { type: "interrupt", data: { interruptId: "perm-1", kind: "command" } }, + { type: "interrupt", data: { interruptId: "perm-2", kind: "tool" } }, + { type: "done", data: { ignored: true } }, + ]) + + expect(events.filter((event) => event.type === EventType.RUN_FINISHED)).toEqual([ + { + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + outcome: { + type: "interrupt", + interrupts: [ + { + id: "perm-1", + reason: "command", + metadata: { interruptId: "perm-1", kind: "command" }, + }, + { + id: "perm-2", + reason: "tool", + metadata: { interruptId: "perm-2", kind: "tool" }, + }, + ], + }, + }, + ]) + }) + + test("natural completion emits accumulated interrupts", async () => { + const events = await collect([ + { type: "interrupt", data: { interruptId: "perm-1" } }, + { type: "interrupt", data: { interruptId: "perm-2" } }, + ]) + + expect(events.at(-1)).toMatchObject({ + type: EventType.RUN_FINISHED, + outcome: { + type: "interrupt", + interrupts: [{ id: "perm-1" }, { id: "perm-2" }], + }, + }) + }) + + test("a nonterminal chunk after interrupts emits the interrupt outcome and stops", async () => { + const events = await collect([ + { type: "interrupt", data: { interruptId: "perm-1" } }, + { type: "token", data: "must not be emitted" }, + { type: "done" }, + ]) + + expect(events.map((event) => event.type)).toEqual([ + EventType.RUN_STARTED, + EventType.RUN_FINISHED, + ]) + expect(events.at(-1)).toMatchObject({ + outcome: { type: "interrupt", interrupts: [{ id: "perm-1" }] }, + }) + }) + + test("an interrupt without a non-empty interruptId terminates with RUN_ERROR", async () => { + const events = await collect([ + { type: "token", data: "waiting" }, + { type: "interrupt", data: { interruptId: "", kind: "command" } }, + { type: "done" }, + ]) + + expect(events.at(-2)).toEqual({ type: EventType.TEXT_MESSAGE_END, messageId: "msg-1" }) + expect(events.at(-1)).toEqual({ + type: EventType.RUN_ERROR, + message: "Malformed Dawn interrupt: missing interruptId", + }) + expect(events.filter((event) => event.type === EventType.RUN_ERROR)).toHaveLength(1) + expect(events.filter((event) => event.type === EventType.RUN_FINISHED)).toHaveLength(0) + }) + + test("done data is preserved as the successful RUN_FINISHED result", async () => { + const result = { error: "application value", answer: 42 } + const events = await collect([{ type: "done", data: result }]) + + expect(events.at(-1)).toEqual({ + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result, + outcome: { type: "success" }, + }) + }) + + test("done without defined data omits the successful result", async () => { + const events = await collect([{ type: "done" }]) + expect(events).toEqual([ + { type: EventType.RUN_STARTED, threadId: "th-1", runId: "rn-1" }, + { type: EventType.RUN_FINISHED, threadId: "th-1", runId: "rn-1", outcome: { type: "success" } }, + ]) + }) + + test("stream that ends without a done chunk still flushes and finishes success", async () => { + const events = await collect([{ type: "token", data: "x" }]) + expect(events.map((e) => e.type)).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.RUN_FINISHED, + ]) + }) + + test("tool_result with a missing upstream id still emits a result with a synthesized toolCallId", async () => { + const events = await collect([ + { type: "tool_result", data: { name: "greet", output: "hi" } }, + { type: "done", data: {} }, + ]) + const result = events.find((e) => e.type === EventType.TOOL_CALL_RESULT) as { + toolCallId: string + messageId: string + content: string + } + expect(result.content).toBe("hi") + expect(result.toolCallId).toBe("tc-1") // fallback id + expect(result.messageId).toBe("tr-1") + }) + + test("upstream throw is emitted as RUN_ERROR, not thrown to the consumer", async () => { + async function* boom(): AsyncGenerator { + yield { type: "token", data: "hi" } + throw new Error("kaboom") + } + const out = [] + for await (const ev of toAguiEvents(boom(), CTX, { idFactory: createCounterIdFactory() })) { + out.push(ev) + } + expect(out.at(-1)).toEqual({ type: EventType.RUN_ERROR, message: "kaboom" }) + // the open text message was flushed before the error + expect(out.some((e) => e.type === EventType.TEXT_MESSAGE_END)).toBe(true) + }) +}) diff --git a/packages/ag-ui/test/public-api.test.ts b/packages/ag-ui/test/public-api.test.ts new file mode 100644 index 00000000..2cd0cae5 --- /dev/null +++ b/packages/ag-ui/test/public-api.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest" +import * as api from "../src/index.js" + +it("exports only the canonical runtime adapter surface from the package root", () => { + expect(Object.keys(api).sort()).toEqual([ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", + ]) +}) diff --git a/packages/ag-ui/test/round-trip.test.ts b/packages/ag-ui/test/round-trip.test.ts new file mode 100644 index 00000000..b2d7de13 --- /dev/null +++ b/packages/ag-ui/test/round-trip.test.ts @@ -0,0 +1,47 @@ +import { EventType, type RunAgentInput } from "@ag-ui/core" +import { describe, expect, test } from "vitest" +import { + createCounterIdFactory, + type DawnAgentStreamChunk, + fromRunAgentInput, + toAguiEvents, +} from "../src/index.js" + +async function* one(chunk: DawnAgentStreamChunk) { + yield chunk + yield { type: "done", data: {} } satisfies DawnAgentStreamChunk +} + +describe("interrupt round-trip", () => { + test("a Dawn interrupt's id survives outbound -> AG-UI -> resume input -> Dawn resume", async () => { + // 1. Dawn emits an interrupt; map it outbound. + const events = [] + for await (const ev of toAguiEvents( + one({ type: "interrupt", data: { interruptId: "perm-42", kind: "command" } }), + { threadId: "th", runId: "rn" }, + { idFactory: createCounterIdFactory() }, + )) { + events.push(ev) + } + const finished = events.find((e) => e.type === EventType.RUN_FINISHED) as { + outcome: { type: string; interrupts: Array<{ id: string }> } + } + expect(finished.outcome.type).toBe("interrupt") + const interruptId = finished.outcome.interrupts[0]?.id + expect(interruptId).toBe("perm-42") + + // 2. The client answers with a resume RunAgentInput addressing that id. + const resumeInput = { + threadId: "th", + runId: "rn-2", + messages: [], + tools: [], + context: [], + resume: [{ interruptId, status: "resolved", payload: "once" }], + } as unknown as RunAgentInput + + // 3. Map it back to Dawn -- the interruptId must survive. + const dawn = fromRunAgentInput(resumeInput) + expect(dawn.resume).toEqual([{ interruptId: "perm-42", status: "resolved", payload: "once" }]) + }) +}) diff --git a/packages/ag-ui/test/run-input.test.ts b/packages/ag-ui/test/run-input.test.ts deleted file mode 100644 index 1f713f21..00000000 --- a/packages/ag-ui/test/run-input.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest" -import { mapRunInput } from "../src/run-input.js" - -const base = { threadId: "t", runId: "r", state: {}, messages: [], tools: [], context: [], forwardedProps: {} } - -describe("mapRunInput", () => { - it("extracts the last user message as the Dawn input", () => { - const result = mapRunInput({ - ...base, - messages: [ - { id: "1", role: "user", content: "old" }, - { id: "2", role: "assistant", content: "hi" }, - { id: "3", role: "user", content: "new question" }, - ], - } as never) - expect(result.resumeDecision).toBeUndefined() - expect(result.dawnInput).toEqual({ messages: [{ role: "user", content: "new question" }] }) - }) - - it("decodes a resume decision from forwardedProps.command.resume", () => { - const result = mapRunInput({ - ...base, - forwardedProps: { command: { resume: { interruptId: "perm-1", decision: "once" } } }, - } as never) - expect(result.resumeDecision).toBe("once") - expect(result.interruptId).toBe("perm-1") - }) - - it("accepts a bare string decision", () => { - const result = mapRunInput({ - ...base, - forwardedProps: { command: { resume: "deny" } }, - } as never) - expect(result.resumeDecision).toBe("deny") - }) - - it("falls back to empty messages when no user message exists", () => { - const result = mapRunInput(base as never) - expect(result.dawnInput).toEqual({ messages: [] }) - }) -}) diff --git a/packages/ag-ui/test/sse.test.ts b/packages/ag-ui/test/sse.test.ts new file mode 100644 index 00000000..8f57716d --- /dev/null +++ b/packages/ag-ui/test/sse.test.ts @@ -0,0 +1,9 @@ +import { EventType } from "@ag-ui/core" +import { expect, test } from "vitest" +import { encodeAgUiSse } from "../src/sse.js" + +test("encodes events from the focused SSE module", () => { + expect(encodeAgUiSse({ type: EventType.RUN_STARTED, threadId: "t", runId: "r" })).toContain( + '"type":"RUN_STARTED"', + ) +}) diff --git a/packages/ag-ui/test/translate-capabilities.test.ts b/packages/ag-ui/test/translate-capabilities.test.ts deleted file mode 100644 index d4e8a93b..00000000 --- a/packages/ag-ui/test/translate-capabilities.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { CustomEventSchema, EventType, StateSnapshotEventSchema } from "@ag-ui/core" -import { describe, expect, it } from "vitest" -import { createAgUiTranslator } from "../src/translate.js" -import type { AgUiEvent } from "../src/types.js" - -const opts = { threadId: "t1", runId: "r1" } -const type1 = (evs: AgUiEvent[]) => evs[0]?.type - -describe("translator: capability events", () => { - it("maps plan_update to a STATE_SNAPSHOT carrying todos", () => { - const t = createAgUiTranslator(opts) - const todos = [{ content: "search", status: "pending" }] - const evs = t.translate({ type: "plan_update", data: { todos } }) - expect(type1(evs)).toBe(EventType.STATE_SNAPSHOT) - const parsed = StateSnapshotEventSchema.parse(evs[0]) - expect((parsed.snapshot as { todos: unknown }).todos).toEqual(todos) - }) - - it("accumulates state across snapshots", () => { - const t = createAgUiTranslator(opts) - t.translate({ type: "plan_update", data: { todos: [{ content: "a", status: "pending" }] } }) - const evs = t.translate({ type: "report_update", data: { report: "hello" } }) - const snap = evs.find((e) => e.type === EventType.STATE_SNAPSHOT) as - | { snapshot: Record } - | undefined - expect(snap?.snapshot.todos).toBeDefined() - expect(snap?.snapshot.report).toBe("hello") - }) - - it("maps subagent events to CUSTOM dawn.", () => { - const t = createAgUiTranslator(opts) - const evs = t.translate({ - type: "subagent.start", - data: { call_id: "c1", subagent: "researcher" }, - }) - expect(type1(evs)).toBe(EventType.CUSTOM) - const parsed = CustomEventSchema.parse(evs[0]) - expect(parsed.name).toBe("dawn.subagent.start") - expect((parsed.value as { call_id: string }).call_id).toBe("c1") - }) - - it("maps interrupt to CUSTOM on_interrupt", () => { - const t = createAgUiTranslator(opts) - const detail = { command: "node scripts/fetch-source.mjs x", suggestedPattern: "node scripts" } - const evs = t.translate({ - type: "interrupt", - data: { interruptId: "perm-1", type: "permission-request", kind: "command", detail }, - }) - expect(type1(evs)).toBe(EventType.CUSTOM) - const parsed = CustomEventSchema.parse(evs[0]) - expect(parsed.name).toBe("on_interrupt") - expect((parsed.value as { interruptId: string }).interruptId).toBe("perm-1") - expect((parsed.value as { kind: string }).kind).toBe("command") - }) - - it("flushes open text before a capability event", () => { - const t = createAgUiTranslator(opts) - const evs = [ - ...t.translate({ type: "token", data: "hi" }), - ...t.translate({ type: "subagent.start", data: { call_id: "c1" } }), - ] - expect(evs.map((e) => e.type)).toEqual([ - EventType.TEXT_MESSAGE_START, - EventType.TEXT_MESSAGE_CONTENT, - EventType.TEXT_MESSAGE_END, - EventType.CUSTOM, - ]) - }) -}) diff --git a/packages/ag-ui/test/translate-text-tools.test.ts b/packages/ag-ui/test/translate-text-tools.test.ts deleted file mode 100644 index cddbff62..00000000 --- a/packages/ag-ui/test/translate-text-tools.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventType } from "@ag-ui/core" -import { - RunStartedEventSchema, - TextMessageStartEventSchema, - TextMessageContentEventSchema, - TextMessageEndEventSchema, - ToolCallStartEventSchema, - ToolCallArgsEventSchema, - ToolCallEndEventSchema, - ToolCallResultEventSchema, - RunFinishedEventSchema, -} from "@ag-ui/core" -import { describe, expect, it } from "vitest" -import { createAgUiTranslator } from "../src/translate.js" -import type { AgUiEvent } from "../src/types.js" - -const opts = { threadId: "t1", runId: "r1" } -const types = (evs: AgUiEvent[]) => evs.map((e) => e.type) - -describe("translator: lifecycle + text + tools", () => { - it("begin() emits a valid RUN_STARTED", () => { - const t = createAgUiTranslator(opts) - const evs = t.begin() - expect(types(evs)).toEqual([EventType.RUN_STARTED]) - expect(() => RunStartedEventSchema.parse(evs[0])).not.toThrow() - }) - - it("maps a token run to START/CONTENT+/END and validates each event", () => { - const t = createAgUiTranslator(opts) - const evs = [ - ...t.translate({ type: "token", data: "Hello" }), - ...t.translate({ type: "token", data: " world" }), - ...t.translate({ type: "done", output: { messages: [] } }), - ] - expect(types(evs)).toEqual([ - EventType.TEXT_MESSAGE_START, - EventType.TEXT_MESSAGE_CONTENT, - EventType.TEXT_MESSAGE_CONTENT, - EventType.TEXT_MESSAGE_END, - EventType.RUN_FINISHED, - ]) - TextMessageStartEventSchema.parse(evs[0]) - TextMessageContentEventSchema.parse(evs[1]) - TextMessageContentEventSchema.parse(evs[2]) - TextMessageEndEventSchema.parse(evs[3]) - RunFinishedEventSchema.parse(evs[4]) - const mid = (evs[0] as { messageId: string }).messageId - expect((evs[1] as { messageId: string }).messageId).toBe(mid) - expect((evs[3] as { messageId: string }).messageId).toBe(mid) - }) - - it("skips empty token deltas (translator no-ops rather than emit empty content)", () => { - const t = createAgUiTranslator(opts) - const evs = t.translate({ type: "token", data: "" }) - expect(evs).toEqual([]) - }) - - it("maps tool_call to START/ARGS/END and pairs tool_result by FIFO", () => { - const t = createAgUiTranslator(opts) - const evs = [ - ...t.translate({ type: "tool_call", name: "searchCorpus", input: { query: "x" } }), - ...t.translate({ type: "tool_result", name: "searchCorpus", output: [{ path: "corpus/a.md" }] }), - ] - expect(types(evs)).toEqual([ - EventType.TOOL_CALL_START, - EventType.TOOL_CALL_ARGS, - EventType.TOOL_CALL_END, - EventType.TOOL_CALL_RESULT, - ]) - ToolCallStartEventSchema.parse(evs[0]) - ToolCallArgsEventSchema.parse(evs[1]) - ToolCallEndEventSchema.parse(evs[2]) - ToolCallResultEventSchema.parse(evs[3]) - const id = (evs[0] as { toolCallId: string }).toolCallId - expect((evs[1] as { toolCallId: string }).toolCallId).toBe(id) - expect((evs[3] as { toolCallId: string }).toolCallId).toBe(id) - expect((evs[1] as { delta: string }).delta).toBe(JSON.stringify({ query: "x" })) - }) - - it("flushes an open text message before a tool_call", () => { - const t = createAgUiTranslator(opts) - const evs = [ - ...t.translate({ type: "token", data: "thinking" }), - ...t.translate({ type: "tool_call", name: "readDoc", input: { path: "corpus/a.md" } }), - ] - expect(types(evs)).toEqual([ - EventType.TEXT_MESSAGE_START, - EventType.TEXT_MESSAGE_CONTENT, - EventType.TEXT_MESSAGE_END, - EventType.TOOL_CALL_START, - EventType.TOOL_CALL_ARGS, - EventType.TOOL_CALL_END, - ]) - }) - - it("maps a done error to RUN_ERROR", () => { - const t = createAgUiTranslator(opts) - const evs = t.translate({ type: "done", output: { error: "boom" } }) - expect(types(evs)).toEqual([EventType.RUN_ERROR]) - expect((evs[0] as { message: string }).message).toBe("boom") - }) -}) diff --git a/packages/ag-ui/test/types.test.ts b/packages/ag-ui/test/types.test.ts new file mode 100644 index 00000000..956bc745 --- /dev/null +++ b/packages/ag-ui/test/types.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest" +import { asToolCallData, asToolResultData, type DawnAgentStreamChunk } from "../src/types.js" + +describe("DawnAgentStreamChunk", () => { + test("accepts canonical chunks and open extension chunks", () => { + const chunks = [ + { type: "token", data: "hello" }, + { type: "tool_call", data: { name: "greet", input: { name: "Dawn" } } }, + { type: "tool_result", data: { name: "greet", output: "hello" } }, + { type: "interrupt", data: { interruptId: "perm-1" } }, + { type: "done" }, + { type: "plan_update", data: { steps: [] } }, + ] satisfies DawnAgentStreamChunk[] + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "token", + "tool_call", + "tool_result", + "interrupt", + "done", + "plan_update", + ]) + }) +}) + +describe("asToolCallData", () => { + test("extracts id/name/input when shape is valid", () => { + expect(asToolCallData({ id: "run-1", name: "greet", input: { x: 1 } })).toEqual({ + id: "run-1", + name: "greet", + input: { x: 1 }, + }) + }) + + test("returns undefined id when absent", () => { + expect(asToolCallData({ name: "greet", input: {} })).toEqual({ + id: undefined, + name: "greet", + input: {}, + }) + }) + + test("returns null when name missing or data not an object", () => { + expect(asToolCallData({ input: {} })).toBeNull() + expect(asToolCallData("nope")).toBeNull() + expect(asToolCallData(null)).toBeNull() + }) +}) + +describe("asToolResultData", () => { + test("extracts id/name/output", () => { + expect(asToolResultData({ id: "run-1", name: "greet", output: "hi" })).toEqual({ + id: "run-1", + name: "greet", + output: "hi", + }) + }) + + test("returns undefined id when absent", () => { + expect(asToolResultData({ name: "greet", output: "hi" })).toEqual({ + id: undefined, + name: "greet", + output: "hi", + }) + }) + + test("returns null when name missing", () => { + expect(asToolResultData({ output: "hi" })).toBeNull() + }) + + test("returns null when data not an object", () => { + expect(asToolResultData("nope")).toBeNull() + expect(asToolResultData(null)).toBeNull() + }) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index 08e44a06..19b0200e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -66,6 +66,7 @@ "@dawn-ai/sdk": "workspace:*", "@dawn-ai/workspace": "workspace:*", "@langchain/core": "1.2.1", + "@langchain/langgraph": "1.4.7", "@langchain/langgraph-checkpoint": "^1.1.3", "@types/node": "26.1.1" } diff --git a/packages/cli/src/lib/dev/abortable-iterable.ts b/packages/cli/src/lib/dev/abortable-iterable.ts new file mode 100644 index 00000000..e19c2863 --- /dev/null +++ b/packages/cli/src/lib/dev/abortable-iterable.ts @@ -0,0 +1,48 @@ +function abortError(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError") +} + +async function nextWithAbort( + iterator: AsyncIterator, + signal: AbortSignal, +): Promise> { + let rejectAbort: ((reason: unknown) => void) | undefined + const abort = new Promise((_resolve, reject) => { + rejectAbort = reject + }) + const onAbort = () => rejectAbort?.(abortError(signal)) + signal.addEventListener("abort", onAbort, { once: true }) + + try { + if (signal.aborted) throw abortError(signal) + return await Promise.race([iterator.next(), abort]) + } finally { + signal.removeEventListener("abort", onAbort) + } +} + +function closeIterator(iterator: AsyncIterator): void { + try { + const cleanup = iterator.return?.() + if (cleanup) void cleanup.catch(() => undefined) + } catch { + // Iterator cleanup is best-effort and must not replace the iteration outcome. + } +} + +export async function* abortableAsyncIterable( + source: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + const iterator = source[Symbol.asyncIterator]() + + try { + while (true) { + const next = await nextWithAbort(iterator, signal) + if (next.done) return + yield next.value + } + } finally { + closeIterator(iterator) + } +} diff --git a/packages/cli/src/lib/dev/agui-handler.ts b/packages/cli/src/lib/dev/agui-handler.ts index c3ad043f..0abfc09c 100644 --- a/packages/cli/src/lib/dev/agui-handler.ts +++ b/packages/cli/src/lib/dev/agui-handler.ts @@ -1,15 +1,23 @@ import type { IncomingMessage, ServerResponse } from "node:http" -import { EventType, RunAgentInputSchema } from "@ag-ui/core" -import { createAgUiTranslator, encodeAgUiSse, mapRunInput } from "@dawn-ai/ag-ui" +import { RunAgentInputSchema } from "@ag-ui/core" +import { type DawnAgentStreamChunk, fromRunAgentInput, toAguiEvents } from "@dawn-ai/ag-ui" +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" import type { DawnMiddleware, MiddlewareRequest } from "@dawn-ai/sdk" import type { ThreadsStore } from "@dawn-ai/sqlite-storage" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" import { streamResolvedRoute } from "../runtime/execute-route.js" import type { SandboxManager } from "../runtime/sandbox-manager.js" -import { extractRouteParams, parseHeaders, runMiddleware } from "./middleware.js" +import type { StreamChunk } from "../runtime/stream-types.js" +import { abortableAsyncIterable } from "./abortable-iterable.js" +import { runMiddleware } from "./middleware.js" +import { readPendingInterrupts, resolveAgUiResume } from "./pending-interrupts.js" +import { extractRouteParams, parseHeaders } from "./request-context.js" import type { RuntimeRegistry } from "./runtime-registry.js" +import { createRequestErrorBody } from "./server-errors.js" interface AgUiRequestOptions { readonly appRoot: string + readonly checkpointer: BaseCheckpointSaver readonly middleware: DawnMiddleware | undefined readonly registry: RuntimeRegistry readonly threadsStore: ThreadsStore @@ -18,6 +26,7 @@ interface AgUiRequestOptions { readonly request: IncomingMessage readonly response: ServerResponse readonly routeKey: string + readonly streamRoute?: typeof streamResolvedRoute } async function readBody(request: IncomingMessage): Promise { @@ -27,108 +36,185 @@ async function readBody(request: IncomingMessage): Promise { return Buffer.concat(chunks).toString("utf8") } +function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { + response.statusCode = statusCode + response.setHeader("content-type", "application/json") + response.end(JSON.stringify(body)) +} + +async function* normalizeDawnStream( + chunks: AsyncIterable, +): AsyncGenerator { + for await (const chunk of chunks) { + switch (chunk.type) { + case "chunk": + yield { + type: "token", + data: typeof chunk.data === "string" ? chunk.data : String(chunk.data ?? ""), + } + break + case "tool_call": { + const toolCall = chunk as Extract + yield { + type: "tool_call", + data: { + ...(toolCall.id ? { id: toolCall.id } : {}), + name: toolCall.name, + input: toolCall.input, + }, + } + break + } + case "tool_result": { + const toolResult = chunk as Extract + yield { + type: "tool_result", + data: { + ...(toolResult.id ? { id: toolResult.id } : {}), + name: toolResult.name, + output: toolResult.output, + }, + } + break + } + case "done": + yield { + type: "done", + data: (chunk as Extract).output, + } + break + default: + yield { + type: chunk.type, + data: (chunk as { readonly type: string; readonly data: unknown }).data, + } + } + } +} + export async function handleAgUiRequest(options: AgUiRequestOptions): Promise { const { appRoot, + checkpointer, middleware, registry, threadsStore, sandboxManager, - signal, + signal: shutdownSignal, request, response, routeKey, + streamRoute = streamResolvedRoute, } = options - const raw = await readBody(request) - let parsedJson: unknown - try { - parsedJson = JSON.parse(raw) - } catch { - response.statusCode = 400 - response.setHeader("content-type", "application/json") - response.end(JSON.stringify({ error: { kind: "request_error", message: "Malformed body" } })) - return + const requestController = new AbortController() + const abortRequest = (message: string) => { + if (!requestController.signal.aborted) requestController.abort(new Error(message)) } - const parsed = RunAgentInputSchema.safeParse(parsedJson) - if (!parsed.success) { - response.statusCode = 400 - response.setHeader("content-type", "application/json") - response.end( - JSON.stringify({ error: { kind: "request_error", message: "Invalid RunAgentInput" } }), - ) - return + const onRequestAborted = () => abortRequest("AG-UI request aborted") + const onResponseClose = () => { + if (!response.writableEnded) abortRequest("AG-UI response closed") } - const input = parsed.data + request.on("aborted", onRequestAborted) + response.on("close", onResponseClose) + const signal = AbortSignal.any([shutdownSignal, requestController.signal]) - const route = registry.lookup(routeKey) - if (!route) { - response.statusCode = 404 - response.setHeader("content-type", "application/json") - response.end( - JSON.stringify({ error: { kind: "request_error", message: `Unknown route: ${routeKey}` } }), - ) - return - } + try { + const raw = await readBody(request) + let parsedJson: unknown + try { + parsedJson = JSON.parse(raw) + } catch { + sendJson(response, 400, createRequestErrorBody("Malformed body")) + return + } - // Run app middleware before starting the run — parity with runs/stream and - // runs/wait so auth/rate-limit/context middleware applies to /agui too. - const mwRequest: MiddlewareRequest = { - assistantId: route.assistantId, - headers: parseHeaders(request), - method: request.method ?? "POST", - params: extractRouteParams(route.routeId, input), - routeId: route.routeId, - url: request.url ?? `/agui/${routeKey}`, - } - const mwResult = await runMiddleware(middleware, mwRequest) - if (mwResult.action === "reject") { - response.statusCode = mwResult.status - response.setHeader("content-type", "application/json") - response.end(JSON.stringify(mwResult.body)) - return - } + const parsed = RunAgentInputSchema.safeParse(parsedJson) + if (!parsed.success) { + sendJson(response, 400, createRequestErrorBody("Invalid RunAgentInput")) + return + } + const input = parsed.data + + const route = registry.lookup(routeKey) + if (!route) { + sendJson(response, 404, createRequestErrorBody(`Unknown route: ${routeKey}`)) + return + } + + const dawnInput = fromRunAgentInput(input) + const middlewareRequest: MiddlewareRequest = { + assistantId: route.assistantId, + headers: parseHeaders(request), + method: request.method ?? "POST", + params: extractRouteParams(route.routeId, dawnInput.raw), + routeId: route.routeId, + url: request.url ?? `/agui/${routeKey}`, + } + const middlewareResult = await runMiddleware(middleware, middlewareRequest) + if (middlewareResult.action === "reject") { + sendJson(response, middlewareResult.status, middlewareResult.body) + return + } - const threadId = input.threadId - const existing = await threadsStore.getThread(threadId) - if (!existing) await threadsStore.createThread({ thread_id: threadId }) - await threadsStore.updateStatus(threadId, "busy") + const newestUserMessage = [...dawnInput.messages] + .reverse() + .find((message) => message.role === "user") + const pending = (await readPendingInterrupts(checkpointer, input.threadId)) ?? { + interrupts: [], + malformed: false, + } + const resumeResolution = resolveAgUiResume(dawnInput.resume, pending) + if (!resumeResolution.ok) { + sendJson( + response, + resumeResolution.status, + createRequestErrorBody(resumeResolution.message, { code: resumeResolution.code }), + ) + return + } - const { dawnInput, resumeDecision } = mapRunInput(input) - const translator = createAgUiTranslator({ threadId, runId: input.runId }) + const threadId = input.threadId + if (!(await threadsStore.getThread(threadId))) { + await threadsStore.createThread({ thread_id: threadId }) + } + await threadsStore.updateMetadata(threadId, { route: routeKey }) + await threadsStore.updateStatus(threadId, "busy") - response.writeHead(200, { - "cache-control": "no-cache", - connection: "keep-alive", - "content-type": "text/event-stream", - }) - for (const e of translator.begin()) response.write(encodeAgUiSse(e)) + response.writeHead(200, { + "cache-control": "no-cache", + connection: "keep-alive", + "content-type": "text/event-stream", + }) - try { - for await (const chunk of streamResolvedRoute({ - appRoot, - input: dawnInput, - ...(mwResult.context ? { middlewareContext: mwResult.context } : {}), - ...(resumeDecision ? { resumeDecision } : {}), - routeFile: route.routeFile, - routeId: route.routeId, - routePath: route.routePath, - ...(sandboxManager ? { sandboxManager } : {}), - signal, - threadId, - })) { - for (const e of translator.translate(chunk)) response.write(encodeAgUiSse(e)) + try { + const routeStream = streamRoute({ + appRoot, + input: { + messages: newestUserMessage ? [{ role: "user", content: newestUserMessage.content }] : [], + }, + ...(resumeResolution.mode === "resume" ? { resume: resumeResolution.resume } : {}), + ...(middlewareResult.context ? { middlewareContext: middlewareResult.context } : {}), + routeFile: route.routeFile, + routeId: route.routeId, + routePath: route.routePath, + ...(sandboxManager ? { sandboxManager } : {}), + signal, + threadId, + }) + const abortableRouteStream = abortableAsyncIterable(routeStream, signal) + for await (const event of toAguiEvents(normalizeDawnStream(abortableRouteStream), { + threadId, + runId: input.runId, + })) { + response.write(encodeAgUiSse(event, request.headers.accept)) + } + } finally { + await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) } - for (const e of translator.end()) response.write(encodeAgUiSse(e)) - await threadsStore.updateStatus(threadId, "idle") - } catch (error) { - response.write( - encodeAgUiSse({ - type: EventType.RUN_ERROR, - message: error instanceof Error ? error.message : String(error), - }), - ) - await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) + response.end() + } finally { + request.removeListener("aborted", onRequestAborted) + response.removeListener("close", onResponseClose) } - response.end() } diff --git a/packages/cli/src/lib/dev/pending-interrupts.ts b/packages/cli/src/lib/dev/pending-interrupts.ts new file mode 100644 index 00000000..e8884e03 --- /dev/null +++ b/packages/cli/src/lib/dev/pending-interrupts.ts @@ -0,0 +1,185 @@ +import type { DawnResumeRequest } from "@dawn-ai/ag-ui" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" + +export type PermissionDecision = "once" | "always" | "deny" + +export interface PendingInterrupt { + readonly aliases: readonly string[] + readonly interruptId: string + readonly resumeKey: string | null +} + +export interface PendingInterruptSnapshot { + readonly interrupts: readonly PendingInterrupt[] + readonly malformed: boolean +} + +export type ResumeResolution = + | { readonly ok: true; readonly mode: "turn" } + | { + readonly ok: true + readonly mode: "resume" + readonly resume: Readonly> + } + | { + readonly ok: false + readonly status: 400 | 409 + readonly code: + | "interrupt_set_mismatch" + | "invalid_resume_payload" + | "malformed_checkpoint" + | "resume_required" + | "stale_interrupt" + readonly message: string + } + +export async function readPendingInterrupts( + checkpointer: BaseCheckpointSaver, + threadId: string, +): Promise { + const tuple = await checkpointer.getTuple({ + configurable: { thread_id: threadId, checkpoint_ns: "" }, + }) + if (!tuple) return null + + const interrupts: PendingInterrupt[] = [] + let malformed = false + for (const write of tuple.pendingWrites ?? []) { + if (!Array.isArray(write) || write[1] !== "__interrupt__") continue + if (write.length < 3 || !isRecord(write[2])) { + malformed = true + continue + } + + const value = write[2] + const hasInnerValue = Object.hasOwn(value, "value") + const innerValue = isRecord(value.value) ? value.value : undefined + if (hasInnerValue && !innerValue) malformed = true + + const rawInnerId = innerValue?.interruptId + const innerId = asIdentifier(rawInnerId) + if (rawInnerId !== undefined && !innerId) malformed = true + + const outerId = asIdentifier(value.id) + const interruptId = innerId ?? outerId + if (!interruptId) { + malformed = true + continue + } + + const resumeKey = outerId && RESUME_KEY_PATTERN.test(outerId) ? outerId : null + if (!resumeKey) malformed = true + + const aliases = innerId && outerId && innerId !== outerId ? [innerId, outerId] : [interruptId] + interrupts.push({ aliases, interruptId, resumeKey }) + } + + const interruptIds = new Set() + const resumeKeys = new Set() + for (const interrupt of interrupts) { + if (interruptIds.has(interrupt.interruptId)) malformed = true + interruptIds.add(interrupt.interruptId) + if (interrupt.resumeKey) { + if (resumeKeys.has(interrupt.resumeKey)) malformed = true + resumeKeys.add(interrupt.resumeKey) + } + } + + return { interrupts, malformed } +} + +export function resolveAgUiResume( + resume: readonly DawnResumeRequest[] | undefined, + snapshot: PendingInterruptSnapshot, +): ResumeResolution { + const pendingById = new Map(snapshot.interrupts.map((entry) => [entry.interruptId, entry])) + const resumeKeys = snapshot.interrupts.map((entry) => entry.resumeKey) + if ( + snapshot.malformed || + pendingById.size !== snapshot.interrupts.length || + resumeKeys.some((key) => key === null) || + new Set(resumeKeys).size !== resumeKeys.length + ) { + return resumeError( + 409, + "malformed_checkpoint", + "Pending checkpoint interrupts cannot be addressed safely", + ) + } + + const pending = snapshot.interrupts + if (!resume || resume.length === 0) { + if (pending.length === 0) return { ok: true, mode: "turn" } + return resumeError(409, "resume_required", "Pending interrupts require resume entries") + } + + if (pending.length === 0) { + return resumeError(409, "stale_interrupt", "No pending interrupts match the resume entries") + } + + const resumeIds = new Set(resume.map((entry) => entry.interruptId)) + if ( + resumeIds.size !== resume.length || + resume.length !== pending.length || + resume.some((entry) => !pendingById.has(entry.interruptId)) + ) { + return resumeError( + 409, + "interrupt_set_mismatch", + "Resume entries must exactly match pending interrupts", + ) + } + + const resumeMap: Record = {} + for (const entry of resume) { + const decision = entry.status === "cancelled" ? "deny" : entry.payload + if (!isPermissionDecision(decision)) { + return resumeError( + 400, + "invalid_resume_payload", + "Resolved resume entries require a once, always, or deny payload", + ) + } + + const pendingEntry = pendingById.get(entry.interruptId) + if (!pendingEntry) { + return resumeError( + 409, + "interrupt_set_mismatch", + "Resume entries must exactly match pending interrupts", + ) + } + if (!pendingEntry.resumeKey) { + return resumeError( + 409, + "malformed_checkpoint", + "Pending checkpoint interrupts cannot be addressed safely", + ) + } + resumeMap[pendingEntry.resumeKey] = decision + } + + return { ok: true, mode: "resume", resume: resumeMap } +} + +const RESUME_KEY_PATTERN = /^[0-9a-f]{32}$/ + +function asIdentifier(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} + +function isPermissionDecision(value: unknown): value is PermissionDecision { + return value === "once" || value === "always" || value === "deny" +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function resumeError( + status: 400 | 409, + code: Extract["code"], + message: string, +): ResumeResolution { + return { ok: false, status, code, message } +} diff --git a/packages/cli/src/lib/dev/request-context.ts b/packages/cli/src/lib/dev/request-context.ts new file mode 100644 index 00000000..34d95eb1 --- /dev/null +++ b/packages/cli/src/lib/dev/request-context.ts @@ -0,0 +1,31 @@ +import type { IncomingMessage } from "node:http" + +export function parseHeaders(request: IncomingMessage): Record { + const headers: Record = {} + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === "string") { + headers[key] = value + } else if (Array.isArray(value)) { + headers[key] = value.join(", ") + } + } + return headers +} + +export function extractRouteParams(routeId: string, input: unknown): Record { + const params: Record = {} + const matches = routeId.matchAll(/\[(\w+)\]/g) + const inputRecord = (typeof input === "object" && input !== null ? input : {}) as Record< + string, + unknown + > + + for (const match of matches) { + const name = match[1] + if (name && name in inputRecord) { + params[name] = String(inputRecord[name]) + } + } + + return params +} diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index a459a04f..2936a075 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -20,7 +20,9 @@ import { handleMemoryListRequest, handleMemoryRejectRequest, } from "./memory-handler.js" -import { extractRouteParams, loadMiddleware, parseHeaders, runMiddleware } from "./middleware.js" +import { loadMiddleware, runMiddleware } from "./middleware.js" +import { readPendingInterrupts } from "./pending-interrupts.js" +import { extractRouteParams, parseHeaders } from "./request-context.js" import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js" import { createExecutionErrorBody, @@ -386,6 +388,7 @@ function buildRouteTable(ctx: { handle: async (req, res, params) => { await handleAgUiRequest({ appRoot, + checkpointer, middleware, registry, threadsStore, @@ -846,13 +849,8 @@ async function handleResumeRequest(options: { return } - // Load the checkpoint tuple to verify the interrupt_id exists in - // pendingWrites. Each entry is [task_id, channel, value] where channel is - // "__interrupt__" and value is {id, value: {interruptId, ...}}. - const tuple = await checkpointer.getTuple({ - configurable: { thread_id: threadId, checkpoint_ns: "" }, - }) - if (!tuple) { + const pendingInterrupts = await readPendingInterrupts(checkpointer, threadId) + if (!pendingInterrupts) { sendJson( response, 404, @@ -861,23 +859,18 @@ async function handleResumeRequest(options: { return } - const interruptWrites = (tuple.pendingWrites ?? []).filter( - ([, channel]) => channel === "__interrupt__", - ) - const matchedWrite = interruptWrites.find(([, , value]) => { - if (!value || typeof value !== "object") return false - const v = value as { id?: unknown; value?: unknown } - // Match on the capability-level interruptId (inside entry.value) - if (v.value && typeof v.value === "object") { - const inner = v.value as { interruptId?: unknown } - if (inner.interruptId === interruptId) return true - } - // Defensive fallback: match on the outer LangGraph entry id - if (v.id === interruptId) return true - return false - }) + if (pendingInterrupts.malformed) { + sendJson( + response, + 409, + createRequestErrorBody("Malformed checkpoint interrupts", { + code: "malformed_checkpoint", + }), + ) + return + } - if (!matchedWrite) { + if (!pendingInterrupts.interrupts.some((pending) => pending.aliases.includes(interruptId))) { sendJson( response, 409, @@ -943,7 +936,7 @@ async function handleResumeRequest(options: { for await (const chunk of streamResolvedRoute({ appRoot, input: {}, - resumeDecision: decision as "once" | "always" | "deny", + resume: decision, ...(mwResult.context ? { middlewareContext: mwResult.context } : {}), routeFile: route.routeFile, routeId: route.routeId, diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 4672ee75..6ab5eae1 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { isAbsolute, join, resolve } from "node:path" import { pathToFileURL } from "node:url" @@ -85,6 +86,16 @@ export interface ExecuteRouteOptions { readonly signal?: AbortSignal } +export type RouteResumePayload = + | "once" + | "always" + | "deny" + | Readonly> + +export function toAgentInput(input: unknown, resume?: RouteResumePayload): unknown { + return resume === undefined ? input : new Command({ resume }) +} + export async function executeRoute(options: ExecuteRouteOptions): Promise { const startedAt = Date.now() const discoveredApp = await discoverApp(options) @@ -244,11 +255,11 @@ export async function* streamResolvedRoute(options: { readonly isSubagent?: boolean readonly middlewareContext?: Readonly> /** - * When set, the agent-adapter receives `Command({resume: resumeDecision})` + * When set, the agent-adapter receives `Command({resume})` * as its input instead of the normal `input` field. Used by the resume * endpoint to replay a parked graph state after a permission interrupt. */ - readonly resumeDecision?: "once" | "always" | "deny" + readonly resume?: RouteResumePayload readonly routeFile: string readonly routeId: string readonly routePath: string @@ -270,8 +281,7 @@ export async function* streamResolvedRoute(options: { }) if (!prepared.ok) { - yield { type: "done", output: { error: prepared.message } } - return + throw new Error(prepared.message) } const { @@ -303,11 +313,7 @@ export async function* streamResolvedRoute(options: { const routeParamNames = extractRouteParamNames(options.routeId) - // For resume runs, pass Command({resume}) directly to the agent-adapter so - // LangGraph replays from the parked checkpoint state. - const agentInput = options.resumeDecision - ? new Command({ resume: options.resumeDecision }) - : options.input + const agentInput = toAgentInput(options.input, options.resume) for await (const chunk of streamAgent({ checkpointer, @@ -331,13 +337,23 @@ export async function* streamResolvedRoute(options: { yield { type: "chunk", data: chunk.data } break case "tool_call": { - const tc = chunk.data as { name: string; input: unknown } - yield { type: "tool_call", name: tc.name, input: tc.input } + const tc = chunk.data as { id?: string; name: string; input: unknown } + yield { + type: "tool_call", + ...(tc.id ? { id: tc.id } : {}), + name: tc.name, + input: tc.input, + } break } case "tool_result": { - const tr = chunk.data as { name: string; output: unknown } - yield { type: "tool_result", name: tr.name, output: tr.output } + const tr = chunk.data as { id?: string; name: string; output: unknown } + yield { + type: "tool_result", + ...(tr.id ? { id: tr.id } : {}), + name: tr.name, + output: tr.output, + } break } case "done": @@ -855,6 +871,9 @@ async function executeRouteAtResolvedPath(options: { sandboxed, } = prepared mode = normalized.kind + const threadId = + options.threadId ?? + (normalized.kind === "agent" ? `t-run-${randomUUID().slice(0, 8)}` : undefined) const context = createDawnContext({ ...(options.middlewareContext ? { middleware: options.middlewareContext } : {}), @@ -875,7 +894,7 @@ async function executeRouteAtResolvedPath(options: { ...(promptFragments && promptFragments.length > 0 ? { promptFragments } : {}), ...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}), ...(subagentResolver ? { subagentResolver } : {}), - ...(options.threadId ? { threadId: options.threadId } : {}), + ...(threadId ? { threadId } : {}), ...(sandboxed ? { sandboxed: true } : {}), }) diff --git a/packages/cli/src/lib/runtime/stream-types.ts b/packages/cli/src/lib/runtime/stream-types.ts index 175ca9b1..a6d21bdf 100644 --- a/packages/cli/src/lib/runtime/stream-types.ts +++ b/packages/cli/src/lib/runtime/stream-types.ts @@ -1,7 +1,17 @@ export type StreamChunk = | { readonly type: "chunk"; readonly data: unknown } - | { readonly type: "tool_call"; readonly name: string; readonly input: unknown } - | { readonly type: "tool_result"; readonly name: string; readonly output: unknown } + | { + readonly type: "tool_call" + readonly id?: string + readonly name: string + readonly input: unknown + } + | { + readonly type: "tool_result" + readonly id?: string + readonly name: string + readonly output: unknown + } | { readonly type: "done"; readonly output: unknown } // Capability-contributed event types (e.g. plan_update from the planning capability). // The langchain layer widened AgentStreamChunk["type"] to allow arbitrary strings; diff --git a/packages/cli/test/abortable-iterable.test.ts b/packages/cli/test/abortable-iterable.test.ts new file mode 100644 index 00000000..8ec16e77 --- /dev/null +++ b/packages/cli/test/abortable-iterable.test.ts @@ -0,0 +1,107 @@ +import { EventType } from "@ag-ui/core" +import { type DawnAgentStreamChunk, toAguiEvents } from "@dawn-ai/ag-ui" +import { expect, test } from "vitest" + +import { abortableAsyncIterable } from "../src/lib/dev/abortable-iterable.js" + +test("aborting a pending next rejects and closes the source iterator", async () => { + let sourceClosed = false + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => undefined), + return: async () => { + sourceClosed = true + return { done: true, value: undefined } + }, + } + }, + } + const controller = new AbortController() + const iterator = abortableAsyncIterable(source, controller.signal)[Symbol.asyncIterator]() + + const next = iterator.next() + controller.abort(new Error("client disconnected")) + + await expect(next).rejects.toThrow("client disconnected") + expect(sourceClosed).toBe(true) +}) + +test("aborting a native async generator with a blocked next rejects promptly", async () => { + const source = (async function* () { + yield await new Promise(() => undefined) + })() + const originalReturn = source.return.bind(source) + let returnCalled = false + source.return = (value) => { + returnCalled = true + return originalReturn(value) + } + const controller = new AbortController() + const reason = new Error("native generator aborted") + const iterator = abortableAsyncIterable(source, controller.signal)[Symbol.asyncIterator]() + let outcome: unknown + void iterator.next().then( + () => { + outcome = new Error("iteration unexpectedly resolved") + }, + (error: unknown) => { + outcome = error + }, + ) + + controller.abort(reason) + await new Promise((resolve) => setImmediate(resolve)) + + expect(returnCalled).toBe(true) + expect(outcome).toBe(reason) +}) + +test("a rejecting source cleanup cannot append RUN_ERROR after RUN_FINISHED", async () => { + let emittedDone = false + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => { + if (emittedDone) return { done: true, value: undefined } + emittedDone = true + return { done: false, value: { type: "done", data: { ok: true } } as const } + }, + return: async () => { + throw new Error("cleanup failed") + }, + } + }, + } + const events = [] + + for await (const event of toAguiEvents( + abortableAsyncIterable(source, new AbortController().signal), + { threadId: "thread-1", runId: "run-1" }, + )) { + events.push(event) + } + + expect(events.map((event) => event.type)).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]) +}) + +test("a source next failure propagates even when cleanup also rejects", async () => { + const nextError = new Error("next failed") + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => { + throw nextError + }, + return: async () => { + throw new Error("cleanup failed") + }, + } + }, + } + const iterator = abortableAsyncIterable(source, new AbortController().signal)[ + Symbol.asyncIterator + ]() + + await expect(iterator.next()).rejects.toBe(nextError) +}) diff --git a/packages/cli/test/agui-endpoint.test.ts b/packages/cli/test/agui-endpoint.test.ts index 6eeaac73..b5b289fe 100644 --- a/packages/cli/test/agui-endpoint.test.ts +++ b/packages/cli/test/agui-endpoint.test.ts @@ -3,10 +3,13 @@ import { createServer, type Server } from "node:http" import type { AddressInfo } from "node:net" import { tmpdir } from "node:os" import { join } from "node:path" - +import type { ThreadsStore } from "@dawn-ai/sqlite-storage" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" import { afterEach, expect, it } from "vitest" import { createAimock, script } from "../../testing/dist/index.js" +import { handleAgUiRequest } from "../src/lib/dev/agui-handler.js" import { createRuntimeRequestListener } from "../src/lib/dev/runtime-server.js" +import type { streamResolvedRoute } from "../src/lib/runtime/execute-route.js" const cleanup: Array<() => Promise | void> = [] @@ -14,7 +17,7 @@ afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() }) -async function fixtureApp(extraFiles: Record = {}): Promise { +async function fixtureApp(overrides: Record = {}): Promise { const appRoot = await mkdtemp(join(tmpdir(), "dawn-agui-")) cleanup.push(() => rm(appRoot, { force: true, recursive: true })) const files: Record = { @@ -22,7 +25,7 @@ async function fixtureApp(extraFiles: Record = {}): Promise = {}): Promise", async () => { +function parseSseEvents(text: string): Record[] { + return text.split("\n\n").flatMap((frame) => { + const data = frame + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice("data: ".length) + return data ? [JSON.parse(data) as Record] : [] + }) +} + +async function postRun( + port: number, + body: Record, + headers: Record = {}, +): Promise<{ events: Record[]; response: Response }> { + const routeKey = encodeURIComponent("/chat#agent") + const response = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { + method: "POST", + headers: { "content-type": "application/json", accept: "text/event-stream", ...headers }, + body: JSON.stringify({ state: {}, tools: [], context: [], forwardedProps: {}, ...body }), + }) + return { events: parseSseEvents(await response.text()), response } +} + +async function setupServer(fixtures: ReturnType["build"]>) { const aimock = await createAimock({ fixtures: [] }) cleanup.push(() => aimock.close()) const prevBaseUrl = process.env.OPENAI_BASE_URL @@ -45,7 +72,7 @@ it("streams AG-UI events from POST /agui/", async () => { if (prevKey === undefined) delete process.env.OPENAI_API_KEY else process.env.OPENAI_API_KEY = prevKey }) - aimock.addFixtures(script().user("hello").replies("Hi there!").build()) + aimock.addFixtures(fixtures) const appRoot = await fixtureApp() const { listener, close } = await createRuntimeRequestListener({ appRoot }) @@ -55,61 +82,431 @@ it("streams AG-UI events from POST /agui/", async () => { await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))) const { port } = server.address() as AddressInfo + return { port } +} - const routeKey = encodeURIComponent("/chat#agent") - const res = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { - method: "POST", - headers: { "content-type": "application/json", accept: "text/event-stream" }, - body: JSON.stringify({ - threadId: "th1", - runId: "rn1", - state: {}, - messages: [{ id: "1", role: "user", content: "hello" }], - tools: [], - context: [], - forwardedProps: {}, - }), +interface ControlledServerOptions { + readonly checkpointer?: BaseCheckpointSaver + readonly streamRoute: typeof streamResolvedRoute +} + +async function setupControlledServer(controlled: ControlledServerOptions): Promise<{ + readonly port: number +}> { + const appRoot = await fixtureApp() + const threads = new Map; status: string }>() + const server: Server = createServer((request, response) => { + const threadMatch = request.url?.match(/^\/threads\/([^/]+)$/) + if (request.method === "GET" && threadMatch) { + const thread = threads.get(decodeURIComponent(threadMatch[1] ?? "")) + response.statusCode = thread ? 200 : 404 + response.setHeader("content-type", "application/json") + response.end(JSON.stringify(thread ?? { error: "not found" })) + return + } + const requestOptions = { + appRoot, + checkpointer: + controlled.checkpointer ?? + ({ getTuple: async () => undefined } as unknown as BaseCheckpointSaver), + middleware: undefined, + registry: { + appRoot, + entries: [], + lookup: () => ({ + assistantId: "/chat#agent", + mode: "agent" as const, + routeFile: join(appRoot, "src/app/chat/index.ts"), + routeId: "/chat", + routePath: "src/app/chat/index.ts", + }), + }, + request, + response, + routeKey: "/chat#agent", + signal: new AbortController().signal, + streamRoute: controlled.streamRoute, + threadsStore: { + createThread: async ({ thread_id }: { thread_id?: string }) => { + const threadId = thread_id ?? "generated" + const now = new Date().toISOString() + threads.set(threadId, { metadata: {}, status: "idle" }) + return { + thread_id: threadId, + created_at: now, + updated_at: now, + metadata: {}, + status: "idle" as const, + } + }, + getThread: async (threadId: string) => { + const thread = threads.get(threadId) + if (!thread) return undefined + const now = new Date().toISOString() + return { + thread_id: threadId, + created_at: now, + updated_at: now, + metadata: thread.metadata, + status: thread.status as "idle" | "busy" | "interrupted", + } + }, + updateMetadata: async (threadId: string, patch: Record) => { + const thread = threads.get(threadId) + if (thread) thread.metadata = { ...thread.metadata, ...patch } + }, + updateStatus: async (threadId: string, status: string) => { + const thread = threads.get(threadId) + if (thread) thread.status = status + }, + } as unknown as ThreadsStore, + } + void handleAgUiRequest(requestOptions).catch((error) => { + response.statusCode = 500 + response.end(String(error)) + }) }) - const text = await res.text() - expect(res.status).toBe(200) - expect(text).toContain('"type":"RUN_STARTED"') - expect(text).toContain('"type":"TEXT_MESSAGE_CONTENT"') - expect(text).toContain("Hi there!") - expect(text).toContain('"type":"RUN_FINISHED"') + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))) + return { + port: (server.address() as AddressInfo).port, + } +} + +it("streams the canonical AG-UI lifecycle and successful result", async () => { + const { port } = await setupServer(script().user("hello").replies("Hi there!").build()) + const { events, response } = await postRun(port, { + threadId: "th1", + runId: "rn1", + messages: [{ id: "1", role: "user", content: "hello" }], + }) + + expect(response.status).toBe(200) + expect(events.map((event) => event.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ]) + expect(events[2]).toMatchObject({ delta: "Hi there!" }) + expect(events.at(-1)).toMatchObject({ + outcome: { type: "success" }, + result: expect.anything(), + runId: "rn1", + threadId: "th1", + }) + expect(events.map((event) => event.type)).not.toContain("STATE_SNAPSHOT") + expect(events.map((event) => event.type)).not.toContain("CUSTOM") }, 60_000) -it("applies app middleware to /agui — a rejecting middleware blocks the run", async () => { - // Parity with runs/stream / runs/wait: /agui must run app middleware. A - // middleware that rejects should return its status/body and NOT start a run. - const appRoot = await fixtureApp({ - "src/middleware.ts": - "export default async () => ({ action: 'reject', status: 403, body: { error: 'blocked by middleware' } })\n", +it("forwards only the newest user message on a later turn", async () => { + const routeInputs: unknown[] = [] + const streamRoute: typeof streamResolvedRoute = async function* (options) { + routeInputs.push(options.input) + yield { type: "done", output: { received: options.input } } + } + const { port } = await setupControlledServer({ streamRoute }) + + await postRun(port, { + threadId: "same-thread", + runId: "run-1", + messages: [{ id: "1", role: "user", content: "first" }], + }) + const { events, response } = await postRun(port, { + threadId: "same-thread", + runId: "run-2", + messages: [ + { id: "1", role: "user", content: "first" }, + { id: "2", role: "assistant", content: "one" }, + { id: "3", role: "user", content: "second" }, + ], }) - const { listener, close } = await createRuntimeRequestListener({ appRoot }) - cleanup.push(() => close()) - const server: Server = createServer(listener) + expect(response.status).toBe(200) + expect(routeInputs).toEqual([ + { messages: [{ role: "user", content: "first" }] }, + { messages: [{ role: "user", content: "second" }] }, + ]) + expect(events.at(-1)).toMatchObject({ + result: { received: { messages: [{ role: "user", content: "second" }] } }, + }) +}, 60_000) + +it("preserves the upstream invocation id across canonical AG-UI tool events", async () => { + const upstreamInvocationId = "upstream-invocation-42" + const streamRoute: typeof streamResolvedRoute = async function* () { + yield { + type: "tool_call", + id: upstreamInvocationId, + name: "lookup", + input: { query: "pricing" }, + } + yield { + type: "tool_result", + id: upstreamInvocationId, + name: "lookup", + output: { answer: "pricing" }, + } + yield { type: "done", output: { ok: true } } + } + const { port } = await setupControlledServer({ streamRoute }) + const { events, response } = await postRun(port, { + threadId: "tool-thread", + runId: "tool-run", + messages: [{ id: "1", role: "user", content: "look up pricing" }], + }) + + expect(response.status).toBe(200) + const toolEvents = events.filter((event) => String(event.type).startsWith("TOOL_CALL")) + expect(toolEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "TOOL_CALL_START", toolCallId: upstreamInvocationId }), + expect.objectContaining({ type: "TOOL_CALL_ARGS", toolCallId: upstreamInvocationId }), + expect.objectContaining({ type: "TOOL_CALL_END", toolCallId: upstreamInvocationId }), + expect.objectContaining({ type: "TOOL_CALL_RESULT", toolCallId: upstreamInvocationId }), + ]), + ) +}, 60_000) + +it("runs middleware before thread creation and exposes allowed context to the route", async () => { + const appRoot = await fixtureApp({ + "src/app/context/index.ts": + "export const graph = async (_input, ctx) => ({ middleware: ctx.middleware })\n", + "src/middleware.ts": ` + export default (request) => request.headers["x-api-key"] === "secret" + ? { action: "continue", context: { tenant: "acme" } } + : { action: "reject", status: 401, body: { error: "missing api key" } } + `, + }) + const runtime = await createRuntimeRequestListener({ appRoot }) + cleanup.push(() => runtime.close()) + const server = createServer(runtime.listener) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))) - const { port } = server.address() as AddressInfo + const port = (server.address() as AddressInfo).port + const postContextRun = async (threadId: string, headers: Record = {}) => { + const response = await fetch(`http://127.0.0.1:${port}/agui/%2Fcontext%23graph`, { + method: "POST", + headers: { "content-type": "application/json", accept: "text/event-stream", ...headers }, + body: JSON.stringify({ + context: [], + forwardedProps: {}, + messages: [{ id: "1", role: "user", content: "hello" }], + runId: `run-${threadId}`, + state: {}, + threadId, + tools: [], + }), + }) + return { events: parseSseEvents(await response.text()), response } + } + + const rejected = await postContextRun("middleware-rejected") + expect(rejected.response.status).toBe(401) + const rejectedThread = await fetch(`http://127.0.0.1:${port}/threads/middleware-rejected`) + expect(rejectedThread.status).toBe(404) + + const allowed = await postContextRun("middleware-allowed", { "x-api-key": "secret" }) + expect(allowed.response.status).toBe(200) + expect(allowed.events.at(-1)).toMatchObject({ result: { middleware: { tenant: "acme" } } }) +}) + +const TASK_UUID_1 = "33a12321-3ec2-56a7-b4d7-0337886c4386" +const TASK_UUID_2 = "44b23432-4fd3-67b8-c5e8-1448997d5497" +const RESUME_KEY_1 = "3336d0e0a2d4f198ef9aecd09cd7ac27" +const RESUME_KEY_2 = "4447e1f1b3e5a209fa0bfde10de8bd38" + +function checkpoint(pendingWrites: readonly unknown[]): BaseCheckpointSaver { + return { getTuple: async () => ({ pendingWrites }) } as unknown as BaseCheckpointSaver +} + +function interrupt(taskId: string, resumeKey: string, interruptId: string): unknown[] { + return [taskId, "__interrupt__", { id: resumeKey, value: { interruptId } }] +} + +async function postResumeCase( + pendingWrites: readonly unknown[], + resume: unknown, +): Promise<{ captured: unknown[]; events: Record[]; response: Response }> { + const captured: unknown[] = [] + const streamRoute: typeof streamResolvedRoute = async function* (options) { + captured.push(options.resume) + yield { type: "done", output: { resumed: true } } + } + const { port } = await setupControlledServer({ + checkpointer: checkpoint(pendingWrites), + streamRoute, + }) + const { events, response } = await postRun(port, { + threadId: `resume-${Math.random()}`, + runId: "resume-run", + messages: [], + ...(resume === undefined ? {} : { resume }), + }) + return { captured, events, response } +} + +it.each([ + ["no resume while pending", [interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1")], undefined], + [ + "incomplete set", + [ + interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1"), + interrupt(TASK_UUID_2, RESUME_KEY_2, "perm-2"), + ], + [{ interruptId: "perm-1", status: "cancelled" }], + ], + [ + "unknown entry", + [interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1")], + [{ interruptId: "unknown", status: "cancelled" }], + ], + [ + "duplicate entry", + [interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1")], + [ + { interruptId: "perm-1", status: "cancelled" }, + { interruptId: "perm-1", status: "cancelled" }, + ], + ], + [ + "malformed checkpoint address", + [interrupt(TASK_UUID_1, "not-a-resume-key", "perm-1")], + [{ interruptId: "perm-1", status: "cancelled" }], + ], + [ + "duplicate checkpoint address", + [ + interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1"), + interrupt(TASK_UUID_2, RESUME_KEY_1, "perm-2"), + ], + [ + { interruptId: "perm-1", status: "cancelled" }, + { interruptId: "perm-2", status: "cancelled" }, + ], + ], +] as const)("rejects %s with 409 before route execution", async (_name, writes, resume) => { + const result = await postResumeCase(writes, resume) + expect(result.response.status).toBe(409) + expect(result.captured).toEqual([]) +}) + +it("rejects an invalid resolved payload with 400", async () => { + const result = await postResumeCase( + [interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1")], + [{ interruptId: "perm-1", payload: "later", status: "resolved" }], + ) + expect(result.response.status).toBe(400) + expect(result.captured).toEqual([]) +}) +it("rejects resume when no interrupt is pending", async () => { + const result = await postResumeCase([], [{ interruptId: "perm-1", status: "cancelled" }]) + expect(result.response.status).toBe(409) + expect(result.captured).toEqual([]) +}) + +it.each([ + { + name: "one entry", + writes: [interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1")], + resume: [{ interruptId: "perm-1", payload: "once", status: "resolved" }], + expected: { [RESUME_KEY_1]: "once" }, + }, + { + name: "two entries", + writes: [ + interrupt(TASK_UUID_1, RESUME_KEY_1, "perm-1"), + interrupt(TASK_UUID_2, RESUME_KEY_2, "perm-2"), + ], + resume: [ + { interruptId: "perm-1", payload: "always", status: "resolved" }, + { interruptId: "perm-2", status: "cancelled" }, + ], + expected: { [RESUME_KEY_1]: "always", [RESUME_KEY_2]: "deny" }, + }, +])("passes the exact outer-keyed resume map for $name", async ({ expected, resume, writes }) => { + const result = await postResumeCase(writes, resume) + expect(result.response.status).toBe(200) + expect(result.captured).toEqual([expected]) + expect(Object.keys(result.captured[0] as object)).not.toContain(TASK_UUID_1) + expect(Object.keys(result.captured[0] as object)).not.toContain(TASK_UUID_2) +}) + +it("aborts route execution on client disconnect and restores the thread to idle", async () => { + let observedSignal: AbortSignal | undefined + let resolveRouteAborted: (() => void) | undefined + const routeAborted = new Promise((resolve) => { + resolveRouteAborted = resolve + }) + const streamRoute: typeof streamResolvedRoute = async function* (options) { + observedSignal = options.signal + yield { type: "chunk", data: "started" } + await new Promise((resolve) => { + options.signal?.addEventListener( + "abort", + () => { + resolveRouteAborted?.() + resolve() + }, + { once: true }, + ) + }) + } + const { port } = await setupControlledServer({ streamRoute }) + const controller = new AbortController() const routeKey = encodeURIComponent("/chat#agent") - const res = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { + const response = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", accept: "text/event-stream" }, body: JSON.stringify({ - threadId: "t-mw", - runId: "r-mw", - state: {}, - messages: [{ id: "1", role: "user", content: "hello" }], - tools: [], context: [], forwardedProps: {}, + messages: [{ id: "1", role: "user", content: "wait" }], + runId: "disconnect-run", + state: {}, + threadId: "disconnect-thread", + tools: [], }), + signal: controller.signal, }) - const text = await res.text() - expect(res.status).toBe(403) - expect(JSON.parse(text)).toEqual({ error: "blocked by middleware" }) - // The run never started — no AG-UI stream was written. - expect(text).not.toContain("RUN_STARTED") -}, 60_000) + const reader = response.body?.getReader() + if (!reader) throw new Error("Expected streaming response body") + const decoder = new TextDecoder() + let body = "" + while (!body.includes("TEXT_MESSAGE_CONTENT")) { + const next = await reader.read() + if (next.done) throw new Error("Stream ended before route content") + body += decoder.decode(next.value) + } + + controller.abort() + await routeAborted + expect(observedSignal?.aborted).toBe(true) + + await expect + .poll(async () => { + const thread = await fetch(`http://127.0.0.1:${port}/threads/disconnect-thread`) + return thread.ok ? ((await thread.json()) as { status: string }).status : "missing" + }) + .toBe("idle") +}) + +it("does not abort the route signal after a normal response", async () => { + let routeSignal: AbortSignal | undefined + const streamRoute: typeof streamResolvedRoute = async function* (options) { + routeSignal = options.signal + yield { type: "done", output: { ok: true } } + } + const { port } = await setupControlledServer({ streamRoute }) + + const result = await postRun(port, { + threadId: "normal-thread", + runId: "normal-run", + messages: [{ id: "1", role: "user", content: "hello" }], + }) + + expect(result.response.status).toBe(200) + expect(routeSignal?.aborted).toBe(false) +}) diff --git a/packages/cli/test/docs-bundle.test.ts b/packages/cli/test/docs-bundle.test.ts index efe34371..38b06dd5 100644 --- a/packages/cli/test/docs-bundle.test.ts +++ b/packages/cli/test/docs-bundle.test.ts @@ -1,3 +1,17 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { dirname, extname, join, relative, sep } from "node:path" +import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" import { buildReadme, @@ -9,6 +23,157 @@ import { parseNavOrder, } from "../src/lib/docs-bundle.js" +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..") +const scannedTextExtensions = new Set([ + ".cjs", + ".css", + ".cts", + ".html", + ".js", + ".json", + ".jsonc", + ".jsx", + ".md", + ".mdx", + ".mjs", + ".mts", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +]) + +function isExcludedDocumentationPath(relativePath: string, pathSeparator = sep): boolean { + const parts = relativePath.split(pathSeparator) + return ( + (parts[0] === "docs" && parts[1] === "superpowers") || + parts.some( + (part) => + part === "node_modules" || + part === ".dawn" || + part === ".next" || + part === ".turbo" || + part === "dist", + ) || + parts.at(-1)?.endsWith(".tsbuildinfo") === true + ) +} + +function currentDocumentationFiles(path: string): string[] { + if (!existsSync(path)) return [] + + const relativePath = relative(repoRoot, path) + if (isExcludedDocumentationPath(relativePath)) { + return [] + } + const stat = lstatSync(path) + if (stat.isSymbolicLink()) return [] + if (stat.isFile()) { + return /changelog/i.test(path) || !scannedTextExtensions.has(extname(path).toLowerCase()) + ? [] + : [path] + } + if (!stat.isDirectory()) return [] + + return readdirSync(path).flatMap((entry) => currentDocumentationFiles(join(path, entry))) +} + +describe("current AG-UI documentation", () => { + it("tolerates an optional documentation root that has not been generated", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "dawn-docs-bundle-")) + try { + expect(currentDocumentationFiles(join(fixtureRoot, "missing"))).toEqual([]) + } finally { + rmSync(fixtureRoot, { recursive: true }) + } + }) + + it("excludes generated cache directories", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "dawn-docs-bundle-")) + const component = join(fixtureRoot, "component.tsx") + const notes = join(fixtureRoot, "notes.mdx") + const readme = join(fixtureRoot, "README.md") + try { + for (const directory of [".dawn", ".next", ".turbo", "dist", "node_modules"]) { + mkdirSync(join(fixtureRoot, directory)) + writeFileSync(join(fixtureRoot, directory, "generated.log"), "createAgUiTranslator") + } + writeFileSync(join(fixtureRoot, "tsconfig.tsbuildinfo"), "createAgUiTranslator") + writeFileSync(join(fixtureRoot, "custom-name.tsbuildinfo"), "createAgUiTranslator") + writeFileSync(join(fixtureRoot, "checkpoint.sqlite"), Buffer.from("\0createAgUiTranslator\0")) + writeFileSync(component, "export function Component() { return null }") + writeFileSync(notes, "# Current documentation") + writeFileSync(readme, "Current documentation") + + expect(currentDocumentationFiles(fixtureRoot).sort()).toEqual( + [component, notes, readme].sort(), + ) + } finally { + rmSync(fixtureRoot, { recursive: true }) + } + }) + + it("classifies Windows-style paths by platform separator", () => { + expect(isExcludedDocumentationPath("examples\\chat\\web\\.turbo\\build.log", "\\")).toBe(true) + expect(isExcludedDocumentationPath("docs\\superpowers\\plans\\task.md", "\\")).toBe(true) + expect(isExcludedDocumentationPath("examples\\chat\\README.md", "\\")).toBe(false) + }) + + it("does not follow symlinked directories", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "dawn-docs-bundle-")) + const scanRoot = join(fixtureRoot, "scan") + const targetRoot = join(fixtureRoot, "target") + const readme = join(scanRoot, "README.md") + try { + mkdirSync(scanRoot) + mkdirSync(targetRoot) + writeFileSync(join(targetRoot, "stale.md"), "createAgUiTranslator") + symlinkSync( + targetRoot, + join(scanRoot, "linked"), + process.platform === "win32" ? "junction" : "dir", + ) + writeFileSync(readme, "Current documentation") + + expect(currentDocumentationFiles(scanRoot)).toEqual([readme]) + } finally { + rmSync(fixtureRoot, { recursive: true }) + } + }) + + it("does not reference removed adapter APIs or example UI", () => { + const removed = [ + "createAgUiTranslator", + "mapRunInput", + 'CUSTOM{name:"on_interrupt"}', + "forwardedProps.command.resume", + "STATE_SNAPSHOT", + "dawn.subagent", + "useInterrupt", + "PermissionInterrupt", + "TodosPanel", + ] + const roots = [ + "packages/ag-ui/README.md", + "packages/cli/docs", + "apps/web/content/docs", + "examples/chat", + "examples/research", + ] + const staleReferences = roots.flatMap((root) => + currentDocumentationFiles(join(repoRoot, root)).flatMap((file) => { + const contents = readFileSync(file, "utf8") + return removed + .filter((term) => contents.includes(term)) + .map((term) => `${relative(repoRoot, file)}: ${term}`) + }), + ) + + expect(staleReferences).toEqual([]) + }) +}) + describe("parseFrontmatter()", () => { it("extracts title and description and strips the frontmatter block", () => { const raw = '---\ntitle: "Tools"\ndescription: Co-located tools\n---\n\nBody text.\n' diff --git a/packages/cli/test/pending-interrupts.test.ts b/packages/cli/test/pending-interrupts.test.ts new file mode 100644 index 00000000..8c4a61ad --- /dev/null +++ b/packages/cli/test/pending-interrupts.test.ts @@ -0,0 +1,305 @@ +import type { DawnResumeRequest } from "@dawn-ai/ag-ui" +import { + Annotation, + Command, + END, + interrupt, + MemorySaver, + START, + StateGraph, +} from "@langchain/langgraph" +import type { BaseCheckpointSaver, CheckpointTuple } from "@langchain/langgraph-checkpoint" +import { describe, expect, test, vi } from "vitest" + +import { + type PendingInterrupt, + type PendingInterruptSnapshot, + type PermissionDecision, + readPendingInterrupts, + resolveAgUiResume, +} from "../src/lib/dev/pending-interrupts.js" + +const TASK_UUID_1 = "33a12321-3ec2-56a7-b4d7-0337886c4386" +const TASK_UUID_2 = "44b23432-4fd3-67b8-c5e8-1448997d5497" +const RESUME_KEY_1 = "3336d0e0a2d4f198ef9aecd09cd7ac27" +const RESUME_KEY_2 = "4447e1f1b3e5a209fa0bfde10de8bd38" + +describe("resolveAgUiResume", () => { + test.each([ + undefined, + [], + ] as const)("starts a turn with no pending interrupts and resume %j", (resume) => { + expect(resolveAgUiResume(resume, snapshot([]))).toEqual({ ok: true, mode: "turn" }) + }) + + test.each([ + undefined, + [], + ] as const)("rejects resume %j when a checkpoint interrupt is pending", (resume) => { + expect(resolveAgUiResume(resume, snapshot([pending("perm-1", RESUME_KEY_1)]))).toMatchObject({ + code: "resume_required", + ok: false, + status: 409, + }) + }) + + test("rejects supplied resume entries when no checkpoint interrupt is pending", () => { + expect( + resolveAgUiResume([{ interruptId: "perm-1", status: "cancelled" }], snapshot([])), + ).toMatchObject({ code: "stale_interrupt", ok: false, status: 409 }) + }) + + test.each([ + "once", + "always", + "deny", + ])("preserves the resolved %s decision under the outer resume key", (decision) => { + expect( + resolveAgUiResume( + [{ interruptId: "perm-1", payload: decision, status: "resolved" }], + snapshot([pending("perm-1", RESUME_KEY_1)]), + ), + ).toEqual({ ok: true, mode: "resume", resume: { [RESUME_KEY_1]: decision } }) + }) + + test("maps a cancelled entry to deny", () => { + expect( + resolveAgUiResume( + [{ interruptId: "perm-1", status: "cancelled" }], + snapshot([pending("perm-1", RESUME_KEY_1)]), + ), + ).toEqual({ ok: true, mode: "resume", resume: { [RESUME_KEY_1]: "deny" } }) + }) + + test("maps two exact pending entries by outer resume key", () => { + expect( + resolveAgUiResume( + [ + { interruptId: "perm-1", payload: "always", status: "resolved" }, + { interruptId: "perm-2", status: "cancelled" }, + ], + snapshot([pending("perm-1", RESUME_KEY_1), pending("perm-2", RESUME_KEY_2)]), + ), + ).toEqual({ + ok: true, + mode: "resume", + resume: { [RESUME_KEY_1]: "always", [RESUME_KEY_2]: "deny" }, + }) + }) + + test.each([ + { + name: "missing pending ID", + pending: [pending("perm-1", RESUME_KEY_1), pending("perm-2", RESUME_KEY_2)], + resume: [{ interruptId: "perm-1", status: "cancelled" }], + }, + { + name: "unknown ID", + pending: [pending("perm-1", RESUME_KEY_1)], + resume: [{ interruptId: "perm-unknown", status: "cancelled" }], + }, + { + name: "duplicate resume ID", + pending: [pending("perm-1", RESUME_KEY_1)], + resume: [ + { interruptId: "perm-1", status: "cancelled" }, + { interruptId: "perm-1", status: "cancelled" }, + ], + }, + ] satisfies ReadonlyArray<{ + name: string + pending: PendingInterrupt[] + resume: DawnResumeRequest[] + }>)("rejects an inexact resume set: $name", ({ pending: entries, resume }) => { + expect(resolveAgUiResume(resume, snapshot(entries))).toMatchObject({ + code: "interrupt_set_mismatch", + ok: false, + status: 409, + }) + }) + + test.each([ + { interruptId: "perm-1", status: "resolved" }, + { interruptId: "perm-1", payload: "sometimes", status: "resolved" }, + { interruptId: "perm-1", payload: { decision: "once" }, status: "resolved" }, + ] satisfies DawnResumeRequest[])("rejects a resolved entry with missing or unsupported payload: %j", (entry) => { + expect(resolveAgUiResume([entry], snapshot([pending("perm-1", RESUME_KEY_1)]))).toMatchObject({ + code: "invalid_resume_payload", + ok: false, + status: 400, + }) + }) + + test.each([ + undefined, + [{ interruptId: "perm-1", payload: "once", status: "resolved" }], + ] satisfies ReadonlyArray< + readonly DawnResumeRequest[] | undefined + >)("rejects malformed checkpoint state before starting or resuming: %j", (resume) => { + expect( + resolveAgUiResume(resume, { + interrupts: [pending("perm-1", RESUME_KEY_1)], + malformed: true, + }), + ).toMatchObject({ code: "malformed_checkpoint", ok: false, status: 409 }) + }) +}) + +describe("readPendingInterrupts", () => { + test("returns null when the checkpoint tuple does not exist", async () => { + const checkpointer = fakeCheckpointer(undefined) + + await expect(readPendingInterrupts(checkpointer, "thread-1")).resolves.toBeNull() + expect(checkpointer.getTuple).toHaveBeenCalledWith({ + configurable: { checkpoint_ns: "", thread_id: "thread-1" }, + }) + }) + + test("uses the inner client ID, outer resume key, and both AP aliases", async () => { + const checkpointer = fakeCheckpointer([ + [TASK_UUID_1, "__interrupt__", { id: RESUME_KEY_1, value: { interruptId: "perm-1" } }], + [TASK_UUID_2, "__interrupt__", { id: RESUME_KEY_2, value: { kind: "permission" } }], + ["55c34543-50e4-78c9-d6f9-2559008e6508", "messages", { id: "not-an-interrupt" }], + ]) + + await expect(readPendingInterrupts(checkpointer, "thread-2")).resolves.toEqual({ + interrupts: [ + { + aliases: ["perm-1", RESUME_KEY_1], + interruptId: "perm-1", + resumeKey: RESUME_KEY_1, + }, + { + aliases: [RESUME_KEY_2], + interruptId: RESUME_KEY_2, + resumeKey: RESUME_KEY_2, + }, + ], + malformed: false, + }) + }) + + test("retains AP aliases but marks an invalid outer resume key malformed", async () => { + const snapshot = await readPendingInterrupts( + fakeCheckpointer([ + [TASK_UUID_1, "__interrupt__", { id: "outer-ap-id", value: { interruptId: "perm-1" } }], + ]), + "thread-3", + ) + + expect(snapshot).toEqual({ + interrupts: [ + { + aliases: ["perm-1", "outer-ap-id"], + interruptId: "perm-1", + resumeKey: null, + }, + ], + malformed: true, + }) + expect(resolveAgUiResume(undefined, requireSnapshot(snapshot))).toMatchObject({ + code: "malformed_checkpoint", + ok: false, + status: 409, + }) + }) + + test("marks malformed interrupt writes and blocks a subset resume", async () => { + const snapshot = await readPendingInterrupts( + fakeCheckpointer([ + [TASK_UUID_1, "__interrupt__", { id: RESUME_KEY_1, value: { interruptId: "perm-1" } }], + [TASK_UUID_2, "__interrupt__", null], + ]), + "thread-4", + ) + + expect(snapshot).toMatchObject({ malformed: true }) + expect( + resolveAgUiResume( + [{ interruptId: "perm-1", payload: "once", status: "resolved" }], + requireSnapshot(snapshot), + ), + ).toMatchObject({ code: "malformed_checkpoint", ok: false, status: 409 }) + }) + + test.each([ + { + name: "duplicate outer resume keys", + writes: [ + [TASK_UUID_1, "__interrupt__", { id: RESUME_KEY_1, value: { interruptId: "perm-1" } }], + [TASK_UUID_2, "__interrupt__", { id: RESUME_KEY_1, value: { interruptId: "perm-2" } }], + ], + }, + { + name: "duplicate client interrupt IDs", + writes: [ + [TASK_UUID_1, "__interrupt__", { id: RESUME_KEY_1, value: { interruptId: "perm-1" } }], + [TASK_UUID_2, "__interrupt__", { id: RESUME_KEY_2, value: { interruptId: "perm-1" } }], + ], + }, + ])("rejects $name as malformed checkpoint state", async ({ writes }) => { + const snapshot = await readPendingInterrupts(fakeCheckpointer(writes), "thread-5") + + expect(snapshot).toMatchObject({ malformed: true }) + expect(resolveAgUiResume(undefined, requireSnapshot(snapshot))).toMatchObject({ + code: "malformed_checkpoint", + ok: false, + status: 409, + }) + }) +}) + +test("resumes a real LangGraph interrupt with the parsed outer resume key", async () => { + const State = Annotation.Root({ answer: Annotation() }) + const checkpointer = new MemorySaver() + const graph = new StateGraph(State) + .addNode("permission", () => ({ + answer: interrupt<{ interruptId: string }, PermissionDecision>({ interruptId: "perm-real" }), + })) + .addEdge(START, "permission") + .addEdge("permission", END) + .compile({ checkpointer }) + const config = { configurable: { checkpoint_ns: "", thread_id: "thread-real" } } + + await graph.invoke({}, config) + const tuple = await checkpointer.getTuple(config) + const interruptWrite = tuple?.pendingWrites?.find(([, channel]) => channel === "__interrupt__") + expect(interruptWrite?.[0]).toMatch(/^[0-9a-f-]{36}$/) + + const snapshot = await readPendingInterrupts(checkpointer, "thread-real") + const resolution = resolveAgUiResume( + [{ interruptId: "perm-real", payload: "once", status: "resolved" }], + requireSnapshot(snapshot), + ) + expect(resolution).toMatchObject({ ok: true, mode: "resume" }) + if (!resolution.ok || resolution.mode !== "resume") throw new Error("Expected resume resolution") + expect(Object.keys(resolution.resume)).toEqual([expect.stringMatching(/^[0-9a-f]{32}$/)]) + expect(Object.keys(resolution.resume)).not.toContain(interruptWrite?.[0]) + + await expect(graph.invoke(new Command({ resume: resolution.resume }), config)).resolves.toEqual({ + answer: "once", + }) +}) + +function pending(interruptId: string, resumeKey: string): PendingInterrupt { + return { aliases: [interruptId, resumeKey], interruptId, resumeKey } +} + +function snapshot(interrupts: readonly PendingInterrupt[]): PendingInterruptSnapshot { + return { interrupts, malformed: false } +} + +function requireSnapshot(value: PendingInterruptSnapshot | null): PendingInterruptSnapshot { + if (!value) throw new Error("Expected checkpoint snapshot") + return value +} + +function fakeCheckpointer(pendingWrites: readonly unknown[] | undefined) { + const tuple = + pendingWrites === undefined ? undefined : ({ pendingWrites } as unknown as CheckpointTuple) + return { + getTuple: vi.fn(async () => tuple), + } as unknown as BaseCheckpointSaver & { + getTuple: ReturnType + } +} diff --git a/packages/cli/test/request-context.test.ts b/packages/cli/test/request-context.test.ts new file mode 100644 index 00000000..f5ee88fa --- /dev/null +++ b/packages/cli/test/request-context.test.ts @@ -0,0 +1,35 @@ +import type { IncomingMessage } from "node:http" +import { describe, expect, test } from "vitest" +import { extractRouteParams, parseHeaders } from "../src/lib/dev/request-context.js" + +describe("parseHeaders", () => { + test("preserves string headers", () => { + const request = { + headers: { authorization: "Bearer token" }, + } as unknown as IncomingMessage + + expect(parseHeaders(request)).toEqual({ authorization: "Bearer token" }) + }) + + test("joins array headers", () => { + const request = { + headers: { "x-scope": ["read", "write"] }, + } as unknown as IncomingMessage + + expect(parseHeaders(request)).toEqual({ "x-scope": "read, write" }) + }) + + test("omits undefined headers", () => { + const request = { + headers: { authorization: undefined, "x-request-id": "request-1" }, + } as unknown as IncomingMessage + + expect(parseHeaders(request)).toEqual({ "x-request-id": "request-1" }) + }) +}) + +describe("extractRouteParams", () => { + test("extracts named route parameters as strings", () => { + expect(extractRouteParams("/users/[userId]", { userId: 42 })).toEqual({ userId: "42" }) + }) +}) diff --git a/packages/cli/test/resume-endpoint.test.ts b/packages/cli/test/resume-endpoint.test.ts index ad0ea599..a48e5e25 100644 --- a/packages/cli/test/resume-endpoint.test.ts +++ b/packages/cli/test/resume-endpoint.test.ts @@ -19,6 +19,115 @@ afterEach(async () => { }) describe("POST /threads/:thread_id/resume", () => { + test.each([ + { + name: "a malformed interrupt write", + pendingWrites: [ + [ + "33a12321-3ec2-56a7-b4d7-0337886c4386", + "__interrupt__", + { + id: "3336d0e0a2d4f198ef9aecd09cd7ac27", + value: { interruptId: "perm-1" }, + }, + ], + ["44b23432-4fd3-67b8-c5e8-1448997d5497", "__interrupt__", null], + ], + }, + { + name: "duplicate checkpoint resume keys", + pendingWrites: [ + [ + "33a12321-3ec2-56a7-b4d7-0337886c4386", + "__interrupt__", + { + id: "3336d0e0a2d4f198ef9aecd09cd7ac27", + value: { interruptId: "perm-1" }, + }, + ], + [ + "44b23432-4fd3-67b8-c5e8-1448997d5497", + "__interrupt__", + { + id: "3336d0e0a2d4f198ef9aecd09cd7ac27", + value: { interruptId: "perm-2" }, + }, + ], + ], + }, + ])("returns structured 409 for $name even when an alias matches", async ({ pendingWrites }) => { + const appRoot = await createCheckpointFixtureApp(pendingWrites) + const server = await startRuntimeServer({ appRoot }) + servers.push(server) + const threadId = `thread-malformed-${tempDirs.length}` + + const seedResponse = await fetch(new URL(`/threads/${threadId}/runs/wait`, server.url), { + body: JSON.stringify({ input: {}, route: "/noop#graph" }), + headers: { "content-type": "application/json" }, + method: "POST", + }) + expect(seedResponse.status).toBe(200) + + const response = await fetch(new URL(`/threads/${threadId}/resume`, server.url), { + body: JSON.stringify({ decision: "once", interrupt_id: "perm-1" }), + headers: { "content-type": "application/json" }, + method: "POST", + }) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ + error: { + details: { code: "malformed_checkpoint" }, + kind: "request_error", + }, + }) + }) + + test.each([ + ["inner capability ID", "perm-1", 200], + ["outer interrupt ID", "3336d0e0a2d4f198ef9aecd09cd7ac27", 200], + ["unmatched ID", "perm-stale", 409], + ])("handles the %s as an AP interrupt_id when both IDs exist", async (_label, interruptId, status) => { + const appRoot = await createFixtureApp({ + "dawn.config.ts": ` + export default { + checkpointer: { + getTuple: async () => ({ + pendingWrites: [[ + "33a12321-3ec2-56a7-b4d7-0337886c4386", + "__interrupt__", + { + id: "3336d0e0a2d4f198ef9aecd09cd7ac27", + value: { interruptId: "perm-1" }, + }, + ]], + }), + }, + }; + `, + "package.json": "{}\n", + "src/app/noop/index.ts": "export const graph = async () => ({ ok: true });\n", + }) + const server = await startRuntimeServer({ appRoot }) + servers.push(server) + const threadId = `thread-${interruptId}` + + const seedResponse = await fetch(new URL(`/threads/${threadId}/runs/wait`, server.url), { + body: JSON.stringify({ input: {}, route: "/noop#graph" }), + headers: { "content-type": "application/json" }, + method: "POST", + }) + expect(seedResponse.status).toBe(200) + + const response = await fetch(new URL(`/threads/${threadId}/resume`, server.url), { + body: JSON.stringify({ decision: "once", interrupt_id: interruptId }), + headers: { "content-type": "application/json" }, + method: "POST", + }) + + expect(response.status).toBe(status) + }) + test("returns 400 when interrupt_id is missing from body", async () => { const appRoot = await createFixtureApp({ "dawn.config.ts": "export default {};\n", @@ -139,3 +248,17 @@ async function createFixtureApp(files: Readonly>) { return appRoot } + +async function createCheckpointFixtureApp(pendingWrites: readonly unknown[]) { + return createFixtureApp({ + "dawn.config.ts": ` + export default { + checkpointer: { + getTuple: async () => ({ pendingWrites: ${JSON.stringify(pendingWrites)} }), + }, + }; + `, + "package.json": "{}\n", + "src/app/noop/index.ts": "export const graph = async () => ({ ok: true });\n", + }) +} diff --git a/packages/cli/test/run-command.test.ts b/packages/cli/test/run-command.test.ts index 9ce6dfd8..d0dbd7b2 100644 --- a/packages/cli/test/run-command.test.ts +++ b/packages/cli/test/run-command.test.ts @@ -3,10 +3,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import type { AddressInfo } from "node:net" import { tmpdir } from "node:os" import { dirname, join } from "node:path" - +import { Command } from "@dawn-ai/langchain" import { afterEach, describe, expect, test } from "vitest" - +import { createAimock, script } from "../../testing/dist/index.js" import { run } from "../src/index.js" +import { streamResolvedRoute, toAgentInput } from "../src/lib/runtime/execute-route.js" import { executeRouteServer } from "../src/lib/runtime/execute-route-server.js" const tempDirs: string[] = [] @@ -18,6 +19,171 @@ afterEach(async () => { }) describe("dawn run", () => { + test("streamResolvedRoute rejects when route preparation fails", async () => { + const appRoot = await createFixtureApp({ + "package.json": "{}\n", + "dawn.config.ts": "export default {};\n", + "src/app/invalid/index.ts": `import { agent } from "@dawn-ai/sdk" +export default agent({ + model: "gpt-5-mini", + tools: { allow: ["missing-tool"] }, +}) +`, + }) + + await expect(async () => { + for await (const _chunk of streamResolvedRoute({ + appRoot, + input: {}, + routeFile: join(appRoot, "src/app/invalid/index.ts"), + routeId: "/invalid#agent", + routePath: "src/app/invalid/index.ts", + })) { + // Consume the stream so preparation errors surface to the caller. + } + }).rejects.toThrow(/route|load|resolve/i) + }) + + test("toAgentInput preserves an addressed resume map", () => { + const input = { messages: [] } + const resume = { + "interrupt-a": "once", + "interrupt-b": "deny", + } as const + + const result = toAgentInput(input, resume) + + expect(result).toBeInstanceOf(Command) + expect((result as Command).resume).toBe(resume) + expect(toAgentInput(input)).toBe(input) + + const emptyResume = {} + const emptyResult = toAgentInput(input, emptyResume) + expect(emptyResult).toBeInstanceOf(Command) + expect((emptyResult as Command).resume).toBe(emptyResume) + }) + + test("executes local agent routes with a generated one-shot thread id", async () => { + const appRoot = await createFixtureApp({ + "package.json": "{}\n", + "dawn.config.ts": "export default {};\n", + "src/app/hello/[tenant]/index.ts": `import { agent } from "@dawn-ai/sdk" +export default agent({ + model: "gpt-5-mini", + systemPrompt: "You are a helpful assistant for {tenant}.", +}) +`, + }) + const mock = await createAimock({ + fixtures: script().user("Say hello").replies("Hello from Acme.").build(), + }) + const prevBaseUrl = process.env.OPENAI_BASE_URL + const prevApiKey = process.env.OPENAI_API_KEY + process.env.OPENAI_BASE_URL = mock.baseUrl + process.env.OPENAI_API_KEY = "test-not-used" + + try { + const result = await invoke(["run", "/hello/[tenant]", "--cwd", appRoot], { + stdin: JSON.stringify({ + messages: [{ role: "user", content: "Say hello" }], + tenant: "acme", + }), + }) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe("") + const payload = JSON.parse(result.stdout) as Record + + expectTiming(payload) + expect(payload).toMatchObject({ + appRoot, + executionSource: "in-process", + mode: "agent", + routeId: "/hello/[tenant]", + routePath: "src/app/hello/[tenant]/index.ts", + status: "passed", + }) + expect(JSON.stringify(payload.output)).toContain("Hello from Acme.") + } finally { + await mock.close() + if (prevBaseUrl === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = prevBaseUrl + if (prevApiKey === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = prevApiKey + } + }) + + test("streamResolvedRoute preserves upstream tool invocation ids", async () => { + const appRoot = await createFixtureApp({ + "package.json": "{}\n", + "dawn.config.ts": "export default {};\n", + "src/app/hello/[tenant]/index.ts": `import { agent } from "@dawn-ai/sdk" +export default agent({ + model: "gpt-5-mini", + systemPrompt: "You are a helpful assistant for {tenant}.", +}) +`, + "src/app/hello/[tenant]/tools/lookup.ts": `export default { + name: "lookup", + description: "Look up a tenant fact", + run: async (input: { query?: string }) => ({ answer: \`found:\${input.query ?? ""}\` }), +} +`, + }) + const mock = await createAimock({ + fixtures: script() + .user("Lookup docs") + .callsTool("lookup", { query: "pricing" }) + .replies("Pricing found.") + .build(), + }) + const prevBaseUrl = process.env.OPENAI_BASE_URL + const prevApiKey = process.env.OPENAI_API_KEY + process.env.OPENAI_BASE_URL = mock.baseUrl + process.env.OPENAI_API_KEY = "test-not-used" + + try { + const chunks = [] + for await (const chunk of streamResolvedRoute({ + appRoot, + input: { + messages: [{ role: "user", content: "Lookup docs" }], + tenant: "acme", + }, + routeFile: join(appRoot, "src/app/hello/[tenant]/index.ts"), + routeId: "/hello/[tenant]#agent", + routePath: "src/app/hello/[tenant]/index.ts", + threadId: "t-tool-id", + })) { + chunks.push(chunk) + } + + const toolCall = chunks.find((chunk) => chunk.type === "tool_call") as + | { readonly id?: string; readonly name: string; readonly type: "tool_call" } + | undefined + const toolResult = chunks.find((chunk) => chunk.type === "tool_result") as + | { readonly id?: string; readonly name: string; readonly type: "tool_result" } + | undefined + + expect(toolCall).toMatchObject({ + id: expect.any(String), + name: "lookup", + type: "tool_call", + }) + expect(toolResult).toMatchObject({ + id: toolCall?.id, + name: "lookup", + type: "tool_result", + }) + } finally { + await mock.close() + if (prevBaseUrl === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = prevBaseUrl + if (prevApiKey === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = prevApiKey + } + }) + test("executes the route directory's index.ts and exposes shared and route-local tools through ctx.tools", async () => { const appRoot = await createFixtureApp({ "package.json": "{}\n", diff --git a/packages/cli/test/serve-runtime.test.ts b/packages/cli/test/serve-runtime.test.ts index ae613737..67c51d18 100644 --- a/packages/cli/test/serve-runtime.test.ts +++ b/packages/cli/test/serve-runtime.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, test } from "vitest" +import { createAimock, script } from "../../testing/dist/index.js" import { resolveServePort, serveRuntime } from "../src/lib/dev/serve-runtime.js" @@ -109,6 +110,59 @@ describe("serveRuntime", () => { // close() must be safe to call again (idempotent). await handle.close() }) + + test("serves canonical AG-UI events through production assembly", async () => { + const aimock = await createAimock({ fixtures: [] }) + aimock.addFixtures(script().user("hello production").replies("Hello from serveRuntime").build()) + const previousBaseUrl = process.env.OPENAI_BASE_URL + const previousKey = process.env.OPENAI_API_KEY + process.env.OPENAI_BASE_URL = aimock.baseUrl + process.env.OPENAI_API_KEY = "test-not-used" + const appRoot = await createFixtureApp({ + "dawn.config.ts": "export default {};\n", + "package.json": '{ "type": "module" }\n', + "src/app/chat/index.ts": + 'import { agent } from "@dawn-ai/sdk";\nexport default agent({ model: "gpt-5-mini", systemPrompt: "You are helpful." });\n', + }) + + try { + const handle = await serveRuntime({ + appRoot, + host: "127.0.0.1", + installSignalHandlers: false, + port: 0, + }) + handles.push(handle) + const response = await fetch(new URL("/agui/%2Fchat%23agent", handle.url), { + method: "POST", + headers: { "content-type": "application/json", accept: "text/event-stream" }, + body: JSON.stringify({ + context: [], + forwardedProps: {}, + messages: [{ id: "1", role: "user", content: "hello production" }], + runId: "production-run", + state: {}, + threadId: "production-thread", + tools: [], + }), + }) + const text = await response.text() + + expect(response.status).toBe(200) + expect(text).toContain('"type":"RUN_STARTED"') + expect(text).toContain('"type":"TEXT_MESSAGE_CONTENT"') + expect(text).toContain('"delta":"Hello from serveRuntime"') + expect(text).toContain('"type":"RUN_FINISHED"') + expect(text).not.toContain('"type":"CUSTOM"') + expect(text).not.toContain('"type":"STATE_SNAPSHOT"') + } finally { + await aimock.close() + if (previousBaseUrl === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = previousBaseUrl + if (previousKey === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = previousKey + } + }, 60_000) }) describe("resolveServePort", () => { diff --git a/packages/cli/test/stream-types.test.ts b/packages/cli/test/stream-types.test.ts index e2f3e193..f81cd382 100644 --- a/packages/cli/test/stream-types.test.ts +++ b/packages/cli/test/stream-types.test.ts @@ -24,6 +24,28 @@ describe("toSseEvent", () => { ) }) + it("preserves optional tool invocation ids in tool payloads", () => { + const call: StreamChunk = { + type: "tool_call", + id: "run-abc", + name: "listDir", + input: { path: "." }, + } + const result: StreamChunk = { + type: "tool_result", + id: "run-abc", + name: "listDir", + output: ["README.md"], + } + + expect(toSseEvent(call)).toBe( + `event: tool_call\ndata: ${JSON.stringify({ id: "run-abc", name: "listDir", input: { path: "." } })}\n\n`, + ) + expect(toSseEvent(result)).toBe( + `event: tool_result\ndata: ${JSON.stringify({ id: "run-abc", name: "listDir", output: ["README.md"] })}\n\n`, + ) + }) + it("emits a done event with output as the payload", () => { const chunk: StreamChunk = { type: "done", output: { final: true } } expect(toSseEvent(chunk)).toBe( diff --git a/packages/langchain/src/agent-adapter.ts b/packages/langchain/src/agent-adapter.ts index afcbe210..93aa77b6 100644 --- a/packages/langchain/src/agent-adapter.ts +++ b/packages/langchain/src/agent-adapter.ts @@ -548,6 +548,7 @@ async function* streamFromRunnable( options: Record, ) => AsyncIterable<{ event: string + run_id: string data: { chunk?: unknown; input?: unknown; output?: unknown; error?: unknown } name: string }> @@ -620,6 +621,10 @@ async function* streamFromRunnable( yield { type: "tool_call" as const, data: { + // LangGraph assigns the same run_id to on_tool_start and + // on_tool_end of one invocation — a stable correlator that + // survives repeated calls to the same tool. + id: event.run_id, name: event.name, input: event.data.input ?? event.data.chunk ?? event.data.output, }, @@ -630,7 +635,7 @@ async function* streamFromRunnable( hasYielded = true yield { type: "tool_result" as const, - data: { name: event.name, output: event.data.output }, + data: { id: event.run_id, name: event.name, output: event.data.output }, } for (const transformer of streamTransformers ?? []) { if (transformer.observes !== "tool_result") continue diff --git a/packages/langchain/test/agent-adapter-toolcall-id.test.ts b/packages/langchain/test/agent-adapter-toolcall-id.test.ts new file mode 100644 index 00000000..6c863142 --- /dev/null +++ b/packages/langchain/test/agent-adapter-toolcall-id.test.ts @@ -0,0 +1,48 @@ +import { MemorySaver } from "@langchain/langgraph" +import { describe, expect, test } from "vitest" +import { streamAgent } from "../src/agent-adapter.js" + +describe("streamAgent — tool-call id correlation", () => { + test("tool_call and tool_result chunks carry the invocation run_id as data.id", async () => { + const mockRunnable = { + invoke: async () => ({}), + streamEvents: async function* (_input: unknown, _options: Record) { + yield { + event: "on_tool_start", + name: "greet", + run_id: "run-xyz", + data: { input: { name: "World" } }, + } + yield { + event: "on_tool_end", + name: "greet", + run_id: "run-xyz", + data: { output: { greeting: "Hello, World!" } }, + } + yield { event: "on_chain_end", name: "LangGraph", data: { output: { messages: [] } } } + }, + } + + const chunks: Array<{ type: string; data: unknown }> = [] + for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), + entry: mockRunnable, + input: { messages: [{ role: "user", content: "greet" }] }, + routeParamNames: [], + signal: new AbortController().signal, + tools: [], + })) { + chunks.push({ type: chunk.type, data: chunk.data }) + } + + const call = chunks.find((c) => c.type === "tool_call") + const result = chunks.find((c) => c.type === "tool_result") + if (!call || !result) throw new Error("Expected correlated tool call and result chunks") + const callId = (call.data as { id?: string }).id + const resultId = (result.data as { id?: string }).id + expect(callId).toBe("run-xyz") + expect(resultId).toBe("run-xyz") + // start and end of the same invocation share the id + expect(callId).toBe(resultId) + }) +}) diff --git a/packages/testing/src/harness.ts b/packages/testing/src/harness.ts index ec60b5b2..065a8688 100644 --- a/packages/testing/src/harness.ts +++ b/packages/testing/src/harness.ts @@ -166,9 +166,7 @@ export async function createAgentHarness(options: AgentHarnessOptions): Promise< routePath: r.routePath, threadId, ...(sandboxManager ? { sandboxManager } : {}), - ...(driveOpts.resumeDecision !== undefined - ? { resumeDecision: driveOpts.resumeDecision } - : {}), + ...(driveOpts.resumeDecision !== undefined ? { resume: driveOpts.resumeDecision } : {}), } const stream = streamResolvedRoute(streamArgs) const result = await collectRunResult(stream, threadId) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2044fde3..814a931d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ importers: '@langchain/core': specifier: 1.2.1 version: 1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/langgraph': + specifier: 1.4.7 + version: 1.4.7(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) '@langchain/langgraph-checkpoint': specifier: ^1.1.3 version: 1.1.3(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 11725526..66ad554b 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -250,7 +250,7 @@ if (runtimeServerSource.includes("/agui/:routeId")) { "POST /agui/{routeId}", "%2Fchat%23agent", "@dawn-ai/ag-ui", - "forwardedProps.command.resume", + "RunAgentInput.resume", ]) { if (!devServerDocs.includes(required)) { failures.push( diff --git a/scripts/lib/pack-check.mjs b/scripts/lib/pack-check.mjs index 5adcfc77..d85922a0 100644 --- a/scripts/lib/pack-check.mjs +++ b/scripts/lib/pack-check.mjs @@ -1,5 +1,7 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs" -import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs" +import { isAbsolute, join, relative, resolve, sep } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" const standardRequiredFields = [ "publishConfig.access", @@ -147,7 +149,35 @@ export const packages = [ }, { dir: "packages/ag-ui", - expectedFiles: ["dist/index.js", "dist/index.d.ts", "README.md", "package.json"], + expectedFiles: [ + "dist/ids.js", + "dist/ids.d.ts", + "dist/inbound.js", + "dist/inbound.d.ts", + "dist/index.js", + "dist/index.d.ts", + "dist/interrupts.js", + "dist/interrupts.d.ts", + "dist/outbound.js", + "dist/outbound.d.ts", + "dist/sse.js", + "dist/sse.d.ts", + "dist/types.js", + "dist/types.d.ts", + "README.md", + "package.json", + ], + forbiddenFiles: ["dist/encode.*", "dist/run-input.*", "dist/translate.*"], + expectedExports: { + ".": { + types: "./dist/index.d.ts", + default: "./dist/index.js", + }, + "./sse": { + types: "./dist/sse.d.ts", + default: "./dist/sse.js", + }, + }, requiredFields: libraryRequiredFields, }, { @@ -228,6 +258,225 @@ export function validatePackManifest(repoRoot, manifest) { } } +export function missingExportTargets(packedRoot, exportsField) { + const missingTargets = relativeExportTargets(exportsField) + .filter(({ exportKey, target }) => !exportTargetHasPackedFile(packedRoot, exportKey, target)) + .map(({ target }) => target) + + return [...new Set(missingTargets)] +} + +export function expectedExportFailures(exportsField, expectedExports = {}) { + const failures = [] + + for (const [exportKey, expectedMapping] of Object.entries(expectedExports)) { + if ( + !exportsField || + typeof exportsField !== "object" || + !Object.hasOwn(exportsField, exportKey) + ) { + failures.push(`missing required export "${exportKey}"`) + continue + } + + if (JSON.stringify(exportsField[exportKey]) !== JSON.stringify(expectedMapping)) { + failures.push(`export "${exportKey}" does not match required mapping`) + } + } + + return failures +} + +export function forbiddenPackedFiles(packedRoot, forbiddenFiles = []) { + const patterns = forbiddenFiles.map( + (pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join("[^/]*")}$`), + ) + + return packedRegularFiles(packedRoot) + .filter((relativePath) => patterns.some((pattern) => pattern.test(relativePath))) + .sort() +} + +function relativeExportTargets(value) { + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).some((key) => key.startsWith(".")) + ) { + return Object.entries(value).flatMap(([exportKey, mapping]) => + relativeExportTargetsForKey(exportKey, mapping), + ) + } + + return relativeExportTargetsForKey(".", value) +} + +function relativeExportTargetsForKey(exportKey, value) { + if (typeof value === "string") { + return value.startsWith("./") ? [{ exportKey, target: value }] : [] + } + + if (Array.isArray(value)) { + return value.flatMap((entry) => relativeExportTargetsForKey(exportKey, entry)) + } + + if (value && typeof value === "object") { + return Object.values(value).flatMap((entry) => relativeExportTargetsForKey(exportKey, entry)) + } + + return [] +} + +function exportTargetHasPackedFile(packedRoot, exportKey, target) { + const targetPath = exportTargetPath(target) + if (!validExportKey(exportKey) || !validPackagePath(targetPath)) { + return false + } + + const resolvedTarget = resolveExportTarget(packedRoot, target, targetPath) + if (!resolvedTarget) { + return false + } + + if (targetPath.includes("*")) { + return exportPatternHasPackedFile(packedRoot, exportKey, resolvedTarget.patternParts) + } + + try { + return lstatSync(resolvedTarget.filePath).isFile() + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + return false + } + throw error + } +} + +function exportTargetPath(target) { + const suffixIndex = target.search(/[?#]/) + return suffixIndex === -1 ? target : target.slice(0, suffixIndex) +} + +function exportPatternHasPackedFile(packedRoot, exportKey, patternParts) { + if (wildcardCount(exportKey) !== 1 || !exportKey.startsWith("./")) { + return false + } + + const pattern = exportPatternRegExp(patternParts) + return packedRegularFiles(packedRoot).some((relativePath) => { + const match = pattern.exec(relativePath) + const subpath = match?.groups?.subpath + return ( + subpath && + validPackageSubpath(subpath) && + validPackagePath(exportKey.replaceAll("*", () => subpath)) + ) + }) +} + +function resolveExportTarget(packedRoot, target, targetPath) { + const wildcardSentinel = `__dawn_export_wildcard_${randomUUID()}__` + const sentinelTarget = `${targetPath.replaceAll("*", wildcardSentinel)}${target.slice(targetPath.length)}` + + try { + const rootPath = resolve(packedRoot) + const rootUrl = pathToFileURL(rootPath) + if (!rootUrl.pathname.endsWith("/")) { + rootUrl.pathname += "/" + } + + const filePath = fileURLToPath(new URL(sentinelTarget, rootUrl)) + const relativePath = relative(rootPath, filePath) + if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + return null + } + + const patternParts = relativePath.split(sep).join("/").split(wildcardSentinel) + if (patternParts.length !== wildcardCount(targetPath) + 1) { + return null + } + + return { filePath, patternParts } + } catch { + return null + } +} + +function wildcardCount(value) { + return [...value].filter((character) => character === "*").length +} + +function validExportKey(exportKey) { + if (exportKey === ".") { + return true + } + + const wildcards = wildcardCount(exportKey) + return ( + wildcards <= 1 && (wildcards === 1 || !exportKey.endsWith("/")) && validPackagePath(exportKey) + ) +} + +function validPackagePath(packagePath) { + if (!packagePath.startsWith("./")) { + return false + } + + return validPackageSubpath(packagePath.slice(2)) +} + +function validPackageSubpath(subpath) { + return subpath.split(/[\\/]/).every(validPackagePathSegment) +} + +function validPackagePathSegment(rawSegment) { + let segment + try { + segment = decodeURIComponent(rawSegment) + } catch { + return false + } + + if (segment.includes("/") || segment.includes("\\")) { + return false + } + + return segment !== "." && segment !== ".." && segment.toLowerCase() !== "node_modules" +} + +function exportPatternRegExp(parts) { + let source = escapeRegExp(parts[0]) + + if (parts.length > 1) { + source += `(?.+)${escapeRegExp(parts[1])}` + for (const part of parts.slice(2)) { + source += `\\k${escapeRegExp(part)}` + } + } + + return new RegExp(`^${source}$`) +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function packedRegularFiles(root, current = root, prefix = "") { + const files = [] + + for (const entry of readdirSync(current, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + files.push(...packedRegularFiles(root, join(current, entry.name), relativePath)) + } else if (entry.isFile()) { + files.push(relativePath) + } + } + + return files +} + function discoverPublicPackageDirs(repoRoot) { const packagesDir = join(repoRoot, "packages") diff --git a/scripts/lib/published-artifacts.mjs b/scripts/lib/published-artifacts.mjs index 83889776..551e6bd8 100644 --- a/scripts/lib/published-artifacts.mjs +++ b/scripts/lib/published-artifacts.mjs @@ -7,11 +7,20 @@ import { fileURLToPath } from "node:url" export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..") export const packageSets = { + "ag-ui": ["@dawn-ai/ag-ui"], "memory-pgvector-core": ["@dawn-ai/memory-pgvector", "@dawn-ai/memory", "@dawn-ai/langchain"], public: null, } const packageFileExpectations = { + "@dawn-ai/ag-ui": [ + "dist/index.js", + "dist/index.d.ts", + "dist/sse.js", + "dist/sse.d.ts", + "README.md", + "package.json", + ], "@dawn-ai/memory-pgvector": ["dist/index.js", "dist/index.d.ts", "README.md", "package.json"], "@dawn-ai/memory": ["dist/index.js", "dist/index.d.ts", "README.md", "package.json"], "@dawn-ai/langchain": ["dist/index.js", "dist/index.d.ts", "README.md", "package.json"], diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 63d116fb..f89ff222 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -4,7 +4,13 @@ import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import { packages, validatePackManifest } from "./lib/pack-check.mjs" +import { + expectedExportFailures, + forbiddenPackedFiles, + missingExportTargets, + packages, + validatePackManifest, +} from "./lib/pack-check.mjs" const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") validatePackManifest(repoRoot, packages) @@ -40,12 +46,31 @@ try { } } + for (const relativePath of forbiddenPackedFiles(packedRoot, packageConfig.forbiddenFiles)) { + failures.push( + `${sourcePackageJson.name}: packed tarball contains forbidden file ${relativePath}`, + ) + } + for (const fieldName of packageConfig.requiredFields) { if (readField(packedPackageJson, fieldName) === undefined) { failures.push(`${sourcePackageJson.name}: packed package.json is missing ${fieldName}`) } } + for (const exportTarget of missingExportTargets(packedRoot, packedPackageJson.exports)) { + failures.push( + `${sourcePackageJson.name}: packed package.json exports missing file ${exportTarget}`, + ) + } + + for (const exportFailure of expectedExportFailures( + packedPackageJson.exports, + packageConfig.expectedExports, + )) { + failures.push(`${sourcePackageJson.name}: packed package.json ${exportFailure}`) + } + for (const [dependencyField, dependencies] of Object.entries({ dependencies: packedPackageJson.dependencies, devDependencies: packedPackageJson.devDependencies, diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs index e5c246dd..073ba1d0 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -5,10 +5,28 @@ import { dirname, join, resolve } from "node:path" import { afterEach, describe, it } from "node:test" import { fileURLToPath } from "node:url" -import { packages, validatePackManifest } from "./lib/pack-check.mjs" +import * as packCheck from "./lib/pack-check.mjs" + +const { + expectedExportFailures, + forbiddenPackedFiles, + missingExportTargets, + packages, + validatePackManifest, +} = packCheck const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const tempRoots = [] +const agUiExpectedExports = { + ".": { + types: "./dist/index.d.ts", + default: "./dist/index.js", + }, + "./sse": { + types: "./dist/sse.d.ts", + default: "./dist/sse.js", + }, +} afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))) @@ -39,6 +57,30 @@ describe("pack manifest validation", () => { } }) + it("checks the AG-UI root and SSE entrypoints", () => { + const agUiPackage = packages.find(({ dir }) => dir === "packages/ag-ui") + + assert.ok(agUiPackage, "Pack manifest is missing packages/ag-ui") + for (const expectedFile of ["dist/sse.js", "dist/sse.d.ts"]) { + assert.ok( + agUiPackage.expectedFiles.includes(expectedFile), + `AG-UI must expect ${expectedFile}`, + ) + } + for (const requiredField of ["exports", "types"]) { + assert.ok( + agUiPackage.requiredFields.includes(requiredField), + `AG-UI must require ${requiredField}`, + ) + } + assert.deepEqual(agUiPackage.expectedExports, agUiExpectedExports) + assert.deepEqual(agUiPackage.forbiddenFiles, [ + "dist/encode.*", + "dist/run-input.*", + "dist/translate.*", + ]) + }) + it("checks the create app executable", () => { const createAppPackage = packages.find(({ dir }) => dir === "packages/create-dawn-app") @@ -110,6 +152,413 @@ describe("pack manifest validation", () => { }) }) +describe("forbiddenPackedFiles", () => { + it("reports packed files matching configured forbidden path patterns", async () => { + const packedRoot = await createPackedRoot([ + "dist/encode.d.ts", + "dist/encode.js", + "dist/index.js", + "dist/nested/encode.js", + "dist/run-input.js", + ]) + + assert.deepEqual(forbiddenPackedFiles(packedRoot, ["dist/encode.*", "dist/run-input.*"]), [ + "dist/encode.d.ts", + "dist/encode.js", + "dist/run-input.js", + ]) + }) + + it("allows manifests to omit forbidden file patterns", async () => { + const packedRoot = await createPackedRoot(["dist/encode.js"]) + + assert.deepEqual(forbiddenPackedFiles(packedRoot), []) + }) +}) + +describe("expectedExportFailures", () => { + it("accepts required export keys with exact mappings", () => { + assert.deepEqual( + expectedExportFailures( + { ...agUiExpectedExports, "./extra": "./dist/extra.js" }, + agUiExpectedExports, + ), + [], + ) + }) + + it("rejects a deleted required export", () => { + assert.deepEqual( + expectedExportFailures({ ".": agUiExpectedExports["."] }, agUiExpectedExports), + ['missing required export "./sse"'], + ) + }) + + it("rejects a wrong required export mapping", () => { + assert.deepEqual( + expectedExportFailures( + { + ...agUiExpectedExports, + "./sse": { ...agUiExpectedExports["./sse"], default: "./dist/index.js" }, + }, + agUiExpectedExports, + ), + ['export "./sse" does not match required mapping'], + ) + }) + + it("rejects reordered export conditions", () => { + const expected = { + "./conditional": { + node: "./dist/node.js", + default: "./dist/default.js", + }, + } + const reordered = { + "./conditional": { + default: "./dist/default.js", + node: "./dist/node.js", + }, + } + + assert.deepEqual(expectedExportFailures(reordered, expected), [ + 'export "./conditional" does not match required mapping', + ]) + }) +}) + +describe("missingExportTargets", () => { + it("finds missing relative targets in root, subpath, conditional, and array exports", async () => { + const packedRoot = await createPackedRoot(["dist/index.js", "dist/index.d.ts"]) + + assert.deepEqual( + missingExportTargets(packedRoot, { + ".": { + types: "./dist/index.d.ts", + default: "./dist/index.js", + }, + "./sse": [ + { types: "./dist/sse.d.ts" }, + { import: "./dist/sse.js", default: "@dawn-ai/fallback" }, + ], + }), + ["./dist/sse.d.ts", "./dist/sse.js"], + ) + }) + + it("ignores non-relative and builtin export targets", async () => { + const packedRoot = await createPackedRoot([]) + + assert.deepEqual( + missingExportTargets(packedRoot, { + ".": "node:fs", + "./bare": "some-package", + }), + [], + ) + }) + + it("requires relative wildcard targets to match a packed regular file", async () => { + const emptyRoot = await createPackedRoot([]) + const populatedRoot = await createPackedRoot(["dist/features/one.js"]) + const exportsField = { "./features/*": "./dist/features/*.js" } + + assert.deepEqual(missingExportTargets(emptyRoot, exportsField), ["./dist/features/*.js"]) + assert.deepEqual(missingExportTargets(populatedRoot, exportsField), []) + }) + + it("rejects wildcard targets with an empty subpath capture", async () => { + const packedRoot = await createPackedRoot(["dist/prepost.js"]) + const exportsField = { "./features/*": "./dist/pre*post.js" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./dist/pre*post.js"]) + }) + + it("rejects a wildcard capture that creates a dot export segment", async () => { + const packedRoot = await createPackedRoot(["dist/pre.post.js"]) + const exportsField = { "./features/*": "./dist/pre*post.js" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./dist/pre*post.js"]) + }) + + it("rejects a conditional wildcard capture that creates a dot-dot export segment", async () => { + const packedRoot = await createPackedRoot(["dist/pre..post.js"]) + const exportsField = { + "./features/*": { import: "./dist/pre*post.js" }, + } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./dist/pre*post.js"]) + }) + + it("rejects an array wildcard capture that creates a node_modules export segment", async () => { + const packedRoot = await createPackedRoot(["dist/preNoDe_MoDuLeSpost.js"]) + const exportsField = { + "./features/*": [{ import: "./dist/pre*post.js" }], + } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./dist/pre*post.js"]) + }) + + it("accepts a valid multi-segment wildcard capture", async () => { + const packedRoot = await createPackedRoot(["dist/prenested/childpost.js"]) + const exportsField = { + "./features/*": [{ import: "./dist/pre*post.js" }], + } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), []) + }) + + it("rejects an embedded wildcard capture equal to dot", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + const exportsField = { "./features/pre*post": "./src/index*ts" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./src/index*ts"]) + }) + + it("rejects an embedded wildcard capture equal to dot-dot", async () => { + const packedRoot = await createPackedRoot(["src/index..ts"]) + const exportsField = { "./features/pre*post": "./src/index*ts" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./src/index*ts"]) + }) + + it("rejects an embedded wildcard capture equal to node_modules", async () => { + const packedRoot = await createPackedRoot(["src/indexNoDe_MoDuLeSts"]) + const exportsField = { "./features/pre*post": "./src/index*ts" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), ["./src/index*ts"]) + }) + + it("accepts a valid embedded wildcard capture", async () => { + const packedRoot = await createPackedRoot(["src/indexvaluets"]) + const exportsField = { "./features/pre*post": "./src/index*ts" } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), []) + }) + + it("rejects wildcard targets paired with malformed export key patterns", async () => { + const packedRoot = await createPackedRoot(["dist/prevaluepost.js"]) + const target = "./dist/pre*post.js" + + assert.deepEqual(missingExportTargets(packedRoot, { "./features": target }), [target]) + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*/*": target }), [target]) + }) + + it("rejects a multi-wildcard export key paired with an exact target", async () => { + const packedRoot = await createPackedRoot(["src/index.js"]) + const target = "./src/index.js" + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*/*": target }), [target]) + }) + + it("accepts a trailing-slash wildcard export key paired with an exact target", async () => { + const packedRoot = await createPackedRoot(["src/index.js"]) + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*/": "./src/index.js" }), []) + }) + + it("deduplicates failures after validating each export key relationship", async () => { + const packedRoot = await createPackedRoot(["dist/prevaluepost.js"]) + const target = "./dist/pre*post.js" + const exportsField = { + "./features/*": [target, { import: target }], + "./features": target, + } + + assert.deepEqual(missingExportTargets(packedRoot, exportsField), [target]) + }) + + it("reuses the first wildcard capture for repeated target wildcards", async () => { + const mismatchedRoot = await createPackedRoot(["dist/a/b.js"]) + const matchedRoot = await createPackedRoot(["dist/a/a.js"]) + const exportsField = { "./features/*": "./dist/*/*.js" } + + assert.deepEqual(missingExportTargets(mismatchedRoot, exportsField), ["./dist/*/*.js"]) + assert.deepEqual(missingExportTargets(matchedRoot, exportsField), []) + }) + + it("disambiguates repeated wildcard captures from adjacent digits", async () => { + const mismatchedRoot = await createPackedRoot(["dist/a1b2.js"]) + const matchedRoot = await createPackedRoot(["dist/a1a2.js"]) + const exportsField = { "./features/*": "./dist/*1*2.js" } + + assert.deepEqual(missingExportTargets(mismatchedRoot, exportsField), ["./dist/*1*2.js"]) + assert.deepEqual(missingExportTargets(matchedRoot, exportsField), []) + }) + + it("rejects exact targets with literal or encoded dot segments", async () => { + const packedRoot = await createPackedRoot(["src/index.js", "src/%2e/index.js"]) + + for (const target of ["./src/./index.js", "./src/%2e/index.js"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), [target]) + } + }) + + it("rejects exact targets with literal or encoded dot-dot segments", async () => { + const packedRoot = await createPackedRoot(["src/index.js", "src/%2E%2E/src/index.js"]) + + for (const target of ["./src/../src/index.js", "./src/%2E%2E/src/index.js"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), [target]) + } + }) + + it("rejects exact targets with literal or encoded node_modules segments", async () => { + const packedRoot = await createPackedRoot([ + "src/NoDe_MoDuLeS/index.js", + "src/%6eode_modules/index.js", + ]) + + for (const target of ["./src/NoDe_MoDuLeS/index.js", "./src/%6eode_modules/index.js"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), [target]) + } + }) + + it("accepts a normal exact relative target", async () => { + const packedRoot = await createPackedRoot(["src/index.js"]) + + assert.deepEqual(missingExportTargets(packedRoot, { ".": "./src/index.js" }), []) + }) + + it("decodes a valid percent-encoded exact target before lookup", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + assert.deepEqual(missingExportTargets(packedRoot, { ".": "./src/%69ndex.ts" }), []) + }) + + it("decodes static pieces of a wildcard target before matching", async () => { + const packedRoot = await createPackedRoot(["src/index-one.ts"]) + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": "./src/%69ndex-*.ts" }), []) + }) + + it("normalizes raw backslashes in exact target paths", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + assert.deepEqual(missingExportTargets(packedRoot, { ".": "./src\\index.ts" }), []) + }) + + it("normalizes raw backslashes in wildcard target paths", async () => { + const packedRoot = await createPackedRoot(["src/index-one.ts"]) + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": "./src\\*.ts" }), []) + }) + + it("rejects percent-encoded backslashes in target paths", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + const target = "./src%5Cindex.ts" + + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), [target]) + }) + + it("applies URL tab, CR, and LF normalization to exact target paths", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + for (const target of ["./src/\tindex.ts", "./src/\rindex.ts", "./src/\nindex.ts"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), []) + } + }) + + it("applies URL trailing space and C0 control normalization to exact targets", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + for (const target of ["./src/index.ts ", "./src/index.ts\u001f"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), []) + } + }) + + it("applies URL whitespace normalization to wildcard target static pieces", async () => { + const packedRoot = await createPackedRoot(["src/index-one.ts"]) + + for (const target of ["./src/\tindex-*.ts", "./src/\rindex-*.ts", "./src/\nindex-*.ts"]) { + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": target }), []) + } + }) + + it("does not treat an encoded literal star as a target wildcard", async () => { + const packedRoot = await createPackedRoot(["src/x-x.ts"]) + const target = "./src/%2A-*.ts" + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": target }), [target]) + }) + + it("strips raw query and fragment suffixes from exact target paths", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + for (const target of ["./src/index.ts?variant", "./src/index.ts#variant"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), []) + } + }) + + it("strips raw query and fragment suffixes from wildcard target paths", async () => { + const packedRoot = await createPackedRoot(["src/index-one.ts"]) + + for (const target of ["./src/index-*.ts?variant", "./src/index-*.ts#variant"]) { + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": target }), []) + } + }) + + it("does not treat stars in a query or fragment as target wildcards", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + for (const target of ["./src/index.ts?variant=*", "./src/index.ts#*"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), []) + } + }) + + it("preserves percent-encoded query and fragment delimiters as path characters", async () => { + const packedRoot = await createPackedRoot(["src/index.ts"]) + + for (const target of ["./src/index.ts%3Fvariant", "./src/index.ts%23variant"]) { + assert.deepEqual(missingExportTargets(packedRoot, { ".": target }), [target]) + } + }) + + it("rejects invalid exact export keys paired with an existing exact target", async () => { + const packedRoot = await createPackedRoot(["src/index.js"]) + const target = "./src/index.js" + + for (const exportKey of [".invalid", "./foo/", "./features/../index", "./NoDe_MoDuLeS/index"]) { + assert.deepEqual(missingExportTargets(packedRoot, { [exportKey]: target }), [target]) + } + }) + + it("rejects exact targets outside packedRoot even when they exist", async () => { + const packedRoot = await createPackedRoot([]) + await writeFile(join(packedRoot, "..", "outside.js"), "", "utf8") + + assert.deepEqual(missingExportTargets(packedRoot, { ".": "./../outside.js" }), [ + "./../outside.js", + ]) + }) + + it("rejects exact targets that are directories", async () => { + const packedRoot = await createPackedRoot([]) + await mkdir(join(packedRoot, "dist", "index.js"), { recursive: true }) + + assert.deepEqual(missingExportTargets(packedRoot, { ".": "./dist/index.js" }), [ + "./dist/index.js", + ]) + }) + + it("rejects wildcard targets that only match directories", async () => { + const packedRoot = await createPackedRoot([]) + await mkdir(join(packedRoot, "dist", "features", "directory.js"), { recursive: true }) + + assert.deepEqual(missingExportTargets(packedRoot, { "./features/*": "./dist/features/*.js" }), [ + "./dist/features/*.js", + ]) + }) + + it("rejects wildcard targets that escape packedRoot", async () => { + const packedRoot = await createPackedRoot([]) + await writeFile(join(packedRoot, "..", "outside-one.js"), "", "utf8") + + assert.deepEqual(missingExportTargets(packedRoot, { "./outside/*": "./../outside-*.js" }), [ + "./../outside-*.js", + ]) + }) +}) + function packageEntry(name) { return { dir: `packages/${name}`, @@ -132,3 +581,20 @@ async function createRepo(publicPackages) { return root } + +async function createPackedRoot(files) { + const base = await mkdtemp(join(tmpdir(), "dawn-packed-export-test-")) + const root = join(base, "package") + tempRoots.push(base) + await mkdir(root, { recursive: true }) + + await Promise.all( + files.map(async (relativePath) => { + const filePath = join(root, relativePath) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, "", "utf8") + }), + ) + + return root +} diff --git a/scripts/published-artifact-smoke.mjs b/scripts/published-artifact-smoke.mjs index fed7e625..d5962919 100644 --- a/scripts/published-artifact-smoke.mjs +++ b/scripts/published-artifact-smoke.mjs @@ -50,6 +50,10 @@ async function main() { const selectedPackages = await selectedPackageVersions(options) await runInstallSmoke(tempDir, selectedPackages) + if (shouldRunAgUiProbe(selectedPackages)) { + await runAgUiInstalledProbe(tempDir) + } + if (!options.pgvector) { console.log("T1 SKIP pgvector disabled") console.log(options.openai ? "T2 SKIP pgvector disabled" : "T2 SKIP") @@ -176,6 +180,168 @@ async function runInstallSmoke(tempDir, packages) { console.log(`T0 PASS installed ${specs.join(" ")}`) } +async function runAgUiInstalledProbe(tempDir) { + await Promise.all([ + writeFile(resolve(tempDir, "smoke-ag-ui.mjs"), agUiEsmProbeSource(), "utf8"), + writeFile(resolve(tempDir, "smoke-ag-ui.ts"), agUiTypeProbeSource(), "utf8"), + writeFile( + resolve(tempDir, "tsconfig.ag-ui.json"), + `${JSON.stringify(agUiTypeScriptConfig(), null, 2)}\n`, + "utf8", + ), + ]) + + for (const { command, args } of agUiProbeCommands()) { + await runCommand(command, args, { cwd: tempDir }) + } + + console.log("T-AG-UI PASS") +} + +export function agUiProbeCommands() { + return [ + { command: "node", args: ["smoke-ag-ui.mjs"] }, + { command: "npm", args: ["install", "--save-dev", "typescript@6.0.2"] }, + { + command: "npm", + args: ["exec", "--", "tsc", "--project", "tsconfig.ag-ui.json"], + }, + ] +} + +export function shouldRunAgUiProbe(packages) { + return packages.some(({ name }) => name === "@dawn-ai/ag-ui") +} + +export function agUiEsmProbeSource() { + return `import assert from "node:assert/strict" + +import * as root from "@dawn-ai/ag-ui" +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" + +assert.deepEqual(Object.keys(root).sort(), [ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", +]) + +for (const exportName of [ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", +]) { + assert.equal(typeof root[exportName], "function", \`canonical export \${exportName} must be a function\`) +} + +const event = { type: "RUN_STARTED", threadId: "published-smoke", runId: "published-smoke" } +const encoded = encodeAgUiSse(event) +assert.equal(encoded, \`data: \${JSON.stringify(event)}\\n\\n\`) + +const payload = JSON.parse(encoded.slice("data: ".length, -2)) +assert.equal(payload.type, "RUN_STARTED") +assert.equal(payload.threadId, "published-smoke") +assert.equal(payload.runId, "published-smoke") +` +} + +export function agUiTypeProbeSource() { + return `import { + createCounterIdFactory, + createDefaultIdFactory, + fromRunAgentInput, + toAguiEvents, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnInterruptEnvelope, + type DawnMessage, + type DawnResumeRequest, + type DawnRunInput, + type IdFactory, + type RunContext, + type ToAguiOptions, +} from "@dawn-ai/ag-ui" +import { encodeAgUiSse as encodeAgUiSseFromSubpath } from "@dawn-ai/ag-ui/sse" + +// @ts-expect-error MappedRunInput was removed from the canonical root +import type { MappedRunInput } from "@dawn-ai/ag-ui" +// @ts-expect-error ResumeDecision was removed from the canonical root +import type { ResumeDecision } from "@dawn-ai/ag-ui" +// @ts-expect-error AgUiTranslator was removed from the canonical root +import type { AgUiTranslator } from "@dawn-ai/ag-ui" +// @ts-expect-error AgUiEvent was removed from the canonical root +import type { AgUiEvent } from "@dawn-ai/ag-ui" +// @ts-expect-error DawnStreamChunk was removed from the canonical root +import type { DawnStreamChunk } from "@dawn-ai/ag-ui" +// @ts-expect-error DawnToolCallData was removed from the canonical root +import type { DawnToolCallData } from "@dawn-ai/ag-ui" +// @ts-expect-error DawnToolResultData was removed from the canonical root +import type { DawnToolResultData } from "@dawn-ai/ag-ui" +// @ts-expect-error RawChunk was removed from the canonical root +import type { RawChunk } from "@dawn-ai/ag-ui" +// @ts-expect-error TranslatorOptions was removed from the canonical root +import type { TranslatorOptions } from "@dawn-ai/ag-ui" + +// @ts-expect-error createAgUiTranslator was removed from the canonical root +import { createAgUiTranslator } from "@dawn-ai/ag-ui" +// @ts-expect-error mapRunInput was removed from the canonical root +import { mapRunInput } from "@dawn-ai/ag-ui" +// @ts-expect-error encodeAgUiSse was removed from the canonical root +import { encodeAgUiSse } from "@dawn-ai/ag-ui" +// @ts-expect-error fromAguiResume was removed from the canonical root +import { fromAguiResume } from "@dawn-ai/ag-ui" +// @ts-expect-error toAguiInterrupt was removed from the canonical root +import { toAguiInterrupt } from "@dawn-ai/ag-ui" +// @ts-expect-error asToolCallData was removed from the canonical root +import { asToolCallData } from "@dawn-ai/ag-ui" +// @ts-expect-error asToolResultData was removed from the canonical root +import { asToolResultData } from "@dawn-ai/ag-ui" + +type RootValueSurface = readonly [ + typeof createCounterIdFactory, + typeof createDefaultIdFactory, + typeof fromRunAgentInput, + typeof toAguiEvents, +] + +type RootTypeSurface = readonly [ + IdFactory, + DawnMessage, + DawnRunInput, + DawnInterruptEnvelope, + DawnResumeRequest, + AguiOutboundEvent, + ToAguiOptions, + DawnAgentStreamChunk, + RunContext, +] + +declare const rootTypeSurface: RootTypeSurface +declare const rootValueSurface: RootValueSurface +const idFactory: IdFactory = createCounterIdFactory() +const chunk: DawnAgentStreamChunk = { type: "token", data: "hello" } +const context: RunContext = { threadId: "published-smoke", runId: "published-smoke" } +const options: ToAguiOptions = { idFactory } +const encoder: typeof encodeAgUiSseFromSubpath = encodeAgUiSseFromSubpath + +void [rootValueSurface, rootTypeSurface, chunk, context, options, encoder] +` +} + +export function agUiTypeScriptConfig() { + return { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + target: "ES2022", + }, + files: ["smoke-ag-ui.ts"], + } +} + export function assertNoNativeInstallOutput(output) { if (NATIVE_BUILD_INDICATORS.test(output)) { throw new Error("npm install output contained native build indicators") diff --git a/scripts/published-artifacts.test.mjs b/scripts/published-artifacts.test.mjs index 9a400363..818b46d9 100644 --- a/scripts/published-artifacts.test.mjs +++ b/scripts/published-artifacts.test.mjs @@ -2,7 +2,8 @@ import assert from "node:assert/strict" import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, it } from "node:test" +import { afterEach, describe, it } from "node:test" +import { fileURLToPath } from "node:url" import { assertCleanDependencySpecs, @@ -14,15 +15,29 @@ import { run, validatePackageMetadata, } from "./lib/published-artifacts.mjs" -import { +import * as publishedSmoke from "./published-artifact-smoke.mjs" + +const { + agUiEsmProbeSource, + agUiProbeCommands, + agUiTypeProbeSource, + agUiTypeScriptConfig, assertNoNativeInstallOutput, assertNoNativeLifecycleScripts, parseDockerMappedHostPort, pgvectorDatabaseUrl, readInstalledPackageManifests, runCommand, + shouldRunAgUiProbe, shouldRunOpenAiSmoke, -} from "./published-artifact-smoke.mjs" +} = publishedSmoke + +const tempRoots = [] +const typescriptCompilerPath = fileURLToPath(import.meta.resolve("typescript/bin/tsc")) + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))) +}) describe("resolvePackageSet", () => { it("resolves the memory-pgvector-core package set", () => { @@ -39,12 +54,27 @@ describe("resolvePackageSet", () => { }) describe("packageSets", () => { + it("includes the AG-UI package set", () => { + assert.deepEqual(packageSets["ag-ui"], ["@dawn-ai/ag-ui"]) + }) + it("includes the public package set placeholder", () => { assert.equal(packageSets.public, null) }) }) describe("expectedFilesForPackage", () => { + it("returns AG-UI entrypoint expectations", () => { + assert.deepEqual(expectedFilesForPackage("@dawn-ai/ag-ui"), [ + "dist/index.js", + "dist/index.d.ts", + "dist/sse.js", + "dist/sse.d.ts", + "README.md", + "package.json", + ]) + }) + it("returns memory-pgvector tarball expectations", () => { assert.deepEqual(expectedFilesForPackage("@dawn-ai/memory-pgvector"), [ "dist/index.js", @@ -74,6 +104,197 @@ describe("expectedFilesForPackage", () => { }) }) +describe("AG-UI installed probes", () => { + it("generates an ESM probe for the exact canonical root surface", () => { + const source = agUiEsmProbeSource() + + assert.match(source, /import \* as root from "@dawn-ai\/ag-ui"/) + assert.match(source, /import \{ encodeAgUiSse \} from "@dawn-ai\/ag-ui\/sse"/) + assert.ok( + source.includes(`assert.deepEqual(Object.keys(root).sort(), [ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", +])`), + "ESM probe must compare the complete sorted root export surface", + ) + assert.ok( + source.includes(`for (const exportName of [ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", +]) { + assert.equal(typeof root[exportName], "function", \`canonical export \${exportName} must be a function\`) +}`), + "ESM probe must verify every canonical root export is a function", + ) + assert.match(source, /type: "RUN_STARTED"/) + const exactSseAssertion = "assert.equal(encoded, `data: $" + "{JSON.stringify(event)}\\n\\n`)" + assert.ok(source.includes(exactSseAssertion), "ESM probe must assert the exact SSE frame") + assert.match(source, /JSON\.parse\(encoded\.slice\("data: "\.length, -2\)\)/) + for (const field of ["type", "threadId", "runId"]) { + assert.match(source, new RegExp(`payload\\.${field}`)) + } + }) + + it("generates a NodeNext consumer for root types and the SSE subpath", () => { + const source = agUiTypeProbeSource() + + assert.match(source, /from "@dawn-ai\/ag-ui"/) + for (const functionName of [ + "createCounterIdFactory", + "createDefaultIdFactory", + "fromRunAgentInput", + "toAguiEvents", + ]) { + assert.match(source, new RegExp(` ${functionName},`)) + } + assert.ok( + source.includes(`type RootValueSurface = readonly [ + typeof createCounterIdFactory, + typeof createDefaultIdFactory, + typeof fromRunAgentInput, + typeof toAguiEvents, +]`), + "type probe must type-use every canonical root function declaration", + ) + for (const typeName of [ + "IdFactory", + "DawnMessage", + "DawnRunInput", + "DawnInterruptEnvelope", + "DawnResumeRequest", + "AguiOutboundEvent", + "ToAguiOptions", + "DawnAgentStreamChunk", + "RunContext", + ]) { + assert.match(source, new RegExp(`type ${typeName}`)) + } + assert.ok( + source.includes(`type RootTypeSurface = readonly [ + IdFactory, + DawnMessage, + DawnRunInput, + DawnInterruptEnvelope, + DawnResumeRequest, + AguiOutboundEvent, + ToAguiOptions, + DawnAgentStreamChunk, + RunContext, +]`), + "type probe must exercise every canonical root type", + ) + for (const removedTypeName of [ + "MappedRunInput", + "ResumeDecision", + "AgUiTranslator", + "AgUiEvent", + "DawnStreamChunk", + "DawnToolCallData", + "DawnToolResultData", + "RawChunk", + "TranslatorOptions", + ]) { + assert.ok( + source.includes(`// @ts-expect-error ${removedTypeName} was removed from the canonical root +import type { ${removedTypeName} } from "@dawn-ai/ag-ui"`), + `type probe must reject restored ${removedTypeName}`, + ) + } + for (const removedFunctionName of [ + "createAgUiTranslator", + "mapRunInput", + "encodeAgUiSse", + "fromAguiResume", + "toAguiInterrupt", + "asToolCallData", + "asToolResultData", + ]) { + assert.ok( + source.includes(`// @ts-expect-error ${removedFunctionName} was removed from the canonical root +import { ${removedFunctionName} } from "@dawn-ai/ag-ui"`), + `type probe must reject restored ${removedFunctionName}`, + ) + } + assert.match(source, /from "@dawn-ai\/ag-ui\/sse"/) + assert.match(source, /typeof encodeAgUiSse/) + assert.deepEqual(agUiTypeScriptConfig(), { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + target: "ES2022", + }, + files: ["smoke-ag-ui.ts"], + }) + }) + + it("installs TypeScript and runs both probes", () => { + assert.deepEqual(agUiProbeCommands(), [ + { command: "node", args: ["smoke-ag-ui.mjs"] }, + { command: "npm", args: ["install", "--save-dev", "typescript@6.0.2"] }, + { + command: "npm", + args: ["exec", "--", "tsc", "--project", "tsconfig.ag-ui.json"], + }, + ]) + }) + + it("selects the AG-UI probe only when the package is installed", () => { + assert.equal(shouldRunAgUiProbe([{ name: "@dawn-ai/ag-ui", version: "1.0.0" }]), true) + assert.equal(shouldRunAgUiProbe([{ name: "@dawn-ai/core", version: "1.0.0" }]), false) + }) + + it("executes generated ESM and type probes against a local package fixture", async () => { + const root = await createAgUiProbeFixture() + + await runCommand(process.execPath, ["smoke-ag-ui.mjs"], { cwd: root }) + await compileAgUiTypeProbe(root) + }) + + it("rejects an installed SSE encoder with incorrect event data", async () => { + const root = await createAgUiProbeFixture({ + sseSource: `export function encodeAgUiSse(event) { + return "data: " + JSON.stringify({ ...event, threadId: "wrong-thread" }) + "\\n\\n" +} +`, + }) + + await assert.rejects( + runCommand(process.execPath, ["smoke-ag-ui.mjs"], { cwd: root }), + /deepStrictEqual|strictEqual/, + ) + }) + + it("fails type compilation if a removed root type reappears", async () => { + const root = await createAgUiProbeFixture({ + extraRootDeclarations: "export type MappedRunInput = unknown\n", + }) + + await assert.rejects(compileAgUiTypeProbe(root), /Unused '@ts-expect-error' directive/) + }) + + it("fails type compilation if a canonical function declaration is missing", async () => { + const root = await createAgUiProbeFixture({ + omitCanonicalDeclaration: "createDefaultIdFactory", + }) + + await assert.rejects(compileAgUiTypeProbe(root), /createDefaultIdFactory/) + }) + + it("fails type compilation if a removed root function declaration reappears", async () => { + const root = await createAgUiProbeFixture({ + extraRootDeclarations: "export declare function mapRunInput(input: unknown): unknown\n", + }) + + await assert.rejects(compileAgUiTypeProbe(root), /Unused '@ts-expect-error' directive/) + }) +}) + describe("resolveRequestedVersion", () => { it("resolves latest through dist-tags", () => { assert.equal( @@ -379,3 +600,85 @@ describe("assertNoNativeInstallOutput", () => { ) }) }) + +async function createAgUiProbeFixture(options = {}) { + const root = await mkdtemp(join(tmpdir(), "dawn-ag-ui-probe-test-")) + const packageRoot = join(root, "node_modules", "@dawn-ai", "ag-ui") + const distRoot = join(packageRoot, "dist") + tempRoots.push(root) + await mkdir(distRoot, { recursive: true }) + + const packageJson = { + name: "@dawn-ai/ag-ui", + type: "module", + types: "./dist/index.d.ts", + exports: { + ".": { types: "./dist/index.d.ts", default: "./dist/index.js" }, + "./sse": { types: "./dist/sse.d.ts", default: "./dist/sse.js" }, + }, + } + const rootJavaScript = `export function createCounterIdFactory() {} +export function createDefaultIdFactory() {} +export function fromRunAgentInput(input) { return input } +export function toAguiEvents(events) { return events } +` + const canonicalFunctionDeclarations = { + createCounterIdFactory: "export declare function createCounterIdFactory(): IdFactory", + createDefaultIdFactory: "export declare function createDefaultIdFactory(): IdFactory", + fromRunAgentInput: "export declare function fromRunAgentInput(input: unknown): DawnRunInput", + toAguiEvents: `export declare function toAguiEvents( + events: AsyncIterable, + context: RunContext, + options?: ToAguiOptions, +): AsyncIterable`, + } + const includedFunctionDeclarations = Object.entries(canonicalFunctionDeclarations) + .filter(([name]) => name !== options.omitCanonicalDeclaration) + .map(([, declaration]) => declaration) + .join("\n") + const rootDeclarations = `export type IdFactory = (kind: string) => string +export interface DawnMessage { readonly role: string; readonly content: string } +export interface DawnRunInput { readonly messages: readonly DawnMessage[] } +export interface DawnInterruptEnvelope { readonly interruptId: string } +export interface DawnResumeRequest { readonly interruptId: string; readonly value: unknown } +export interface AguiOutboundEvent { readonly type: string } +export interface ToAguiOptions { readonly idFactory?: IdFactory } +export type DawnAgentStreamChunk = { readonly type: string; readonly data?: unknown } +export interface RunContext { readonly threadId: string; readonly runId: string } +${includedFunctionDeclarations} +${options.extraRootDeclarations ?? ""}` + const sseJavaScript = + options.sseSource ?? + `export function encodeAgUiSse(event) { + return "data: " + JSON.stringify(event) + "\\n\\n" +} +` + const sseDeclarations = `export declare function encodeAgUiSse(event: { + readonly type: string + readonly threadId: string + readonly runId: string +}): string +` + + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ type: "module" }), "utf8"), + writeFile(join(root, "smoke-ag-ui.mjs"), agUiEsmProbeSource(), "utf8"), + writeFile(join(root, "smoke-ag-ui.ts"), agUiTypeProbeSource(), "utf8"), + writeFile(join(root, "tsconfig.ag-ui.json"), JSON.stringify(agUiTypeScriptConfig()), "utf8"), + writeFile(join(packageRoot, "package.json"), JSON.stringify(packageJson), "utf8"), + writeFile(join(distRoot, "index.js"), rootJavaScript, "utf8"), + writeFile(join(distRoot, "index.d.ts"), rootDeclarations, "utf8"), + writeFile(join(distRoot, "sse.js"), sseJavaScript, "utf8"), + writeFile(join(distRoot, "sse.d.ts"), sseDeclarations, "utf8"), + ]) + + return root +} + +async function compileAgUiTypeProbe(root) { + return runCommand( + process.execPath, + [typescriptCompilerPath, "--project", "tsconfig.ag-ui.json"], + { cwd: root }, + ) +}