Skip to content

Commit b42dac7

Browse files
bloveclaude
andauthored
feat(ag-ui): subagent cards over AG-UI via native ACTIVITY events (F5) (#668)
* docs(ag-ui): F5 subagent-cards-over-AG-UI design spec Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ag-ui): F5 design — native ACTIVITY architecture (supersede CUSTOM) Subagent cards over AG-UI via the protocol's native ACTIVITY events (activityType=subagent), not a private CUSTOM convention. Three layers: generic ACTIVITY reducer + subagent projection (libs/ag-ui); owned server-side LangGraphAgent.run() transform mapping subagent intent to ACTIVITY events; reference graph emits the intent. Reusable activity infra; CUSTOM->native migration of state/interrupt/a2ui deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ag-ui): F5 Layer 2 refinement — prefer _dispatch_event override + stateless transform Override the bridge's 1:1 _dispatch_event to convert the subagent_activity custom event to an ACTIVITY event; the handler sends accumulated text_so_far (like A2uiPartialHandler's args_so_far) so the transform stays stateless. run()-wrap / custom-route are documented fallbacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ag-ui): F5 implementation plan — native ACTIVITY subagent cards (8 tasks) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ag-ui example): L2 transport — subagent_activity CUSTOM → native ACTIVITY (_dispatch_event) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ag-ui): F5 T1 spike finding — emit via adispatch_custom_event (not get_stream_writer) The installed ag_ui_langgraph bridge drives the graph with astream_events and converts on_custom_event callbacks to AG-UI CUSTOM events; get_stream_writer surfaces only as RAW. Correct L3 emission in spec + plan T2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ag-ui example): activity_transform returns None on malformed JSON value (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ag-ui example): L3 graph — research tool emits subagent_activity via adispatch_custom_event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ag-ui): L1 reducer — generic ACTIVITY store + SNAPSHOT/DELTA handling Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ag-ui): cover ACTIVITY_SNAPSHOT merge branch (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ag-ui): L1 — project subagent activities to Agent.subagents (stable identity) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ag-ui): prune subagent wrapper cache on activity removal (review) Unbounded growth + stale-binding if a tool-call-id is reused after a RUN_STARTED reset. Prune wrappers not present in the current activities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ag-ui example): e2e — research subagent renders a live card, child text isolated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ag-ui example): poll subagent settle to avoid CI micro-race (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cockpit): ag-ui/subagents capability — native ACTIVITY subagent cards Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deployments): add ag-ui subagents capability to ag-ui-dev bundle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ag-ui): correct activity_transform docstring (adispatch_custom_event, not get_stream_writer) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 64dbde1 commit b42dac7

75 files changed

Lines changed: 7115 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cockpit/scripts/capability-registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const capabilities: readonly Capability[] = [
5757
{ id: 'ag-ui-json-render', product: 'ag-ui', topic: 'json-render', angularProject: 'cockpit-ag-ui-json-render-angular', port: 4323, pythonPort: 5323, pythonDir: 'cockpit/ag-ui/json-render/python' },
5858
{ id: 'ag-ui-client-tools', product: 'ag-ui', topic: 'client-tools', angularProject: 'cockpit-ag-ui-client-tools-angular', port: 4325, pythonPort: 5325, pythonDir: 'cockpit/ag-ui/client-tools/python' },
5959
{ id: 'ag-ui-a2ui', product: 'ag-ui', topic: 'a2ui', angularProject: 'cockpit-ag-ui-a2ui-angular', port: 4324, pythonPort: 5324, pythonDir: 'cockpit/ag-ui/a2ui/python' },
60+
{ id: 'ag-ui-subagents', product: 'ag-ui', topic: 'subagents', angularProject: 'cockpit-ag-ui-subagents-angular', port: 4326, pythonPort: 5326, pythonDir: 'cockpit/ag-ui/subagents/python' },
6061
] as const;
6162

6263
export function findCapability(id: string): Capability | undefined {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "Plan a trip from LAX to JFK",
6+
"turnIndex": 0,
7+
"hasToolResult": false
8+
},
9+
"response": {
10+
"toolCalls": [
11+
{
12+
"name": "task",
13+
"arguments": "{\"role\":\"research\",\"task_description\":\"Gather destination intel on LAX and JFK airports for a coast-to-coast trip.\"}",
14+
"id": "call_research_001"
15+
}
16+
]
17+
}
18+
},
19+
{
20+
"match": {
21+
"userMessage": "Gather destination intel on LAX and JFK airports for a coast-to-coast trip.",
22+
"turnIndex": 0,
23+
"hasToolResult": false
24+
},
25+
"response": {
26+
"content": "LAX sprawls across nine terminals while JFK runs six under the AirTrain loop. New York in spring is mild, mid-50s to low-70s with occasional showers, so pack layers for the JFK arrival."
27+
}
28+
},
29+
{
30+
"match": {
31+
"userMessage": "Plan a trip from LAX to JFK",
32+
"turnIndex": 1,
33+
"hasToolResult": true
34+
},
35+
"response": {
36+
"toolCalls": [
37+
{
38+
"name": "task",
39+
"arguments": "{\"role\":\"booking\",\"task_description\":\"Find nonstop flight options from LAX to JFK and back for one adult.\"}",
40+
"id": "call_booking_001"
41+
}
42+
]
43+
}
44+
},
45+
{
46+
"match": {
47+
"userMessage": "Find nonstop flight options from LAX to JFK and back for one adult.",
48+
"turnIndex": 0,
49+
"hasToolResult": false
50+
},
51+
"response": {
52+
"content": "Three nonstop options: a low-cost JetBlue red-eye (~$210), a balanced mid-morning Delta departure (~$330), and a premium United widebody with extra legroom (~$780). All run roughly 5h30 westbound to eastbound."
53+
}
54+
},
55+
{
56+
"match": {
57+
"userMessage": "Plan a trip from LAX to JFK",
58+
"turnIndex": 2,
59+
"hasToolResult": true
60+
},
61+
"response": {
62+
"toolCalls": [
63+
{
64+
"name": "task",
65+
"arguments": "{\"role\":\"itinerary\",\"task_description\":\"Synthesize a final LAX to JFK trip plan from the research and booking notes.\"}",
66+
"id": "call_itinerary_001"
67+
}
68+
]
69+
}
70+
},
71+
{
72+
"match": {
73+
"userMessage": "Synthesize a final LAX to JFK trip plan from the research and booking notes.",
74+
"turnIndex": 0,
75+
"hasToolResult": false
76+
},
77+
"response": {
78+
"content": "Take the balanced mid-morning Delta nonstop from LAX to JFK; arrive about two hours early and ride the AirTrain into the city. Expect mild spring weather in New York, so bring a light jacket and a compact umbrella."
79+
}
80+
},
81+
{
82+
"match": {
83+
"userMessage": "Plan a trip from LAX to JFK",
84+
"turnIndex": 3,
85+
"hasToolResult": true
86+
},
87+
"response": {
88+
"content": "All set — I researched both airports, found flights, and drafted an itinerary. Recommended plan: book the balanced mid-morning nonstop from LAX to JFK, arrive about two hours early, and take the AirTrain into the city. Pack for mild spring weather in New York."
89+
}
90+
}
91+
]
92+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: MIT
2+
import { resolve } from 'node:path';
3+
import { portsFor } from '../../../../../cockpit/ports.mjs';
4+
import { createAgUiGlobalSetup } from '@threadplane-internal/e2e-harness';
5+
6+
const ports = portsFor('cockpit-ag-ui-subagents-angular');
7+
8+
export default createAgUiGlobalSetup({
9+
pythonCwd: 'cockpit/ag-ui/subagents/python',
10+
backendPort: ports.langgraph,
11+
angularProject: 'cockpit-ag-ui-subagents-angular',
12+
angularPort: ports.angular,
13+
fixturesDir: resolve(__dirname, 'fixtures'),
14+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MIT
2+
// Manual record-mode harness. Run against a live OpenAI key to capture new
3+
// fixture entries into cockpit/ag-ui/subagents/angular/e2e/fixtures/subagents.json.
4+
//
5+
// Prerequisites:
6+
// 1. Start the uvicorn backend in record mode (OPENAI_API_KEY set, no aimock):
7+
// cd cockpit/ag-ui/subagents/python && uv run uvicorn src.server:app --port 5326
8+
// 2. Start the Angular dev server:
9+
// npx nx serve cockpit-ag-ui-subagents-angular --port 4326
10+
// 3. Run this harness via:
11+
// npx playwright test --config cockpit/ag-ui/subagents/angular/e2e/playwright.config.ts \
12+
// manual/subagents.manual.ts --headed
13+
import { expect, test } from '@playwright/test';
14+
15+
test.describe('AG-UI Subagents Example', () => {
16+
test.beforeEach(async ({ page }) => {
17+
await page.goto('http://localhost:4326');
18+
await page.waitForSelector('app-subagents', { state: 'attached' });
19+
});
20+
21+
test('renders the chat interface', async ({ page }) => {
22+
await expect(page.locator('chat')).toBeVisible();
23+
await expect(page.locator('textarea[name="messageText"]')).toBeVisible();
24+
});
25+
26+
test('delegates to a research subagent and surfaces a summary', async ({ page }) => {
27+
await page.fill('textarea[name="messageText"]', 'Plan a trip from LAX to JFK');
28+
await page.click('button[type="submit"]');
29+
await expect(page.locator('.chat-md').first()).toBeVisible({ timeout: 30000 });
30+
await expect(page.locator('.chat-md').first()).not.toBeEmpty({ timeout: 30000 });
31+
});
32+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MIT
2+
import { defineConfig, devices } from '@playwright/test';
3+
import { portsFor } from '../../../../../cockpit/ports.mjs';
4+
5+
const { angular: angularPort } = portsFor('cockpit-ag-ui-subagents-angular');
6+
7+
8+
export default defineConfig({
9+
testDir: '.',
10+
testMatch: '**/*.spec.ts',
11+
fullyParallel: false,
12+
workers: 1,
13+
retries: process.env.CI ? 2 : 0,
14+
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
15+
use: {
16+
baseURL: `http://localhost:${angularPort}`,
17+
trace: 'retain-on-failure',
18+
},
19+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
20+
globalSetup: './global-setup-impl.ts',
21+
globalTeardown: require.resolve('../../../../../libs/e2e-harness/src/global-teardown'),
22+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-License-Identifier: MIT
2+
import { test, expect, type Page } from '@playwright/test';
3+
import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness';
4+
5+
const PROMPT = 'Plan a trip from LAX to JFK';
6+
7+
// Distinctive sentence the research subagent streams. It must be unique enough
8+
// that finding it in the parent's assistant bubble would be a real leak, not a
9+
// coincidental substring of the orchestrator's final answer (which deliberately
10+
// paraphrases instead of quoting this verbatim).
11+
const RESEARCH_SENTENCE =
12+
'LAX sprawls across nine terminals while JFK runs six under the AirTrain loop.';
13+
14+
interface SubagentProbe {
15+
size: number;
16+
entries: { name?: string; status?: string; text?: string }[];
17+
}
18+
19+
// Reads the live `agent.subagents()` projection off the cockpit host component
20+
// via Angular's dev-mode global. The chat-subagents primitive renders a
21+
// <chat-subagent-card> only while a subagent is pending/running, so the map IS
22+
// the data the card binds to. Under the aimock harness the run settles
23+
// near-instantly (started → finished within one SSE flush), so the live card
24+
// transits the RUNNING state below a render frame and is filtered out of the
25+
// DOM by the time the assistant turn finalizes — exactly the reason this spec
26+
// asserts on the durable projection rather than the card element. The map
27+
// proves the ACTIVITY snapshot/delta pipeline populated the subagent (name +
28+
// streamed child text) and that it settled to `complete`.
29+
async function readSubagents(page: Page): Promise<SubagentProbe> {
30+
return page.evaluate(() => {
31+
const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => unknown } }).ng;
32+
const el = document.querySelector('app-subagents');
33+
const out: SubagentProbe = { size: 0, entries: [] };
34+
if (!ng?.getComponent || !el) return out;
35+
const cmp = ng.getComponent(el) as { agent?: { subagents?: () => Map<string, unknown> } };
36+
const map = cmp?.agent?.subagents?.();
37+
if (!map) return out;
38+
out.size = map.size;
39+
map.forEach((s) => {
40+
const sa = s as {
41+
name?: string;
42+
status?: () => string;
43+
messages?: () => { content?: string }[];
44+
};
45+
out.entries.push({
46+
name: sa.name,
47+
status: sa.status?.(),
48+
text: sa.messages?.()[0]?.content,
49+
});
50+
});
51+
return out;
52+
});
53+
}
54+
55+
// Research delegation over the AG-UI transport: the orchestrator LLM calls the
56+
// `task` tool, the subagent LLM streams a summary, and the ag-ui server
57+
// converts the subagent_activity CUSTOM events into native ACTIVITY_SNAPSHOT/
58+
// ACTIVITY_DELTA. The @threadplane/ag-ui reducer projects the activity to
59+
// agent.subagents() (what chat-subagents renders as a live card) and the
60+
// child's research text must stay OUT of the parent's bubble.
61+
test('ag-ui subagents: orchestrator dispatches subagent cards that settle complete', async ({
62+
page,
63+
}) => {
64+
const bubble = await submitAndWaitForResponse(page, PROMPT);
65+
66+
// The orchestrator dispatched `task` subagents — the chat-tool-calls
67+
// primitive renders a durable collapsible chip labeled with the tool name,
68+
// unlike the active-only subagent card. Asserting it is in the DOM proves the
69+
// orchestrator emitted real task tool_calls.
70+
const taskChip = page.getByRole('button', { name: /called task|task/i }).first();
71+
await expect(taskChip).toBeVisible({ timeout: 30_000 });
72+
73+
// The live subagent-card data path populated and settled: agent.subagents()
74+
// carries the research subagent with its streamed child summary, now
75+
// `complete`. This is the exact projection chat-subagents binds the card to.
76+
// Poll until the research subagent reaches `complete` to avoid CI micro-races
77+
// where signal propagation hasn't settled at the moment of the first read.
78+
await expect
79+
.poll(
80+
async () => {
81+
const subs = await readSubagents(page);
82+
const research = subs.entries.find((e) => e.name === 'research');
83+
return research?.status ?? null;
84+
},
85+
{ timeout: 15_000 },
86+
)
87+
.toBe('complete');
88+
89+
const subs = await readSubagents(page);
90+
expect(subs.size).toBeGreaterThan(0);
91+
const research = subs.entries.find((e) => e.name === 'research');
92+
expect(research, 'a research subagent should be projected').toBeTruthy();
93+
expect(research?.text).toContain(RESEARCH_SENTENCE);
94+
95+
// The child research text must NOT leak into the parent's rendered markdown
96+
// surface. Target chat-streaming-md (the assistant's answer) rather than the
97+
// whole chat-message, which nests the per-message subagent card host.
98+
const mainMarkdown = bubble.locator('chat-streaming-md').first();
99+
await expect(mainMarkdown).toBeVisible();
100+
await expect(mainMarkdown).not.toContainText(RESEARCH_SENTENCE);
101+
102+
// The final orchestrator summary still surfaces in the bubble.
103+
const finalText = await bubble.innerText();
104+
expect(finalText.toLowerCase()).toMatch(/lax|jfk|itinerary|trip|flight/);
105+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ES2022",
5+
"moduleResolution": "Bundler",
6+
"esModuleInterop": true,
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"noEmit": true,
10+
"types": [
11+
"node"
12+
],
13+
"baseUrl": "../../../../..",
14+
"paths": {
15+
"@threadplane-internal/e2e-harness": [
16+
"libs/e2e-harness/src/index.ts"
17+
],
18+
"@threadplane-internal/e2e-harness/global-teardown": [
19+
"libs/e2e-harness/src/global-teardown.ts"
20+
]
21+
},
22+
"allowJs": true
23+
},
24+
"include": [
25+
"**/*.ts"
26+
],
27+
"exclude": [
28+
"node_modules",
29+
"test-results",
30+
"playwright-report"
31+
]
32+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@threadplane/cockpit-ag-ui-subagents-angular",
3+
"private": true,
4+
"version": "0.0.1",
5+
"peerDependencies": {
6+
"@threadplane/ag-ui": "*",
7+
"@threadplane/chat": "*"
8+
},
9+
"license": "MIT",
10+
"sideEffects": false
11+
}

0 commit comments

Comments
 (0)