From 049e09677d982c3927a707051a6d115259ea254f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 09:28:58 -0400 Subject: [PATCH 1/7] docs: AG-UI adapter design New @cacheplane/ag-ui package wraps @ag-ui/client AbstractAgent into the runtime-neutral Agent contract. Scope B: messages + lifecycle + tool calls + state. toAgent(source) primitive + provideAgUiAgent({url}) DI convenience. Pure-function reducer; conformance test against shared suite. Cockpit demo proves end-to-end decoupling. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-04-27-ag-ui-adapter-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md diff --git a/docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md b/docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md new file mode 100644 index 000000000..1331491ce --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md @@ -0,0 +1,192 @@ +# `@cacheplane/ag-ui` Adapter Design + +## Goal + +Build a runtime adapter that projects an AG-UI `AbstractAgent`'s event stream into the `@cacheplane/chat` `Agent` contract. Proves the chat-runtime decoupling end-to-end: the same chat UI primitives can be driven by AG-UI as well as LangGraph. + +## Motivation + +Phases 1–2 plus the rename and `events$` contract work made `@cacheplane/chat` runtime-neutral. AG-UI is the most strategic second adapter: + +- **Industry standard.** AG-UI is a CopilotKit-led protocol with broad ecosystem support — LangGraph Platform, CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands, Google Agent SDK. One adapter unlocks all of them. +- **Event-stream native.** AG-UI is fundamentally an `Observable` model. Mapping into our signals + `events$` contract is direct. +- **Validates the abstraction.** Without a second adapter, the `Agent` contract is "LangGraph types with the LangGraph filed off." AG-UI exposes whether the abstraction holds in practice. + +This is the originating motivation behind the chat-decoupling work; AG-UI is the demand-side it was always pointing at. + +## Architecture + +### Package + +New package `@cacheplane/ag-ui` at `libs/ag-ui/`. + +**Dependencies:** +- `@cacheplane/chat` (peer dep) +- `@cacheplane/licensing` (peer dep — same as other libs) +- `@ag-ui/client` (peer dep) +- `@angular/core`, `rxjs` (peer deps) + +`@cacheplane/chat` does NOT depend on `@cacheplane/ag-ui`. The dep graph stays one-way: `ag-ui → chat`. Symmetric with `langgraph → chat`. + +### Public API + +```ts +// libs/ag-ui/src/public-api.ts (sketch) + +// Primitive — wraps any AbstractAgent subclass (custom transports, mocks). +export function toAgent(source: AbstractAgent): Agent; + +// Ergonomic — instantiates HttpAgent under the hood for the common case. +export function provideAgUiAgent(cfg: AgUiAgentConfig): Provider[]; + +// Re-exports for convenience. +export type { AgUiAgentConfig } from './lib/provide-ag-ui-agent'; +``` + +```ts +export interface AgUiAgentConfig { + url: string; + agentId?: string; + threadId?: string; + headers?: Record; +} +``` + +### Naming + +`toAgent` matches the LangGraph adapter's export name. Consumers importing both packages alias at the import site: + +```ts +import { toAgent as toAgUiAgent } from '@cacheplane/ag-ui'; +import { toAgent as toLangGraphAgent } from '@cacheplane/langgraph'; +``` + +Same naming convention agreed during the rename design — see `docs/superpowers/specs/2026-04-24-agent-rename-design.md`. + +### Wrapping strategy + +`toAgent(source: AbstractAgent)` is the primitive — works with any `AbstractAgent` subclass, including user-written ones with custom transports. Most users go through `provideAgUiAgent({ url })` which constructs an `HttpAgent` (the SSE/HTTP transport `@ag-ui/client` ships) and threads it through `toAgent`. + +This combo gives custom transports (subclass `AbstractAgent`, hand to `toAgent`) and ergonomic defaults (`provideAgUiAgent({ url })`) in one package. + +## Event → Contract Mapping + +Scope B: messages + lifecycle + tool calls + state. No interrupt, no subagents, no history. + +| AG-UI event | Agent contract effect | +|---|---| +| `RunStarted` | `status: 'running'`, `isLoading: true`, `error: null` | +| `RunFinished` | `status: 'idle'`, `isLoading: false` | +| `RunError` | `status: 'error'`, `isLoading: false`, `error: event.message` | +| `TextMessageStart` | append `Message { role: 'assistant', content: '' }` | +| `TextMessageContent` | replace in-flight assistant message content with accumulated delta | +| `TextMessageEnd` | finalize (no signal change) | +| `ToolCallStart` | append `ToolCall { id, name, args: {}, status: 'running' }` | +| `ToolCallArgs` | replace `args` on the in-flight tool call (full-replace, not JSON-merge) | +| `ToolCallEnd` | mark `status: 'complete'` | +| `ToolCallResult` | set `result` on the matching tool call | +| `StateSnapshot` | replace `state` signal wholesale | +| `StateDelta` | apply JSON-Patch (RFC 6902) to `state` | +| `MessagesSnapshot` | replace `messages` signal (thread restore) | +| `CustomEvent` | emit on `events$`. If `name === 'state_update'` and `data` is `Record`, emit `{ type: 'state_update', data }`; otherwise emit `{ type: 'custom', name, data }` | + +### Submit / stop + +`submit({ message })`: +1. Optimistically append the user message to `messages` signal (so the UI echoes immediately, matching the LangGraph adapter's behavior). +2. Build `runAgent` parameters: `{ messages: messages(), state: state() }`. +3. Call `source.runAgent(params, { signal: abortController.signal })`. +4. The reducer drives subsequent events into signals. + +`stop()`: +- Aborts the in-flight `AbortController` if any. The underlying `runAgent` rejects with an abort error, which is swallowed (already represented in `error` signal if `RunError` fires). + +### State store + +The adapter owns: +- `WritableSignal` for `messages` +- `WritableSignal` for `status` +- `WritableSignal` for `isLoading` +- `WritableSignal` for `error` +- `WritableSignal` for `toolCalls` +- `WritableSignal>` for `state` +- `Subject` for `events$` +- `AbortController | undefined` for `stop()` +- A subscription handle to `source.agent()` (cleaned up on caller's destroy via injection context, or by replacing the subscription in subsequent `submit` calls) + +### Reducer extraction + +The mapping logic lives in a pure function `reduceEvent(event, store)` in `libs/ag-ui/src/lib/reducer.ts`. Trivially unit-testable: drive events through, assert signal contents. + +`toAgent` wires the reducer to the source agent's event stream: + +```ts +source.agent().subscribe((evt) => reduceEvent(evt, store)); +``` + +## Initial State for Late Subscribers + +Not a problem. AG-UI emits `MessagesSnapshot` and `StateSnapshot` events natively when a session bootstraps. Primitives mounting mid-conversation read current signal values, which the reducer populated from those snapshots. + +`events$` carries only `state_update` and `custom` events per the contract invariant — snapshots are state-bearing and flow through signals, not `events$`. No replay machinery needed. + +## Testing Strategy + +### Unit (`libs/ag-ui/src/lib/reducer.spec.ts`) + +Table-driven tests, one per event kind, hitting the right signal updates. Pure-function reducer means tests are fast and deterministic. + +### Integration (`libs/ag-ui/src/lib/to-agent.spec.ts`) + +Stub `AbstractAgent` exposing a `Subject` the test controls. Drive events through the stub; assert on the resulting `Agent` signals. + +```ts +class StubAgent extends AbstractAgent { + readonly events$ = new Subject(); + agent() { return this.events$.asObservable(); } + // ...minimal runAgent / abortRun stubs +} +``` + +### Conformance (`libs/ag-ui/src/lib/to-agent.conformance.spec.ts`) + +Runs `runAgentConformance(label, factory)` from `@cacheplane/chat/testing` against an `Agent` built from a stub source. Validates the AG-UI adapter passes the same contract conformance suite as `toAgent` from `@cacheplane/langgraph`. + +### Out of scope + +- Real-network HTTP testing of `provideAgUiAgent` — manual cockpit-demo verification only. +- AG-UI protocol-version pinning tests — relies on `@ag-ui/client`'s own contract. + +## Cockpit Demo + +One new app: `cockpit/ag-ui/streaming/angular/`. Mirrors `cockpit/langgraph/streaming/angular/` structurally: + +- `app.config.ts` calls `provideAgUiAgent({ url: environment.agUiUrl })`. +- `streaming.component.ts` uses the standard `` composition from `@cacheplane/chat`. +- Demonstrates: same chat UI, different runtime. + +If no public AG-UI backend is reachable from CI, the cockpit app builds but is env-flagged like other secret-gated demos. + +## Out of Scope (Phase-1 of AG-UI adapter) + +- `interrupt`, `subagents`, `history` signals on the produced `Agent`. Returns plain `Agent`, not `AgentWithHistory`. AG-UI debug/timeline UIs deferred until a future phase translates the relevant AG-UI concepts. +- Tool-call streaming with incremental JSON-merge of `args`. First pass treats `ToolCallArgs` as full-replace. +- Custom transports beyond `HttpAgent` for `provideAgUiAgent`. Custom-transport users go through `toAgent(customAgent)`. +- Auth/headers beyond `headers?: Record`. +- Thread switching beyond construction-time `threadId` — `Agent` contract has no `switchThread` method. +- Translation of AG-UI's interrupt model to `AgentInterrupt`. Different shape; deferred. +- Shared adapter reducer infrastructure. AG-UI ships its own bespoke reducer; cross-adapter extraction comes after we've seen both reducers in production. + +## Risk + +- **AG-UI protocol churn.** `@ag-ui/client` is pre-1.0 and may break across versions. Mitigation: pin to a stable point release in `package.json`; document the version in this spec when implementing. +- **`StateDelta` JSON-Patch dependency.** AG-UI uses RFC 6902 for partial state updates. We need a JSON-Patch implementation; `fast-json-patch` is the standard. Adds a runtime dep — flag if undesirable. +- **Cockpit demo backend availability.** A public AG-UI demo URL may not exist that's stable enough for CI. Mitigation: env-flag the demo wiring; fall back to a stub backend in CI. +- **Subscription lifetime.** `source.agent().subscribe(...)` inside `toAgent` runs without an injection context. The subscription must be cleaned up — either by tying it to a passed `DestroyRef`, or by exposing a `dispose()` on the produced `Agent` (NOT on the contract). Need to decide during implementation; documenting risk here. + +## When to Revisit + +- A second AG-UI demo backend lands and the JSON-Patch dependency is exercised — confirm `fast-json-patch` choice or replace. +- AG-UI 1.0 ships and breaks our adapter — refresh the protocol version pin. +- Real consumers ask for `interrupt` / `subagents` / `history` on the AG-UI adapter — design the translation in a follow-up. +- The shared adapter reducer is extracted (after we've seen both LangGraph and AG-UI reducers in practice) — refactor `reduceEvent` to drive the shared reducer. From 8e8ed3c84ccf0614614fff6aa4d78e9fc82003b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:03:07 -0400 Subject: [PATCH 2/7] docs: AG-UI adapter implementation plan 6 tasks: scaffold @cacheplane/ag-ui lib, pure-function reducer with table-driven spec, toAgent + conformance test, provideAgUiAgent DI convenience, cockpit streaming demo, final verify + PR. Pinned to @ag-ui/client and fast-json-patch (RFC 6902 for StateDelta). Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-27-ag-ui-adapter.md | 991 ++++++++++++++++++ 1 file changed, 991 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-ag-ui-adapter.md diff --git a/docs/superpowers/plans/2026-04-27-ag-ui-adapter.md b/docs/superpowers/plans/2026-04-27-ag-ui-adapter.md new file mode 100644 index 000000000..8f0f8e175 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-ag-ui-adapter.md @@ -0,0 +1,991 @@ +# `@cacheplane/ag-ui` 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 `@cacheplane/ag-ui` — a runtime adapter wrapping `@ag-ui/client`'s `AbstractAgent` into the `Agent` contract. Scope B: messages + lifecycle + tool calls + state. Conformance-tested against `runAgentConformance`. Cockpit demo proves end-to-end decoupling. + +**Architecture:** New Nx library `libs/ag-ui/`. Pure-function reducer (`reduceEvent(event, store)`) maps AG-UI events into signal updates. `toAgent(source: AbstractAgent): Agent` wires the reducer to `source.agent()`. `provideAgUiAgent({ url })` DI convenience instantiates `HttpAgent` and threads it through `toAgent`. + +**Tech Stack:** Angular 21 (signals + RxJS), Nx, Vitest, ng-packagr, `@ag-ui/client`, `fast-json-patch` (RFC 6902 for `StateDelta`). + +**Spec:** `docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md` + +--- + +## File Structure + +### New library `libs/ag-ui/` + +``` +libs/ag-ui/ +├── eslint.config.mjs +├── ng-package.json +├── package.json +├── project.json +├── README.md +├── src/ +│ ├── public-api.ts +│ └── lib/ +│ ├── ag-ui-event.ts # narrowed type aliases for AG-UI events we consume +│ ├── reducer.ts # pure function: (event, store) → void +│ ├── reducer.spec.ts +│ ├── to-agent.ts # wraps AbstractAgent → Agent +│ ├── to-agent.spec.ts +│ ├── to-agent.conformance.spec.ts +│ ├── provide-ag-ui-agent.ts # DI convenience +│ └── provide-ag-ui-agent.spec.ts +├── tsconfig.json +├── tsconfig.lib.json +├── tsconfig.lib.prod.json +└── vite.config.mts +``` + +### New cockpit app `cockpit/ag-ui/streaming/angular/` + +``` +cockpit/ag-ui/streaming/angular/ +├── project.json +├── src/ +│ ├── app/ +│ │ ├── app.config.ts +│ │ ├── streaming.component.ts +│ │ └── streaming.component.spec.ts +│ ├── environments/environment.ts +│ ├── index.html +│ └── main.ts +├── tsconfig.app.json +├── tsconfig.json +└── vite.config.mts +``` + +### Modified + +- `tsconfig.base.json` — add path mapping for `@cacheplane/ag-ui` +- Workspace `package.json` — add `@ag-ui/client` and `fast-json-patch` to root deps if not already present +- `nx.json` — only if generator output requires it + +--- + +### Task 1: Scaffold `libs/ag-ui/` library + +Use the existing `libs/langgraph/` as a structural reference (peer-deps, ng-package.json, tsconfig, eslint, vite config, project.json shape). + +- [ ] **Step 1: Generate the Angular library** + +```bash +npx nx g @nx/angular:library libs/ag-ui --buildable --publishable --importPath=@cacheplane/ag-ui --skipTests=false --standalone=true +``` + +If the generator's output diverges from the existing `langgraph` shape, hand-edit to match: same `executor: '@nx/angular:package'` build target, same `prefix: 'lib'`, same `release.version` block, same `vite.config.mts` pattern. + +- [ ] **Step 2: Update `libs/ag-ui/package.json`** + +```json +{ + "name": "@cacheplane/ag-ui", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/chat": "^0.0.1", + "@cacheplane/licensing": "^0.0.1", + "@angular/core": "^20.0.0 || ^21.0.0", + "@ag-ui/client": "^0.0.30", + "fast-json-patch": "^3.1.1", + "rxjs": "~7.8.0" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} +``` + +(Pin `@ag-ui/client` to a stable point release. `^0.0.30` is illustrative — pick the latest stable at implementation time and document the version in a comment.) + +- [ ] **Step 3: Update `libs/ag-ui/eslint.config.mjs`** + +Mirror `libs/langgraph/eslint.config.mjs`. Selector prefix allowlist: `['ag-ui']`. + +```js +prefix: ['ag-ui'], +``` + +Add `vitest` to `ignoredDependencies` in the `@nx/dependency-checks` block (matches `libs/chat/eslint.config.mjs`). + +- [ ] **Step 4: Add path mapping in `tsconfig.base.json`** + +```json +"paths": { + // ...existing entries... + "@cacheplane/ag-ui": ["libs/ag-ui/src/public-api.ts"] +} +``` + +- [ ] **Step 5: Initialize `libs/ag-ui/src/public-api.ts`** + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export { toAgent } from './lib/to-agent'; +export { provideAgUiAgent } from './lib/provide-ag-ui-agent'; +export type { AgUiAgentConfig } from './lib/provide-ag-ui-agent'; +``` + +(File-level placeholders — implementations come in later tasks.) + +- [ ] **Step 6: Stub the implementation files** + +Create empty stubs so the build doesn't fail before later tasks fill them in: + +```ts +// libs/ag-ui/src/lib/to-agent.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { AbstractAgent } from '@ag-ui/client'; +import type { Agent } from '@cacheplane/chat'; + +export function toAgent(source: AbstractAgent): Agent { + void source; + throw new Error('not implemented'); +} +``` + +```ts +// libs/ag-ui/src/lib/provide-ag-ui-agent.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { Provider } from '@angular/core'; + +export interface AgUiAgentConfig { + url: string; + agentId?: string; + threadId?: string; + headers?: Record; +} + +export function provideAgUiAgent(config: AgUiAgentConfig): Provider[] { + void config; + throw new Error('not implemented'); +} +``` + +- [ ] **Step 7: Verify scaffold builds** + +```bash +npx nx run-many -t lint,build -p ag-ui +``` + +Expected: PASS (with lint pre-existing warnings if any). Tests are skipped at this stage because no spec files exist yet. + +- [ ] **Step 8: Commit** + +```bash +git add libs/ag-ui/ tsconfig.base.json +git commit -m "feat(ag-ui): scaffold @cacheplane/ag-ui library" +``` + +--- + +### Task 2: Implement the event reducer + +**Files:** +- Create: `libs/ag-ui/src/lib/reducer.ts` +- Create: `libs/ag-ui/src/lib/reducer.spec.ts` + +The reducer is a pure function `(event, store) => void`. The store is a bag of `WritableSignal` handles plus a `Subject`. Lives in its own file so it's trivially unit-testable independent of `toAgent`'s wiring. + +- [ ] **Step 1: Write the reducer signature and store interface** + +```ts +// libs/ag-ui/src/lib/reducer.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { WritableSignal } from '@angular/core'; +import type { Subject } from 'rxjs'; +import type { + Message, AgentStatus, ToolCall, AgentEvent, +} from '@cacheplane/chat'; +import type { BaseEvent } from '@ag-ui/client'; +import { applyPatch, type Operation } from 'fast-json-patch'; + +export interface ReducerStore { + messages: WritableSignal; + status: WritableSignal; + isLoading: WritableSignal; + error: WritableSignal; + toolCalls: WritableSignal; + state: WritableSignal>; + events$: Subject; +} + +/** + * Pure function: applies a single AG-UI BaseEvent to the store. Caller + * subscribes to source.agent() and forwards each event here. Designed + * for testability — no side effects beyond the supplied store. + */ +export function reduceEvent(event: BaseEvent, store: ReducerStore): void { + switch (event.type) { + case 'RUN_STARTED': { + store.status.set('running'); + store.isLoading.set(true); + store.error.set(null); + return; + } + case 'RUN_FINISHED': { + store.status.set('idle'); + store.isLoading.set(false); + return; + } + case 'RUN_ERROR': { + store.status.set('error'); + store.isLoading.set(false); + store.error.set((event as { message?: unknown }).message ?? event); + return; + } + case 'TEXT_MESSAGE_START': { + store.messages.update((prev) => [ + ...prev, + { id: messageIdFrom(event), role: 'assistant', content: '' }, + ]); + return; + } + case 'TEXT_MESSAGE_CONTENT': { + const id = messageIdFrom(event); + const delta = (event as { delta?: string }).delta ?? ''; + store.messages.update((prev) => + prev.map((m) => m.id === id ? { ...m, content: m.content + delta } : m), + ); + return; + } + case 'TEXT_MESSAGE_END': { + // No-op — message is finalized by virtue of TEXT_MESSAGE_CONTENT + // having been applied. Reserved for future hooks. + return; + } + case 'TOOL_CALL_START': { + const e = event as { toolCallId: string; toolCallName: string }; + store.toolCalls.update((prev) => [ + ...prev, + { id: e.toolCallId, name: e.toolCallName, args: {}, status: 'running' }, + ]); + return; + } + case 'TOOL_CALL_ARGS': { + const e = event as { toolCallId: string; delta: string }; + const args = safeParseArgs(e.delta); + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, args } : t), + ); + return; + } + case 'TOOL_CALL_END': { + const e = event as { toolCallId: string }; + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, status: 'complete' } : t), + ); + return; + } + case 'TOOL_CALL_RESULT': { + const e = event as { toolCallId: string; content: unknown }; + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, result: e.content } : t), + ); + return; + } + case 'STATE_SNAPSHOT': { + const e = event as { snapshot: Record }; + store.state.set(e.snapshot ?? {}); + return; + } + case 'STATE_DELTA': { + const e = event as { delta: Operation[] }; + const next = applyPatch(deepClone(store.state()), e.delta).newDocument; + store.state.set(next); + return; + } + case 'MESSAGES_SNAPSHOT': { + const e = event as { messages: Message[] }; + store.messages.set(e.messages ?? []); + return; + } + case 'CUSTOM': { + const e = event as { name: string; value: unknown }; + if (e.name === 'state_update' && isRecord(e.value)) { + store.events$.next({ type: 'state_update', data: e.value }); + } else { + store.events$.next({ type: 'custom', name: e.name, data: e.value }); + } + return; + } + default: { + // Unknown event types are ignored; AG-UI may add new ones in + // future protocol versions. We surface them as no-ops rather + // than throwing, so a partial-version mismatch doesn't crash. + return; + } + } +} + +function messageIdFrom(event: BaseEvent): string { + return (event as { messageId?: string }).messageId ?? 'unknown'; +} + +function safeParseArgs(delta: string): Record { + try { + const parsed = JSON.parse(delta); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function deepClone(v: T): T { + return JSON.parse(JSON.stringify(v)); +} +``` + +(Type narrowing on `event` uses casts — `BaseEvent` from `@ag-ui/client` is a discriminated union but the per-type fields aren't always reachable via TS narrowing on `.type`. Cast-and-validate at each site.) + +- [ ] **Step 2: Write the reducer spec** + +```ts +// libs/ag-ui/src/lib/reducer.spec.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import type { + Message, AgentStatus, ToolCall, AgentEvent, +} from '@cacheplane/chat'; +import { reduceEvent, type ReducerStore } from './reducer'; + +function makeStore(): ReducerStore { + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; +} + +describe('reduceEvent', () => { + it('RUN_STARTED sets status running, isLoading true, clears error', () => { + const store = makeStore(); + store.error.set('previous'); + reduceEvent({ type: 'RUN_STARTED' } as any, store); + expect(store.status()).toBe('running'); + expect(store.isLoading()).toBe(true); + expect(store.error()).toBeNull(); + }); + + it('RUN_FINISHED sets status idle, isLoading false', () => { + const store = makeStore(); + store.status.set('running'); + store.isLoading.set(true); + reduceEvent({ type: 'RUN_FINISHED' } as any, store); + expect(store.status()).toBe('idle'); + expect(store.isLoading()).toBe(false); + }); + + it('RUN_ERROR sets status error, captures message', () => { + const store = makeStore(); + reduceEvent({ type: 'RUN_ERROR', message: 'boom' } as any, store); + expect(store.status()).toBe('error'); + expect(store.error()).toBe('boom'); + }); + + it('TEXT_MESSAGE_START appends an empty assistant message', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + expect(store.messages()).toEqual([{ id: 'm1', role: 'assistant', content: '' }]); + }); + + it('TEXT_MESSAGE_CONTENT appends delta to in-flight message', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'hi ' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'there' } as any, store); + expect(store.messages()[0].content).toBe('hi there'); + }); + + it('TOOL_CALL_START appends a running tool call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + expect(store.toolCalls()).toEqual([{ id: 't1', name: 'search', args: {}, status: 'running' }]); + }); + + it('TOOL_CALL_ARGS replaces args on the matching tool call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"hi"}' } as any, store); + expect(store.toolCalls()[0].args).toEqual({ q: 'hi' }); + }); + + it('TOOL_CALL_END marks the matching tool call complete', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_END', toolCallId: 't1' } as any, store); + expect(store.toolCalls()[0].status).toBe('complete'); + }); + + it('TOOL_CALL_RESULT sets the result on the matching call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_RESULT', toolCallId: 't1', content: 'found' } as any, store); + expect(store.toolCalls()[0].result).toBe('found'); + }); + + it('STATE_SNAPSHOT replaces state wholesale', () => { + const store = makeStore(); + store.state.set({ prior: true }); + reduceEvent({ type: 'STATE_SNAPSHOT', snapshot: { fresh: 1 } } as any, store); + expect(store.state()).toEqual({ fresh: 1 }); + }); + + it('STATE_DELTA applies JSON Patch operations', () => { + const store = makeStore(); + store.state.set({ a: 1 }); + reduceEvent({ + type: 'STATE_DELTA', + delta: [{ op: 'replace', path: '/a', value: 2 }, { op: 'add', path: '/b', value: 3 }], + } as any, store); + expect(store.state()).toEqual({ a: 2, b: 3 }); + }); + + it('MESSAGES_SNAPSHOT replaces messages wholesale', () => { + const store = makeStore(); + store.messages.set([{ id: 'old', role: 'user', content: 'old' }]); + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [{ id: 'new', role: 'assistant', content: 'fresh' }], + } as any, store); + expect(store.messages()).toEqual([{ id: 'new', role: 'assistant', content: 'fresh' }]); + }); + + it('CUSTOM with name=state_update emits AgentStateUpdateEvent', async () => { + const store = makeStore(); + const events: AgentEvent[] = []; + store.events$.subscribe((e) => events.push(e)); + reduceEvent({ type: 'CUSTOM', name: 'state_update', value: { count: 1 } } as any, store); + expect(events).toEqual([{ type: 'state_update', data: { count: 1 } }]); + }); + + it('CUSTOM with other name emits AgentCustomEvent', async () => { + const store = makeStore(); + const events: AgentEvent[] = []; + store.events$.subscribe((e) => events.push(e)); + reduceEvent({ type: 'CUSTOM', name: 'tick', value: 42 } as any, store); + expect(events).toEqual([{ type: 'custom', name: 'tick', data: 42 }]); + }); + + it('unknown event types are no-ops', () => { + const store = makeStore(); + reduceEvent({ type: 'FUTURE_EVENT' } as any, store); + expect(store.messages()).toEqual([]); + expect(store.status()).toBe('idle'); + }); +}); +``` + +- [ ] **Step 3: Run the reducer spec** + +```bash +npx nx test ag-ui +``` + +Expected: all reducer tests PASS. + +- [ ] **Step 4: 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): pure-function reducer mapping AG-UI events to Agent signals" +``` + +--- + +### Task 3: Implement `toAgent` + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` +- Create: `libs/ag-ui/src/lib/to-agent.spec.ts` +- Create: `libs/ag-ui/src/lib/to-agent.conformance.spec.ts` + +- [ ] **Step 1: Replace stub `to-agent.ts` with real implementation** + +```ts +// libs/ag-ui/src/lib/to-agent.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal, type WritableSignal } from '@angular/core'; +import { Subject, type Subscription } from 'rxjs'; +import type { AbstractAgent } from '@ag-ui/client'; +import type { + Agent, Message, AgentStatus, ToolCall, AgentEvent, + AgentSubmitInput, AgentSubmitOptions, +} from '@cacheplane/chat'; +import { reduceEvent, type ReducerStore } from './reducer'; + +/** + * Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract. + * + * The adapter subscribes to source.agent() and reduces every event into + * the produced Agent's signals. submit() optimistically appends the user + * message and calls source.runAgent(); stop() aborts the in-flight run. + * + * Subscription cleanup: the returned Agent does NOT manage its own + * lifetime. Callers using DI should rely on the provider's destroy hook; + * direct callers of toAgent() should treat the returned object's + * lifecycle as tied to the agent instance they constructed. + */ +export function toAgent(source: AbstractAgent): Agent { + const store: ReducerStore = { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; + + const subscription: Subscription = source.agent().subscribe({ + next: (evt) => reduceEvent(evt, store), + // RxJS errors should not silently kill the subscription; surface as + // a synthetic RUN_ERROR-equivalent. + error: (err) => { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + }, + }); + + let abort: AbortController | undefined; + + return { + messages: store.messages, + status: store.status, + isLoading: store.isLoading, + error: store.error, + toolCalls: store.toolCalls, + state: store.state, + events$: store.events$.asObservable(), + submit: async (input: AgentSubmitInput, opts?: AgentSubmitOptions) => { + abort?.abort(); + abort = new AbortController(); + const linkedSignal = opts?.signal + ? linkAbortSignals(abort.signal, opts.signal) + : abort.signal; + + // Optimistic append of user message + const userMsg = buildUserMessage(input); + if (userMsg) { + store.messages.update((prev) => [...prev, userMsg]); + } + + try { + await source.runAgent({ + messages: store.messages(), + state: store.state(), + }, { signal: linkedSignal }); + } catch (err) { + // If the abort came from us (stop()), we don't surface an error. + if (linkedSignal.aborted) return; + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + } + }, + stop: async () => { + abort?.abort(); + }, + }; + + // Returned Agent doesn't expose subscription cleanup. If the caller + // needs deterministic teardown they unsubscribe via the source agent's + // own lifecycle. Documented in the spec under "Subscription lifetime." + void subscription; +} + +function buildUserMessage(input: AgentSubmitInput): Message | undefined { + if (input.message === undefined) return undefined; + const content = typeof input.message === 'string' + ? input.message + : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join(''); + return { id: randomId(), role: 'user', content }; +} + +function linkAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal { + const ctrl = new AbortController(); + if (a.aborted || b.aborted) { + ctrl.abort(); + return ctrl.signal; + } + a.addEventListener('abort', () => ctrl.abort(), { once: true }); + b.addEventListener('abort', () => ctrl.abort(), { once: true }); + return ctrl.signal; +} + +function randomId(): string { + return Math.random().toString(36).slice(2); +} +``` + +- [ ] **Step 2: Write `to-agent.spec.ts`** + +```ts +// libs/ag-ui/src/lib/to-agent.spec.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { Subject } from 'rxjs'; +import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; +import { toAgent } from './to-agent'; + +class StubAgent { + readonly events = new Subject(); + runAgent = vi.fn(async () => {}); + abortRun = vi.fn(); + agent() { return this.events.asObservable(); } +} + +describe('toAgent', () => { + it('reduces RUN_STARTED into running status', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + stub.events.next({ type: 'RUN_STARTED' } as any); + expect(a.status()).toBe('running'); + expect(a.isLoading()).toBe(true); + }); + + it('appends user message optimistically on submit', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + void a.submit({ message: 'hello' }); + expect(a.messages()[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); + }); + + it('passes current messages and state to runAgent', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + stub.events.next({ type: 'STATE_SNAPSHOT', snapshot: { foo: 1 } } as any); + await a.submit({ message: 'hi' }); + expect(stub.runAgent).toHaveBeenCalledWith( + expect.objectContaining({ messages: expect.any(Array), state: { foo: 1 } }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('stop() aborts the in-flight run', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + let signal: AbortSignal | undefined; + stub.runAgent = vi.fn(async (_params, opts) => { + signal = (opts as { signal: AbortSignal }).signal; + await new Promise((resolve) => signal!.addEventListener('abort', resolve)); + }); + void a.submit({ message: 'hi' }); + await a.stop(); + expect(signal?.aborted).toBe(true); + }); + + it('events$ emits state_update on CUSTOM with that name', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + const seen: any[] = []; + a.events$.subscribe((e) => seen.push(e)); + stub.events.next({ type: 'CUSTOM', name: 'state_update', value: { x: 1 } } as any); + expect(seen).toEqual([{ type: 'state_update', data: { x: 1 } }]); + }); +}); +``` + +- [ ] **Step 3: Write `to-agent.conformance.spec.ts`** + +```ts +// libs/ag-ui/src/lib/to-agent.conformance.spec.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Subject } from 'rxjs'; +import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; +import { runAgentConformance } from '@cacheplane/chat'; +import { toAgent } from './to-agent'; + +class StubAgent { + readonly events = new Subject(); + runAgent = async () => {}; + abortRun = () => {}; + agent() { return this.events.asObservable(); } +} + +runAgentConformance('toAgent (AG-UI adapter)', () => { + return toAgent(new StubAgent() as unknown as AbstractAgent); +}); +``` + +- [ ] **Step 4: Run all ag-ui tests** + +```bash +npx nx test ag-ui +``` + +Expected: PASS for reducer + to-agent + conformance. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ag-ui/ +git commit -m "feat(ag-ui): toAgent wraps AbstractAgent into Agent contract" +``` + +--- + +### Task 4: Implement `provideAgUiAgent` + +**Files:** +- Modify: `libs/ag-ui/src/lib/provide-ag-ui-agent.ts` +- Create: `libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts` + +- [ ] **Step 1: Implement the provider** + +```ts +// libs/ag-ui/src/lib/provide-ag-ui-agent.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, inject, Provider } from '@angular/core'; +import { HttpAgent } from '@ag-ui/client'; +import type { Agent } from '@cacheplane/chat'; +import { toAgent } from './to-agent'; + +export interface AgUiAgentConfig { + url: string; + agentId?: string; + threadId?: string; + headers?: Record; +} + +export const AG_UI_AGENT = new InjectionToken('AG_UI_AGENT'); + +export function provideAgUiAgent(config: AgUiAgentConfig): Provider[] { + return [ + { + provide: AG_UI_AGENT, + useFactory: () => { + const source = new HttpAgent({ + url: config.url, + ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + }); + return toAgent(source); + }, + }, + ]; +} + +/** + * Convenience helper for components — `inject(AG_UI_AGENT)` directly works + * the same way; this just exports the typed token. + */ +export function injectAgUiAgent(): Agent { + return inject(AG_UI_AGENT); +} +``` + +(Adjust constructor field names — `agentId`, `threadId`, `headers` — to match the actual `HttpAgent` API at the pinned `@ag-ui/client` version. The illustrative shape above mirrors the spec; check the SDK and update.) + +- [ ] **Step 2: Spec the provider** + +```ts +// libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideAgUiAgent, AG_UI_AGENT } from './provide-ag-ui-agent'; + +describe('provideAgUiAgent', () => { + it('registers AG_UI_AGENT in the injector', () => { + TestBed.configureTestingModule({ + providers: provideAgUiAgent({ url: 'http://example.test/agent' }), + }); + const agent = TestBed.inject(AG_UI_AGENT); + expect(agent).toBeDefined(); + expect(typeof agent.submit).toBe('function'); + expect(typeof agent.stop).toBe('function'); + }); +}); +``` + +(This test exercises only the DI wiring — no real HTTP. `HttpAgent` is constructed with a URL but no event loop runs.) + +- [ ] **Step 3: Re-export from `public-api.ts`** + +If not already there, ensure `libs/ag-ui/src/public-api.ts` exports `AG_UI_AGENT` and `injectAgUiAgent`: + +```ts +export { provideAgUiAgent, AG_UI_AGENT, injectAgUiAgent } from './lib/provide-ag-ui-agent'; +``` + +- [ ] **Step 4: Run all tests + build** + +```bash +npx nx run-many -t lint,test,build -p ag-ui +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ag-ui/ +git commit -m "feat(ag-ui): provideAgUiAgent DI convenience for HttpAgent" +``` + +--- + +### Task 5: Cockpit demo + +**Files:** new app `cockpit/ag-ui/streaming/angular/` + +- [ ] **Step 1: Generate the cockpit Angular app** + +Use `cockpit/langgraph/streaming/angular/` as the structural reference. + +```bash +npx nx g @nx/angular:application cockpit/ag-ui/streaming/angular --standalone --routing=false --style=css --skipTests=false +``` + +Adjust the generated `project.json` to mirror the `cockpit-langgraph-streaming-angular` shape (build/serve targets, vite config, etc.). + +- [ ] **Step 2: Implement `streaming.component.ts`** + +```ts +// cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, inject } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; +import { AG_UI_AGENT } from '@cacheplane/ag-ui'; + +@Component({ + selector: 'app-streaming', + standalone: true, + imports: [ChatComponent], + template: ``, +}) +export class StreamingComponent { + protected readonly agent = inject(AG_UI_AGENT); +} +``` + +- [ ] **Step 3: Wire `app.config.ts`** + +```ts +// cockpit/ag-ui/streaming/angular/src/app/app.config.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideAgUiAgent } from '@cacheplane/ag-ui'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgUiAgent({ url: environment.agUiUrl }), + ], +}; +``` + +- [ ] **Step 4: Add an environment file** + +```ts +// cockpit/ag-ui/streaming/angular/src/environments/environment.ts +export const environment = { + agUiUrl: 'http://localhost:3000/agent', // demo backend URL; override per env +}; +``` + +If a `.env`-style mechanism exists in the workspace, prefer that. Otherwise, the URL is documented for manual override. + +- [ ] **Step 5: Build the cockpit app** + +```bash +npx nx build cockpit-ag-ui-streaming-angular +``` + +Expected: PASS. (If lint complains about an unused `environment` field or a missing one, add a placeholder `production: false`.) + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/ag-ui/ +git commit -m "feat(cockpit): AG-UI streaming demo using @cacheplane/ag-ui" +``` + +--- + +### Task 6: Final verification, push, PR + +- [ ] **Step 1: Verify no stale references** + +```bash +rg "ChatAgent|customEvents\\\$" libs/ag-ui/ cockpit/ag-ui/ +``` + +Expected: zero hits (these belong to old vocabulary). + +- [ ] **Step 2: Full lint/test/build** + +```bash +npx nx run-many -t lint,test,build -p chat,langgraph,ag-ui +npx nx affected -t build --base=origin/main +``` + +Expected: all pass. + +- [ ] **Step 3: Verify dep graph** + +```bash +npx nx graph --file=/tmp/nxgraph.json +jq '.graph.dependencies.chat, .graph.dependencies.langgraph, .graph.dependencies["ag-ui"]' /tmp/nxgraph.json +``` + +Expected: `chat` does NOT depend on `ag-ui` or `langgraph`. Both `langgraph` and `ag-ui` depend on `chat`. + +- [ ] **Step 4: Push** + +```bash +git push -u origin feat/ag-ui-adapter +``` + +- [ ] **Step 5: Open PR** + +```bash +gh pr create --title "feat(ag-ui): @cacheplane/ag-ui adapter wrapping @ag-ui/client" --body "$(cat <<'EOF' +## Summary +- New \`@cacheplane/ag-ui\` library providing \`toAgent(source: AbstractAgent): Agent\` and \`provideAgUiAgent({ url })\` DI convenience. +- Pure-function reducer maps AG-UI \`BaseEvent\`s into Agent contract signals + \`events\$\`. Conformance-tested against the shared \`runAgentConformance\` suite. +- Scope: messages + lifecycle + tool calls + state. \`interrupt\`, \`subagents\`, \`history\` deferred. +- Cockpit demo \`cockpit/ag-ui/streaming/angular/\` proves end-to-end decoupling — same \`\` composition, AG-UI runtime. + +## Motivation +Validates the chat-runtime decoupling shipped in #131..#138 by adding a second adapter on a different protocol. + +## Test Plan +- [x] \`nx run-many -t lint,test,build -p chat,langgraph,ag-ui\` passes +- [x] \`nx affected -t build\` passes +- [x] Dep graph: \`chat\` independent; \`ag-ui → chat\`, \`langgraph → chat\` +- [ ] Cockpit demo renders against a live AG-UI backend (manual) + +## Design + plan +- Spec: \`docs/superpowers/specs/2026-04-27-ag-ui-adapter-design.md\` +- Plan: \`docs/superpowers/plans/2026-04-27-ag-ui-adapter.md\` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Out of Scope + +- `interrupt`, `subagents`, `history` translations from AG-UI events. +- Tool-call streaming with incremental JSON-merge of `args`. +- Custom transports beyond `HttpAgent` for `provideAgUiAgent`. +- Auth / headers configuration beyond `headers?: Record`. +- Real-network CI tests for the cockpit demo. +- Shared adapter reducer extraction (deferred until both LangGraph and AG-UI reducers are live). From 24605c095efacc7ca8caaf6bdc5124cc7f61bbec Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:12:51 -0400 Subject: [PATCH 3/7] feat(ag-ui): scaffold @cacheplane/ag-ui library Mirror libs/langgraph/ structure. Stubbed toAgent and provideAgUiAgent will be implemented in subsequent commits. Co-Authored-By: Claude Opus 4.7 --- libs/ag-ui/README.md | 22 ++++ libs/ag-ui/eslint.config.mjs | 57 +++++++++++ libs/ag-ui/ng-package.json | 7 ++ libs/ag-ui/package.json | 14 +++ libs/ag-ui/project.json | 46 +++++++++ libs/ag-ui/src/lib/provide-ag-ui-agent.ts | 21 ++++ libs/ag-ui/src/lib/to-agent.ts | 8 ++ libs/ag-ui/src/public-api.ts | 4 + libs/ag-ui/src/test-setup.ts | 11 ++ libs/ag-ui/tsconfig.json | 23 +++++ libs/ag-ui/tsconfig.lib.json | 13 +++ libs/ag-ui/tsconfig.lib.prod.json | 9 ++ libs/ag-ui/vite.config.mts | 13 +++ package-lock.json | 116 +++++++++++++++++++++- package.json | 2 + tsconfig.base.json | 19 ++-- 16 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 libs/ag-ui/README.md create mode 100644 libs/ag-ui/eslint.config.mjs create mode 100644 libs/ag-ui/ng-package.json create mode 100644 libs/ag-ui/package.json create mode 100644 libs/ag-ui/project.json create mode 100644 libs/ag-ui/src/lib/provide-ag-ui-agent.ts create mode 100644 libs/ag-ui/src/lib/to-agent.ts create mode 100644 libs/ag-ui/src/public-api.ts create mode 100644 libs/ag-ui/src/test-setup.ts create mode 100644 libs/ag-ui/tsconfig.json create mode 100644 libs/ag-ui/tsconfig.lib.json create mode 100644 libs/ag-ui/tsconfig.lib.prod.json create mode 100644 libs/ag-ui/vite.config.mts diff --git a/libs/ag-ui/README.md b/libs/ag-ui/README.md new file mode 100644 index 000000000..46a05bbdf --- /dev/null +++ b/libs/ag-ui/README.md @@ -0,0 +1,22 @@ +# @cacheplane/ag-ui + +Adapter that wraps an [AG-UI](https://github.com/ag-ui-protocol/ag-ui) `AbstractAgent` into the runtime-neutral `Agent` contract from `@cacheplane/chat`. + +```ts +import { provideAgUiAgent, AG_UI_AGENT } from '@cacheplane/ag-ui'; +import { ChatComponent } from '@cacheplane/chat'; + +// app.config.ts +export const appConfig: ApplicationConfig = { + providers: [provideAgUiAgent({ url: 'https://your.agent.endpoint' })], +}; + +// component +@Component({ + imports: [ChatComponent], + template: ``, +}) +export class App { + protected readonly agent = inject(AG_UI_AGENT); +} +``` diff --git a/libs/ag-ui/eslint.config.mjs b/libs/ag-ui/eslint.config.mjs new file mode 100644 index 000000000..4f165dc8d --- /dev/null +++ b/libs/ag-ui/eslint.config.mjs @@ -0,0 +1,57 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: [ + 'vite', + '@nx/vite', + 'vitest', + // peerDeps used by later tasks (stub-only in Task 1) + '@cacheplane/licensing', + 'fast-json-patch', + 'rxjs', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: ['ag-ui'], + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: ['ag-ui'], + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/ag-ui/ng-package.json b/libs/ag-ui/ng-package.json new file mode 100644 index 000000000..ad524499a --- /dev/null +++ b/libs/ag-ui/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/ag-ui", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json new file mode 100644 index 000000000..e7a9b5b8e --- /dev/null +++ b/libs/ag-ui/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cacheplane/ag-ui", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/chat": "^0.0.1", + "@cacheplane/licensing": "^0.0.1", + "@angular/core": "^20.0.0 || ^21.0.0", + "@ag-ui/client": "*", + "fast-json-patch": "*", + "rxjs": "~7.8.0" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/libs/ag-ui/project.json b/libs/ag-ui/project.json new file mode 100644 index 000000000..2e820f237 --- /dev/null +++ b/libs/ag-ui/project.json @@ -0,0 +1,46 @@ +{ + "name": "ag-ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ag-ui/src", + "prefix": "lib", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/ag-ui/ng-package.json", + "tsConfig": "libs/ag-ui/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/ag-ui/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "libs/ag-ui/vite.config.mts" + } + } + } +} diff --git a/libs/ag-ui/src/lib/provide-ag-ui-agent.ts b/libs/ag-ui/src/lib/provide-ag-ui-agent.ts new file mode 100644 index 000000000..ab62bd2e0 --- /dev/null +++ b/libs/ag-ui/src/lib/provide-ag-ui-agent.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, type Provider } from '@angular/core'; +import type { Agent } from '@cacheplane/chat'; + +export interface AgUiAgentConfig { + url: string; + agentId?: string; + threadId?: string; + headers?: Record; +} + +export const AG_UI_AGENT = new InjectionToken('AG_UI_AGENT'); + +export function provideAgUiAgent(config: AgUiAgentConfig): Provider[] { + void config; + throw new Error('not implemented'); +} + +export function injectAgUiAgent(): Agent { + throw new Error('not implemented'); +} diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts new file mode 100644 index 000000000..1e25fed52 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { AbstractAgent } from '@ag-ui/client'; +import type { Agent } from '@cacheplane/chat'; + +export function toAgent(source: AbstractAgent): Agent { + void source; + throw new Error('not implemented'); +} diff --git a/libs/ag-ui/src/public-api.ts b/libs/ag-ui/src/public-api.ts new file mode 100644 index 000000000..83eab7e93 --- /dev/null +++ b/libs/ag-ui/src/public-api.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export { toAgent } from './lib/to-agent'; +export { provideAgUiAgent, AG_UI_AGENT, injectAgUiAgent } from './lib/provide-ag-ui-agent'; +export type { AgUiAgentConfig } from './lib/provide-ag-ui-agent'; diff --git a/libs/ag-ui/src/test-setup.ts b/libs/ag-ui/src/test-setup.ts new file mode 100644 index 000000000..ca3d8a2b3 --- /dev/null +++ b/libs/ag-ui/src/test-setup.ts @@ -0,0 +1,11 @@ +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/libs/ag-ui/tsconfig.json b/libs/ag-ui/tsconfig.json new file mode 100644 index 000000000..da190b437 --- /dev/null +++ b/libs/ag-ui/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "experimentalDecorators": true, + "noPropertyAccessFromIndexSignature": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/ag-ui/tsconfig.lib.json b/libs/ag-ui/tsconfig.lib.json new file mode 100644 index 000000000..afcadee07 --- /dev/null +++ b/libs/ag-ui/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/ag-ui/tsconfig.lib.prod.json b/libs/ag-ui/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/ag-ui/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/ag-ui/vite.config.mts b/libs/ag-ui/vite.config.mts new file mode 100644 index 000000000..ce406638a --- /dev/null +++ b/libs/ag-ui/vite.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index fa1ebba5d..8c1588d11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "apps/*" ], "dependencies": { + "@ag-ui/client": "^0.0.52", "@angular/common": "~21.1.0", "@angular/compiler": "~21.1.0", "@angular/core": "~21.1.0", @@ -25,6 +26,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@noble/ed25519": "^2.3.0", "drizzle-orm": "^0.45.2", + "fast-json-patch": "^3.1.1", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", @@ -139,6 +141,90 @@ "tailwind-merge": "^2.5.0" } }, + "node_modules/@ag-ui/client": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.52.tgz", + "integrity": "sha512-U407VvDDwR5qs8TiyN1qY38x87qMWc2n0epw8iA5aa1qwzCKBBDgg3Fkm4JogQf0X4jwNsz8HUbIZrBB56mrpg==", + "dependencies": { + "@ag-ui/core": "0.0.52", + "@ag-ui/encoder": "0.0.52", + "@ag-ui/proto": "0.0.52", + "@types/uuid": "^10.0.0", + "compare-versions": "^6.1.1", + "fast-json-patch": "^3.1.1", + "rxjs": "7.8.1", + "untruncate-json": "^0.0.1", + "uuid": "^11.1.0", + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/client/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@ag-ui/client/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@ag-ui/client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@ag-ui/core": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.52.tgz", + "integrity": "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==", + "dependencies": { + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@ag-ui/encoder": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.52.tgz", + "integrity": "sha512-6GVDTb1dv2rjap7VVnmXYypDutZi6nrsTcdfxoP6ryDG5ynlXtmmS+FSDAt62JbIMD5CtEE963xNCb6d1iXw9g==", + "dependencies": { + "@ag-ui/core": "0.0.52", + "@ag-ui/proto": "0.0.52" + } + }, + "node_modules/@ag-ui/proto": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.52.tgz", + "integrity": "sha512-+iCGzNUNL50YIoThVmsolWPjG4MJidl+R9k8QAGVwErEfHRtQ64KFyrdpeOXNVuWtM3SViJqPSgFyv7eGVS63A==", + "dependencies": { + "@ag-ui/core": "0.0.52", + "@bufbuild/protobuf": "^2.2.5", + "@protobuf-ts/protoc": "^2.11.1" + } + }, "node_modules/@algolia/abtesting": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.12.2.tgz", @@ -6820,7 +6906,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bytecodealliance/preview2-shim": { @@ -16611,6 +16696,15 @@ "node": ">=18" } }, + "node_modules/@protobuf-ts/protoc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz", + "integrity": "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==", + "license": "Apache-2.0", + "bin": { + "protoc": "protoc.js" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -24680,6 +24774,12 @@ "dev": true, "license": "MIT" }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -27941,6 +28041,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -41816,6 +41922,12 @@ "node": ">= 0.8" } }, + "node_modules/untruncate-json": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/untruncate-json/-/untruncate-json-0.0.1.tgz", + "integrity": "sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==", + "license": "MIT" + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -43296,7 +43408,7 @@ "@modelcontextprotocol/sdk": "^1.0.0" }, "bin": { - "angular-mcp": "src/index.js" + "langgraph-mcp": "src/index.js" }, "devDependencies": { "typescript": "^5.4.0" diff --git a/package.json b/package.json index 1b91126f6..7ba06c32e 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "apps/*" ], "dependencies": { + "@ag-ui/client": "^0.0.52", "@angular/common": "~21.1.0", "@angular/compiler": "~21.1.0", "@angular/core": "~21.1.0", @@ -86,6 +87,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@noble/ed25519": "^2.3.0", "drizzle-orm": "^0.45.2", + "fast-json-patch": "^3.1.1", "framer-motion": "^12.38.0", "next": "~16.1.6", "next-mdx-remote": "^6.0.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index ce82e20d1..f83660fdd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,23 +15,24 @@ "noUnusedLocals": true, "baseUrl": ".", "paths": { + "@cacheplane/ag-ui": ["libs/ag-ui/src/public-api.ts"], + "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"], + "@cacheplane/chat": ["libs/chat/src/public-api.ts"], "@cacheplane/cockpit-docs": ["libs/cockpit-docs/src/index.ts"], + "@cacheplane/cockpit-langgraph-streaming-python": [ + "cockpit/langgraph/streaming/python/src/index.ts" + ], "@cacheplane/cockpit-registry": ["libs/cockpit-registry/src/index.ts"], "@cacheplane/cockpit-shell": ["libs/cockpit-shell/src/index.ts"], "@cacheplane/cockpit-testing": ["libs/cockpit-testing/src/index.ts"], "@cacheplane/cockpit-ui": ["libs/cockpit-ui/src/index.ts"], - "@cacheplane/cockpit-langgraph-streaming-python": [ - "cockpit/langgraph/streaming/python/src/index.ts" - ], - "@cacheplane/langgraph": ["libs/langgraph/src/public-api.ts"], - "@cacheplane/render": ["libs/render/src/public-api.ts"], - "@cacheplane/chat": ["libs/chat/src/public-api.ts"], - "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], - "@cacheplane/a2ui": ["libs/a2ui/src/index.ts"], "@cacheplane/db": ["libs/db/src/index.ts"], + "@cacheplane/example-layouts": ["libs/example-layouts/src/public-api.ts"], + "@cacheplane/langgraph": ["libs/langgraph/src/public-api.ts"], "@cacheplane/licensing": ["libs/licensing/src/index.ts"], "@cacheplane/licensing/testing": ["libs/licensing/src/testing.ts"], - "@cacheplane/example-layouts": ["libs/example-layouts/src/public-api.ts"] + "@cacheplane/partial-json": ["libs/partial-json/src/index.ts"], + "@cacheplane/render": ["libs/render/src/public-api.ts"] }, "skipLibCheck": true, "strict": true, From 4bb358f9f6fb5976cea035f77056548d89342e90 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:16:34 -0400 Subject: [PATCH 4/7] feat(ag-ui): pure-function reducer mapping AG-UI events to Agent signals Implements reduceEvent(event, store) covering RUN_STARTED/FINISHED/ERROR, TEXT_MESSAGE_START/CONTENT/END, TOOL_CALL_START/ARGS/END/RESULT, STATE_SNAPSHOT/DELTA, MESSAGES_SNAPSHOT, and CUSTOM. Discriminates state_update CustomEvents into AgentStateUpdateEvent. Table-driven tests cover every variant. Co-Authored-By: Claude Opus 4.7 --- libs/ag-ui/src/lib/reducer.spec.ts | 138 ++++++++++++++++++++++++++ libs/ag-ui/src/lib/reducer.ts | 151 +++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 libs/ag-ui/src/lib/reducer.spec.ts create mode 100644 libs/ag-ui/src/lib/reducer.ts diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts new file mode 100644 index 000000000..6d6e4844b --- /dev/null +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import type { + Message, AgentStatus, ToolCall, AgentEvent, +} from '@cacheplane/chat'; +import { reduceEvent, type ReducerStore } from './reducer'; + +function makeStore(): ReducerStore { + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; +} + +describe('reduceEvent', () => { + it('RUN_STARTED sets status running, isLoading true, clears error', () => { + const store = makeStore(); + store.error.set('previous'); + reduceEvent({ type: 'RUN_STARTED' } as any, store); + expect(store.status()).toBe('running'); + expect(store.isLoading()).toBe(true); + expect(store.error()).toBeNull(); + }); + + it('RUN_FINISHED sets status idle, isLoading false', () => { + const store = makeStore(); + store.status.set('running'); + store.isLoading.set(true); + reduceEvent({ type: 'RUN_FINISHED' } as any, store); + expect(store.status()).toBe('idle'); + expect(store.isLoading()).toBe(false); + }); + + it('RUN_ERROR sets status error, captures message', () => { + const store = makeStore(); + reduceEvent({ type: 'RUN_ERROR', message: 'boom' } as any, store); + expect(store.status()).toBe('error'); + expect(store.error()).toBe('boom'); + }); + + it('TEXT_MESSAGE_START appends an empty assistant message', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + expect(store.messages()).toEqual([{ id: 'm1', role: 'assistant', content: '' }]); + }); + + it('TEXT_MESSAGE_CONTENT appends delta to in-flight message', () => { + const store = makeStore(); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'hi ' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'there' } as any, store); + expect(store.messages()[0].content).toBe('hi there'); + }); + + it('TOOL_CALL_START appends a running tool call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + expect(store.toolCalls()).toEqual([{ id: 't1', name: 'search', args: {}, status: 'running' }]); + }); + + it('TOOL_CALL_ARGS replaces args on the matching tool call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"hi"}' } as any, store); + expect(store.toolCalls()[0].args).toEqual({ q: 'hi' }); + }); + + it('TOOL_CALL_END marks the matching tool call complete', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_END', toolCallId: 't1' } as any, store); + expect(store.toolCalls()[0].status).toBe('complete'); + }); + + it('TOOL_CALL_RESULT sets the result on the matching call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_RESULT', toolCallId: 't1', content: 'found' } as any, store); + expect(store.toolCalls()[0].result).toBe('found'); + }); + + it('STATE_SNAPSHOT replaces state wholesale', () => { + const store = makeStore(); + store.state.set({ prior: true }); + reduceEvent({ type: 'STATE_SNAPSHOT', snapshot: { fresh: 1 } } as any, store); + expect(store.state()).toEqual({ fresh: 1 }); + }); + + it('STATE_DELTA applies JSON Patch operations', () => { + const store = makeStore(); + store.state.set({ a: 1 }); + reduceEvent({ + type: 'STATE_DELTA', + delta: [{ op: 'replace', path: '/a', value: 2 }, { op: 'add', path: '/b', value: 3 }], + } as any, store); + expect(store.state()).toEqual({ a: 2, b: 3 }); + }); + + it('MESSAGES_SNAPSHOT replaces messages wholesale', () => { + const store = makeStore(); + store.messages.set([{ id: 'old', role: 'user', content: 'old' }]); + reduceEvent({ + type: 'MESSAGES_SNAPSHOT', + messages: [{ id: 'new', role: 'assistant', content: 'fresh' }], + } as any, store); + expect(store.messages()).toEqual([{ id: 'new', role: 'assistant', content: 'fresh' }]); + }); + + it('CUSTOM with name=state_update emits AgentStateUpdateEvent', async () => { + const store = makeStore(); + const events: AgentEvent[] = []; + store.events$.subscribe((e) => events.push(e)); + reduceEvent({ type: 'CUSTOM', name: 'state_update', value: { count: 1 } } as any, store); + expect(events).toEqual([{ type: 'state_update', data: { count: 1 } }]); + }); + + it('CUSTOM with other name emits AgentCustomEvent', async () => { + const store = makeStore(); + const events: AgentEvent[] = []; + store.events$.subscribe((e) => events.push(e)); + reduceEvent({ type: 'CUSTOM', name: 'tick', value: 42 } as any, store); + expect(events).toEqual([{ type: 'custom', name: 'tick', data: 42 }]); + }); + + it('unknown event types are no-ops', () => { + const store = makeStore(); + reduceEvent({ type: 'FUTURE_EVENT' } as any, store); + expect(store.messages()).toEqual([]); + expect(store.status()).toBe('idle'); + }); +}); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts new file mode 100644 index 000000000..10f49b9ff --- /dev/null +++ b/libs/ag-ui/src/lib/reducer.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// @ag-ui/client@0.0.52 — EventType is a string enum with uppercase values. +// Discriminator strings (e.g. 'RUN_STARTED') match EventType enum members +// verbatim; the switch cases below use the string literals directly so this +// file has no runtime dependency on the EventType enum import. +import type { WritableSignal } from '@angular/core'; +import type { Subject } from 'rxjs'; +import type { + Message, AgentStatus, ToolCall, AgentEvent, +} from '@cacheplane/chat'; +import type { BaseEvent } from '@ag-ui/client'; +import { applyPatch, type Operation } from 'fast-json-patch'; + +export interface ReducerStore { + messages: WritableSignal; + status: WritableSignal; + isLoading: WritableSignal; + error: WritableSignal; + toolCalls: WritableSignal; + state: WritableSignal>; + events$: Subject; +} + +/** + * Pure function: applies a single AG-UI BaseEvent to the store. Caller + * subscribes to source.agent() and forwards each event here. Designed + * for testability — no side effects beyond the supplied store. + */ +export function reduceEvent(event: BaseEvent, store: ReducerStore): void { + switch (event.type) { + case 'RUN_STARTED': { + store.status.set('running'); + store.isLoading.set(true); + store.error.set(null); + return; + } + case 'RUN_FINISHED': { + store.status.set('idle'); + store.isLoading.set(false); + return; + } + case 'RUN_ERROR': { + store.status.set('error'); + store.isLoading.set(false); + store.error.set((event as { message?: unknown }).message ?? event); + return; + } + case 'TEXT_MESSAGE_START': { + store.messages.update((prev) => [ + ...prev, + { id: messageIdFrom(event), role: 'assistant', content: '' }, + ]); + return; + } + case 'TEXT_MESSAGE_CONTENT': { + const id = messageIdFrom(event); + const delta = (event as { delta?: string }).delta ?? ''; + store.messages.update((prev) => + prev.map((m) => m.id === id ? { ...m, content: m.content + delta } : m), + ); + return; + } + case 'TEXT_MESSAGE_END': { + // No-op — message is finalized by virtue of TEXT_MESSAGE_CONTENT + // having been applied. Reserved for future hooks. + return; + } + case 'TOOL_CALL_START': { + const e = event as { toolCallId: string; toolCallName: string }; + store.toolCalls.update((prev) => [ + ...prev, + { id: e.toolCallId, name: e.toolCallName, args: {}, status: 'running' }, + ]); + return; + } + case 'TOOL_CALL_ARGS': { + const e = event as { toolCallId: string; delta: string }; + const args = safeParseArgs(e.delta); + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, args } : t), + ); + return; + } + case 'TOOL_CALL_END': { + const e = event as { toolCallId: string }; + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, status: 'complete' } : t), + ); + return; + } + case 'TOOL_CALL_RESULT': { + const e = event as { toolCallId: string; content: unknown }; + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, result: e.content } : t), + ); + return; + } + case 'STATE_SNAPSHOT': { + const e = event as { snapshot: Record }; + store.state.set(e.snapshot ?? {}); + return; + } + case 'STATE_DELTA': { + const e = event as { delta: Operation[] }; + const next = applyPatch(deepClone(store.state()), e.delta).newDocument; + store.state.set(next); + return; + } + case 'MESSAGES_SNAPSHOT': { + const e = event as { messages: Message[] }; + store.messages.set(e.messages ?? []); + return; + } + case 'CUSTOM': { + const e = event as { name: string; value: unknown }; + if (e.name === 'state_update' && isRecord(e.value)) { + store.events$.next({ type: 'state_update', data: e.value }); + } else { + store.events$.next({ type: 'custom', name: e.name, data: e.value }); + } + return; + } + default: { + // Unknown event types are ignored; AG-UI may add new ones in + // future protocol versions. We surface them as no-ops rather + // than throwing, so a partial-version mismatch doesn't crash. + return; + } + } +} + +function messageIdFrom(event: BaseEvent): string { + return (event as { messageId?: string }).messageId ?? 'unknown'; +} + +function safeParseArgs(delta: string): Record { + try { + const parsed = JSON.parse(delta); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function deepClone(v: T): T { + return JSON.parse(JSON.stringify(v)); +} From 67ea7f2b62092d06696320044660b9e0c1e216fb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:24:53 -0400 Subject: [PATCH 5/7] feat(ag-ui): toAgent wraps AbstractAgent into Agent contract Subscribes to source via AgentSubscriber.onEvent and reduces every event into the produced Agent's signals. submit() optimistically appends user message to both our signals and source.addMessage(), then calls source.runAgent(). stop() calls source.abortRun(). Conformance suite validates the AG-UI adapter passes the same contract assertions as the LangGraph adapter. Also fixes reducer.ts type casts (as unknown as) required by @ag-ui/client 0.0.52 which uses Zod-inferred BaseEvent types that do not overlap with narrower cast targets without the intermediate unknown step. Co-Authored-By: Claude Opus 4.7 --- libs/ag-ui/src/lib/reducer.ts | 16 +- .../src/lib/to-agent.conformance.spec.ts | 45 ++++++ libs/ag-ui/src/lib/to-agent.spec.ts | 145 ++++++++++++++++++ libs/ag-ui/src/lib/to-agent.ts | 93 ++++++++++- 4 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 libs/ag-ui/src/lib/to-agent.conformance.spec.ts create mode 100644 libs/ag-ui/src/lib/to-agent.spec.ts diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 10f49b9ff..1a9830822 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -66,7 +66,7 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { return; } case 'TOOL_CALL_START': { - const e = event as { toolCallId: string; toolCallName: string }; + const e = event as unknown as { toolCallId: string; toolCallName: string }; store.toolCalls.update((prev) => [ ...prev, { id: e.toolCallId, name: e.toolCallName, args: {}, status: 'running' }, @@ -74,7 +74,7 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { return; } case 'TOOL_CALL_ARGS': { - const e = event as { toolCallId: string; delta: string }; + const e = event as unknown as { toolCallId: string; delta: string }; const args = safeParseArgs(e.delta); store.toolCalls.update((prev) => prev.map((t) => t.id === e.toolCallId ? { ...t, args } : t), @@ -82,37 +82,37 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { return; } case 'TOOL_CALL_END': { - const e = event as { toolCallId: string }; + const e = event as unknown as { toolCallId: string }; store.toolCalls.update((prev) => prev.map((t) => t.id === e.toolCallId ? { ...t, status: 'complete' } : t), ); return; } case 'TOOL_CALL_RESULT': { - const e = event as { toolCallId: string; content: unknown }; + const e = event as unknown as { toolCallId: string; content: unknown }; store.toolCalls.update((prev) => prev.map((t) => t.id === e.toolCallId ? { ...t, result: e.content } : t), ); return; } case 'STATE_SNAPSHOT': { - const e = event as { snapshot: Record }; + const e = event as unknown as { snapshot: Record }; store.state.set(e.snapshot ?? {}); return; } case 'STATE_DELTA': { - const e = event as { delta: Operation[] }; + const e = event as unknown as { delta: Operation[] }; const next = applyPatch(deepClone(store.state()), e.delta).newDocument; store.state.set(next); return; } case 'MESSAGES_SNAPSHOT': { - const e = event as { messages: Message[] }; + const e = event as unknown as { messages: Message[] }; store.messages.set(e.messages ?? []); return; } case 'CUSTOM': { - const e = event as { name: string; value: unknown }; + const e = event as unknown as { name: string; value: unknown }; if (e.name === 'state_update' && isRecord(e.value)) { store.events$.next({ type: 'state_update', data: e.value }); } else { diff --git a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts new file mode 100644 index 000000000..93d0f2d15 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Observable } from 'rxjs'; +import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; +import type { RunAgentInput } from '@ag-ui/core'; +import { runAgentConformance } from '@cacheplane/chat'; +import { toAgent } from './to-agent'; + +/** + * Minimal stub that satisfies the AbstractAgent shape for conformance testing. + * Implements all methods that toAgent() calls: subscribe(), runAgent(), + * abortRun(), addMessage(), and the abstract run() method. + */ +class StubAgent { + private readonly _subscribers: Array<{ + onEvent?: (p: { event: BaseEvent }) => void; + onRunFailed?: (p: { error: Error }) => void; + }> = []; + + subscribe(sub: { + onEvent?: (p: { event: BaseEvent }) => void; + onRunFailed?: (p: { error: Error }) => void; + }) { + this._subscribers.push(sub); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return { unsubscribe: () => {} }; + } + + async runAgent() { + return { result: undefined, newMessages: [] }; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + abortRun() {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + addMessage(_msg: unknown) {} + + run(_input: RunAgentInput): Observable { + return new Observable(); + } +} + +runAgentConformance('toAgent (AG-UI adapter)', () => { + return toAgent(new StubAgent() as unknown as AbstractAgent); +}); diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts new file mode 100644 index 000000000..3b9f51bff --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { Observable, Subject } from 'rxjs'; +import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; +import type { RunAgentInput } from '@ag-ui/core'; +import { toAgent } from './to-agent'; + +/** + * Minimal concrete subclass of AbstractAgent for unit testing. + * + * AbstractAgent requires one abstract method: run(input: RunAgentInput). + * The concrete implementation here emits events from a Subject so tests + * can push events synchronously. + * + * NOTE: abortRun() on the base AbstractAgent class is a no-op ({}). Only + * HttpAgent overrides it with real AbortController logic. For unit tests + * we spy on abortRun() directly; integration tests against a real server + * would exercise HttpAgent's override. + */ +class StubAgent { + // Subject that tests push events into via runAgent internal dispatch. + // We override runAgent to emit events through our subscriber pattern. + private readonly _events = new Subject(); + + // Simulate subscriber list just like AbstractAgent does + private readonly _subscribers: Array<{ onEvent?: (p: { event: BaseEvent }) => void; onRunFailed?: (p: { error: Error }) => void }> = []; + + subscribe(sub: { onEvent?: (p: { event: BaseEvent }) => void; onRunFailed?: (p: { error: Error }) => void }) { + this._subscribers.push(sub); + return { unsubscribe: () => { /* no-op for tests */ } }; + } + + /** Convenience: push an event to all subscribers. */ + emit(event: BaseEvent): void { + for (const sub of this._subscribers) { + sub.onEvent?.({ event }); + } + } + + /** Convenience: fail the run by calling onRunFailed on all subscribers. */ + failRun(error: Error): void { + for (const sub of this._subscribers) { + sub.onRunFailed?.({ error }); + } + } + + // runAgent: the public API toAgent() calls via submit(). + // We make it a spy so tests can verify call args and control resolution. + runAgent = vi.fn(async () => ({ result: undefined, newMessages: [] })); + + // abortRun: spy so tests can verify stop() calls it. + abortRun = vi.fn(); + + // addMessage: spy to verify user messages are synced to the source. + addMessage = vi.fn(); + + // run(): required abstract method. Not called directly in our adapter + // since we mock runAgent(), but must be present for type satisfaction. + run(_input: RunAgentInput): Observable { + return this._events.asObservable(); + } +} + +describe('toAgent', () => { + it('starts with idle status and no messages', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + expect(a.status()).toBe('idle'); + expect(a.messages()).toEqual([]); + expect(a.isLoading()).toBe(false); + }); + + it('reduces RUN_STARTED into running status', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + stub.emit({ type: 'RUN_STARTED' } as BaseEvent); + expect(a.status()).toBe('running'); + expect(a.isLoading()).toBe(true); + }); + + it('reduces RUN_FINISHED into idle status', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + stub.emit({ type: 'RUN_STARTED' } as BaseEvent); + stub.emit({ type: 'RUN_FINISHED' } as BaseEvent); + expect(a.status()).toBe('idle'); + expect(a.isLoading()).toBe(false); + }); + + it('appends user message optimistically on submit', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + void a.submit({ message: 'hello' }); + expect(a.messages()[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); + }); + + it('syncs user message to source.addMessage()', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({ message: 'hello' }); + expect(stub.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ role: 'user', content: 'hello' }), + ); + }); + + it('calls source.runAgent() on submit', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({ message: 'hi' }); + expect(stub.runAgent).toHaveBeenCalledOnce(); + }); + + it('stop() calls source.abortRun()', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + await a.stop(); + expect(stub.abortRun).toHaveBeenCalledOnce(); + }); + + it('events$ emits state_update on CUSTOM with that name', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + const seen: unknown[] = []; + a.events$.subscribe((e) => seen.push(e)); + stub.emit({ type: 'CUSTOM', name: 'state_update', value: { x: 1 } } as unknown as BaseEvent); + expect(seen).toEqual([{ type: 'state_update', data: { x: 1 } }]); + }); + + it('sets error status when onRunFailed subscriber fires', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + stub.failRun(new Error('something went wrong')); + expect(a.status()).toBe('error'); + expect(a.isLoading()).toBe(false); + expect(a.error()).toBeInstanceOf(Error); + }); + + it('does not append user message when input.message is undefined', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({}); + expect(a.messages()).toEqual([]); + expect(stub.addMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 1e25fed52..22de6434e 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -1,8 +1,95 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; -import type { Agent } from '@cacheplane/chat'; +import type { + Agent, Message, AgentStatus, ToolCall, AgentEvent, + AgentSubmitInput, AgentSubmitOptions, +} from '@cacheplane/chat'; +import { reduceEvent, type ReducerStore } from './reducer'; +/** + * Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract. + * + * The adapter subscribes to source.subscribe({ onEvent }) and reduces every + * event into the produced Agent's signals. submit() optimistically appends the + * user message to both our signals and the source agent's internal message + * list, then calls source.runAgent(). stop() calls source.abortRun(). + * + * Subscription cleanup: the returned Agent does NOT manage its own lifetime. + * Callers using DI should rely on the provider's destroy hook; direct callers + * of toAgent() should treat the returned object's lifecycle as tied to the + * agent instance they constructed. The subscriber registered via + * source.subscribe() will fire for the lifetime of source. + */ export function toAgent(source: AbstractAgent): Agent { - void source; - throw new Error('not implemented'); + const store: ReducerStore = { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; + + // Tap all events from the source agent via the AgentSubscriber API. + // This subscription lives for the lifetime of `source`. + source.subscribe({ + onEvent({ event }) { + reduceEvent(event, store); + }, + onRunFailed({ error }) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(error); + }, + }); + + return { + messages: store.messages, + status: store.status, + isLoading: store.isLoading, + error: store.error, + toolCalls: store.toolCalls, + state: store.state, + events$: store.events$.asObservable(), + + submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { + // Optimistic append of user message to our signals and to the source + // agent's own message list so runAgent() sees the new message. + const userMsg = buildUserMessage(input); + if (userMsg) { + store.messages.update((prev) => [...prev, userMsg]); + // Sync to AG-UI source so it's included in the next run's input. + source.addMessage(userMsg as Parameters[0]); + } + + try { + await source.runAgent(); + } catch (err) { + // If the run was aborted via stop(), abortRun() resolves the promise + // rather than rejecting — but catch any unexpected errors here. + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + } + }, + + stop: async () => { + source.abortRun(); + }, + }; +} + +function buildUserMessage(input: AgentSubmitInput): Message | undefined { + if (input.message === undefined) return undefined; + const content = typeof input.message === 'string' + ? input.message + : input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join(''); + return { id: randomId(), role: 'user', content }; +} + +function randomId(): string { + return Math.random().toString(36).slice(2); } From d6cc70289e7ef3757fecddbb9e71fe59e2288a3f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:27:32 -0400 Subject: [PATCH 6/7] feat(ag-ui): provideAgUiAgent DI convenience for HttpAgent Constructs HttpAgent from config and wires it through toAgent into the AG_UI_AGENT injection token. Convenience entry point for the common case; custom transports go through toAgent(customAgent) directly. Co-Authored-By: Claude Opus 4.7 --- .../ag-ui/src/lib/provide-ag-ui-agent.spec.ts | 126 ++++++++++++++++++ libs/ag-ui/src/lib/provide-ag-ui-agent.ts | 40 +++++- 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts diff --git a/libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts b/libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts new file mode 100644 index 000000000..d7e0d9cb5 --- /dev/null +++ b/libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { Observable } from 'rxjs'; +import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; +import type { RunAgentInput } from '@ag-ui/core'; +import { provideAgUiAgent, AG_UI_AGENT } from './provide-ag-ui-agent'; + +/** + * Minimal stub that satisfies the AbstractAgent shape for provider testing. + */ +class StubAgent { + agentId?: string; + threadId?: string; + url: string; + headers: Record; + + private readonly _subscribers: Array<{ + onEvent?: (p: { event: BaseEvent }) => void; + onRunFailed?: (p: { error: Error }) => void; + }> = []; + + constructor(config: { + url: string; + agentId?: string; + threadId?: string; + headers?: Record; + }) { + this.url = config.url; + this.agentId = config.agentId; + this.threadId = config.threadId; + this.headers = config.headers || {}; + } + + subscribe(sub: { + onEvent?: (p: { event: BaseEvent }) => void; + onRunFailed?: (p: { error: Error }) => void; + }) { + this._subscribers.push(sub); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return { unsubscribe: () => {} }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async runAgent() { + return { result: undefined, newMessages: [] }; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + abortRun() {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + addMessage(_msg: unknown) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + run(_input: RunAgentInput): Observable { + return new Observable(); + } +} + +describe('provideAgUiAgent', () => { + it('returns a provider array', () => { + const providers = provideAgUiAgent({ url: 'http://example.test/agent' }); + expect(Array.isArray(providers)).toBe(true); + expect(providers.length).toBeGreaterThan(0); + }); + + it('provides AG_UI_AGENT token', () => { + const providers = provideAgUiAgent({ url: 'http://example.test/agent' }); + const agentProvider = providers[0]; + expect(agentProvider).toBeDefined(); + expect(agentProvider.provide).toBe(AG_UI_AGENT); + }); + + it('factory creates agent with all methods', () => { + // Mock HttpAgent to be our stub + vi.doMock('@ag-ui/client', async () => { + const actual = await vi.importActual('@ag-ui/client'); + return { + ...actual, + HttpAgent: StubAgent, + }; + }); + + const providers = provideAgUiAgent({ url: 'http://example.test/agent' }); + const agentProvider = providers[0] as any; + const agent = agentProvider.useFactory(); + + expect(agent).toBeDefined(); + expect(typeof agent.submit).toBe('function'); + expect(typeof agent.stop).toBe('function'); + expect(agent.messages).toBeDefined(); + expect(agent.status).toBeDefined(); + expect(agent.isLoading).toBeDefined(); + expect(agent.error).toBeDefined(); + expect(agent.toolCalls).toBeDefined(); + expect(agent.state).toBeDefined(); + expect(agent.events$).toBeDefined(); + + vi.doUnmock('@ag-ui/client'); + }); + + it('passes config fields to HttpAgent constructor', () => { + const config = { + url: 'http://test.example/agent', + agentId: 'test-agent-123', + threadId: 'thread-456', + headers: { Authorization: 'Bearer token' }, + }; + + const providers = provideAgUiAgent(config); + const agentProvider = providers[0] as any; + + // We can't easily test the actual HttpAgent call without mocking, + // but we verify the provider structure is correct. + expect(agentProvider.provide).toBe(AG_UI_AGENT); + expect(typeof agentProvider.useFactory).toBe('function'); + }); + + it('handles optional config fields', () => { + const providers = provideAgUiAgent({ url: 'http://example.test/agent' }); + const agentProvider = providers[0] as any; + + expect(agentProvider.provide).toBe(AG_UI_AGENT); + expect(typeof agentProvider.useFactory).toBe('function'); + }); +}); diff --git a/libs/ag-ui/src/lib/provide-ag-ui-agent.ts b/libs/ag-ui/src/lib/provide-ag-ui-agent.ts index ab62bd2e0..8ef400537 100644 --- a/libs/ag-ui/src/lib/provide-ag-ui-agent.ts +++ b/libs/ag-ui/src/lib/provide-ag-ui-agent.ts @@ -1,7 +1,17 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { InjectionToken, type Provider } from '@angular/core'; +import { InjectionToken, inject, type Provider } from '@angular/core'; +import { HttpAgent } from '@ag-ui/client'; import type { Agent } from '@cacheplane/chat'; +import { toAgent } from './to-agent'; +/** + * Configuration for the AG-UI agent provider. + * HttpAgentConfig shape (from @ag-ui/client@0.0.52): + * - url: string (required) — endpoint for the HTTP agent + * - agentId: string (optional) — agent identifier + * - threadId: string (optional) — thread identifier + * - headers: Record (optional) — custom HTTP headers + */ export interface AgUiAgentConfig { url: string; agentId?: string; @@ -11,11 +21,33 @@ export interface AgUiAgentConfig { export const AG_UI_AGENT = new InjectionToken('AG_UI_AGENT'); +/** + * Provides an Agent instance wired through HttpAgent and toAgent. + * Constructs an HttpAgent from config and wraps it in the runtime-neutral + * Agent contract via toAgent(). Returns a provider array suitable for + * bootstrapApplication or TestBed.configureTestingModule(). + */ export function provideAgUiAgent(config: AgUiAgentConfig): Provider[] { - void config; - throw new Error('not implemented'); + return [ + { + provide: AG_UI_AGENT, + useFactory: () => { + const source = new HttpAgent({ + url: config.url, + ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + }); + return toAgent(source); + }, + }, + ]; } +/** + * Injects the AG_UI_AGENT from Angular's dependency injection container. + * Use this in components or services that have been provided via provideAgUiAgent(). + */ export function injectAgUiAgent(): Agent { - throw new Error('not implemented'); + return inject(AG_UI_AGENT); } From 235c0d98c7431c0c10b88c8b16c85aa6256a43f7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 27 Apr 2026 11:32:28 -0400 Subject: [PATCH 7/7] feat(cockpit): AG-UI streaming demo using @cacheplane/ag-ui Mirrors cockpit/langgraph/streaming/angular/ structure. Demonstrates the chat-runtime decoupling: same composition, AG-UI runtime instead of LangGraph. Co-Authored-By: Claude Opus 4.7 --- cockpit/ag-ui/streaming/angular/package.json | 10 +++ cockpit/ag-ui/streaming/angular/project.json | 61 +++++++++++++++++++ .../streaming/angular/prompts/streaming.md | 7 +++ .../ag-ui/streaming/angular/proxy.conf.json | 8 +++ .../streaming/angular/src/app/app.config.ts | 10 +++ .../angular/src/app/streaming.component.ts | 29 +++++++++ .../environments/environment.development.ts | 10 +++ .../angular/src/environments/environment.ts | 10 +++ .../ag-ui/streaming/angular/src/index.html | 13 ++++ cockpit/ag-ui/streaming/angular/src/index.ts | 33 ++++++++++ cockpit/ag-ui/streaming/angular/src/main.ts | 6 ++ .../ag-ui/streaming/angular/src/styles.css | 30 +++++++++ .../ag-ui/streaming/angular/tsconfig.app.json | 11 ++++ cockpit/ag-ui/streaming/angular/tsconfig.json | 24 ++++++++ cockpit/ag-ui/streaming/angular/vercel.json | 6 ++ 15 files changed, 268 insertions(+) create mode 100644 cockpit/ag-ui/streaming/angular/package.json create mode 100644 cockpit/ag-ui/streaming/angular/project.json create mode 100644 cockpit/ag-ui/streaming/angular/prompts/streaming.md create mode 100644 cockpit/ag-ui/streaming/angular/proxy.conf.json create mode 100644 cockpit/ag-ui/streaming/angular/src/app/app.config.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/environments/environment.development.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/environments/environment.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/index.html create mode 100644 cockpit/ag-ui/streaming/angular/src/index.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/main.ts create mode 100644 cockpit/ag-ui/streaming/angular/src/styles.css create mode 100644 cockpit/ag-ui/streaming/angular/tsconfig.app.json create mode 100644 cockpit/ag-ui/streaming/angular/tsconfig.json create mode 100644 cockpit/ag-ui/streaming/angular/vercel.json diff --git a/cockpit/ag-ui/streaming/angular/package.json b/cockpit/ag-ui/streaming/angular/package.json new file mode 100644 index 000000000..e286afb70 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/package.json @@ -0,0 +1,10 @@ +{ + "name": "@cacheplane/cockpit-ag-ui-streaming-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/ag-ui": "^0.0.1", + "@cacheplane/chat": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/ag-ui/streaming/angular/project.json b/cockpit/ag-ui/streaming/angular/project.json new file mode 100644 index 000000000..68ec45727 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-ag-ui-streaming-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/ag-ui/streaming/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/ag-ui/streaming/angular", + "browser": "" + }, + "browser": "cockpit/ag-ui/streaming/angular/src/main.ts", + "tsConfig": "cockpit/ag-ui/streaming/angular/tsconfig.app.json", + "styles": ["cockpit/ag-ui/streaming/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/ag-ui/streaming/angular/src/environments/environment.ts", + "with": "cockpit/ag-ui/streaming/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-ag-ui-streaming-angular:build:production" }, + "development": { "buildTarget": "cockpit-ag-ui-streaming-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/ag-ui/streaming/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/ag-ui/streaming/angular", + "command": "npx tsx -e \"import { agUiStreamingAngularModule } from './src/index.ts'; const module = agUiStreamingAngularModule; if (module.id !== 'ag-ui-streaming-angular' || module.title !== 'AG-UI Streaming (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/ag-ui/streaming/angular/prompts/streaming.md b/cockpit/ag-ui/streaming/angular/prompts/streaming.md new file mode 100644 index 000000000..197577a9e --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/prompts/streaming.md @@ -0,0 +1,7 @@ +# AG-UI Streaming (Angular) + +This capability demonstrates real-time token streaming from an AG-UI compatible agent using the `@cacheplane/chat` Angular component library. The example shows how to wire the `AG_UI_AGENT` injection token (provided by `provideAgUiAgent`) into the `` host component and compose ``, ``, and `` to deliver a responsive, streaming chat experience. + +Key components used: ``, ``, ``, ``. The `provideAgUiAgent` provider handles SSE event processing from the AG-UI streaming endpoint, and the chat components subscribe reactively without any manual subscription management. + +The demo illustrates the chat-runtime decoupling: the same `` composition works with any agent runtime — LangGraph, AG-UI, or others — by conforming to the `AgentRef` interface. diff --git a/cockpit/ag-ui/streaming/angular/proxy.conf.json b/cockpit/ag-ui/streaming/angular/proxy.conf.json new file mode 100644 index 000000000..dcc8e4202 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/agent": { + "target": "http://localhost:3000", + "secure": false, + "changeOrigin": true, + "ws": true + } +} diff --git a/cockpit/ag-ui/streaming/angular/src/app/app.config.ts b/cockpit/ag-ui/streaming/angular/src/app/app.config.ts new file mode 100644 index 000000000..9c2bbd8b7 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/app/app.config.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideAgUiAgent } from '@cacheplane/ag-ui'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgUiAgent({ url: environment.agUiUrl }), + ], +}; diff --git a/cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts b/cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts new file mode 100644 index 000000000..92c805b7e --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, inject } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; +import { AG_UI_AGENT } from '@cacheplane/ag-ui'; +import { ExampleChatLayoutComponent } from '@cacheplane/example-layouts'; + +/** + * Streaming demo — simplest possible @cacheplane/chat integration with AG-UI. + * + * Injects the AG_UI_AGENT token (provided by provideAgUiAgent) and passes it + * to the prebuilt composition. The composition handles message rendering, + * input, typing indicator, and error display internally. + * + * Demonstrates the chat-runtime decoupling: same composition as the + * LangGraph cockpit, AG-UI runtime instead of LangGraph. + */ +@Component({ + selector: 'app-streaming', + standalone: true, + imports: [ChatComponent, ExampleChatLayoutComponent], + template: ` + + + + `, +}) +export class StreamingComponent { + protected readonly agent = inject(AG_UI_AGENT); +} diff --git a/cockpit/ag-ui/streaming/angular/src/environments/environment.development.ts b/cockpit/ag-ui/streaming/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..bb73b8762 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/environments/environment.development.ts @@ -0,0 +1,10 @@ +/** + * Development environment configuration. + * + * Points to a local AG-UI compatible agent server started on port 3000. + * The dev-server proxy (proxy.conf.json) forwards /agent to http://localhost:3000. + */ +export const environment = { + production: false, + agUiUrl: 'http://localhost:3000/agent', +}; diff --git a/cockpit/ag-ui/streaming/angular/src/environments/environment.ts b/cockpit/ag-ui/streaming/angular/src/environments/environment.ts new file mode 100644 index 000000000..9e32acf0f --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/environments/environment.ts @@ -0,0 +1,10 @@ +/** + * Production environment configuration. + * + * Uses relative /agent URL — configure a reverse proxy or Vercel rewrite + * to forward requests to the AG-UI backend. + */ +export const environment = { + production: true, + agUiUrl: '/agent', +}; diff --git a/cockpit/ag-ui/streaming/angular/src/index.html b/cockpit/ag-ui/streaming/angular/src/index.html new file mode 100644 index 000000000..e73a9b36a --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + AG-UI Streaming — Angular + + + + + + + + diff --git a/cockpit/ag-ui/streaming/angular/src/index.ts b/cockpit/ag-ui/streaming/angular/src/index.ts new file mode 100644 index 000000000..3b6550d1d --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'ag-ui'; + section: 'core-capabilities'; + topic: 'streaming'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const agUiStreamingAngularModule: CockpitCapabilityModule = { + id: 'ag-ui-streaming-angular', + manifestIdentity: { + product: 'ag-ui', + section: 'core-capabilities', + topic: 'streaming', + page: 'overview', + language: 'angular', + }, + title: 'AG-UI Streaming (Angular)', + docsPath: '/docs/ag-ui/core-capabilities/streaming/overview/angular', + promptAssetPaths: [ + 'cockpit/ag-ui/streaming/angular/prompts/streaming.md', + ], + codeAssetPaths: [ + 'cockpit/ag-ui/streaming/angular/src/app/streaming.component.ts', + ], +}; diff --git a/cockpit/ag-ui/streaming/angular/src/main.ts b/cockpit/ag-ui/streaming/angular/src/main.ts new file mode 100644 index 000000000..aed8e4d3e --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { StreamingComponent } from './app/streaming.component'; + +bootstrapApplication(StreamingComponent, appConfig).catch(console.error); diff --git a/cockpit/ag-ui/streaming/angular/src/styles.css b/cockpit/ag-ui/streaming/angular/src/styles.css new file mode 100644 index 000000000..061c66cf8 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/src/styles.css @@ -0,0 +1,30 @@ +@import "../../../../../libs/design-tokens/src/lib/tokens.css"; +@import "tailwindcss"; +@source "../../../../../libs/chat/src/"; + +@theme { + --color-bg: var(--ds-bg); + --color-surface: #ffffff; + --color-accent: var(--ds-accent); + --color-accent-light: var(--ds-accent-light); + --color-text-primary: var(--ds-text-primary); + --color-text-secondary: var(--ds-text-secondary); + --color-text-muted: var(--ds-text-muted); + --color-border: var(--ds-accent-border); + --color-error: #ef4444; + --color-success: #22c55e; + --font-sans: var(--ds-font-sans); + --font-serif: var(--ds-font-serif); + --font-mono: var(--ds-font-mono); +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--ds-font-sans); + background: var(--ds-bg); + color: var(--ds-text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/cockpit/ag-ui/streaming/angular/tsconfig.app.json b/cockpit/ag-ui/streaming/angular/tsconfig.app.json new file mode 100644 index 000000000..64731b107 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/tsconfig.app.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "lib": ["es2022", "dom"], + "types": [], + "emitDeclarationOnly": false + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"] +} diff --git a/cockpit/ag-ui/streaming/angular/tsconfig.json b/cockpit/ag-ui/streaming/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/ag-ui/streaming/angular/vercel.json b/cockpit/ag-ui/streaming/angular/vercel.json new file mode 100644 index 000000000..38a57b4f9 --- /dev/null +++ b/cockpit/ag-ui/streaming/angular/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "npx nx build cockpit-ag-ui-streaming-angular", + "outputDirectory": "dist/cockpit/ag-ui/streaming/angular/browser", + "framework": null +}