|
| 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 | +}); |
0 commit comments