From b84b83f9e58d74b5cece6a9027f7b93c36912b71 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 7 Jul 2026 16:29:28 -0700 Subject: [PATCH 01/30] docs(spec): @dawn-ai/ag-ui transport-agnostic adapter design Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-07-ag-ui-adapter-design.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-ag-ui-adapter-design.md 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. +``` From c3de9433ec34eda47a2a1972a57ceabc3d31a096 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 7 Jul 2026 16:49:15 -0700 Subject: [PATCH 02/30] docs(plan): @dawn-ai/ag-ui adapter implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-07-ag-ui-adapter.md | 1402 +++++++++++++++++ 1 file changed, 1402 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-ag-ui-adapter.md 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. +``` From 026c373bf8d58a2ef90f2151dbdc7803786f784b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 08:08:42 -0700 Subject: [PATCH 03/30] feat(ag-ui): id factories (deterministic + default) --- packages/ag-ui/src/ids.ts | 32 ++++++++++++++++++++++++++++++++ packages/ag-ui/test/ids.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/ag-ui/src/ids.ts create mode 100644 packages/ag-ui/test/ids.test.ts 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/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) + }) +}) From 813a3a1df104e4da0448015b7ff40dd3371ec6aa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 08:16:50 -0700 Subject: [PATCH 04/30] feat(ag-ui): Dawn chunk input types + guards --- packages/ag-ui/src/types.ts | 54 +++++++++++++++++++++++++++++++ packages/ag-ui/test/types.test.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 packages/ag-ui/test/types.test.ts diff --git a/packages/ag-ui/src/types.ts b/packages/ag-ui/src/types.ts index e1a82693..8fde73b4 100644 --- a/packages/ag-ui/src/types.ts +++ b/packages/ag-ui/src/types.ts @@ -11,6 +11,7 @@ export type AgUiEvent = BaseEvent export interface DawnStreamChunk { readonly type: string readonly data?: unknown + readonly id?: string readonly name?: string readonly input?: unknown readonly output?: unknown @@ -20,3 +21,56 @@ export interface TranslatorOptions { readonly threadId: string readonly runId: string } + +/** 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 | undefined + readonly name: string + readonly input: unknown +} + +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/types.test.ts b/packages/ag-ui/test/types.test.ts new file mode 100644 index 00000000..8c69301c --- /dev/null +++ b/packages/ag-ui/test/types.test.ts @@ -0,0 +1,53 @@ +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 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() + }) +}) From c30193eb428e7c7a43f11aa546ad094016c94dd1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 14:01:21 -0700 Subject: [PATCH 05/30] feat(ag-ui): interrupt/resume translation --- packages/ag-ui/src/interrupts.ts | 66 ++++++++++++++++++++++++ packages/ag-ui/test/interrupts.test.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 packages/ag-ui/src/interrupts.ts create mode 100644 packages/ag-ui/test/interrupts.test.ts diff --git a/packages/ag-ui/src/interrupts.ts b/packages/ag-ui/src/interrupts.ts new file mode 100644 index 00000000..962eae77 --- /dev/null +++ b/packages/ag-ui/src/interrupts.ts @@ -0,0 +1,66 @@ +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 { + const env = isPlainRecord(data) && typeof data.interruptId === "string" ? 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, + ...(Object.hasOwn(entry, "payload") ? { payload: entry.payload } : {}), + })) +} diff --git a/packages/ag-ui/test/interrupts.test.ts b/packages/ag-ui/test/interrupts.test.ts new file mode 100644 index 00000000..4835dd1e --- /dev/null +++ b/packages/ag-ui/test/interrupts.test.ts @@ -0,0 +1,70 @@ +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: {} }) + }) + + test("treats arrays as malformed envelopes", () => { + expect(toAguiInterrupt([])).toEqual({ id: "", reason: "interrupt", metadata: {} }) + }) + + test("treats non-plain objects as malformed envelopes", () => { + expect(toAguiInterrupt(new Date(0))).toEqual({ id: "", reason: "interrupt", metadata: {} }) + }) + + test("treats plain objects without a string interruptId as malformed envelopes", () => { + expect(toAguiInterrupt({ foo: "bar" })).toEqual({ id: "", reason: "interrupt", metadata: {} }) + expect(toAguiInterrupt({ interruptId: 123, kind: "command" })).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" }, + ]) + }) + + 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) + }) +}) From fb9acaa5314ec2433a55c0c036ae30237635b183 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 14:36:59 -0700 Subject: [PATCH 06/30] feat(ag-ui): toAguiEvents outbound stream mapper --- packages/ag-ui/src/outbound.ts | 167 ++++++++++++++++++++ packages/ag-ui/test/outbound.test.ts | 218 +++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 packages/ag-ui/src/outbound.ts create mode 100644 packages/ag-ui/test/outbound.test.ts diff --git a/packages/ag-ui/src/outbound.ts b/packages/ag-ui/src/outbound.ts new file mode 100644 index 00000000..be750120 --- /dev/null +++ b/packages/ag-ui/src/outbound.ts @@ -0,0 +1,167 @@ +import type { + 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 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 { + 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 + const pendingFallbackToolCallIds = new Map() + + 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") + 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() + 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: + 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() + 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/test/outbound.test.ts b/packages/ag-ui/test/outbound.test.ts new file mode 100644 index 00000000..5889a081 --- /dev/null +++ b/packages/ag-ui/test/outbound.test.ts @@ -0,0 +1,218 @@ +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("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("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) + }) +}) From 82b4ef4f5f9aa7b2e56919c9b5b666a1db98ecc3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 16:24:17 -0700 Subject: [PATCH 07/30] feat(ag-ui): fromRunAgentInput inbound mapper --- packages/ag-ui/src/inbound.ts | 66 +++++++++++++++++++++ packages/ag-ui/test/inbound.test.ts | 89 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 packages/ag-ui/src/inbound.ts create mode 100644 packages/ag-ui/test/inbound.test.ts 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/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) + }) +}) From 01dae2de83be987bb42f7c8a0c29a91703bd3b11 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 20:22:29 -0700 Subject: [PATCH 08/30] feat(ag-ui): public barrel + interrupt round-trip test --- packages/ag-ui/src/index.ts | 21 +++++++++++++- packages/ag-ui/test/round-trip.test.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/ag-ui/test/round-trip.test.ts diff --git a/packages/ag-ui/src/index.ts b/packages/ag-ui/src/index.ts index 0c7c5e13..9cd43ae2 100644 --- a/packages/ag-ui/src/index.ts +++ b/packages/ag-ui/src/index.ts @@ -1,4 +1,23 @@ export { encodeAgUiSse } from "./encode.js" +export { createCounterIdFactory, createDefaultIdFactory, type IdFactory } from "./ids.js" +export { type DawnMessage, type DawnRunInput, fromRunAgentInput } from "./inbound.js" +export { + type DawnInterruptEnvelope, + type DawnResumeRequest, + fromAguiResume, + toAguiInterrupt, +} from "./interrupts.js" +export { type AguiOutboundEvent, type ToAguiOptions, toAguiEvents } from "./outbound.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 { + asToolCallData, + asToolResultData, + type AgUiEvent, + type DawnStreamChunk, + type DawnToolCallData, + type DawnToolResultData, + type RawChunk, + type RunContext, + type TranslatorOptions, +} from "./types.js" 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..ce63e4b6 --- /dev/null +++ b/packages/ag-ui/test/round-trip.test.ts @@ -0,0 +1,39 @@ +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" }]) + }) +}) From 0866b5e626a8c029ea8aa69187cb6222aab29872 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 20:44:10 -0700 Subject: [PATCH 09/30] feat(langchain): surface tool invocation run_id on tool_call/tool_result chunks --- packages/langchain/src/agent-adapter.ts | 7 ++- .../test/agent-adapter-toolcall-id.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 packages/langchain/test/agent-adapter-toolcall-id.test.ts diff --git a/packages/langchain/src/agent-adapter.ts b/packages/langchain/src/agent-adapter.ts index 1bc2f023..f9f2f946 100644 --- a/packages/langchain/src/agent-adapter.ts +++ b/packages/langchain/src/agent-adapter.ts @@ -542,6 +542,7 @@ async function* streamFromRunnable( options: Record, ) => AsyncIterable<{ event: string + run_id: string data: { chunk?: unknown; input?: unknown; output?: unknown; error?: unknown } name: string }> @@ -614,6 +615,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, }, @@ -624,7 +629,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..4c22eee0 --- /dev/null +++ b/packages/langchain/test/agent-adapter-toolcall-id.test.ts @@ -0,0 +1,45 @@ +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) + }) +}) From aae7a9b62357304140b01eada0a4c4a53f3603f7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 20:52:13 -0700 Subject: [PATCH 10/30] docs(ag-ui): changeset + package README --- .changeset/ag-ui-adapter.md | 18 +++++++++++ packages/ag-ui/README.md | 64 +++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/ag-ui-adapter.md diff --git a/.changeset/ag-ui-adapter.md b/.changeset/ag-ui-adapter.md new file mode 100644 index 00000000..47ca645e --- /dev/null +++ b/.changeset/ag-ui-adapter.md @@ -0,0 +1,18 @@ +--- +"@dawn-ai/ag-ui": patch +"@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. + +Release note: this uses `patch` for both packages to preserve the 0.8.x release +line and avoid fixed-group surprises; confirm the intended bump with the +maintainer at release time. diff --git a/packages/ag-ui/README.md b/packages/ag-ui/README.md index 8f45c559..aa8c5c01 100644 --- a/packages/ag-ui/README.md +++ b/packages/ag-ui/README.md @@ -4,7 +4,7 @@ # @dawn-ai/ag-ui -AG-UI protocol translation for Dawn's local runtime. This package maps Dawn's +AG-UI protocol translation for Dawn's local runtime. This package maps Dawn 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. @@ -38,18 +38,78 @@ POST http://127.0.0.1:3001/agui/%2Fchat%23agent import { createAgUiTranslator, encodeAgUiSse, + fromRunAgentInput, mapRunInput, + toAguiEvents, type AgUiEvent, + type DawnRunInput, type DawnStreamChunk, type MappedRunInput, type ResumeDecision, + type RunContext, type TranslatorOptions, } from "@dawn-ai/ag-ui" ``` +## Transport Helpers + +Use `toAguiEvents` and `fromRunAgentInput` when you own the transport and only +need pure mapping helpers. These helpers do not depend on the CLI, HTTP, or +LangGraph. + +### `toAguiEvents(chunks, ctx)` + +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. +} +``` + +Supported chunks are: + +```ts +type DawnChunk = + | { 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: { interruptId: string; kind?: string; [key: string]: unknown } } + | { type: "done"; data?: unknown } +``` + +`dawnChunks` can be any `AsyncIterable`, including the langchain +adapter's `AgentStreamChunk` stream. Tool call ids from Dawn chunks are preserved +as AG-UI `toolCallId`. + +### `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) +``` + +`resume` is omitted when empty. When present, it is an array of answers addressed +by `interruptId`, for example: + +```ts +[{ interruptId: "perm-1", status: "resolved", payload: "once" }] +``` + +Translate those answers to your runtime's resume call. `raw` exposes the +untouched AG-UI input for `tools`/`state`/`context`. + +## Dev Server Helpers + ### `mapRunInput(input)` -Maps an AG-UI `RunAgentInput` to Dawn's route input: +Maps an AG-UI `RunAgentInput` to Dawn's route input for the built-in local dev +endpoint: - The newest user message becomes `{ messages: [{ role: "user", content }] }`. - Dawn keeps conversation history in the checkpoint keyed by AG-UI `threadId`, From 6bc15b8cafcbbe8e3dd9e83581fb1c1073a6e26c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Jul 2026 08:50:39 -0700 Subject: [PATCH 11/30] fix(cli): support AG-UI smoke integration --- .changeset/ag-ui-adapter.md | 8 +- packages/cli/src/lib/runtime/execute-route.ts | 24 +++- packages/cli/src/lib/runtime/stream-types.ts | 14 +- packages/cli/test/run-command.test.ts | 124 +++++++++++++++++- packages/cli/test/stream-types.test.ts | 22 ++++ 5 files changed, 183 insertions(+), 9 deletions(-) diff --git a/.changeset/ag-ui-adapter.md b/.changeset/ag-ui-adapter.md index 47ca645e..023e7a88 100644 --- a/.changeset/ag-ui-adapter.md +++ b/.changeset/ag-ui-adapter.md @@ -1,5 +1,6 @@ --- "@dawn-ai/ag-ui": patch +"@dawn-ai/cli": patch "@dawn-ai/langchain": patch --- @@ -13,6 +14,11 @@ The langchain adapter now surfaces each tool invocation's `run_id` as `id` on it `tool_call`/`tool_result` stream chunks, so AG-UI `toolCallId` correlation is faithful even when a tool is called more than once. -Release note: this uses `patch` for both packages to preserve the 0.8.x release +The CLI runtime now preserves those optional tool invocation ids in Dawn SSE +`tool_call`/`tool_result` payloads. Local in-process `dawn run` also generates a +one-shot thread id for agent routes so the default SQLite checkpointer can run +the same agent route shape that `dawn dev` already supports. + +Release note: this uses `patch` for all packages to preserve the 0.8.x release line and avoid fixed-group surprises; confirm the intended bump with the maintainer at release time. diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 4672ee75..d5dc0d19 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" @@ -331,13 +332,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 +866,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 +889,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/run-command.test.ts b/packages/cli/test/run-command.test.ts index 9ce6dfd8..70cd93d3 100644 --- a/packages/cli/test/run-command.test.ts +++ b/packages/cli/test/run-command.test.ts @@ -5,8 +5,9 @@ import { tmpdir } from "node:os" import { dirname, join } from "node:path" import { afterEach, describe, expect, test } from "vitest" - +import { createAimock, script } from "../../testing/dist/index.js" import { run } from "../src/index.js" +import { streamResolvedRoute } from "../src/lib/runtime/execute-route.js" import { executeRouteServer } from "../src/lib/runtime/execute-route-server.js" const tempDirs: string[] = [] @@ -18,6 +19,127 @@ afterEach(async () => { }) describe("dawn run", () => { + 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/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( From f9e23dcc8676c950034686c175a38afc5d9a8cc0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 9 Jul 2026 12:28:52 -0700 Subject: [PATCH 12/30] fix(ag-ui): preserve upstream tool ids in translator --- packages/ag-ui/src/index.ts | 2 +- packages/ag-ui/src/translate.ts | 5 +++-- packages/ag-ui/test/translate-text-tools.test.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/ag-ui/src/index.ts b/packages/ag-ui/src/index.ts index 9cd43ae2..e8b70396 100644 --- a/packages/ag-ui/src/index.ts +++ b/packages/ag-ui/src/index.ts @@ -11,9 +11,9 @@ export { type AguiOutboundEvent, type ToAguiOptions, toAguiEvents } from "./outb export { type MappedRunInput, mapRunInput, type ResumeDecision } from "./run-input.js" export { type AgUiTranslator, createAgUiTranslator } from "./translate.js" export { + type AgUiEvent, asToolCallData, asToolResultData, - type AgUiEvent, type DawnStreamChunk, type DawnToolCallData, type DawnToolResultData, diff --git a/packages/ag-ui/src/translate.ts b/packages/ag-ui/src/translate.ts index b024f158..14224c05 100644 --- a/packages/ag-ui/src/translate.ts +++ b/packages/ag-ui/src/translate.ts @@ -49,7 +49,7 @@ export function createAgUiTranslator(options: TranslatorOptions): AgUiTranslator function toToolCall(chunk: DawnStreamChunk): AgUiEvent[] { const name = chunk.name ?? "tool" - const toolCallId = nextId("call") + const toolCallId = chunk.id ?? nextId("call") const queue = pendingToolCalls.get(name) ?? [] queue.push(toolCallId) pendingToolCalls.set(name, queue) @@ -64,7 +64,8 @@ export function createAgUiTranslator(options: TranslatorOptions): AgUiTranslator function toToolResult(chunk: DawnStreamChunk): AgUiEvent[] { const name = chunk.name ?? "tool" const queue = pendingToolCalls.get(name) ?? [] - const toolCallId = queue.shift() ?? nextId("call") + const queuedToolCallId = queue.shift() + const toolCallId = chunk.id ?? queuedToolCallId ?? nextId("call") pendingToolCalls.set(name, queue) const content = typeof chunk.output === "string" ? chunk.output : JSON.stringify(chunk.output ?? null) diff --git a/packages/ag-ui/test/translate-text-tools.test.ts b/packages/ag-ui/test/translate-text-tools.test.ts index cddbff62..d499f9db 100644 --- a/packages/ag-ui/test/translate-text-tools.test.ts +++ b/packages/ag-ui/test/translate-text-tools.test.ts @@ -77,6 +77,18 @@ describe("translator: lifecycle + text + tools", () => { expect((evs[1] as { delta: string }).delta).toBe(JSON.stringify({ query: "x" })) }) + it("preserves upstream tool invocation ids when present", () => { + const t = createAgUiTranslator(opts) + const evs = [ + ...t.translate({ type: "tool_call", id: "run-abc", name: "searchCorpus", input: { query: "x" } }), + ...t.translate({ type: "tool_result", id: "run-abc", name: "searchCorpus", output: "ok" }), + ] + + expect((evs[0] as { toolCallId: string }).toolCallId).toBe("run-abc") + expect((evs[1] as { toolCallId: string }).toolCallId).toBe("run-abc") + expect((evs[3] as { toolCallId: string }).toolCallId).toBe("run-abc") + }) + it("flushes an open text message before a tool_call", () => { const t = createAgUiTranslator(opts) const evs = [ From 8fd42960b7dc051aad1509a3c5d3ae4ef83bce93 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Jul 2026 15:06:35 -0700 Subject: [PATCH 13/30] docs(spec): canonicalize AG-UI adapter API --- ...26-07-10-ag-ui-canonical-adapter-design.md | 608 ++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md 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..ee17685b --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-ag-ui-canonical-adapter-design.md @@ -0,0 +1,608 @@ +# `@dawn-ai/ag-ui` Canonical Adapter API Design + +**Status:** Independent review issues addressed, maintainer review pending +**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 its LangGraph task 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 task-keyed +resume map and invokes `Command({ resume: { [taskId]: decision } })`. Task IDs +come from the first element of each pending-write tuple 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 task-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 task-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 a task-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. From 2609bed7670f02b6584ae840c3c86460059fdf53 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 12:29:21 -0700 Subject: [PATCH 14/30] docs(spec): approve canonical AG-UI design --- .../specs/2026-07-10-ag-ui-canonical-adapter-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee17685b..a8b9852b 100644 --- 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 @@ -1,6 +1,6 @@ # `@dawn-ai/ag-ui` Canonical Adapter API Design -**Status:** Independent review issues addressed, maintainer review pending +**Status:** Approved **Date:** 2026-07-10 **Supersedes:** `2026-07-07-ag-ui-adapter-design.md` where this document conflicts From d210ce17f0fbe6792977edfeeeb3acfed2ba9f7f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 12:45:04 -0700 Subject: [PATCH 15/30] docs(plan): canonical AG-UI adapter cutover --- .../2026-07-11-ag-ui-canonical-adapter.md | 699 ++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md 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..18821109 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md @@ -0,0 +1,699 @@ +# 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 task-ID 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 task keys; missing/unknown/duplicate IDs -> 409; invalid resolved payload -> 400. + +```ts +expect(resolveAgUiResume([ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, +], [ + { interruptId: "perm-1", taskId: "11111111111111111111111111111111" }, + { interruptId: "perm-2", taskId: "22222222222222222222222222222222" }, +])).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 taskId: string } +export async function readPendingInterrupts( + checkpointer: BaseCheckpointSaver, + threadId: string, +): Promise +export function resolveAgUiResume( + resume: readonly DawnResumeRequest[] | undefined, + pending: readonly PendingInterrupt[], +): ResumeResolution +``` + +Read `__interrupt__` pending writes, use tuple element 0 as task ID, and use `value.value.interruptId` with `value.id` as fallback. Never accept task IDs 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 explicit `__interrupt__` pending-write tuples 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; 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 task-ID 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. From 2933cc3aebc01ac32e1575658347ff0c47e8b2df Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 12:50:10 -0700 Subject: [PATCH 16/30] feat(ag-ui): complete canonical outbound mapping --- packages/ag-ui/src/interrupts.ts | 10 ++- packages/ag-ui/src/outbound.ts | 48 ++++++++-- packages/ag-ui/src/types.ts | 13 +++ packages/ag-ui/test/interrupts.test.ts | 19 ++-- packages/ag-ui/test/outbound.test.ts | 119 +++++++++++++++++++++++-- packages/ag-ui/test/types.test.ts | 24 ++++- 6 files changed, 202 insertions(+), 31 deletions(-) diff --git a/packages/ag-ui/src/interrupts.ts b/packages/ag-ui/src/interrupts.ts index 962eae77..0c420c03 100644 --- a/packages/ag-ui/src/interrupts.ts +++ b/packages/ag-ui/src/interrupts.ts @@ -33,9 +33,13 @@ function isPlainRecord(value: unknown): value is Record { * preserved under `metadata` so no capability-specific information is lost on * the way to the client. */ -export function toAguiInterrupt(data: unknown): Interrupt { - const env = isPlainRecord(data) && typeof data.interruptId === "string" ? data : {} - const interruptId = typeof env.interruptId === "string" ? env.interruptId : "" +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, diff --git a/packages/ag-ui/src/outbound.ts b/packages/ag-ui/src/outbound.ts index be750120..6ad5be1e 100644 --- a/packages/ag-ui/src/outbound.ts +++ b/packages/ag-ui/src/outbound.ts @@ -1,4 +1,5 @@ import type { + Interrupt, RunErrorEvent, RunFinishedEvent, RunStartedEvent, @@ -13,7 +14,12 @@ import type { import { EventType } 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" +import { + asToolCallData, + asToolResultData, + type DawnAgentStreamChunk, + type RunContext, +} from "./types.js" /** The AG-UI events this mapper can emit. */ export type AguiOutboundEvent = @@ -57,13 +63,14 @@ function stringifyContent(output: unknown): string { * upstream error becomes a `RUN_ERROR` event and a clean return. */ export async function* toAguiEvents( - chunks: AsyncIterable, + 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) { @@ -76,6 +83,16 @@ export async function* toAguiEvents( 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 : "" @@ -127,13 +144,16 @@ export async function* toAguiEvents( } case "interrupt": { yield* flushText() - yield { - type: EventType.RUN_FINISHED, - threadId: ctx.threadId, - runId: ctx.runId, - outcome: { type: "interrupt", interrupts: [toAguiInterrupt(chunk.data)] }, + const interrupt = toAguiInterrupt(chunk.data) + if (interrupt === null) { + yield { + type: EventType.RUN_ERROR, + message: "Malformed Dawn interrupt: missing interruptId", + } + return } - return + pendingInterrupts.push(interrupt) + break } case "done": { yield* flushText() @@ -141,6 +161,9 @@ export async function* toAguiEvents( type: EventType.RUN_FINISHED, threadId: ctx.threadId, runId: ctx.runId, + ...(Object.hasOwn(chunk, "data") && chunk.data !== undefined + ? { result: chunk.data } + : {}), outcome: { type: "success" }, } return @@ -154,6 +177,15 @@ export async function* toAguiEvents( } // 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, diff --git a/packages/ag-ui/src/types.ts b/packages/ag-ui/src/types.ts index 8fde73b4..297455a8 100644 --- a/packages/ag-ui/src/types.ts +++ b/packages/ag-ui/src/types.ts @@ -28,6 +28,19 @@ export interface RunContext { readonly runId: string } +/** + * 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 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 } + /** * The minimal Dawn stream-chunk shape the mapper consumes. Structurally * compatible with `AgentStreamChunk` from `@dawn-ai/langchain` (which is diff --git a/packages/ag-ui/test/interrupts.test.ts b/packages/ag-ui/test/interrupts.test.ts index 4835dd1e..bb574210 100644 --- a/packages/ag-ui/test/interrupts.test.ts +++ b/packages/ag-ui/test/interrupts.test.ts @@ -27,25 +27,22 @@ describe("toAguiInterrupt", () => { }) }) - test("falls back to empty id and 'interrupt' reason for a malformed envelope", () => { - expect(toAguiInterrupt(null)).toEqual({ id: "", reason: "interrupt", metadata: {} }) + test("rejects a malformed envelope instead of synthesizing an interrupt id", () => { + expect(toAguiInterrupt(null)).toBeNull() }) test("treats arrays as malformed envelopes", () => { - expect(toAguiInterrupt([])).toEqual({ id: "", reason: "interrupt", metadata: {} }) + expect(toAguiInterrupt([])).toBeNull() }) test("treats non-plain objects as malformed envelopes", () => { - expect(toAguiInterrupt(new Date(0))).toEqual({ id: "", reason: "interrupt", metadata: {} }) + expect(toAguiInterrupt(new Date(0))).toBeNull() }) - test("treats plain objects without a string interruptId as malformed envelopes", () => { - expect(toAguiInterrupt({ foo: "bar" })).toEqual({ id: "", reason: "interrupt", metadata: {} }) - expect(toAguiInterrupt({ interruptId: 123, kind: "command" })).toEqual({ - id: "", - reason: "interrupt", - metadata: {}, - }) + 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() }) }) diff --git a/packages/ag-ui/test/outbound.test.ts b/packages/ag-ui/test/outbound.test.ts index 5889a081..182b7fde 100644 --- a/packages/ag-ui/test/outbound.test.ts +++ b/packages/ag-ui/test/outbound.test.ts @@ -1,12 +1,12 @@ 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" +import type { DawnAgentStreamChunk } from "../src/types.js" const CTX = { threadId: "th-1", runId: "rn-1" } -async function collect(chunks: RawChunk[]) { +async function collect(chunks: DawnAgentStreamChunk[]) { const out = [] for await (const ev of toAguiEvents(toAsync(chunks), CTX, { idFactory: createCounterIdFactory(), @@ -16,7 +16,7 @@ async function collect(chunks: RawChunk[]) { return out } -async function* toAsync(items: RawChunk[]) { +async function* toAsync(items: DawnAgentStreamChunk[]) { for (const item of items) yield item } @@ -33,7 +33,13 @@ describe("toAguiEvents", () => { { 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" } }, + { + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result: {}, + outcome: { type: "success" }, + }, ]) }) @@ -54,7 +60,13 @@ describe("toAguiEvents", () => { toolCallId: "run-abc", content: '{"greeting":"hi"}', }, - { type: EventType.RUN_FINISHED, threadId: "th-1", runId: "rn-1", outcome: { type: "success" } }, + { + type: EventType.RUN_FINISHED, + threadId: "th-1", + runId: "rn-1", + result: {}, + outcome: { type: "success" }, + }, ]) }) @@ -168,8 +180,99 @@ describe("toAguiEvents", () => { 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: {} }]) + 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" } }, @@ -203,7 +306,7 @@ describe("toAguiEvents", () => { }) test("upstream throw is emitted as RUN_ERROR, not thrown to the consumer", async () => { - async function* boom(): AsyncGenerator { + async function* boom(): AsyncGenerator { yield { type: "token", data: "hi" } throw new Error("kaboom") } diff --git a/packages/ag-ui/test/types.test.ts b/packages/ag-ui/test/types.test.ts index 8c69301c..956bc745 100644 --- a/packages/ag-ui/test/types.test.ts +++ b/packages/ag-ui/test/types.test.ts @@ -1,5 +1,27 @@ import { describe, expect, test } from "vitest" -import { asToolCallData, asToolResultData } from "../src/types.js" +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", () => { From 7732cec27b768adcf6318eca0811eb88998b4450 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 15:48:38 -0700 Subject: [PATCH 17/30] feat(ag-ui): add focused SSE encoder subpath --- packages/ag-ui/package.json | 4 ++ packages/ag-ui/src/sse.ts | 8 +++ packages/ag-ui/test/conformance.test.ts | 80 ++++++++++++++++--------- packages/ag-ui/test/sse.test.ts | 9 +++ 4 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 packages/ag-ui/src/sse.ts create mode 100644 packages/ag-ui/test/sse.test.ts diff --git a/packages/ag-ui/package.json b/packages/ag-ui/package.json index 1e32731d..1710abf7 100644 --- a/packages/ag-ui/package.json +++ b/packages/ag-ui/package.json @@ -24,6 +24,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./sse": { + "types": "./dist/sse.d.ts", + "default": "./dist/sse.js" } }, "publishConfig": { diff --git a/packages/ag-ui/src/sse.ts b/packages/ag-ui/src/sse.ts new file mode 100644 index 00000000..5bda3e05 --- /dev/null +++ b/packages/ag-ui/src/sse.ts @@ -0,0 +1,8 @@ +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 { + const encoder = new EventEncoder(accept ? { accept } : {}) + return encoder.encode(event) +} 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/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"', + ) +}) From e43435c6a61d624b89c253a5010e24734861f034 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 17:44:29 -0700 Subject: [PATCH 18/30] refactor(cli): centralize pending interrupt resolution --- .../2026-07-11-ag-ui-canonical-adapter.md | 43 ++- ...26-07-10-ag-ui-canonical-adapter-design.md | 28 +- packages/cli/package.json | 1 + .../cli/src/lib/dev/pending-interrupts.ts | 185 +++++++++++ packages/cli/src/lib/dev/runtime-server.ts | 37 +-- packages/cli/test/pending-interrupts.test.ts | 305 ++++++++++++++++++ packages/cli/test/resume-endpoint.test.ts | 123 +++++++ pnpm-lock.yaml | 3 + 8 files changed, 679 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/lib/dev/pending-interrupts.ts create mode 100644 packages/cli/test/pending-interrupts.test.ts 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 index 18821109..2c05aa6b 100644 --- a/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md +++ b/docs/superpowers/plans/2026-07-11-ag-ui-canonical-adapter.md @@ -19,7 +19,7 @@ - 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 task-ID keyed resume map. New turns are rejected while interrupts remain pending. +- 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`. @@ -190,16 +190,27 @@ git commit -m "feat(ag-ui): add focused SSE encoder subpath" - [ ] **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 task keys; missing/unknown/duplicate IDs -> 409; invalid resolved payload -> 400. +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" }, -], [ - { interruptId: "perm-1", taskId: "11111111111111111111111111111111" }, - { interruptId: "perm-2", taskId: "22222222222222222222222222222222" }, -])).toEqual({ +], { + 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: { @@ -219,18 +230,26 @@ corepack pnpm --filter @dawn-ai/cli test -- pending-interrupts.test.ts ```ts export type PermissionDecision = "once" | "always" | "deny" -export interface PendingInterrupt { readonly interruptId: string; readonly taskId: string } +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 +): Promise export function resolveAgUiResume( resume: readonly DawnResumeRequest[] | undefined, - pending: readonly PendingInterrupt[], + pending: PendingInterruptSnapshot, ): ResumeResolution ``` -Read `__interrupt__` pending writes, use tuple element 0 as task ID, and use `value.value.interruptId` with `value.id` as fallback. Never accept task IDs from HTTP input. Return exact 400/409 result objects rather than throwing protocol errors. +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** @@ -469,9 +488,9 @@ Fixture middleware rejects without `x-api-key` and allows with context `{ tenant - [ ] **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 explicit `__interrupt__` pending-write tuples and (b) an injected stream function that records its `resume` option and yields `{ type: "done", output: { resumed: true } }`. +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; 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 task-ID 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. +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** 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 index a8b9852b..9614118e 100644 --- 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 @@ -341,7 +341,8 @@ 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 its LangGraph task ID. +`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: @@ -355,11 +356,14 @@ set of open checkpoint interrupts: before execution, using the same state-based validation as the existing Dawn resume endpoint. -The bridge converts the validated answers to LangGraph's supported task-keyed -resume map and invokes `Command({ resume: { [taskId]: decision } })`. Task IDs -come from the first element of each pending-write tuple and are never accepted -from the client. This supports one or many parallel open interrupts without -weakening stale-interrupt protection. +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. @@ -369,7 +373,7 @@ 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 task-keyed map. +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. @@ -577,8 +581,9 @@ 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 task-keyed resume map. It -rejects new turns while interrupts remain unresolved. +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. @@ -599,8 +604,9 @@ execution side effect. 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 a task-keyed LangGraph - resume map, and unresolved interrupts block new turns. +- 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 96cf879b..93804d2b 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/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/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index 63753f0f..d9d53d5c 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -14,6 +14,7 @@ import type { SandboxManager } from "../runtime/sandbox-manager.js" import { type StreamChunk, toSseEvent } from "../runtime/stream-types.js" import { handleAgUiRequest } from "./agui-handler.js" import { loadMiddleware, runMiddleware } from "./middleware.js" +import { readPendingInterrupts } from "./pending-interrupts.js" import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js" import { createExecutionErrorBody, createRequestErrorBody } from "./server-errors.js" @@ -775,13 +776,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, @@ -790,23 +786,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, 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index cb63ba2f..fda0bd23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,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)) From 263b389e4bdf585fb311c4444f9c2ec1667ec1ca Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 11 Jul 2026 20:31:38 -0700 Subject: [PATCH 19/30] fix(cli): support addressed resume maps in route streams --- packages/cli/src/lib/dev/runtime-server.ts | 2 +- packages/cli/src/lib/runtime/execute-route.ts | 21 ++++---- packages/cli/test/run-command.test.ts | 48 ++++++++++++++++++- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index d9d53d5c..4c630bbc 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -863,7 +863,7 @@ async function handleResumeRequest(options: { for await (const chunk of streamResolvedRoute({ appRoot, input: {}, - resumeDecision: decision as "once" | "always" | "deny", + resumeDecision: 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 d5dc0d19..b5dd3cfd 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -86,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) @@ -249,7 +259,7 @@ export async function* streamResolvedRoute(options: { * 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 resumeDecision?: RouteResumePayload readonly routeFile: string readonly routeId: string readonly routePath: string @@ -271,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 { @@ -304,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.resumeDecision) for await (const chunk of streamAgent({ checkpointer, diff --git a/packages/cli/test/run-command.test.ts b/packages/cli/test/run-command.test.ts index 70cd93d3..d0dbd7b2 100644 --- a/packages/cli/test/run-command.test.ts +++ b/packages/cli/test/run-command.test.ts @@ -3,11 +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 } from "../src/lib/runtime/execute-route.js" +import { streamResolvedRoute, toAgentInput } from "../src/lib/runtime/execute-route.js" import { executeRouteServer } from "../src/lib/runtime/execute-route-server.js" const tempDirs: string[] = [] @@ -19,6 +19,50 @@ 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", From 0bbafd0c6525d78471c5b1e7c8821c31ff88bd08 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 12 Jul 2026 19:55:11 -0700 Subject: [PATCH 20/30] refactor(cli): share middleware request projection --- packages/cli/src/lib/dev/request-context.ts | 31 ++++++++++++++++++ packages/cli/src/lib/dev/runtime-server.ts | 31 +----------------- packages/cli/test/request-context.test.ts | 35 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/lib/dev/request-context.ts create mode 100644 packages/cli/test/request-context.test.ts 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 4c630bbc..7712996a 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -15,6 +15,7 @@ import { type StreamChunk, toSseEvent } from "../runtime/stream-types.js" import { handleAgUiRequest } from "./agui-handler.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, createRequestErrorBody } from "./server-errors.js" @@ -989,36 +990,6 @@ async function listen( }) } -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 -} - -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 -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } 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" }) + }) +}) From 1ce0f6b7f00be0843f32d9d87400bea461900489 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 12 Jul 2026 20:22:24 -0700 Subject: [PATCH 21/30] refactor(cli): route AG-UI through canonical adapter --- packages/ag-ui/src/encode.ts | 8 - packages/ag-ui/src/index.ts | 22 +- packages/ag-ui/src/run-input.ts | 50 ---- packages/ag-ui/src/sse.ts | 4 +- packages/ag-ui/src/translate.ts | 148 ------------ packages/ag-ui/src/types.ts | 35 --- packages/ag-ui/test/public-api.test.ts | 11 + packages/ag-ui/test/round-trip.test.ts | 18 +- packages/ag-ui/test/run-input.test.ts | 41 ---- .../ag-ui/test/translate-capabilities.test.ts | 69 ------ .../ag-ui/test/translate-text-tools.test.ts | 114 ---------- packages/cli/src/lib/dev/agui-handler.ts | 168 +++++++++++--- packages/cli/src/lib/dev/runtime-server.ts | 4 +- packages/cli/src/lib/runtime/execute-route.ts | 6 +- packages/cli/test/agui-endpoint.test.ts | 215 ++++++++++++++++-- 15 files changed, 360 insertions(+), 553 deletions(-) delete mode 100644 packages/ag-ui/src/encode.ts delete mode 100644 packages/ag-ui/src/run-input.ts delete mode 100644 packages/ag-ui/src/translate.ts create mode 100644 packages/ag-ui/test/public-api.test.ts delete mode 100644 packages/ag-ui/test/run-input.test.ts delete mode 100644 packages/ag-ui/test/translate-capabilities.test.ts delete mode 100644 packages/ag-ui/test/translate-text-tools.test.ts diff --git a/packages/ag-ui/src/encode.ts b/packages/ag-ui/src/encode.ts deleted file mode 100644 index 5bda3e05..00000000 --- a/packages/ag-ui/src/encode.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 { - const encoder = new EventEncoder(accept ? { accept } : {}) - return encoder.encode(event) -} diff --git a/packages/ag-ui/src/index.ts b/packages/ag-ui/src/index.ts index e8b70396..432a5d46 100644 --- a/packages/ag-ui/src/index.ts +++ b/packages/ag-ui/src/index.ts @@ -1,23 +1,5 @@ -export { encodeAgUiSse } from "./encode.js" export { createCounterIdFactory, createDefaultIdFactory, type IdFactory } from "./ids.js" export { type DawnMessage, type DawnRunInput, fromRunAgentInput } from "./inbound.js" -export { - type DawnInterruptEnvelope, - type DawnResumeRequest, - fromAguiResume, - toAguiInterrupt, -} from "./interrupts.js" +export type { DawnInterruptEnvelope, DawnResumeRequest } from "./interrupts.js" export { type AguiOutboundEvent, type ToAguiOptions, toAguiEvents } from "./outbound.js" -export { type MappedRunInput, mapRunInput, type ResumeDecision } from "./run-input.js" -export { type AgUiTranslator, createAgUiTranslator } from "./translate.js" -export { - type AgUiEvent, - asToolCallData, - asToolResultData, - type DawnStreamChunk, - type DawnToolCallData, - type DawnToolResultData, - type RawChunk, - type RunContext, - type TranslatorOptions, -} from "./types.js" +export type { DawnAgentStreamChunk, RunContext } from "./types.js" 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/sse.ts b/packages/ag-ui/src/sse.ts index 5bda3e05..6c3be4ed 100644 --- a/packages/ag-ui/src/sse.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 14224c05..00000000 --- a/packages/ag-ui/src/translate.ts +++ /dev/null @@ -1,148 +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 = chunk.id ?? 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 queuedToolCallId = queue.shift() - const toolCallId = chunk.id ?? queuedToolCallId ?? 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 297455a8..9beb2a5c 100644 --- a/packages/ag-ui/src/types.ts +++ b/packages/ag-ui/src/types.ts @@ -1,27 +1,3 @@ -import type { BaseEvent } from "@ag-ui/core" - -/** An AG-UI protocol event. Alias kept local so consumers import one name. */ -export type AgUiEvent = BaseEvent - -/** - * 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`. - */ -export interface DawnStreamChunk { - readonly type: string - readonly data?: unknown - readonly id?: string - readonly name?: string - readonly input?: unknown - readonly output?: unknown -} - -export interface TranslatorOptions { - readonly threadId: string - readonly runId: string -} - /** Run identity the consumer supplies; never synthesized by the mapper. */ export interface RunContext { readonly threadId: string @@ -41,17 +17,6 @@ export type DawnAgentStreamChunk = | { readonly type: "done"; readonly data?: unknown } | { readonly type: string; readonly data?: unknown } -/** - * 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 | undefined readonly name: string 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 index ce63e4b6..b2d7de13 100644 --- a/packages/ag-ui/test/round-trip.test.ts +++ b/packages/ag-ui/test/round-trip.test.ts @@ -1,18 +1,26 @@ 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" +import { + createCounterIdFactory, + type DawnAgentStreamChunk, + fromRunAgentInput, + toAguiEvents, +} from "../src/index.js" -async function* one(chunk: RawChunk) { +async function* one(chunk: DawnAgentStreamChunk) { yield chunk - yield { type: "done", data: {} } as RawChunk + 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() })) { + 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 { 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/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 d499f9db..00000000 --- a/packages/ag-ui/test/translate-text-tools.test.ts +++ /dev/null @@ -1,114 +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("preserves upstream tool invocation ids when present", () => { - const t = createAgUiTranslator(opts) - const evs = [ - ...t.translate({ type: "tool_call", id: "run-abc", name: "searchCorpus", input: { query: "x" } }), - ...t.translate({ type: "tool_result", id: "run-abc", name: "searchCorpus", output: "ok" }), - ] - - expect((evs[0] as { toolCallId: string }).toolCallId).toBe("run-abc") - expect((evs[1] as { toolCallId: string }).toolCallId).toBe("run-abc") - expect((evs[3] as { toolCallId: string }).toolCallId).toBe("run-abc") - }) - - 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/cli/src/lib/dev/agui-handler.ts b/packages/cli/src/lib/dev/agui-handler.ts index 178e83ae..8dfcfae0 100644 --- a/packages/cli/src/lib/dev/agui-handler.ts +++ b/packages/cli/src/lib/dev/agui-handler.ts @@ -1,13 +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 type { StreamChunk } from "../runtime/stream-types.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 readonly sandboxManager?: SandboxManager @@ -15,6 +25,7 @@ interface AgUiRequestOptions { readonly request: IncomingMessage readonly response: ServerResponse readonly routeKey: string + readonly streamRoute?: typeof streamResolvedRoute } async function readBody(request: IncomingMessage): Promise { @@ -24,79 +35,166 @@ 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, registry, threadsStore, sandboxManager, signal, request, response, routeKey } = - options + const { + appRoot, + checkpointer, + middleware, + registry, + threadsStore, + sandboxManager, + signal, + 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" } })) + sendJson(response, 400, createRequestErrorBody("Malformed body")) return } + 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" } }), - ) + sendJson(response, 400, createRequestErrorBody("Invalid RunAgentInput")) return } const input = parsed.data 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}` } }), + 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 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 threadId = input.threadId - const existing = await threadsStore.getThread(threadId) - if (!existing) await threadsStore.createThread({ thread_id: threadId }) + if (!(await threadsStore.getThread(threadId))) { + await threadsStore.createThread({ thread_id: threadId }) + } + await threadsStore.updateMetadata(threadId, { route: routeKey }) await threadsStore.updateStatus(threadId, "busy") - const { dawnInput, resumeDecision } = mapRunInput(input) - const translator = createAgUiTranslator({ threadId, runId: input.runId }) - 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)) try { - for await (const chunk of streamResolvedRoute({ + const routeStream = streamRoute({ appRoot, - input: dawnInput, - ...(resumeDecision ? { resumeDecision } : {}), + 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, + }) + for await (const event of toAguiEvents(normalizeDawnStream(routeStream), { + threadId, + runId: input.runId, })) { - for (const e of translator.translate(chunk)) response.write(encodeAgUiSse(e)) + response.write(encodeAgUiSse(event, request.headers.accept)) } - 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), - }), - ) + } finally { await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) } response.end() diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index 7712996a..9b66c21c 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -351,6 +351,8 @@ function buildRouteTable(ctx: { handle: async (req, res, params) => { await handleAgUiRequest({ appRoot, + checkpointer, + middleware, registry, threadsStore, ...(sandboxManager ? { sandboxManager } : {}), @@ -864,7 +866,7 @@ async function handleResumeRequest(options: { for await (const chunk of streamResolvedRoute({ appRoot, input: {}, - resumeDecision: decision, + 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 b5dd3cfd..6ab5eae1 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -255,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?: RouteResumePayload + readonly resume?: RouteResumePayload readonly routeFile: string readonly routeId: string readonly routePath: string @@ -313,7 +313,7 @@ export async function* streamResolvedRoute(options: { const routeParamNames = extractRouteParamNames(options.routeId) - const agentInput = toAgentInput(options.input, options.resumeDecision) + const agentInput = toAgentInput(options.input, options.resume) for await (const chunk of streamAgent({ checkpointer, diff --git a/packages/cli/test/agui-endpoint.test.ts b/packages/cli/test/agui-endpoint.test.ts index ec7f4d11..b9b62a38 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> = [] @@ -31,7 +34,30 @@ async function fixtureApp(): Promise { return appRoot } -it("streams AG-UI events from POST /agui/", 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, +): 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" }, + 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 @@ -44,7 +70,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 }) @@ -54,25 +80,170 @@ 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: {}, - }), +async function setupControlledServer(streamRoute: typeof streamResolvedRoute): Promise { + const appRoot = await fixtureApp() + const threads = new Map; status: string }>() + const server: Server = createServer((request, response) => { + const options = { + appRoot, + 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, + 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(options).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 (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("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" }, + ], + }) + + 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) From b9a9bb1a706d59eb24e92c91b5e2b2508463bdc6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 12 Jul 2026 20:43:48 -0700 Subject: [PATCH 22/30] fix(cli): enforce AG-UI runtime boundaries --- .../cli/src/lib/dev/abortable-iterable.ts | 48 +++ packages/cli/src/lib/dev/agui-handler.ts | 181 ++++++----- packages/cli/test/abortable-iterable.test.ts | 107 +++++++ packages/cli/test/agui-endpoint.test.ts | 283 +++++++++++++++++- packages/cli/test/serve-runtime.test.ts | 54 ++++ 5 files changed, 582 insertions(+), 91 deletions(-) create mode 100644 packages/cli/src/lib/dev/abortable-iterable.ts create mode 100644 packages/cli/test/abortable-iterable.test.ts 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 8dfcfae0..0abfc09c 100644 --- a/packages/cli/src/lib/dev/agui-handler.ts +++ b/packages/cli/src/lib/dev/agui-handler.ts @@ -8,6 +8,7 @@ import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" import { streamResolvedRoute } from "../runtime/execute-route.js" import type { SandboxManager } from "../runtime/sandbox-manager.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" @@ -99,103 +100,121 @@ export async function handleAgUiRequest(options: AgUiRequestOptions): Promise { + if (!requestController.signal.aborted) requestController.abort(new Error(message)) } - - const parsed = RunAgentInputSchema.safeParse(parsedJson) - if (!parsed.success) { - sendJson(response, 400, createRequestErrorBody("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) { - sendJson(response, 404, createRequestErrorBody(`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 + } - 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 parsed = RunAgentInputSchema.safeParse(parsedJson) + if (!parsed.success) { + sendJson(response, 400, createRequestErrorBody("Invalid RunAgentInput")) + return + } + const input = parsed.data - 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 route = registry.lookup(routeKey) + if (!route) { + sendJson(response, 404, createRequestErrorBody(`Unknown route: ${routeKey}`)) + return + } - 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") + 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 + } - response.writeHead(200, { - "cache-control": "no-cache", - connection: "keep-alive", - "content-type": "text/event-stream", - }) + 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 + } - 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 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 await (const event of toAguiEvents(normalizeDawnStream(routeStream), { - threadId, - runId: input.runId, - })) { - response.write(encodeAgUiSse(event, request.headers.accept)) + + 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) } + response.end() } finally { - await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) + request.removeListener("aborted", onRequestAborted) + response.removeListener("close", onResponseClose) } - response.end() } 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 b9b62a38..b5b289fe 100644 --- a/packages/cli/test/agui-endpoint.test.ts +++ b/packages/cli/test/agui-endpoint.test.ts @@ -17,7 +17,7 @@ afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() }) -async function fixtureApp(): 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 = { @@ -25,6 +25,7 @@ async function fixtureApp(): Promise { "package.json": '{ "name": "agui-fixture", "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', + ...overrides, } for (const [rel, body] of Object.entries(files)) { const p = join(appRoot, rel) @@ -47,11 +48,12 @@ function parseSseEvents(text: string): 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: { "content-type": "application/json", accept: "text/event-stream", ...headers }, body: JSON.stringify({ state: {}, tools: [], context: [], forwardedProps: {}, ...body }), }) return { events: parseSseEvents(await response.text()), response } @@ -83,13 +85,30 @@ async function setupServer(fixtures: ReturnType["build return { port } } -async function setupControlledServer(streamRoute: typeof streamResolvedRoute): Promise { +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 options = { + 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: { getTuple: async () => undefined } as unknown as BaseCheckpointSaver, + checkpointer: + controlled.checkpointer ?? + ({ getTuple: async () => undefined } as unknown as BaseCheckpointSaver), middleware: undefined, registry: { appRoot, @@ -106,7 +125,7 @@ async function setupControlledServer(streamRoute: typeof streamResolvedRoute): P response, routeKey: "/chat#agent", signal: new AbortController().signal, - streamRoute, + streamRoute: controlled.streamRoute, threadsStore: { createThread: async ({ thread_id }: { thread_id?: string }) => { const threadId = thread_id ?? "generated" @@ -142,14 +161,16 @@ async function setupControlledServer(streamRoute: typeof streamResolvedRoute): P }, } as unknown as ThreadsStore, } - void handleAgUiRequest(options).catch((error) => { + void handleAgUiRequest(requestOptions).catch((error) => { response.statusCode = 500 response.end(String(error)) }) }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))) - return (server.address() as AddressInfo).port + return { + port: (server.address() as AddressInfo).port, + } } it("streams the canonical AG-UI lifecycle and successful result", async () => { @@ -185,7 +206,7 @@ it("forwards only the newest user message on a later turn", async () => { routeInputs.push(options.input) yield { type: "done", output: { received: options.input } } } - const port = await setupControlledServer(streamRoute) + const { port } = await setupControlledServer({ streamRoute }) await postRun(port, { threadId: "same-thread", @@ -229,7 +250,7 @@ it("preserves the upstream invocation id across canonical AG-UI tool events", as } yield { type: "done", output: { ok: true } } } - const port = await setupControlledServer(streamRoute) + const { port } = await setupControlledServer({ streamRoute }) const { events, response } = await postRun(port, { threadId: "tool-thread", runId: "tool-run", @@ -247,3 +268,245 @@ it("preserves the upstream invocation id across canonical AG-UI tool events", as ]), ) }, 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).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 response = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { + method: "POST", + headers: { "content-type": "application/json", accept: "text/event-stream" }, + body: JSON.stringify({ + context: [], + forwardedProps: {}, + messages: [{ id: "1", role: "user", content: "wait" }], + runId: "disconnect-run", + state: {}, + threadId: "disconnect-thread", + tools: [], + }), + signal: controller.signal, + }) + 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/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", () => { From e29a0faf6eae374833f5d4d8b0a764df586de74a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 11:46:39 -0700 Subject: [PATCH 23/30] docs(ag-ui): document canonical adapter API --- apps/web/content/docs/api.mdx | 83 ++++----- apps/web/content/docs/deployment.mdx | 2 + apps/web/content/docs/dev-server.mdx | 20 ++- apps/web/content/docs/faq.mdx | 2 +- examples/chat/README.md | 26 +-- examples/chat/web/README.md | 39 ++--- examples/chat/web/app/api/copilotkit/route.ts | 9 +- .../app/components/PermissionInterrupt.tsx | 42 ----- .../chat/web/app/components/TodosPanel.tsx | 29 ---- examples/chat/web/app/page.tsx | 20 +-- packages/ag-ui/README.md | 141 ++++++--------- packages/cli/test/docs-bundle.test.ts | 164 ++++++++++++++++++ scripts/check-docs.mjs | 2 +- 13 files changed, 305 insertions(+), 274 deletions(-) delete mode 100644 examples/chat/web/app/components/PermissionInterrupt.tsx delete mode 100644 examples/chat/web/app/components/TodosPanel.tsx diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 1f5797bc..572c4801 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -705,87 +705,70 @@ 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, + fromRunAgentInput, + toAguiEvents, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnMessage, + type DawnRunInput, + type RunContext, + type ToAguiOptions, } from "@dawn-ai/ag-ui" ``` See [Dev Server](/docs/dev-server#ag-ui-endpoint). -### `mapRunInput(input)` +### `toAguiEvents(chunks, context)` ```ts -export function mapRunInput(input: RunAgentInput): MappedRunInput +export function toAguiEvents( + chunks: AsyncIterable, + context: RunContext, + options?: ToAguiOptions, +): AsyncGenerator ``` -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`. +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 type ResumeDecision = "once" | "always" | "deny" +Planning updates, subagent capability events, and other unknown chunk types have no v1 mapping and are ignored. -export interface MappedRunInput { - readonly dawnInput: { readonly messages: ReadonlyArray<{ role: string; content: string }> } - readonly resumeDecision?: ResumeDecision - readonly interruptId?: string -} -``` - -### `createAgUiTranslator(options)` +### `fromRunAgentInput(input)` ```ts -export function createAgUiTranslator(options: TranslatorOptions): AgUiTranslator +export function fromRunAgentInput(input: RunAgentInput): DawnRunInput ``` -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 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 interface TranslatorOptions { - readonly threadId: string - readonly runId: string -} - -export interface AgUiTranslator { - begin(): AgUiEvent[] - translate(chunk: DawnStreamChunk): AgUiEvent[] - end(): AgUiEvent[] +export interface DawnRunInput { + readonly messages: DawnMessage[] + readonly resume?: Array<{ + readonly interruptId: string + readonly status: "resolved" | "cancelled" + readonly payload?: unknown + }> + readonly raw: RunAgentInput } ``` -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."}`. +The adapter leaves AG-UI `tools`, `state`, and `context` uninterpreted in v1; consumers can read them from `raw`. -### `encodeAgUiSse(event, accept?)` +### SSE subpath: `encodeAgUiSse(event, accept?)` ```ts -export function encodeAgUiSse(event: AgUiEvent, accept?: string): string +import { encodeAgUiSse } from "@dawn-ai/ag-ui/sse" ``` -Encodes one AG-UI event as an SSE frame using `@ag-ui/encoder`. - -### Types - ```ts -export type AgUiEvent = BaseEvent - -export interface DawnStreamChunk { - readonly type: string - readonly data?: unknown - readonly name?: string - readonly input?: unknown - readonly output?: unknown -} +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 0d104fcb..f7193d9b 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 edf47f0b..98da43fc 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/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/packages/ag-ui/README.md b/packages/ag-ui/README.md index aa8c5c01..82c4ab4d 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 -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,30 +32,24 @@ 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, fromRunAgentInput, - mapRunInput, toAguiEvents, - type AgUiEvent, + type AguiOutboundEvent, + type DawnAgentStreamChunk, + type DawnInterruptEnvelope, + type DawnMessage, + type DawnResumeRequest, type DawnRunInput, - type DawnStreamChunk, - type MappedRunInput, - type ResumeDecision, type RunContext, - type TranslatorOptions, } from "@dawn-ai/ag-ui" ``` -## Transport Helpers - -Use `toAguiEvents` and `fromRunAgentInput` when you own the transport and only -need pure mapping helpers. These helpers do not depend on the CLI, HTTP, or -LangGraph. +The root package is a pure, transport-agnostic adapter. It has no CLI, HTTP, or +LangGraph dependency. ### `toAguiEvents(chunks, ctx)` @@ -72,17 +66,20 @@ for await (const event of toAguiEvents(dawnChunks, { threadId, runId })) { Supported chunks are: ```ts -type DawnChunk = +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: { interruptId: string; kind?: string; [key: string]: 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. Tool call ids from Dawn chunks are preserved -as AG-UI `toolCallId`. +`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)` @@ -94,78 +91,57 @@ import { fromRunAgentInput } from "@dawn-ai/ag-ui" const { messages, resume, raw } = fromRunAgentInput(runAgentInput) ``` -`resume` is omitted when empty. When present, it is an array of answers addressed -by `interruptId`, for example: +`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: ```ts -[{ interruptId: "perm-1", status: "resolved", payload: "once" }] -``` - -Translate those answers to your runtime's resume call. `raw` exposes the -untouched AG-UI input for `tools`/`state`/`context`. - -## Dev Server Helpers - -### `mapRunInput(input)` - -Maps an AG-UI `RunAgentInput` to Dawn's route input for the built-in local dev -endpoint: - -- 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`. - -Resume accepts either a string decision: - -```json -{ "forwardedProps": { "command": { "resume": "once" } } } +resume?: Array<{ + interruptId: string + status: "resolved" | "cancelled" + payload?: unknown +}> ``` -or an object carrying both the decision and interrupt id: +When present, the adapter preserves those fields in `DawnRunInput.resume`: -```json +```ts { - "forwardedProps": { - "command": { - "resume": { "decision": "once", "interruptId": "perm-abc123" } - } - } + messages: [{ role: "user", content: "Continue", id: "message-1" }], + resume: [ + { interruptId: "perm-1", status: "resolved", payload: "once" }, + { interruptId: "perm-2", status: "cancelled" }, + ], + raw: runAgentInput, } ``` -`ResumeDecision` is `"once" | "always" | "deny"`. - -### `createAgUiTranslator(options)` - -Creates a translator for one AG-UI run: +The adapter does not interpret AG-UI `tools`, `state`, or `context` in v1; they +remain available through `raw`. -```ts -const translator = createAgUiTranslator({ threadId, runId }) -``` +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`. -Call `begin()` once, feed each Dawn stream chunk to `translate(chunk)`, then call -`end()` if the stream finishes without a Dawn `done` chunk. +Planning updates, subagent capability events, and other unknown Dawn chunk types +have no v1 mapping and are ignored. -The translator emits: - -- `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 @@ -179,19 +155,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/cli/test/docs-bundle.test.ts b/packages/cli/test/docs-bundle.test.ts index efe34371..6ece577c 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,156 @@ 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", + ] + 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/scripts/check-docs.mjs b/scripts/check-docs.mjs index 6d478184..9ff60fba 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -197,7 +197,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( From d1136bc27980919018c265fd6459d9aac2b5a690 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:53:03 -0700 Subject: [PATCH 24/30] test(ag-ui): verify published adapter entrypoints --- scripts/lib/pack-check.mjs | 232 +++++++++++++- scripts/lib/published-artifacts.mjs | 9 + scripts/pack-check.mjs | 20 +- scripts/pack-check.test.mjs | 433 ++++++++++++++++++++++++++- scripts/published-artifact-smoke.mjs | 166 ++++++++++ scripts/published-artifacts.test.mjs | 309 ++++++++++++++++++- 6 files changed, 1161 insertions(+), 8 deletions(-) diff --git a/scripts/lib/pack-check.mjs b/scripts/lib/pack-check.mjs index 5adcfc77..19265c09 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,24 @@ export const packages = [ }, { dir: "packages/ag-ui", - expectedFiles: ["dist/index.js", "dist/index.d.ts", "README.md", "package.json"], + expectedFiles: [ + "dist/index.js", + "dist/index.d.ts", + "dist/sse.js", + "dist/sse.d.ts", + "README.md", + "package.json", + ], + expectedExports: { + ".": { + types: "./dist/index.d.ts", + default: "./dist/index.js", + }, + "./sse": { + types: "./dist/sse.d.ts", + default: "./dist/sse.js", + }, + }, requiredFields: libraryRequiredFields, }, { @@ -228,6 +247,213 @@ 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 +} + +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.replace("*", 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..0252b15f 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -4,7 +4,12 @@ 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, + missingExportTargets, + packages, + validatePackManifest, +} from "./lib/pack-check.mjs" const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") validatePackManifest(repoRoot, packages) @@ -46,6 +51,19 @@ try { } } + 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..81900d43 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -5,10 +5,22 @@ 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, 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 +51,25 @@ 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) + }) + it("checks the create app executable", () => { const createAppPackage = packages.find(({ dir }) => dir === "packages/create-dawn-app") @@ -110,6 +141,389 @@ describe("pack manifest validation", () => { }) }) +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 +546,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 }, + ) +} From 59c77a1d792163aee4bef13119d62984e9f49a19 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 15:15:30 -0700 Subject: [PATCH 25/30] chore(ag-ui): finalize canonical adapter release --- .changeset/ag-ui-adapter.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.changeset/ag-ui-adapter.md b/.changeset/ag-ui-adapter.md index 023e7a88..a905ea10 100644 --- a/.changeset/ag-ui-adapter.md +++ b/.changeset/ag-ui-adapter.md @@ -4,21 +4,19 @@ "@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. +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 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. +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 CLI runtime now preserves those optional tool invocation ids in Dawn SSE -`tool_call`/`tool_result` payloads. Local in-process `dawn run` also generates a -one-shot thread id for agent routes so the default SQLite checkpointer can run -the same agent route shape that `dawn dev` already supports. - -Release note: this uses `patch` for all packages to preserve the 0.8.x release -line and avoid fixed-group surprises; confirm the intended bump with the -maintainer at release time. +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`. From dc802f0a46471c9975c1054ed7717b0565e57017 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 16:02:58 -0700 Subject: [PATCH 26/30] fix(ag-ui): close release audit gaps --- apps/web/content/docs/api.mdx | 23 +++++++++++++++++ packages/ag-ui/README.md | 27 +++++++++++++++++++- packages/ag-ui/package.json | 2 +- packages/ag-ui/src/outbound.ts | 3 ++- packages/ag-ui/test/outbound.test.ts | 26 ++++++++++++++++++- scripts/lib/pack-check.mjs | 21 ++++++++++++++++ scripts/pack-check.mjs | 7 ++++++ scripts/pack-check.test.mjs | 37 +++++++++++++++++++++++++++- 8 files changed, 141 insertions(+), 5 deletions(-) diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 572c4801..479ebe3a 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -709,12 +709,17 @@ Pure, transport-agnostic AG-UI translation utilities. The CLI uses the same adap ```ts 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" @@ -722,6 +727,24 @@ import { See [Dev Server](/docs/dev-server#ag-ui-endpoint). +### 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 +const events = toAguiEvents(chunks, context, { + idFactory: createCounterIdFactory(), +}) +``` + +```ts +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string + +export interface ToAguiOptions { + readonly idFactory?: IdFactory +} +``` + ### `toAguiEvents(chunks, context)` ```ts diff --git a/packages/ag-ui/README.md b/packages/ag-ui/README.md index 82c4ab4d..971c88b5 100644 --- a/packages/ag-ui/README.md +++ b/packages/ag-ui/README.md @@ -36,6 +36,8 @@ POST http://127.0.0.1:3001/agui/%2Fchat%23agent ```ts import { + createCounterIdFactory, + createDefaultIdFactory, fromRunAgentInput, toAguiEvents, type AguiOutboundEvent, @@ -44,13 +46,36 @@ import { type DawnMessage, type DawnResumeRequest, type DawnRunInput, + type IdFactory, type RunContext, + type ToAguiOptions, } from "@dawn-ai/ag-ui" ``` The root package is a pure, transport-agnostic adapter. It has no CLI, HTTP, or LangGraph dependency. +### 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 +const events = toAguiEvents(chunks, context, { + idFactory: createCounterIdFactory(), +}) +``` + +```ts +export type IdFactory = (kind: "message" | "toolCall" | "toolResult") => string + +export interface ToAguiOptions { + readonly idFactory?: IdFactory +} +``` + ### `toAguiEvents(chunks, ctx)` Maps a Dawn agent stream to AG-UI events: @@ -116,7 +141,7 @@ When present, the adapter preserves those fields in `DawnRunInput.resume`: } ``` -The adapter does not interpret AG-UI `tools`, `state`, or `context` in v1; they +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 diff --git a/packages/ag-ui/package.json b/packages/ag-ui/package.json index 1710abf7..0a26a450 100644 --- a/packages/ag-ui/package.json +++ b/packages/ag-ui/package.json @@ -34,7 +34,7 @@ "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/outbound.ts b/packages/ag-ui/src/outbound.ts index 6ad5be1e..76b433c2 100644 --- a/packages/ag-ui/src/outbound.ts +++ b/packages/ag-ui/src/outbound.ts @@ -50,7 +50,8 @@ function stringifyContent(output: unknown): string { if (typeof output === "string") return output if (output === undefined || output === null) return "" try { - return JSON.stringify(output) + const serialized = JSON.stringify(output) + return typeof serialized === "string" ? serialized : String(output) } catch { return String(output) } diff --git a/packages/ag-ui/test/outbound.test.ts b/packages/ag-ui/test/outbound.test.ts index 182b7fde..df957b63 100644 --- a/packages/ag-ui/test/outbound.test.ts +++ b/packages/ag-ui/test/outbound.test.ts @@ -1,7 +1,8 @@ -import { EventType } from "@ag-ui/core" +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" } @@ -70,6 +71,29 @@ describe("toAguiEvents", () => { ]) }) + 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" } }, diff --git a/scripts/lib/pack-check.mjs b/scripts/lib/pack-check.mjs index 19265c09..a91ac26e 100644 --- a/scripts/lib/pack-check.mjs +++ b/scripts/lib/pack-check.mjs @@ -150,13 +150,24 @@ export const packages = [ { dir: "packages/ag-ui", 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", @@ -276,6 +287,16 @@ export function expectedExportFailures(exportsField, expectedExports = {}) { 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 && diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 0252b15f..f89ff222 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url" import { expectedExportFailures, + forbiddenPackedFiles, missingExportTargets, packages, validatePackManifest, @@ -45,6 +46,12 @@ 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}`) diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs index 81900d43..073ba1d0 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -7,7 +7,13 @@ import { fileURLToPath } from "node:url" import * as packCheck from "./lib/pack-check.mjs" -const { expectedExportFailures, missingExportTargets, packages, validatePackManifest } = packCheck +const { + expectedExportFailures, + forbiddenPackedFiles, + missingExportTargets, + packages, + validatePackManifest, +} = packCheck const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const tempRoots = [] @@ -68,6 +74,11 @@ describe("pack manifest validation", () => { ) } assert.deepEqual(agUiPackage.expectedExports, agUiExpectedExports) + assert.deepEqual(agUiPackage.forbiddenFiles, [ + "dist/encode.*", + "dist/run-input.*", + "dist/translate.*", + ]) }) it("checks the create app executable", () => { @@ -141,6 +152,30 @@ 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( From 97ab1045a52399b75be5e8b91241784378d9e42c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 14 Jul 2026 09:57:52 -0700 Subject: [PATCH 27/30] test(langchain): satisfy tool id lint --- .../langchain/test/agent-adapter-toolcall-id.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/langchain/test/agent-adapter-toolcall-id.test.ts b/packages/langchain/test/agent-adapter-toolcall-id.test.ts index 4c22eee0..6c863142 100644 --- a/packages/langchain/test/agent-adapter-toolcall-id.test.ts +++ b/packages/langchain/test/agent-adapter-toolcall-id.test.ts @@ -37,9 +37,12 @@ describe("streamAgent — tool-call id correlation", () => { 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") + 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((call?.data as { id?: string }).id).toBe((result?.data as { id?: string }).id) + expect(callId).toBe(resultId) }) }) From 4734c081a8203e080ee536d43b21623c4ed539d4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 14 Jul 2026 09:59:33 -0700 Subject: [PATCH 28/30] fix(chat-web): clean stale Next build output --- examples/chat/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From 141048721f41454e887dd733d395f8fd887e617f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 14 Jul 2026 10:04:16 -0700 Subject: [PATCH 29/30] fix(testing): pass canonical route resume input --- packages/testing/src/harness.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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) From 33527d0f1fc9123727bcb493eed9be84d94b8d68 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 14 Jul 2026 10:16:36 -0700 Subject: [PATCH 30/30] fix(pack-check): replace export wildcards safely --- scripts/lib/pack-check.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/lib/pack-check.mjs b/scripts/lib/pack-check.mjs index a91ac26e..d85922a0 100644 --- a/scripts/lib/pack-check.mjs +++ b/scripts/lib/pack-check.mjs @@ -368,7 +368,9 @@ function exportPatternHasPackedFile(packedRoot, exportKey, patternParts) { const match = pattern.exec(relativePath) const subpath = match?.groups?.subpath return ( - subpath && validPackageSubpath(subpath) && validPackagePath(exportKey.replace("*", subpath)) + subpath && + validPackageSubpath(subpath) && + validPackagePath(exportKey.replaceAll("*", () => subpath)) ) }) }