open-design/packages/agui-adapter/tests/encode.test.ts
Cursor Agent 0631f04a00
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>
2026-05-09 13:11:48 +00:00

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();
});
});