mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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>
182 lines
5.1 KiB
TypeScript
182 lines
5.1 KiB
TypeScript
// 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();
|
|
});
|
|
});
|