diff --git a/cockpit/ag-ui/streaming/angular/package.json b/cockpit/ag-ui/streaming/angular/package.json new file mode 100644 index 00000000..e286afb7 --- /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 00000000..68ec4572 --- /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 00000000..197577a9 --- /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 00000000..dcc8e420 --- /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 00000000..9c2bbd8b --- /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 00000000..92c805b7 --- /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 00000000..bb73b876 --- /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 00000000..9e32acf0 --- /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 00000000..e73a9b36 --- /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 00000000..3b6550d1 --- /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 00000000..aed8e4d3 --- /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 00000000..061c66cf --- /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 00000000..64731b10 --- /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 00000000..3fd97037 --- /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 00000000..38a57b4f --- /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 +} 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 00000000..8f0f8e17 --- /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). 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 00000000..1331491c --- /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. diff --git a/libs/ag-ui/README.md b/libs/ag-ui/README.md new file mode 100644 index 00000000..46a05bbd --- /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 00000000..4f165dc8 --- /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 00000000..ad524499 --- /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 00000000..e7a9b5b8 --- /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 00000000..2e820f23 --- /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.spec.ts b/libs/ag-ui/src/lib/provide-ag-ui-agent.spec.ts new file mode 100644 index 00000000..d7e0d9cb --- /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 new file mode 100644 index 00000000..8ef40053 --- /dev/null +++ b/libs/ag-ui/src/lib/provide-ag-ui-agent.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +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; + threadId?: string; + headers?: Record; +} + +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[] { + 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 { + return inject(AG_UI_AGENT); +} 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 00000000..6d6e4844 --- /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 00000000..1a983082 --- /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 unknown 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 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), + ); + return; + } + case 'TOOL_CALL_END': { + 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 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 unknown as { snapshot: Record }; + store.state.set(e.snapshot ?? {}); + return; + } + case 'STATE_DELTA': { + 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 unknown as { messages: Message[] }; + store.messages.set(e.messages ?? []); + return; + } + case 'CUSTOM': { + 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 { + 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)); +} 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 00000000..93d0f2d1 --- /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 00000000..3b9f51bf --- /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 new file mode 100644 index 00000000..22de6434 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -0,0 +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, 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 { + 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); +} diff --git a/libs/ag-ui/src/public-api.ts b/libs/ag-ui/src/public-api.ts new file mode 100644 index 00000000..83eab7e9 --- /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 00000000..ca3d8a2b --- /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 00000000..da190b43 --- /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 00000000..afcadee0 --- /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 00000000..2a2faa88 --- /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 00000000..ce406638 --- /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 fa1ebba5..8c1588d1 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 1b91126f..7ba06c32 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 ce82e20d..f83660fd 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,