mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
d06c1b4905
commit
0631f04a00
12 changed files with 676 additions and 0 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
37
apps/daemon/tests/agui-route.test.ts
Normal file
37
apps/daemon/tests/agui-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
packages/agui-adapter/esbuild.config.mjs
Normal file
13
packages/agui-adapter/esbuild.config.mjs
Normal 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',
|
||||
});
|
||||
33
packages/agui-adapter/package.json
Normal file
33
packages/agui-adapter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
207
packages/agui-adapter/src/encode.ts
Normal file
207
packages/agui-adapter/src/encode.ts
Normal 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;
|
||||
}
|
||||
5
packages/agui-adapter/src/index.ts
Normal file
5
packages/agui-adapter/src/index.ts
Normal 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';
|
||||
100
packages/agui-adapter/src/types.ts
Normal file
100
packages/agui-adapter/src/types.ts
Normal 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;
|
||||
182
packages/agui-adapter/tests/encode.test.ts
Normal file
182
packages/agui-adapter/tests/encode.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
packages/agui-adapter/tsconfig.json
Normal file
18
packages/agui-adapter/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
packages/agui-adapter/tsconfig.tests.json
Normal file
9
packages/agui-adapter/tsconfig.tests.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue