diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 7ae78b18..85cbb532 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -402,6 +402,112 @@ ], "examples": [] }, + { + "name": "AgUiAgent", + "kind": "interface", + "description": "The neutral Agent contract, widened with the AG-UI adapter's optional\n`customEvents` signal (the chat composition feature-detects it to enable\nlive a2ui streaming). Mirrors langgraph's LangGraphAgent extension.", + "properties": [ + { + "name": "customEvents", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "error", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "events$", + "type": "Observable", + "description": "", + "optional": false + }, + { + "name": "interrupt", + "type": "Signal", + "description": "", + "optional": true + }, + { + "name": "isLoading", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "messages", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "regenerate", + "type": "(assistantMessageIndex: number) => Promise", + "description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.", + "optional": false + }, + { + "name": "state", + "type": "Signal>", + "description": "", + "optional": false + }, + { + "name": "status", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "stop", + "type": "() => Promise", + "description": "", + "optional": false + }, + { + "name": "subagents", + "type": "Signal>", + "description": "", + "optional": true + }, + { + "name": "submit", + "type": "(input: AgentSubmitInput, opts: AgentSubmitOptions) => Promise", + "description": "", + "optional": false + }, + { + "name": "toolCalls", + "type": "Signal", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "CustomStreamEvent", + "kind": "interface", + "description": "A custom event surfaced to consumers via the agent's `customEvents` signal.\nMirrors the LangGraph adapter's CustomStreamEvent shape so the chat\na2ui partial-args bridge consumes both transports identically.", + "properties": [ + { + "name": "data", + "type": "unknown", + "description": "Arbitrary payload from the backend (JSON-string values are parsed).", + "optional": false + }, + { + "name": "name", + "type": "string", + "description": "Event name set by the backend (e.g. 'a2ui-partial', 'state_update').", + "optional": false + } + ], + "examples": [] + }, { "name": "ToAgentOptions", "kind": "interface", @@ -495,7 +601,7 @@ "name": "toAgent", "kind": "function", "description": "Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.\n\nThe adapter subscribes to source.subscribe({ onEvent }) and reduces every\nevent into the produced Agent's signals. submit() optimistically appends the\nuser message to both our signals and the source agent's internal message\nlist, then calls source.runAgent(). stop() calls source.abortRun().\n\nSubscription cleanup: the returned Agent does NOT manage its own lifetime.\nCallers using DI should rely on the provider's destroy hook; direct callers\nof toAgent() should treat the returned object's lifecycle as tied to the\nagent instance they constructed. The subscriber registered via\nsource.subscribe() will fire for the lifetime of source.", - "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): Agent", + "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent", "params": [ { "name": "source", @@ -511,7 +617,7 @@ } ], "returns": { - "type": "Agent", + "type": "AgUiAgent", "description": "" }, "examples": [] diff --git a/apps/website/content/docs/ag-ui/concepts/architecture.mdx b/apps/website/content/docs/ag-ui/concepts/architecture.mdx index 9966fdf3..140f4fdf 100644 --- a/apps/website/content/docs/ag-ui/concepts/architecture.mdx +++ b/apps/website/content/docs/ag-ui/concepts/architecture.mdx @@ -94,6 +94,16 @@ When the user submits input, the adapter builds a user message, appends it local This is optimistic on purpose. The user message appears immediately while the backend starts the run. +## Live a2ui streaming via `customEvents` + +The adapter exposes a `customEvents` signal on the agent returned by +`toAgent` / `injectAgent`, accumulating every non-`on_interrupt` `CUSTOM` +AG-UI event for the current run (reset on each `RUN_STARTED`). The chat +composition feature-detects this signal to drive **progressive** a2ui +surface rendering — token-by-token, as the backend streams `a2ui-partial` +events — matching the LangGraph adapter. Without it, a2ui still renders from +the final tool-call surface; with it, surfaces build up live. + ## Provider choices Use `provideAgent()` when you have a real AG-UI HTTP endpoint. diff --git a/docs/superpowers/plans/2026-06-06-ag-ui-custom-events-signal.md b/docs/superpowers/plans/2026-06-06-ag-ui-custom-events-signal.md new file mode 100644 index 00000000..a7bd8ff9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-ag-ui-custom-events-signal.md @@ -0,0 +1,408 @@ +# AG-UI `customEvents` Signal Implementation Plan (PR 1 of examples/ag-ui) + +> **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:** Add a `customEvents` Signal to the `@threadplane/ag-ui` adapter so live/progressive a2ui streaming renders over AG-UI (closing the one parity gap found in the spike). + +**Architecture:** The reducer already computes `{name, data}` for every non-interrupt `CUSTOM` AG-UI event. We accumulate those into a new `customEvents` `WritableSignal` on the reducer store (reset on `RUN_STARTED`), expose it on the agent returned by `toAgent`, and widen that return type to `AgUiAgent` (mirrors langgraph's `LangGraphAgent`). `chat.component.ts:551` already duck-types for `agent.customEvents()` to feed the a2ui partial-args bridge — no chat changes needed. + +**Tech Stack:** TypeScript, Angular signals, RxJS, `@ag-ui/client`, vitest, Nx. + +**Spec:** `docs/superpowers/specs/2026-06-06-examples-ag-ui-standalone-design.md` (Part 1) + +--- + +## Background facts (verified) + +- `chat.component.ts:548-564` feature-detects `customEvents?: () => readonly { name: string; data: unknown }[]` and processes entries where `name === 'a2ui-partial'` (`data = { tool_call_id, args_so_far }`). It's the ONLY consumer; no chat changes required. +- `libs/ag-ui/src/lib/reducer.ts` `CUSTOM` case: after an `on_interrupt` early-return, it computes `parsedValue` and emits `{type:'custom'|'state_update', ...}` on `events$`. We add accumulation here. +- `ReducerStore` (reducer.ts) is the signal bundle; `to-agent.ts` builds it and returns the neutral `Agent`. `customEvents` is NOT part of the neutral `Agent` type, so the return type must widen. +- Tests use vitest; run with `npx nx test ag-ui`. + +--- + +## File Structure + +**Modified:** +- `libs/ag-ui/src/lib/reducer.ts` — export `CustomStreamEvent`; add `customEvents` to `ReducerStore`; reset on `RUN_STARTED`; accumulate in `CUSTOM`. +- `libs/ag-ui/src/lib/reducer.spec.ts` — extend `makeStore()`; add accumulation/reset tests. +- `libs/ag-ui/src/lib/to-agent.ts` — init `customEvents` in store; return it; widen return type to `AgUiAgent`. +- `libs/ag-ui/src/lib/to-agent.spec.ts` — assert `customEvents` exposure. +- `libs/ag-ui/src/public-api.ts` — export `CustomStreamEvent`, `AgUiAgent`. +- `apps/website/content/docs/ag-ui/api/api-docs.json` — regenerated. +- `apps/website/content/docs/ag-ui/concepts/architecture.mdx` — short note on live a2ui via `customEvents`. + +--- + +## Task 1: Reducer — `customEvents` accumulator (TDD) + +**Files:** +- Modify: `libs/ag-ui/src/lib/reducer.ts` +- Modify: `libs/ag-ui/src/lib/reducer.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +In `libs/ag-ui/src/lib/reducer.spec.ts`, first extend `makeStore()` to include the new signal. Change the `makeStore` return object to add the `customEvents` line (keep all existing lines): + +```ts +function makeStore(): ReducerStore { + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + interrupt: signal(undefined), + events$: new Subject(), + customEvents: signal([]), + }; +} +``` + +Add the import at the top of the spec (alongside the existing `reduceEvent` import): + +```ts +import { reduceEvent, type ReducerStore, type CustomStreamEvent } from './reducer'; +``` + +Then add this `describe` block at the end of the file: + +```ts +describe('reduceEvent — customEvents accumulation', () => { + it('accumulates non-interrupt CUSTOM events as {name, data} in order', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } } as any, store); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{"a":1' } } as any, store); + expect(store.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{"a":1' } }, + ]); + }); + + it('parses JSON-string CUSTOM values before storing', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: '{"tool_call_id":"t1","args_so_far":"{"}' } as any, store); + expect(store.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + ]); + }); + + it('does NOT accumulate on_interrupt CUSTOM events (those drive the interrupt signal)', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'on_interrupt', value: { foo: 'bar' } } as any, store); + expect(store.customEvents()).toEqual([]); + expect(store.interrupt()).toMatchObject({ value: { foo: 'bar' } }); + }); + + it('RUN_STARTED resets customEvents for the new run', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } } as any, store); + expect(store.customEvents()).toHaveLength(1); + reduceEvent({ type: 'RUN_STARTED' } as any, store); + expect(store.customEvents()).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test ag-ui` +Expected: FAIL — `CustomStreamEvent` not exported / `customEvents` missing on `ReducerStore` (compile error) and the new assertions fail. + +- [ ] **Step 3: Implement in reducer.ts** + +In `libs/ag-ui/src/lib/reducer.ts`: + +(a) Add the exported type just above the `ReducerStore` interface: + +```ts +/** + * A custom event surfaced to consumers via the agent's `customEvents` signal. + * Mirrors the LangGraph adapter's CustomStreamEvent shape so the chat + * a2ui partial-args bridge consumes both transports identically. + */ +export interface CustomStreamEvent { + /** Event name set by the backend (e.g. 'a2ui-partial', 'state_update'). */ + name: string; + /** Arbitrary payload from the backend (JSON-string values are parsed). */ + data: unknown; +} +``` + +(b) Add `customEvents` to the `ReducerStore` interface (after the `events$` line): + +```ts +export interface ReducerStore { + messages: WritableSignal; + status: WritableSignal; + isLoading: WritableSignal; + error: WritableSignal; + toolCalls: WritableSignal; + state: WritableSignal>; + interrupt: WritableSignal; + events$: Subject; + customEvents: WritableSignal; +} +``` + +(c) In the `RUN_STARTED` case, add the reset (after `store.interrupt.set(undefined);`): + +```ts + case 'RUN_STARTED': { + store.status.set('running'); + store.isLoading.set(true); + store.error.set(null); + store.interrupt.set(undefined); + store.customEvents.set([]); + return; + } +``` + +(d) In the `CUSTOM` case, accumulate after the `on_interrupt` early-return (so on_interrupt is excluded). The case becomes: + +```ts + case 'CUSTOM': { + const e = event as unknown as { name: string; value: unknown }; + // ag_ui_langgraph serializes interrupt payloads as JSON strings. + // Parse the value if it arrives as a string so downstream consumers + // (e.g. ChatApprovalCardComponent) receive a plain object, not a string. + const parsedValue = typeof e.value === 'string' ? safeParseJson(e.value) : e.value; + if (e.name === 'on_interrupt') { + store.interrupt.set({ id: randomId(), value: parsedValue, resumable: true }); + return; + } + // Surface every other custom event on the customEvents signal so the + // chat a2ui partial-args bridge (which reads agent.customEvents()) lights + // up live/progressive a2ui rendering — parity with the LangGraph adapter. + store.customEvents.update((prev) => [...prev, { name: e.name, data: parsedValue }]); + if (e.name === 'state_update' && isRecord(parsedValue)) { + store.events$.next({ type: 'state_update', data: parsedValue }); + } else { + store.events$.next({ type: 'custom', name: e.name, data: parsedValue }); + } + return; + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test ag-ui` +Expected: PASS (all reducer tests, including the 4 new ones). + +- [ ] **Step 5: Commit** + +```bash +git add libs/ag-ui/src/lib/reducer.ts libs/ag-ui/src/lib/reducer.spec.ts +git commit -m "feat(ag-ui): accumulate non-interrupt CUSTOM events into a customEvents store signal" +``` + +--- + +## Task 2: Expose `customEvents` on the agent (TDD) + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` +- Modify: `libs/ag-ui/src/lib/to-agent.spec.ts` +- Modify: `libs/ag-ui/src/public-api.ts` + +- [ ] **Step 1: Write the failing test** + +In `libs/ag-ui/src/lib/to-agent.spec.ts`, add a test that builds an agent and asserts `customEvents` is exposed and reactive. Use the existing test's pattern for constructing a fake `AbstractAgent` source (mirror whatever harness the file already uses to drive `onEvent`). Add: + +```ts +it('exposes a customEvents signal that reflects reduced CUSTOM events', () => { + // `source` is the fake AbstractAgent used elsewhere in this file; it must + // let the test push events into the subscriber's onEvent callback. + const agent = toAgent(source) as import('./to-agent').AgUiAgent; + expect(typeof agent.customEvents).toBe('function'); + expect(agent.customEvents()).toEqual([]); + + emit({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } }); + + expect(agent.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + ]); +}); +``` + +> If `to-agent.spec.ts` already defines a helper to create `source` + an `emit(event)` function that invokes the registered `onEvent`, reuse it. If it does not, model `emit` on how the existing tests deliver events (find the `source.subscribe` capture in the file's setup and expose the captured `onEvent`). Do not invent a new harness if one exists. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx nx test ag-ui` +Expected: FAIL — `AgUiAgent` not exported / `customEvents` missing on the returned object. + +- [ ] **Step 3: Implement in to-agent.ts** + +(a) Update the imports. Change the `@angular/core` import to also bring `Signal`, and the reducer import to bring the new type: + +```ts +import { signal, type Signal } from '@angular/core'; +``` +```ts +import { reduceEvent, type ReducerStore, type CustomStreamEvent } from './reducer'; +``` + +(b) Add an exported return-type interface just above `export function toAgent(...)`: + +```ts +/** + * The neutral Agent contract, widened with the AG-UI adapter's optional + * `customEvents` signal (the chat composition feature-detects it to enable + * live a2ui streaming). Mirrors langgraph's LangGraphAgent extension. + */ +export interface AgUiAgent extends Agent { + customEvents: Signal; +} +``` + +(c) Add `customEvents` to the store initialization (after the `events$` line): + +```ts + const store: ReducerStore = { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + interrupt: signal(undefined), + events$: new Subject(), + customEvents: signal([]), + }; +``` + +(d) Change the function return type from `: Agent` to `: AgUiAgent`: + +```ts +export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): AgUiAgent { +``` + +(e) Add `customEvents` to the returned object (after the `events$:` line in the `return { ... }`): + +```ts + events$: store.events$.asObservable(), + customEvents: store.customEvents, +``` + +- [ ] **Step 4: Export the new types from the public API** + +In `libs/ag-ui/src/public-api.ts`, extend the to-agent export line and add the reducer type: + +```ts +export { toAgent } from './lib/to-agent'; +export type { ToAgentOptions, AgUiAgent } from './lib/to-agent'; +export type { CustomStreamEvent } from './lib/reducer'; +``` + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npx nx test ag-ui` +Expected: PASS (including the new to-agent test). + +Run: `npx nx build ag-ui` +Expected: builds cleanly (confirms the widened return type + exports compile and the public API surface is valid). + +- [ ] **Step 6: Commit** + +```bash +git add libs/ag-ui/src/lib/to-agent.ts libs/ag-ui/src/lib/to-agent.spec.ts libs/ag-ui/src/public-api.ts +git commit -m "feat(ag-ui): expose customEvents signal on toAgent (AgUiAgent) for live a2ui streaming" +``` + +--- + +## Task 3: Docs — regenerate API reference + architecture note + +**Files:** +- Modify: `apps/website/content/docs/ag-ui/api/api-docs.json` (generated) +- Modify: `apps/website/content/docs/ag-ui/concepts/architecture.mdx` + +- [ ] **Step 1: Regenerate the API docs** + +Run: `npm run generate-api-docs` +Then confirm the ag-ui reference picked up the new symbols: + +Run: `grep -c "customEvents\|AgUiAgent\|CustomStreamEvent" apps/website/content/docs/ag-ui/api/api-docs.json` +Expected: a non-zero count (the new exports appear in the generated reference). + +> If `npm run generate-api-docs` regenerates other products' `api-docs.json` too, only stage the `ag-ui` one (Step 4) — leave unrelated regenerated files out of this commit unless they are also stale on main. + +- [ ] **Step 2: Add a short note to the architecture doc** + +Read `apps/website/content/docs/ag-ui/concepts/architecture.mdx`. Find the section that discusses events/state or the agent surface (where `events$`, interrupts, or state are described). Append this subsection at a sensible spot (end of the relevant section): + +```mdx +## Live a2ui streaming via `customEvents` + +The adapter exposes a `customEvents` signal on the agent returned by +`toAgent` / `injectAgent`, accumulating every non-`on_interrupt` `CUSTOM` +AG-UI event for the current run (reset on each `RUN_STARTED`). The chat +composition feature-detects this signal to drive **progressive** a2ui +surface rendering — token-by-token, as the backend streams `a2ui-partial` +events — matching the LangGraph adapter. Without it, a2ui still renders from +the final tool-call surface; with it, surfaces build up live. +``` + +- [ ] **Step 3: Verify the website still builds the MDX** + +Run: `npx nx build website 2>&1 | tail -5` +Expected: builds (or at minimum, no error referencing `architecture.mdx`). If a full website build is too slow/heavy in this environment, instead validate the MDX parses by confirming no syntax error: `npx tsx -e "require('fs').readFileSync('apps/website/content/docs/ag-ui/concepts/architecture.mdx','utf8')"` and visually confirm the fenced block is balanced. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/content/docs/ag-ui/api/api-docs.json apps/website/content/docs/ag-ui/concepts/architecture.mdx +git commit -m "docs(ag-ui): document customEvents signal + live a2ui streaming; regen API reference" +``` + +--- + +## Task 4: PR + merge + +- [ ] **Step 1: Push the branch** + +Run: `git push -u origin claude/ag-ui-custom-events-signal` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "feat(ag-ui): customEvents signal for live a2ui streaming parity" --body "$(cat <<'EOF' +## Summary + +Adds a `customEvents` signal to the `@threadplane/ag-ui` adapter, closing the one gap that prevented live/progressive a2ui streaming over AG-UI (found via the examples/ag-ui spike). + +- Reducer accumulates every non-`on_interrupt` `CUSTOM` event as `{name, data}` into a new store signal; resets on `RUN_STARTED`. +- `toAgent` exposes it (return type widened to `AgUiAgent`, mirroring langgraph's `LangGraphAgent`). `chat.component.ts` already feature-detects `agent.customEvents()` — no chat changes. +- New public exports: `AgUiAgent`, `CustomStreamEvent`. +- Docs: regenerated ag-ui API reference + architecture note. + +Without this, a2ui renders from the final tool-call surface (fallback); with it, surfaces build up token-by-token. Part 1 of the `examples/ag-ui` design (`docs/superpowers/specs/2026-06-06-examples-ag-ui-standalone-design.md`). + +## Test plan + +- [x] Unit: reducer accumulation/reset/JSON-parse/on_interrupt-exclusion; toAgent exposure. +- [ ] CI green; `nx build ag-ui` clean. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Arm auto-merge** + +Run: `gh pr merge --squash --auto --delete-branch` + +- [ ] **Step 4: Wait for green + merge** + +Poll until merged. If the `Vercel – threadplane` preview fails on a transient npm-registry 404 (known flake this session), redeploy that preview via the Vercel API rather than treating it as a real failure. + +--- + +## Self-Review + +- [ ] **Spec coverage (Part 1):** `customEvents` signal → Tasks 1-2. Unit tests → Tasks 1-2. API-docs regen + signal docs → Task 3. Lifecycle (reset per run) → Task 1 RUN_STARTED test. +- [ ] **No placeholders:** every step has literal code/commands. +- [ ] **Type consistency:** `CustomStreamEvent { name, data }` defined in reducer.ts (Task 1), imported in to-agent (Task 2), exported in public-api (Task 2). `AgUiAgent extends Agent { customEvents: Signal }` consistent between to-agent return (Task 2) and the test cast (Task 2). `customEvents` is a `WritableSignal` in the store, exposed as `Signal` on the agent — assignable. +- [ ] **No chat changes:** confirmed `chat.component.ts:551` duck-types structurally; returning the signal (callable, returns `CustomStreamEvent[]` ⊆ `readonly {name,data}[]`) satisfies it. diff --git a/docs/superpowers/specs/2026-06-06-examples-ag-ui-standalone-design.md b/docs/superpowers/specs/2026-06-06-examples-ag-ui-standalone-design.md new file mode 100644 index 00000000..d320a03e --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-examples-ag-ui-standalone-design.md @@ -0,0 +1,121 @@ +# Standalone AG-UI Example (examples/ag-ui) — Design + +**Status:** Draft +**Date:** 2026-06-06 +**Owner:** Brian Love + +## Problem + +`examples/chat/` is the canonical, clone-and-run, full-stack reference example: a polished Angular chat app (LangGraph adapter) + a feature-rich Python graph (a2ui generative UI, streaming envelopes, tool calls), deployed to `demo.threadplane.ai` (frontend) and LangGraph Cloud (backend). There is no equivalent standalone example for the **AG-UI** transport. The cockpit ships only `ag-ui/streaming` and `ag-ui/interrupts` cards — single-capability demos, not a polished peer to the canonical chat demo. + +We want a standalone `examples/ag-ui/` that is a true peer to `examples/chat/` — the **same chat UX with full feature parity**, re-fronted with the AG-UI adapter — to demonstrate LangGraph↔AG-UI transport parity as a marketing-grade reference. + +## Feasibility (resolved via static spike) + +The chat UI consumes a neutral `Agent` contract that both adapters implement, so the entire UI + a2ui rendering stack is transport-agnostic — with **one** gap: + +- **Final a2ui surfaces** arrive in assistant-message content (`---a2ui_JSON---` wrapper), parsed by `libs/chat/.../content-classifier.ts`, rendered from `classified.a2uiSurfaces()`. Fully neutral — renders over AG-UI today. +- **Live/progressive a2ui streaming** is driven by `chat.component.ts:548`, which duck-types for an `agent.customEvents()` **Signal**. Only the LangGraph adapter exposes it (`libs/langgraph/.../agent.fn.ts`). The AG-UI adapter emits the same custom events on its `events$` Observable (`libs/ag-ui/src/lib/reducer.ts:180` CUSTOM case) but not as a `customEvents` signal. Without it, progressive a2ui silently falls back to final-surface rendering. + +**Conclusion:** full parity is achievable by adding one bounded library capability (the `customEvents` signal) plus the example itself. No rework. + +## Goals + +- A standalone `examples/ag-ui/` peer to `examples/chat/` with full feature parity (a2ui, tool calls, streaming). +- Close the live-a2ui-streaming gap in `@threadplane/ag-ui` (genuine adapter capability, valuable beyond this example). +- Deploy as a true peer to the canonical demo: dedicated Railway backend + `ag-ui-demo.threadplane.ai` frontend. +- Demonstrate that everything above the `Agent` contract is byte-identical to `examples/chat` — that *is* the parity proof. + +## Non-Goals + +- No change to the LangGraph adapter or the canonical demo (LangGraph already has `customEvents`; nothing to migrate). +- No LangGraph Cloud deployment of this graph (uvicorn/Railway by nature). +- No merge with the cockpit `ag-ui-dev` service (deliberately separate marketing/dev boundary). +- No capabilities beyond `examples/chat` parity. + +## Design + +### Part 1 — Library: `customEvents` signal on `@threadplane/ag-ui` + +Add `customEvents: Signal` to the agent returned by `toAgent`/`provideAgent`, accumulating the custom events the reducer already emits (the `CUSTOM` case re-emits `{type:'custom', name, data}` on `events$`). Mirrors `libs/langgraph/.../agent.fn.ts` (`custom$` BehaviorSubject → `toSignal`). Lifecycle: accumulates within a run, resets per run, does not leak across threads. + +This is the one symbol `chat.component.ts:548` duck-types for to feed the partial-args bridge — adding it enables token-by-token a2ui rendering over AG-UI. Without it, a2ui still renders (final-surface fallback). + +- **Unit tests** (co-located in `libs/ag-ui`): `customEvents` accumulates CUSTOM events in order; resets per run; absent custom events → empty signal. +- **Docs:** regenerate `apps/website/content/docs/ag-ui/api/api-docs.json` (via `npm run generate-api-docs`); update the AG-UI adapter reference/guide that enumerates the agent surface to list `customEvents`; add a note to the a2ui/generative-ui guidance that AG-UI now supports live a2ui streaming. + +Lands as its own PR (small, self-contained), before the example. + +### Part 2 — Example: `examples/ag-ui/`, a peer to `examples/chat/` + +Three units mirroring the canonical demo: + +**`examples/ag-ui/angular/`** — the chat UI, fronted by the AG-UI adapter: +- `provideAgent({ url: '/agent' })` + `@threadplane/ag-ui` instead of `LANGGRAPH_THREADS_CONFIG` + `@threadplane/langgraph`. +- The `` composition, a2ui rendering, and `@threadplane/render` usage are **unchanged** (neutral contract) — this identity is the parity demonstration. +- `provideChat`, telemetry, routing mirror `examples/chat/angular`. + +**`examples/ag-ui/python/`** — the `examples/chat` a2ui graph, **duplicated** (copy, don't import, per the standalone-examples convention), served over AG-UI: +- `server.py` wraps the graph with `ag-ui-langgraph`'s `LangGraphAgent` + `add_langgraph_fastapi_endpoint(path='/agent')`, plus an unauthenticated `/ok` health route. +- Auth middleware reuses the proven `ag-ui-dev` shape (returns `JSONResponse(status_code=401)` — NOT `raise HTTPException`, which surfaces as 500 inside Starlette middleware), **enforced only when `AG_UI_INTERNAL_TOKEN` is set** so `git clone && docker run` works locally without it. +- Self-contained deployment artifacts live here: `Dockerfile` (multi-stage python:3.12-slim), `entrypoint.sh` (uvicorn + watchdog with startup grace), `railway.json` (healthcheck `/ok`). The example deploys on its own — no separate `deployments/` dir, no generator (single graph). + +**`examples/ag-ui/smoke/`** — the create-app smoke harness mirrored from `examples/chat/smoke/` (`cli.mjs` + `template/`): scaffolds a fresh consumer app from the **published** `@threadplane/ag-ui` + `@threadplane/chat` packages and verifies it builds/runs, catching ag-ui packaging regressions. + +### Part 3 — Deploy: dedicated Railway + Vercel subdomain + +**Backend (Railway):** a new dedicated service `ag-ui-demo` (separate from cockpit `ag-ui-dev`). Built from `examples/ag-ui/python/`'s self-contained Dockerfile. Env: `OPENAI_API_KEY`, `AG_UI_INTERNAL_TOKEN`. + +**Frontend (Vercel):** a new project `threadplane-ag-ui-demo` → `ag-ui-demo.threadplane.ai`, assembled by `scripts/assemble-ag-ui-demo.ts` (mirrors `assemble-demo.ts`): builds the SPA, emits Build Output `config.json` routing `/agent*` → a proxy function (named `[[...path]]`, per the Vercel Build Output rule) and SPA fallback. + +**Proxy:** a small dedicated middleware (mirrors `demo-middleware.ts` + the established `ag-ui-proxy.ts` defense): origin allowlist (`ag-ui-demo.threadplane.ai` + localhost), Upstash rate-limit (fail-open), `X-Internal-Token` injection, streaming-aware fetch → the `ag-ui-demo` Railway service. + +**CI** (`.github/workflows/ci.yml`): three new jobs mirroring `examples/chat` + the canonical demo: +- `examples/ag-ui — python smoke` (graph imports/tests) +- `examples/ag-ui — e2e` (aimock replay — see Testing) +- `ag-ui demo → Vercel` deploy, gated on the above being green (refuse-on-red guard like the canonical demo) + a Railway `up`. +Path-filtered on `examples/ag-ui/**` + the assemble/proxy scripts. + +**Provisioning** (Railway service + project deploy token + env; Vercel project + custom domain + env) done via API; GitHub secret for the Railway token. Only manual step: the OpenAI spend cap. + +### Testing + +The key enabler: **aimock mocks the OpenAI provider, not the transport** — so the same graph over AG-UI replaying the same recorded LLM fixtures yields the same a2ui surfaces. Fixtures port transport-agnostically. + +**e2e — port `examples/chat/angular/e2e/`:** +- `aimock-runner.ts` + `global-setup.ts`: start the uvicorn AG-UI backend with aimock intercepting OpenAI + replaying fixtures; serve the Angular app via `provideAgent`. +- Port specs: `initial-render`, `a2ui-single-bubble` (**critical parity assertion** + regression guard for the new `customEvents` signal), `markdown-surfaces`, `color-scheme`, `error-handling`, `browser-hygiene`. Fixtures copied from `examples/chat` (same graph, same responses). + +**Library unit tests** (Part 1): `customEvents` accumulation/order/reset. + +**Production validation (post-deploy):** `/ok` 200; `ag-ui-demo.threadplane.ai` loads; one live a2ui run renders end-to-end; proxy returns 403/413/429 on the abuse paths. + +## Risks + +- **`customEvents` lifecycle** — must reset per run, not leak across threads. Reference: the LangGraph impl. Guarded by unit tests + the `a2ui-single-bubble` e2e. +- **AG-UI SSE encoding of tool-call/custom args** — aimock sits below the transport (deterministic LLM replay), but ag-ui-langgraph wraps tool-call/custom events differently than the LangGraph transport. If a2ui renders differently over AG-UI, the ported e2e catches it — the parity signal we want. +- **Graph duplication drift** — the copied graph can diverge from `examples/chat`'s; accepted per the standalone-examples convention, noted in the example README. +- **New infra** — new Railway service + Vercel project + subdomain (API-provisionable, but the most moving parts of recent efforts). + +## Sequencing + +1. **PR 1 — Library:** `customEvents` signal + unit tests + docs regen. Small, self-contained; lands first. +2. **PR 2 — Example (local):** `examples/ag-ui/{angular,python,smoke}` + ported e2e, fully runnable via clone (token-optional backend). No cloud. +3. **PR 3 — Deploy:** assemble-ag-ui-demo + proxy + CI jobs + provisioning. + +Honest effort: the largest recent effort — three PRs, a full e2e port, fresh infra. Realistically multi-session. + +## Files (high level) + +**New:** +- `libs/ag-ui/src/lib/...` — `customEvents` signal wiring + spec (Part 1) +- `examples/ag-ui/angular/**` — chat app over ag-ui + ported e2e +- `examples/ag-ui/python/**` — duplicated graph + server.py + Dockerfile + entrypoint.sh + railway.json +- `examples/ag-ui/smoke/**` — create-app smoke harness +- `examples/ag-ui/project.json` (+ angular/python/smoke project.json) +- `scripts/assemble-ag-ui-demo.ts` + the ag-ui demo proxy middleware + +**Modified:** +- `libs/ag-ui/src/lib/to-agent.ts` (expose `customEvents`) +- `apps/website/content/docs/ag-ui/**` (api-docs regen + signal docs) +- `.github/workflows/ci.yml` (three new jobs + deploy trigger) diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index b6c3a2b5..edf0ba05 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -5,7 +5,7 @@ import { Subject } from 'rxjs'; import type { Message, AgentStatus, ToolCall, AgentEvent, } from '@threadplane/chat'; -import { reduceEvent, type ReducerStore } from './reducer'; +import { reduceEvent, type ReducerStore, type CustomStreamEvent } from './reducer'; function makeStore(): ReducerStore { return { @@ -17,6 +17,7 @@ function makeStore(): ReducerStore { state: signal>({}), interrupt: signal(undefined), events$: new Subject(), + customEvents: signal([]), }; } @@ -301,3 +302,38 @@ describe('reduceEvent — REASONING_MESSAGE_*', () => { expect(msgs[0].content).toBe('hello'); }); }); + +describe('reduceEvent — customEvents accumulation', () => { + it('accumulates non-interrupt CUSTOM events as {name, data} in order', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } } as any, store); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{"a":1' } } as any, store); + expect(store.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{"a":1' } }, + ]); + }); + + it('parses JSON-string CUSTOM values before storing', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: '{"tool_call_id":"t1","args_so_far":"{"}' } as any, store); + expect(store.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + ]); + }); + + it('does NOT accumulate on_interrupt CUSTOM events (those drive the interrupt signal)', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'on_interrupt', value: { foo: 'bar' } } as any, store); + expect(store.customEvents()).toEqual([]); + expect(store.interrupt()).toMatchObject({ value: { foo: 'bar' } }); + }); + + it('RUN_STARTED resets customEvents for the new run', () => { + const store = makeStore(); + reduceEvent({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } } as any, store); + expect(store.customEvents()).toHaveLength(1); + reduceEvent({ type: 'RUN_STARTED' } as any, store); + expect(store.customEvents()).toEqual([]); + }); +}); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 2f8aca66..7cc2b354 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -31,15 +31,28 @@ interface AgUiSnapshotMessage { [key: string]: unknown; } +/** + * A custom event surfaced to consumers via the agent's `customEvents` signal. + * Mirrors the LangGraph adapter's CustomStreamEvent shape so the chat + * a2ui partial-args bridge consumes both transports identically. + */ +export interface CustomStreamEvent { + /** Event name set by the backend (e.g. 'a2ui-partial', 'state_update'). */ + name: string; + /** Arbitrary payload from the backend (JSON-string values are parsed). */ + data: unknown; +} + export interface ReducerStore { - messages: WritableSignal; - status: WritableSignal; - isLoading: WritableSignal; - error: WritableSignal; - toolCalls: WritableSignal; - state: WritableSignal>; - interrupt: WritableSignal; - events$: Subject; + messages: WritableSignal; + status: WritableSignal; + isLoading: WritableSignal; + error: WritableSignal; + toolCalls: WritableSignal; + state: WritableSignal>; + interrupt: WritableSignal; + events$: Subject; + customEvents: WritableSignal; } /** @@ -73,6 +86,7 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { store.isLoading.set(true); store.error.set(null); store.interrupt.set(undefined); + store.customEvents.set([]); return; } case 'RUN_FINISHED': { @@ -262,6 +276,10 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { store.interrupt.set({ id: randomId(), value: parsedValue, resumable: true }); return; } + // Surface every other custom event on the customEvents signal so the + // chat a2ui partial-args bridge (which reads agent.customEvents()) lights + // up live/progressive a2ui rendering — parity with the LangGraph adapter. + store.customEvents.update((prev) => [...prev, { name: e.name, data: parsedValue }]); if (e.name === 'state_update' && isRecord(parsedValue)) { store.events$.next({ type: 'state_update', data: parsedValue }); } else { diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 1b66c43e..8f5223ba 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -175,6 +175,19 @@ describe('toAgent', () => { expect(seen).toEqual([{ type: 'state_update', data: { x: 1 } }]); }); + it('exposes a customEvents signal that reflects reduced CUSTOM events', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + expect(typeof a.customEvents).toBe('function'); + expect(a.customEvents()).toEqual([]); + + stub.emit({ type: 'CUSTOM', name: 'a2ui-partial', value: { tool_call_id: 't1', args_so_far: '{' } } as unknown as BaseEvent); + + expect(a.customEvents()).toEqual([ + { name: 'a2ui-partial', data: { tool_call_id: 't1', args_so_far: '{' } }, + ]); + }); + it('sets error status when onRunFailed subscriber fires', () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index f6bf1dc8..de19825e 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { signal } from '@angular/core'; +import { signal, type Signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; import type { @@ -10,7 +10,7 @@ import type { AgentRuntimeTelemetrySink, AgentSubmitInput, AgentSubmitOptions, } from '@threadplane/chat'; -import { reduceEvent, type ReducerStore } from './reducer'; +import { reduceEvent, type ReducerStore, type CustomStreamEvent } from './reducer'; export interface ToAgentOptions { /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ @@ -44,6 +44,15 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { return 'UnknownError'; } +/** + * The neutral Agent contract, widened with the AG-UI adapter's optional + * `customEvents` signal (the chat composition feature-detects it to enable + * live a2ui streaming). Mirrors langgraph's LangGraphAgent extension. + */ +export interface AgUiAgent extends Agent { + customEvents: Signal; +} + /** * Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract. * @@ -58,16 +67,17 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { * agent instance they constructed. The subscriber registered via * source.subscribe() will fire for the lifetime of source. */ -export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Agent { +export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): AgUiAgent { const store: ReducerStore = { - messages: signal([]), - status: signal('idle'), - isLoading: signal(false), - error: signal(null), - toolCalls: signal([]), - state: signal>({}), - interrupt: signal(undefined), - events$: new Subject(), + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + interrupt: signal(undefined), + events$: new Subject(), + customEvents: signal([]), }; const telemetryProperties = { transport: 'ag-ui' as const, surface: 'to_agent' }; let activeRun: { startedAt: number; errored: boolean } | null = null; @@ -131,7 +141,8 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag toolCalls: store.toolCalls, state: store.state, interrupt: store.interrupt, - events$: store.events$.asObservable(), + events$: store.events$.asObservable(), + customEvents: store.customEvents, submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { if (input.resume !== undefined) { diff --git a/libs/ag-ui/src/public-api.ts b/libs/ag-ui/src/public-api.ts index 054f5c4b..2073cd80 100644 --- a/libs/ag-ui/src/public-api.ts +++ b/libs/ag-ui/src/public-api.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT export { toAgent } from './lib/to-agent'; -export type { ToAgentOptions } from './lib/to-agent'; +export type { ToAgentOptions, AgUiAgent } from './lib/to-agent'; +export type { CustomStreamEvent } from './lib/reducer'; export { provideAgent, injectAgent } from './lib/provide-agent'; export type { AgentConfig } from './lib/provide-agent'; export { FakeAgent } from './lib/testing/fake-agent';