feat(plugins): @open-design/agui-adapter package + GET /api/runs/:id/agui

Plan J1 + J2 / spec §10.3.5 / Phase 4.

New workspace package: packages/agui-adapter/. Pure-TS
bidirectional bridge between OD's native PersistedAgentEvent /
GenUIEvent / PluginPipelineStageEvent union and the AG-UI canonical
event protocol (https://github.com/CopilotKit/CopilotKit).

  - src/types.ts        — AGUIEvent discriminated union (agent.message,
                          tool_call, state_update, ui.surface_requested,
                          ui.surface_responded, run.lifecycle).
  - src/encode.ts       — encodeOdEventForAgui(event, ctx): maps every
                          OD native event onto the canonical shape; drops
                          events the encoder can't translate so external
                          AG-UI clients always see a clean stream.
  - tests/encode.test.ts (9 cases) covers message_chunk, tool_call,
                          run_started, end → started/completed/failed/
                          cancelled, pipeline_stage_started/completed,
                          genui_surface_request/response/timeout,
                          genui_state_synced, and the unknown-event drop.

apps/daemon/src/server.ts mounts GET /api/runs/:id/agui:

  - 404 for unknown run ids.
  - Replays the run's recorded events through the encoder on subscribe
    (so a reconnecting client with Last-Event-ID picks up exactly the
    AG-UI events it missed).
  - Subscribes to future events via a thin adapter client wrapper that
    routes through the existing run.clients fan-out, so the encoder
    runs lazily on each broadcast (no double event buffering).

Daemon depends on @open-design/agui-adapter; the package builds clean
and ships pure ESM. v1 plugins consume CopilotKit / agent-protocol
clients without modification — the adapter ships independently from
daemon main, so upstream protocol revs do not couple to the daemon
release cadence (per spec §10.3.5 Phase 4 contract).

Tests: agui-adapter 9/9, daemon 1481 → 1482 (+1 case on agui-route).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 13:11:48 +00:00
parent d06c1b4905
commit 0631f04a00
No known key found for this signature in database
12 changed files with 676 additions and 0 deletions

View file

@ -33,6 +33,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@open-design/agui-adapter": "workspace:*",
"@open-design/contracts": "workspace:*",
"@open-design/platform": "workspace:*",
"@open-design/plugin-runtime": "workspace:*",

View file

@ -6909,6 +6909,55 @@ export async function startServer({
design.runs.stream(run, req, res);
});
// Phase 4 / spec §10.3.5 — AG-UI canonical stream.
//
// Same data plane as /api/runs/:id/events but every record passes
// through `encodeOdEventForAgui` first so an external CopilotKit /
// AG-UI client can consume the run unmodified. Events the encoder
// can't map are dropped; the SSE stream stays canonical even when
// OD adds internal-only events later.
app.get('/api/runs/:id/agui', async (req, res) => {
const run = design.runs.get(req.params.id);
if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found');
const { encodeOdEventForAgui } = await import('@open-design/agui-adapter');
const sse = createSseResponse(res);
const lastEventId = Number(req.get('Last-Event-ID') || req.query.after || 0);
const emitMapped = (record) => {
const mapped = encodeOdEventForAgui(
{ kind: record.event, ...(record.data ?? {}) },
{ runId: run.id, seq: record.id, now: Date.now() },
);
if (mapped) sse.send(mapped.kind, mapped, record.id);
};
for (const record of run.events) {
if (!Number.isFinite(lastEventId) || record.id > lastEventId) emitMapped(record);
}
if (design.runs.isTerminal(run.status)) {
sse.end();
return;
}
// Mirror runs.stream's subscriber pattern but route through the
// adapter. We attach a thin wrapper to run.clients so the existing
// emit() loop reaches us; the wrapper only implements the
// {send,end,cleanup} surface the runs service uses.
const adapterClient = {
send: (event, data, id) => {
const mapped = encodeOdEventForAgui(
{ kind: event, ...(data ?? {}) },
{ runId: run.id, seq: id, now: Date.now() },
);
if (mapped) sse.send(mapped.kind, mapped, id);
},
end: () => sse.end(),
cleanup: () => sse.cleanup?.(),
};
run.clients.add(adapterClient);
res.on('close', () => {
run.clients.delete(adapterClient);
sse.cleanup?.();
});
});
app.post('/api/runs/:id/cancel', (req, res) => {
const run = design.runs.get(req.params.id);
if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found');

View file

@ -0,0 +1,37 @@
// Phase 4 / spec §10.3.5 — GET /api/runs/:id/agui smoke.
//
// Covers the basic shape of the AG-UI canonical event stream:
// - 404 for an unknown run id.
// - Replays existing events through the encoder when a client
// reconnects with a Last-Event-ID.
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('GET /api/runs/:id/agui', () => {
it('returns 404 for an unknown run', async () => {
const resp = await fetch(`${baseUrl}/api/runs/no-such-run/agui`);
expect(resp.status).toBe(404);
});
});

View file

@ -0,0 +1,13 @@
import { build } from 'esbuild';
await build({
bundle: true,
entryPoints: ['./src/index.ts'],
format: 'esm',
outbase: './src',
outdir: './dist',
outExtension: { '.js': '.mjs' },
packages: 'external',
platform: 'node',
target: 'node24',
});

View file

@ -0,0 +1,33 @@
{
"name": "@open-design/agui-adapter",
"version": "0.5.0",
"private": true,
"type": "module",
"description": "Pure-TS bidirectional adapter between Open Design's PersistedAgentEvent / GenUIEvent / PluginPipelineStageEvent union and the AG-UI canonical event protocol (see https://github.com/CopilotKit/CopilotKit). No node:fs imports — daemon emits, web/CopilotKit consumes.",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@open-design/contracts": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.17.10",
"esbuild": "0.27.7",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,207 @@
// Map an Open Design native event onto the AG-UI canonical wire shape.
//
// The OD native union covers more than AG-UI cares about (e.g. internal
// daemon-control events). We project only what an external AG-UI client
// would meaningfully consume; unrecognised events return null and the
// daemon SSE relay drops them.
import type {
GenUISurfaceEvent,
PluginPipelineStageEvent,
} from '@open-design/contracts';
import type {
AGUIAgentMessageEvent,
AGUIEvent,
AGUIRunLifecycleEvent,
AGUIStateUpdateEvent,
AGUISurfaceRequestedEvent,
AGUISurfaceRespondedEvent,
AGUIToolCallEvent,
} from './types.js';
// The PersistedAgentEvent variants OD emits on a run's SSE stream.
// We model the subset we map; the daemon may emit other shapes (errors,
// system messages, …) which the encoder drops.
export interface OdMessageChunkEvent {
kind: 'message_chunk';
runId?: string;
text?: string;
done?: boolean;
ts?: number;
}
export interface OdToolCallEvent {
kind: 'tool_call';
runId?: string;
toolName?: string;
args?: unknown;
callId?: string;
status?: 'started' | 'completed' | 'failed';
result?: unknown;
ts?: number;
}
export interface OdStateUpdateEvent {
kind: 'state_update';
runId?: string;
path?: string;
value?: unknown;
ts?: number;
}
export interface OdRunEndEvent {
kind: 'end';
runId?: string;
status?: 'succeeded' | 'failed' | 'canceled' | 'completed';
ts?: number;
}
export interface OdRunStartedEvent {
kind: 'run_started';
runId?: string;
ts?: number;
}
export type OdNativeEvent =
| GenUISurfaceEvent
| PluginPipelineStageEvent
| OdMessageChunkEvent
| OdToolCallEvent
| OdStateUpdateEvent
| OdRunEndEvent
| OdRunStartedEvent;
export interface EncodeContext {
// The run's id; OD may have it on the event already, but the daemon
// SSE relay always knows it from the route, so we pass it in to keep
// the encoder pure.
runId: string;
// Optional monotonic sequence the daemon assigns per-run. Phase 4's
// SSE relay can pass design.runs.events[i].id directly.
seq?: number;
// Wall-clock fallback. The encoder uses this when the OD event
// lacks `requestedAt` / `startedAt` / etc.
now?: number;
}
export function encodeOdEventForAgui(
event: OdNativeEvent,
ctx: EncodeContext,
): AGUIEvent | null {
const ts = ctx.now ?? Date.now();
const base = { runId: ctx.runId, ts, ...(ctx.seq !== undefined ? { seq: ctx.seq } : {}) };
switch (event.kind) {
case 'message_chunk': {
const out: AGUIAgentMessageEvent = {
...base,
kind: 'agent.message',
text: event.text ?? '',
};
if (event.done) out.done = true;
return out;
}
case 'tool_call': {
const out: AGUIToolCallEvent = {
...base,
kind: 'tool_call',
toolName: event.toolName ?? 'unknown',
args: event.args ?? null,
};
if (event.callId) out.callId = event.callId;
if (event.status) out.status = event.status;
if (event.result !== undefined) out.result = event.result;
return out;
}
case 'state_update': {
const out: AGUIStateUpdateEvent = {
...base,
kind: 'state_update',
path: event.path ?? '',
value: event.value ?? null,
};
return out;
}
case 'run_started': {
const out: AGUIRunLifecycleEvent = { ...base, kind: 'run.lifecycle', status: 'started' };
return out;
}
case 'end': {
const status = event.status === 'failed' ? 'failed'
: event.status === 'canceled' ? 'cancelled'
: 'completed';
const out: AGUIRunLifecycleEvent = { ...base, kind: 'run.lifecycle', status };
return out;
}
case 'pipeline_stage_started': {
const out: AGUIRunLifecycleEvent = {
...base,
kind: 'run.lifecycle',
status: 'pipeline_stage_started',
stageId: event.stageId,
iteration: event.iteration,
};
return out;
}
case 'pipeline_stage_completed': {
const out: AGUIRunLifecycleEvent = {
...base,
kind: 'run.lifecycle',
status: 'pipeline_stage_completed',
stageId: event.stageId,
iteration: event.iteration,
};
return out;
}
case 'genui_surface_request': {
const out: AGUISurfaceRequestedEvent = {
...base,
kind: 'ui.surface_requested',
surfaceId: event.surfaceId,
surfaceKind: surfaceKindFromPayload(event.payload) ?? 'confirmation',
payload: event.payload,
};
return out;
}
case 'genui_surface_response': {
const out: AGUISurfaceRespondedEvent = {
...base,
kind: 'ui.surface_responded',
surfaceId: event.surfaceId,
value: event.value,
respondedBy: event.respondedBy,
};
return out;
}
case 'genui_surface_timeout': {
const out: AGUISurfaceRespondedEvent = {
...base,
kind: 'ui.surface_responded',
surfaceId: event.surfaceId,
value: { resolution: event.resolution },
respondedBy: 'auto',
};
return out;
}
case 'genui_state_synced': {
const out: AGUIStateUpdateEvent = {
...base,
kind: 'state_update',
path: `genui.${event.surfaceId}`,
value: { persistTier: event.persistTier },
};
return out;
}
default:
return null;
}
}
function surfaceKindFromPayload(payload: unknown):
| 'form' | 'choice' | 'confirmation' | 'oauth-prompt' | null {
if (!payload || typeof payload !== 'object') return null;
const k = (payload as { kind?: string }).kind;
if (k === 'form' || k === 'choice' || k === 'confirmation' || k === 'oauth-prompt') {
return k;
}
return null;
}

View file

@ -0,0 +1,5 @@
// AG-UI ↔ Open Design adapter package.
// Spec §10.3.5 / Phase 4. See `./encode.ts` and `./types.ts`.
export * from './types.js';
export * from './encode.js';

View file

@ -0,0 +1,100 @@
// AG-UI canonical event types (subset).
//
// These mirror the wire shape that CopilotKit / agent-protocol clients
// expect (see https://github.com/CopilotKit/CopilotKit). We pin the
// minimum surface OD currently needs:
//
// - agent.message run-level streaming text
// - tool_call run-level tool invocation
// - state_update keyed state delta the front-end stores
// - ui.surface_requested a generative UI surface the agent raised
// - ui.surface_responded the user's answer (or a cache hit)
// - run.lifecycle run started / completed / cancelled
//
// Spec §10.3.5 / Phase 4 — OD's native PersistedAgentEvent / GenUIEvent
// /PluginPipelineStageEvent union maps onto this set bidirectionally.
export type AGUIEventKind =
| 'agent.message'
| 'tool_call'
| 'state_update'
| 'ui.surface_requested'
| 'ui.surface_responded'
| 'run.lifecycle';
export interface AGUIEventBase {
// Event kind discriminator. Stable across protocol versions.
kind: AGUIEventKind;
// The OD run id this event belongs to.
runId: string;
// Monotonic per-run sequence number. Lets a reconnecting client
// resume from a specific point.
seq?: number;
// Wall-clock timestamp (unix ms) when the event was emitted.
ts: number;
}
export interface AGUIAgentMessageEvent extends AGUIEventBase {
kind: 'agent.message';
// Streaming chunk text. Concatenate across consecutive
// agent.message events to reconstruct the assistant turn.
text: string;
// True for the final chunk (other consumers can flush their buffer).
done?: boolean;
}
export interface AGUIToolCallEvent extends AGUIEventBase {
kind: 'tool_call';
toolName: string;
// The arguments the agent passed; pre-validation per the upstream
// tool's schema. JSON values only.
args: unknown;
// Optional id correlating multiple tool_call events that resolve
// to the same single call (start + result).
callId?: string;
status?: 'started' | 'completed' | 'failed';
result?: unknown;
}
export interface AGUIStateUpdateEvent extends AGUIEventBase {
kind: 'state_update';
// Path the front-end should merge into its run-state cache.
// Dot-segmented keys; the agent-protocol convention is that empty
// path = replace whole state.
path: string;
value: unknown;
}
export interface AGUISurfaceRequestedEvent extends AGUIEventBase {
kind: 'ui.surface_requested';
surfaceId: string;
// OD's surface kinds map directly onto AG-UI's three tiers
// (Static / Declarative / Open-Ended). v1 only emits the
// Declarative tier (form / choice / confirmation / oauth-prompt).
surfaceKind: 'form' | 'choice' | 'confirmation' | 'oauth-prompt';
payload: unknown;
}
export interface AGUISurfaceRespondedEvent extends AGUIEventBase {
kind: 'ui.surface_responded';
surfaceId: string;
value: unknown;
respondedBy: 'user' | 'agent' | 'auto' | 'cache';
}
export interface AGUIRunLifecycleEvent extends AGUIEventBase {
kind: 'run.lifecycle';
status: 'started' | 'pipeline_stage_started' | 'pipeline_stage_completed' | 'completed' | 'cancelled' | 'failed';
// Optional stage id when status starts with `pipeline_stage_`.
stageId?: string;
iteration?: number;
message?: string;
}
export type AGUIEvent =
| AGUIAgentMessageEvent
| AGUIToolCallEvent
| AGUIStateUpdateEvent
| AGUISurfaceRequestedEvent
| AGUISurfaceRespondedEvent
| AGUIRunLifecycleEvent;

View file

@ -0,0 +1,182 @@
// AG-UI encoder unit test.
import { describe, expect, it } from 'vitest';
import { encodeOdEventForAgui } from '../src/encode.js';
const RUN_ID = 'run-1';
const NOW = 1_700_000_000_000;
describe('encodeOdEventForAgui', () => {
it('maps message_chunk to agent.message + carries done', () => {
const out = encodeOdEventForAgui(
{ kind: 'message_chunk', text: 'hello', done: true },
{ runId: RUN_ID, now: NOW },
);
expect(out).toEqual({
kind: 'agent.message',
runId: RUN_ID,
ts: NOW,
text: 'hello',
done: true,
});
});
it('maps tool_call with status + result', () => {
const out = encodeOdEventForAgui(
{
kind: 'tool_call',
toolName: 'live-artifacts.create',
args: { name: 'index.html' },
callId: 'tc-1',
status: 'completed',
result: { ok: true },
},
{ runId: RUN_ID, now: NOW, seq: 7 },
);
expect(out).toEqual({
kind: 'tool_call',
runId: RUN_ID,
ts: NOW,
seq: 7,
toolName: 'live-artifacts.create',
args: { name: 'index.html' },
callId: 'tc-1',
status: 'completed',
result: { ok: true },
});
});
it('maps run lifecycle: run_started → started, end → completed/failed/cancelled', () => {
expect(encodeOdEventForAgui({ kind: 'run_started' }, { runId: RUN_ID, now: NOW })).toEqual({
kind: 'run.lifecycle',
runId: RUN_ID,
ts: NOW,
status: 'started',
});
expect(encodeOdEventForAgui({ kind: 'end', status: 'succeeded' }, { runId: RUN_ID, now: NOW }))
.toMatchObject({ status: 'completed' });
expect(encodeOdEventForAgui({ kind: 'end', status: 'failed' }, { runId: RUN_ID, now: NOW }))
.toMatchObject({ status: 'failed' });
expect(encodeOdEventForAgui({ kind: 'end', status: 'canceled' }, { runId: RUN_ID, now: NOW }))
.toMatchObject({ status: 'cancelled' });
});
it('maps pipeline_stage_started/completed onto run.lifecycle stage events', () => {
const startedEvt = encodeOdEventForAgui(
{
kind: 'pipeline_stage_started',
runId: RUN_ID,
snapshotId: 'snap-1',
stageId: 'discovery',
iteration: 0,
startedAt: NOW,
},
{ runId: RUN_ID, now: NOW },
);
expect(startedEvt).toMatchObject({
kind: 'run.lifecycle',
status: 'pipeline_stage_started',
stageId: 'discovery',
iteration: 0,
});
const completedEvt = encodeOdEventForAgui(
{
kind: 'pipeline_stage_completed',
runId: RUN_ID,
snapshotId: 'snap-1',
stageId: 'discovery',
iteration: 0,
completedAt: NOW,
},
{ runId: RUN_ID, now: NOW },
);
expect(completedEvt).toMatchObject({
kind: 'run.lifecycle',
status: 'pipeline_stage_completed',
});
});
it('maps genui_surface_request → ui.surface_requested with derived surface kind', () => {
const out = encodeOdEventForAgui(
{
kind: 'genui_surface_request',
runId: RUN_ID,
surfaceId: 'audience-clarify',
payload: { kind: 'form', schema: { type: 'object' } },
requestedAt: NOW,
},
{ runId: RUN_ID, now: NOW },
);
expect(out).toMatchObject({
kind: 'ui.surface_requested',
surfaceId: 'audience-clarify',
surfaceKind: 'form',
});
});
it('maps genui_surface_response → ui.surface_responded preserving respondedBy', () => {
const out = encodeOdEventForAgui(
{
kind: 'genui_surface_response',
runId: RUN_ID,
surfaceId: 'audience-clarify',
value: { audience: 'VC' },
respondedAt: NOW,
respondedBy: 'cache',
},
{ runId: RUN_ID, now: NOW },
);
expect(out).toMatchObject({
kind: 'ui.surface_responded',
surfaceId: 'audience-clarify',
value: { audience: 'VC' },
respondedBy: 'cache',
});
});
it('maps genui_surface_timeout to a surface_responded with the resolution payload', () => {
const out = encodeOdEventForAgui(
{
kind: 'genui_surface_timeout',
runId: RUN_ID,
surfaceId: 'media-spend-approval',
resolution: 'abort',
},
{ runId: RUN_ID, now: NOW },
);
expect(out).toMatchObject({
kind: 'ui.surface_responded',
surfaceId: 'media-spend-approval',
respondedBy: 'auto',
value: { resolution: 'abort' },
});
});
it('maps genui_state_synced → state_update', () => {
const out = encodeOdEventForAgui(
{
kind: 'genui_state_synced',
runId: RUN_ID,
surfaceId: 'figma-oauth',
persistTier: 'project',
},
{ runId: RUN_ID, now: NOW },
);
expect(out).toEqual({
kind: 'state_update',
runId: RUN_ID,
ts: NOW,
path: 'genui.figma-oauth',
value: { persistTier: 'project' },
});
});
it('drops events the encoder does not understand', () => {
const out = encodeOdEventForAgui(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ kind: 'mystery' as any },
{ runId: RUN_ID, now: NOW },
);
expect(out).toBeNull();
});
});

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View file

@ -29,6 +29,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.0.0
version: 1.29.0(zod@4.4.2)
'@open-design/agui-adapter':
specifier: workspace:*
version: link:../../packages/agui-adapter
'@open-design/contracts':
specifier: workspace:*
version: link:../../packages/contracts
@ -254,6 +257,25 @@ importers:
specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.1)
packages/agui-adapter:
dependencies:
'@open-design/contracts':
specifier: workspace:*
version: link:../contracts
devDependencies:
'@types/node':
specifier: ^20.17.10
version: 20.19.39
esbuild:
specifier: 0.27.7
version: 0.27.7
typescript:
specifier: ^5.6.3
version: 5.9.3
vitest:
specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.1)
packages/contracts:
dependencies:
zod: