From ad762d9a6afed09e31031d8e09e95586dbde52a4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 11 Oct 2026 10:47:03 -0700 Subject: [PATCH 1/2] feat(react): add native Deep Agents Subagents preview --- .../deep-agents/capabilities/subagents.mdx | 23 +- ...eact-deep-agents-subagents-preview.spec.ts | 261 ++ apps/website/playwright.config.ts | 7 + apps/website/project.json | 3 +- .../[library]/[section]/[slug]/page.spec.tsx | 12 + .../docs/[library]/[section]/[slug]/page.tsx | 7 +- .../docs/ReactDeepAgentsSubagentsPreview.tsx | 80 + .../website/src/lib/docs-example-code.spec.ts | 15 + apps/website/src/playwright-config.spec.ts | 17 +- .../deep-agents/subagents/python/project.json | 11 + .../subagents/python/tests/test_streaming.py | 165 ++ cockpit/deep-agents/subagents/react/README.md | 13 + .../subagents/react/e2e/playwright.config.ts | 18 + .../subagents/react/e2e/subagents.spec.ts | 322 +++ .../subagents/react/eslint.config.mjs | 2 + .../deep-agents/subagents/react/index.html | 12 + .../deep-agents/subagents/react/project.json | 75 + .../subagents/react/src/app.spec.tsx | 74 + .../deep-agents/subagents/react/src/app.tsx | 236 ++ .../subagents/react/src/application.spec.ts | 763 ++++++ .../subagents/react/src/application.ts | 527 ++++ .../subagents/react/src/authority.spec.ts | 2407 +++++++++++++++++ .../subagents/react/src/authority.ts | 745 +++++ .../subagents/react/src/children.spec.ts | 916 +++++++ .../subagents/react/src/children.ts | 293 ++ .../subagents/react/src/connection.spec.ts | 105 + .../subagents/react/src/connection.ts | 150 + .../deep-agents/subagents/react/src/main.tsx | 45 + .../subagents/react/src/projection.spec.ts | 359 +++ .../subagents/react/src/projection.ts | 261 ++ .../subagents/react/src/styles.css | 129 + .../subagents/react/src/styles.d.ts | 1 + .../deep-agents/subagents/react/tsconfig.json | 54 + .../subagents/react/vite.config.mts | 22 + .../src/lib/capability-registry.ts | 2 +- .../src/lib/content-descriptors.spec.ts | 20 + .../src/lib/content-descriptors.ts | 19 + .../src/lib/frontend-descriptors.spec.ts | 15 +- .../src/lib/frontend-pilot.spec.ts | 36 +- scripts/cockpit-frontend-matrix.spec.mjs | 25 +- scripts/examples/e2e-wiring.spec.ts | 13 + scripts/react-cockpit/configuration.mjs | 2 + ...ep-agents-subagents-configuration.spec.mjs | 19 + .../deep-agents-subagents-fixture.mjs | 589 ++++ .../deep-agents-subagents-fixture.spec.mjs | 726 +++++ .../deep-agents-subagents-wire.py | 152 ++ scripts/react-cockpit/serve.mjs | 8 +- scripts/react-parity/baseline.json | 10 +- 48 files changed, 9718 insertions(+), 48 deletions(-) create mode 100644 apps/website/e2e/react-deep-agents-subagents-preview.spec.ts create mode 100644 apps/website/src/components/docs/ReactDeepAgentsSubagentsPreview.tsx create mode 100644 cockpit/deep-agents/subagents/python/tests/test_streaming.py create mode 100644 cockpit/deep-agents/subagents/react/README.md create mode 100644 cockpit/deep-agents/subagents/react/e2e/playwright.config.ts create mode 100644 cockpit/deep-agents/subagents/react/e2e/subagents.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/eslint.config.mjs create mode 100644 cockpit/deep-agents/subagents/react/index.html create mode 100644 cockpit/deep-agents/subagents/react/project.json create mode 100644 cockpit/deep-agents/subagents/react/src/app.spec.tsx create mode 100644 cockpit/deep-agents/subagents/react/src/app.tsx create mode 100644 cockpit/deep-agents/subagents/react/src/application.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/src/application.ts create mode 100644 cockpit/deep-agents/subagents/react/src/authority.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/src/authority.ts create mode 100644 cockpit/deep-agents/subagents/react/src/children.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/src/children.ts create mode 100644 cockpit/deep-agents/subagents/react/src/connection.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/src/connection.ts create mode 100644 cockpit/deep-agents/subagents/react/src/main.tsx create mode 100644 cockpit/deep-agents/subagents/react/src/projection.spec.ts create mode 100644 cockpit/deep-agents/subagents/react/src/projection.ts create mode 100644 cockpit/deep-agents/subagents/react/src/styles.css create mode 100644 cockpit/deep-agents/subagents/react/src/styles.d.ts create mode 100644 cockpit/deep-agents/subagents/react/tsconfig.json create mode 100644 cockpit/deep-agents/subagents/react/vite.config.mts create mode 100644 scripts/react-cockpit/deep-agents-subagents-configuration.spec.mjs create mode 100644 scripts/react-cockpit/deep-agents-subagents-fixture.mjs create mode 100644 scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs create mode 100644 scripts/react-cockpit/deep-agents-subagents-wire.py diff --git a/apps/website/content/docs/deep-agents/capabilities/subagents.mdx b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx index 57f225ae2..7fe51ceb0 100644 --- a/apps/website/content/docs/deep-agents/capabilities/subagents.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx @@ -1,15 +1,18 @@ --- title: Subagents -description: Passing subagents to create_deep_agent puts your specialists on the task tool, and every dispatch runs as a real child graph that renders as its own card. +description: Register specialists on the task tool and inspect delegation through the Angular example or native React preview. --- # Subagents +Angular is the default example on this guide. Choose React in **Example UI** for native parent task cards and separate current-session child observations. Both frontends share the same Python graph, prompt and runtime. The card tracking and description-based attribution described below apply to Angular only. + Delegation in Deep Agents is one tool. `SubAgentMiddleware` registers a tool named `task` taking `{ description, subagent_type }`, and each call runs a real child graph with its own system prompt, its own tools, and its own transcript. Nothing about that is special on the wire — it is an ordinary tool call — and yet the browser renders it as a child agent, because `task` is the name the subagent tracker already watches for. The running example is an aviation dispatch desk, and this page walks the three files behind it. -## What the demo does +## What the Angular demo does + +The Angular Run tab shows the prebuilt `` composition beside a specialist roster and live dispatch count. The first suggestion requests field data and weather for KASE and KDEN; the second asks for field data at KSFO. The prompt requests parallel tasks with one airport per dispatch. Actual model behavior determines the number and type of calls; four calls, mandatory delegation and exclusive use of the two named specialists are not guaranteed. The graph can respond without a task call, use generic tools or dispatch to general-purpose. All airport and weather lookups use fixed sample tables. -The Run tab shows the prebuilt `` composition beside a sidebar holding the specialist roster and a live dispatch count. The first welcome suggestion asks for a brief on KASE and KDEN, covering field data and weather at both, and the orchestrator answers by dispatching four specialists in a single turn: four cards appear in the conversation at once, each expanded while its child works and collapsed to its header row when it reports. The second suggestion asks for the field data at KSFO alone, which is one dispatch and one card, so the two suggestions are the fan-out and the single case side by side. The orchestrator has no lookup tools of its own, so there is no run in which it answers without delegating. ## How it is built @@ -31,17 +34,17 @@ Omitting `tools` would inherit the main agent's tools instead, which is the oppo ### Installing the task tool -`create_deep_agent` assembles a middleware stack, and `SubAgentMiddleware` is already on it: the factory adds a `general-purpose` subagent unless that one is explicitly disabled, so the `task` tool ships by default. Passing `subagents` is what puts your own specialists on that tool. The demo passes no tools of its own, so delegation is the only path to an answer. +`create_deep_agent` assembles a middleware stack, and `SubAgentMiddleware` is already on it: the factory adds a `general-purpose` subagent unless that one is explicitly disabled, so the `task` tool ships by default. Passing `subagents` is what puts your own specialists on that tool. The demo passes no custom lookup tools to the orchestrator; middleware can still supply generic tools, and a model response need not delegate. -Unless it is explicitly disabled, `create_deep_agent` inserts a `general-purpose` subagent ahead of the specs you pass, with the main agent's model and tools. The `task` tool description therefore lists three agent types rather than two. The demo does not disable it; the system prompt simply names the two specialists it wants, which is enough to keep the orchestrator on them. +Unless it is explicitly disabled, `create_deep_agent` inserts a `general-purpose` subagent ahead of the specs you pass, with the main agent's model and tools. The `task` tool description therefore lists three agent types rather than two. The demo does not disable it; the system prompt names the two specialists it prefers, without guaranteeing that the model will use only them. ### Asking for the dispatches in one turn -The middleware supplies a tool, not a policy. A model left to itself will dispatch one specialist, wait for the report, and dispatch the next, which is correct but produces a run with nothing to see. Two paragraphs in `prompts/subagents.md` change the shape of the run instead. +The middleware supplies a tool, not a policy. A model may dispatch sequentially or in parallel. Two paragraphs in `prompts/subagents.md` request parallel work; they do not guarantee its shape or call count. ```markdown Give each dispatch a `description` that names the airport and says exactly what @@ -54,13 +57,13 @@ parallel. Do not wait for one to report before sending the next. Both halves matter, and the first one matters for a reason worth reading the next section for. -### Providing the agent +### Providing the Angular agent `provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and it is the only provider the `` composition requires. `subagentToolNames: ['task']` is already the default, so the line changes nothing at runtime and is there to say which tool call means that a child started. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. -### Reading the dispatches +### Reading Angular dispatches `injectAgent()` returns the agent, and `agent.subagents()` is a signal holding a map of the dispatches in the current thread. The sidebar spreads that map into an array once and derives both numbers from it, so the two counts can never disagree. @@ -74,7 +77,7 @@ The `` composition already renders every dispatch as a card inside the con -## How a dispatch becomes a card +## How an Angular dispatch becomes a card Two independent things have to happen for a `task` call to render as a child agent, and they come from opposite ends of the run. @@ -86,7 +89,7 @@ The second is attribution: deciding which child stream belongs to which dispatch The deepagents `task` tool cannot announce its binding, so inside this graph the description is the only signal that survives fan-out; a graph that announces its children through the [Threadplane middleware](/docs/langgraph/guides/subgraphs) gets an exact binding instead. With one child outstanding the adapter can fall back to claiming the single unmatched dispatch, but with four outstanding at once there is nothing positional to fall back on: arrival order is not dispatch order, and guessing would put one specialist's transcript in another specialist's card. Descriptions that name their airport keep the four apart. Two dispatches whose descriptions are identical give the ladder nothing to tell them apart: the exact rung claims the first unmapped dispatch, so the second child lands on the wrong card rather than on none. -Until a stream is attributed its chunks are held rather than dropped, and they are replayed into the card the moment the match lands. A dispatch that never matches shows an empty card, which is a worse card but never a wrong one. +Until a stream is attributed its chunks are held rather than dropped, and they are replayed into the card the moment the match lands. An unmatched Angular dispatch can show an empty card. Description matching is a heuristic and can misattribute children, particularly when descriptions are identical. The native React preview instead presents separate unlinked namespace observations and never uses this matching heuristic. A dispatch `description` is both the child's opening instruction and the key the adapter attributes its output by. A vague description costs twice: the child starts with less to go on, and its stream is harder to tell from its siblings. diff --git a/apps/website/e2e/react-deep-agents-subagents-preview.spec.ts b/apps/website/e2e/react-deep-agents-subagents-preview.spec.ts new file mode 100644 index 000000000..c024059d6 --- /dev/null +++ b/apps/website/e2e/react-deep-agents-subagents-preview.spec.ts @@ -0,0 +1,261 @@ +import { test, expect, type Page } from '@playwright/test'; +import { fromPageOrReactPreview } from './fixtures/react-preview-requests'; +const route = '/docs/deep-agents/capabilities/subagents'; +const reactFrame = /(?:localhost:4630|deep-agents\/subagents\/react)/; +const observations = new WeakMap< + Page, + { requests: string[]; errors: string[] } +>(); +test.beforeEach(async ({ page }) => { + const observed = { requests: [] as string[], errors: [] as string[] }; + observations.set(page, observed); + page.on('request', (request) => { + if ( + ['fetch', 'xhr'].includes(request.resourceType()) && + /\/threads(?:\/|$)/.test(new URL(request.url()).pathname) && + fromPageOrReactPreview(request, reactFrame) + ) + observed.requests.push(request.method()); + }); + page.on('pageerror', (error) => observed.errors.push(error.message)); +}); +test.afterEach(async ({ page }) => { + expect(observations.get(page)?.requests).toEqual([]); + expect(observations.get(page)?.errors).toEqual([]); +}); +async function hydrated(page: Page, mode: 'docs' | 'code' | 'run') { + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true' + ); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-workspace-mode', + { docs: 'Docs', code: 'Code', run: 'Run' }[mode] + ); +} +async function emptyRun(page: Page, draft = '') { + await hydrated(page, 'run'); + await expect(page.getByLabel('Example UI')).toHaveValue('react'); + await expect(page).toHaveURL(/(?:\?|&)mode=run(?:&|$)/); + await expect(page.locator('iframe')).toBeVisible(); + await expect(page.locator('iframe')).toHaveAttribute('src', reactFrame); + const frame = page.frameLocator('iframe'); + await expect(frame.getByRole('status')).toHaveText('Ready.'); + await expect( + frame + .getByRole('region', { name: 'Conversation', exact: true }) + .locator('article') + ).toHaveCount(0); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue(draft); +} +async function noOverflow(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +} +test('native Deep Agents Subagents keeps canonical Docs, Code and Run aligned', async ({ + page, +}, testInfo) => { + await page.goto(route + '?frontend=react', { waitUntil: 'domcontentloaded' }); + await hydrated(page, 'docs'); + await expect(page.getByLabel('Example UI')).toHaveValue('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Subagents preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/deep-agents/subagents/react/src/application.ts"]' + ) + ).toBeVisible(); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-subagents-docs-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-subagents-docs-mobile.png'), + }); + await page.setViewportSize({ width: 1280, height: 720 }); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: 'Code', exact: true }) + .click(); + await hydrated(page, 'code'); + for (const name of [ + 'app.tsx', + 'application.ts', + 'connection.ts', + 'authority.ts', + 'projection.ts', + 'children.ts', + 'main.tsx', + 'styles.css', + 'project.json', + ]) + await expect( + page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name, exact: true }) + ).toBeVisible(); + await expect(page.getByRole('tabpanel')).toContainText( + 'createConnectedApplication' + ); + await expect(page.getByRole('complementary', { name: 'File tree' })).not.toContainText(/fixture|wire\.py|\.spec\.|proof/); + await noOverflow(page); + await page.screenshot({ path: testInfo.outputPath('react-deep-agents-subagents-code-desktop.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ path: testInfo.outputPath('react-deep-agents-subagents-code-mobile.png') }); + await page.setViewportSize({ width: 1280, height: 720 }); + const graph = page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name: 'graph.py', exact: true }); + await graph.click(); + await expect(page.getByRole('tabpanel')).toContainText('create_deep_agent'); + await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: /^Run(?:,|$)/ }) + .click(); + await emptyRun(page); + await noOverflow(page); + expect(await page.frameLocator('iframe').locator('html').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-subagents-run-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByLabel('Example UI')).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + expect( + await page + .frameLocator('iframe') + .locator('html') + .evaluate((element) => element.scrollWidth <= element.clientWidth) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-subagents-run-mobile.png'), + }); +}); +test('the canonical Subagents guide keeps Angular as its default frontend', async ({ + page, +}) => { + await page.goto(route, { waitUntil: 'domcontentloaded' }); + await hydrated(page, 'docs'); + await expect(page.getByLabel('Example UI')).toHaveValue('angular'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Subagents preview', + exact: true, + }) + ).toHaveCount(0); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Subagents preview', + exact: true, + }) + ).toBeVisible(); + await expect(page).toHaveURL(/frontend=react/); +}); +test('a direct React run link mounts an empty conversation without creating a thread', async ({ + page, +}) => { + await page.goto(route + '?frontend=react&mode=run', { + waitUntil: 'domcontentloaded', + }); + await emptyRun(page); +}); +test('history preserves the mounted React owner and unsent draft without runtime requests', async ({ + page, +}) => { + await page.goto(route + '?frontend=react&mode=run', { + waitUntil: 'domcontentloaded', + }); + await emptyRun(page); + const mountedFrame = await page.locator('iframe').elementHandle(); + await page + .frameLocator('iframe') + .getByLabel('Message', { exact: true }) + .fill('Unsent history draft'); + await expect( + page.frameLocator('iframe').getByLabel('Message', { exact: true }) + ).toHaveValue('Unsent history draft'); + const before = [...(observations.get(page)?.requests ?? [])]; + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: 'Code', exact: true }) + .click(); + await hydrated(page, 'code'); + await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await emptyRun(page, 'Unsent history draft'); + expect( + await page + .locator('iframe') + .evaluate((frame, original) => frame === original, mountedFrame) + ).toBe(true); + expect(observations.get(page)?.requests).toEqual(before); +}); +test('reload clears an unsent React draft without runtime requests', async ({ + page, +}) => { + await page.goto(route + '?frontend=react&mode=run', { + waitUntil: 'domcontentloaded', + }); + await emptyRun(page); + const before = [...(observations.get(page)?.requests ?? [])]; + await page + .frameLocator('iframe') + .getByLabel('Message', { exact: true }) + .fill('Unsent reload draft'); + await expect( + page.frameLocator('iframe').getByLabel('Message', { exact: true }) + ).toHaveValue('Unsent reload draft'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await emptyRun(page); + expect(observations.get(page)?.requests).toEqual(before); +}); + +test('the aviation suggestion fills an exact inert draft', async ({ page }) => { + await page.goto(route + '?frontend=react&mode=run', { + waitUntil: 'domcontentloaded', + }); + await emptyRun(page); + const frame = page.frameLocator('iframe'); + await frame + .getByRole('button', { name: 'Brief KASE and KDEN', exact: true }) + .click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue( + 'Brief me on KASE and KDEN: I need field data for both and the current weather at both.' + ); + await frame.getByRole('button', { name: 'Field data for KSFO', exact: true }).click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue('What is the field data for KSFO?'); + await frame + .getByRole('button', { name: 'New conversation', exact: true }) + .click(); + await emptyRun(page); +}); + +test('frontend changes release the old React frame and return to an empty inert owner', async ({ page }) => { + await page.goto(route + '?frontend=react&mode=run', { waitUntil: 'domcontentloaded' }); + await emptyRun(page); + const oldFrame = await page.locator('iframe').elementHandle(); + await page.frameLocator('iframe').getByLabel('Message', { exact: true }).fill('Old frontend draft'); + await page.locator('[data-workspace-desktop-navigation]').getByRole('button', { name: 'Docs', exact: true }).click(); + await hydrated(page, 'docs'); + await page.getByLabel('Example UI').selectOption('angular'); + await expect(page.getByLabel('Example UI')).toHaveValue('angular'); + await expect(page.getByRole('heading', { name: 'React Deep Agents Subagents preview', exact: true })).toHaveCount(0); + expect(await oldFrame?.evaluate(frame => frame.isConnected)).toBe(false); + await page.getByLabel('Example UI').selectOption('react'); + await expect(page.getByRole('heading', { name: 'React Deep Agents Subagents preview', exact: true })).toBeVisible(); + await page.locator('[data-workspace-desktop-navigation]').getByRole('button', { name: /^Run(?:,|$)/ }).click(); + await emptyRun(page); + expect(await page.locator('iframe').evaluate((frame, original) => frame === original, oldFrame)).toBe(false); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 7a09ea315..5e67a75c7 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -416,6 +416,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'npx nx run cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4630', + reuseExistingServer, + timeout: 180_000, + }, { command: 'npx nx run cockpit-chat-generative-ui-python:smoke && node scripts/react-cockpit/serve.mjs chat-generative-ui --no-parent', cwd: '../..', diff --git a/apps/website/project.json b/apps/website/project.json index d1d5a87c0..499e10928 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -104,7 +104,8 @@ "cockpit-chat-timeline-react", "cockpit-chat-generative-ui-react", "cockpit-deep-agents-planning-react", - "cockpit-deep-agents-filesystem-react" + "cockpit-deep-agents-filesystem-react", + "cockpit-deep-agents-subagents-react" ] } ], diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx index 56014563c..13f7d54a8 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -33,6 +33,18 @@ import { ReactTimeTravelPreview } from '../../../../../components/docs/ReactTime import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; import DocsPage, { generateMetadata } from './page'; +it('selects Subagents-specific native React Docs and exact sources while retaining Angular Docs', async () => { + const tree = await route('deep-agents', 'capabilities', 'subagents'); + const workspace = findElement(tree, WebsiteWorkspace as ComponentType); + const article = workspace?.props.reactDocsSlot as React.ReactElement | undefined; + expect(article).toBeTruthy(); + expect((article?.type as { name?: string })?.name).toBe('ReactDeepAgentsSubagentsPreview'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/subagents/react/src/projection.ts'); + expect(article?.props.exampleCode?.sources?.['cockpit/deep-agents/subagents/react/src/authority.ts']).toContain('checkpoint'); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths.some(path => path.includes('/subagents/angular/'))).toBe(true); + expect(article?.props.exampleCode?.assetPaths.some(path => /fixture|wire\.py|\.spec\.|proof/.test(path))).toBe(false); +}); it('selects Filesystem-specific native React Docs and exact sources while retaining Angular Docs', async () => { const tree = await route('deep-agents', 'capabilities', 'filesystem'); const workspace = findElement(tree, WebsiteWorkspace as ComponentType); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 472236bda..c91023c93 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -23,6 +23,7 @@ import { ReactStateManagementPreview } from '../../../../../components/docs/Reac import { ReactComputedFunctionsPreview } from '../../../../../components/docs/ReactComputedFunctionsPreview'; import { ReactChatMessagesPreview } from '../../../../../components/docs/ReactChatMessagesPreview'; import { ReactChatGenerativeUiPreview } from '../../../../../components/docs/ReactChatGenerativeUiPreview'; +import { ReactDeepAgentsSubagentsPreview } from '../../../../../components/docs/ReactDeepAgentsSubagentsPreview'; import { ReactDeepAgentsFilesystemPreview } from '../../../../../components/docs/ReactDeepAgentsFilesystemPreview'; import { ReactDeepAgentsPlanningPreview } from '../../../../../components/docs/ReactDeepAgentsPlanningPreview'; import { ReactChatTimelinePreview } from '../../../../../components/docs/ReactChatTimelinePreview'; @@ -284,11 +285,13 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem'].includes(slug)) || + (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents'].includes(slug)) || (library === 'render' && ((section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (section === 'api' && ['render-spec-component','provide-render'].includes(slug)))) || (library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','json-render','subagents'].includes(slug)))) || (library === 'chat' && ((section === 'guides' && ['thread-routing', 'generative-ui'].includes(slug)) || slug === 'client-tools' || (section === 'concepts' && slug === 'message-model') || (section === 'components' && ['chat-trace', 'chat-input', 'chat-interrupt-panel', 'chat-tool-calls', 'chat-subagent-card'].includes(slug))))) ? ( - library === 'deep-agents' && section === 'capabilities' && slug === 'filesystem' ? ( + library === 'deep-agents' && section === 'capabilities' && slug === 'subagents' ? ( + + ) : library === 'deep-agents' && section === 'capabilities' && slug === 'filesystem' ? ( ) : library === 'deep-agents' && section === 'capabilities' && slug === 'planning' ? ( diff --git a/apps/website/src/components/docs/ReactDeepAgentsSubagentsPreview.tsx b/apps/website/src/components/docs/ReactDeepAgentsSubagentsPreview.tsx new file mode 100644 index 000000000..465979045 --- /dev/null +++ b/apps/website/src/components/docs/ReactDeepAgentsSubagentsPreview.tsx @@ -0,0 +1,80 @@ +import { MdxRenderer } from './MdxRenderer'; +import type { ExampleCodeContext } from '../../lib/example-code'; + +const source = `# React Deep Agents Subagents preview + +This experimental example composes native React chat components with parent task cards and separate child response observations. Angular remains the default on this canonical guide. Both frontends use the same Python graph, Subagents prompt and shared FREE runtime. The React, core, content and native LangGraph candidates are private packages built from source. + +## Start an aviation brief + +Choose **Run**, type a message and choose **Send** or press **Enter**. **Brief KASE and KDEN** fills this exact draft without sending: + +> Brief me on KASE and KDEN: I need field data for both and the current weather at both. + +**Field data for KSFO** fills this exact draft: + +> What is the field data for KSFO? + +The roster names **field-researcher** and **weather-analyst**. All aviation and weather lookups use fixed sample data. The prompt requests one airport per task call and parallel dispatch when appropriate, but does not guarantee four calls, delegation in every answer, or only those two agent types. The graph also supports general-purpose dispatches and generic tools. The sidebar counts actual observed task calls. + +Only an explicit Send creates the owned thread, lazily, using the native selector **subagents**. Opening Docs or Run, changing frontend or mode, navigating history, filling suggestions and choosing **New conversation** do not author a run. Mode navigation and browser history preserve the mounted frame and unsent draft. Reload and New clear local conversation state; New waits for the next Send to create a thread. + + + + +## Read parent task cards + +Parent cards preserve canonical tool-call IDs and declaration order. They show the actual literal description, subagent_type, arguments and results. Generic tools and general-purpose task calls remain literal observations too. Missing or unsupported task fields stay visibly unavailable; the preview does not invent a specialist name, result or dispatch count. + + + +## Inspect separate child observations + +Expand **Child response observations** to read user, assistant and tool transcripts under their full namespace arrays. These are current-session observations from this open conversation. The preview does not guess a binding between a namespace and a parent task card from description text, arrival order or position. Identical descriptions therefore do not imply a shared or matched card. + +**Receiving** means local delivery is active. **Observed response** means the local turn finished with a supported child observation; it is not checkpoint-confirmed child history. **Stopped** marks canceled local delivery. **Incomplete** marks unsupported, interrupted or uncertain child data. These labels describe the lifetime of local observations, not restored execution status. + +Child observations remain visible across root checkpoint reconciliation and canonical root load. They are not restored historical children and are never labeled Saved child history. Reload and New clear them. + + + +## Confirm the saved root conversation + +**Response saved.** requires native success plus the actual owned raw current root state, a fresh history head and the exact checkpoint. The live and loaded canonical messages must match. The application performs canonical native load before permitting another Send. Model prose and child observations cannot supply this authority. A resolved root tool error can still be saved; its error card remains visible beside the saved root conversation. + +Unsupported data or incomplete authority produces a notice, retains the prior confirmed root conversation and disables Send until **New conversation**. No inferred result is promoted to saved state. + +The display bounds are cumulative across the open conversation: 100 task observations and 100 child namespaces, namespace depth 16 and components of at most 1024 UTF-16 code units, and at most 65,536 units per task description, result or joined child transcript. One shared 1,048,576-unit UTF-16 display-text ledger covers root messages, parent cards and children. Raw capture separately limits 10,000 nodes, depth 32 and 4 × 1024 × 1024 UTF-16 text units, including keys. Those structural limits can make a payload unavailable before it reaches 100 tasks or namespaces. Unsupported or oversized data stays visibly incomplete. + + + + +## Stop and start again + +**Stop** cancels local delivery and preserves prior confirmed root content. It does not roll back backend work. Start a **New conversation** before sending again after Stop or uncertain confirmation. There is no automatic resume, replay or retry. + +## Build from source + +From a Threadplane checkout with dependencies installed: + +~~~sh +npx nx build cockpit-deep-agents-subagents-react +npx nx e2e cockpit-deep-agents-subagents-react +~~~ + +Browser verification uses the actual compiled Python graph, installed native SDK and deterministic model construction. Fixtures, proof endpoints and verification controls are excluded from public Code and the production frontend. +`; + +export function ReactDeepAgentsSubagentsPreview({ + exampleCode, +}: { + readonly exampleCode: ExampleCodeContext | null; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 3f5701cef..ce171996d 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -28,6 +28,21 @@ const findWorkspaceRoot = (): string => { const WORKSPACE_ROOT = findWorkspaceRoot(); const CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content'); +it('resolves every native Subagents article include from its registered sources and shared backend', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-subagents-python'); + if (!canonical) throw new Error('Missing canonical Subagents'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react')!; + const page = { docsPath: react.docsPath, assetPaths: [...react.codeAssetPaths, ...(react.backendAssetPaths ?? [])] }; + const context = contextFor(page); + const article = readFileSync(join(WORKSPACE_ROOT, 'apps/website/src/components/docs/ReactDeepAgentsSubagentsPreview.tsx'), 'utf8'); + for (const include of includesIn(article)) { + expect(resolveExampleFile(include.file, context)).toBe(include.file); + expect(context.sources[include.file]?.length).toBeGreaterThan(0); + } + expect(includesIn(article)).toHaveLength(6); + expect(page.assetPaths.some(path => /fixture|wire\.py|\.spec\./.test(path))).toBe(false); + for (const path of page.assetPaths) expect(context.sources[path]?.length).toBeGreaterThan(0); +}); it('resolves every native Filesystem article include from its registered sources and shared backend', () => { const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); if (!canonical) throw new Error('Missing canonical Filesystem'); diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 96ac8917f..55f0938c6 100644 --- a/apps/website/src/playwright-config.spec.ts +++ b/apps/website/src/playwright-config.spec.ts @@ -12,8 +12,8 @@ describe('Website Playwright configuration', () => { const prerequisites = project.targets.e2e.dependsOn?.find( (dependency: { target?: string }) => dependency.target === 'build' )?.projects ?? []; - expect(prerequisites).toHaveLength(30); - expect(new Set(prerequisites).size).toBe(30); + expect(prerequisites).toHaveLength(31); + expect(new Set(prerequisites).size).toBe(31); expect([...prerequisites].sort()).toEqual( getCockpitFrontends() .filter(frontend => frontend.frontend === 'react') @@ -59,19 +59,19 @@ describe('Website Playwright configuration', () => { const project = JSON.parse(readFileSync(resolve(import.meta.dirname,'../project.json'),'utf8')); expect(project.implicitDependencies).toContain('cockpit-ag-ui-tool-views-angular'); }); - it('starts thirty owned React servers after exact fresh Nx prerequisites', () => { + it('starts thirty-one owned React servers after exact fresh Nx prerequisites', () => { const config = createWebsitePlaywrightConfig({ CI: 'true' }); const servers = Array.isArray(config.webServer) ? config.webServer : []; - const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(30); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|30)$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(31); expect(reactServers.every(server => server.reuseExistingServer === false)).toBe(true); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4610')?.command).toBe('node scripts/react-cockpit/serve.mjs ag-ui-interrupts --no-parent'); const project = JSON.parse(readFileSync(resolve(import.meta.dirname, '../project.json'), 'utf8')); const projects = project.targets.e2e.dependsOn.find( (dependency: { target?: string }) => dependency.target === 'build' ).projects; - expect(projects).toHaveLength(30); - expect(new Set(projects).size).toBe(30); + expect(projects).toHaveLength(31); + expect(new Set(projects).size).toBe(31); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -89,6 +89,8 @@ describe('Website Playwright configuration', () => { expect(projects).toContain('cockpit-chat-threads-react'); expect(projects).toContain('cockpit-deep-agents-planning-react'); expect(projects).toContain('cockpit-deep-agents-filesystem-react'); + expect(projects).toContain('cockpit-deep-agents-subagents-react'); + expect(reactServers.find(server => server.url === 'http://127.0.0.1:4630')?.command).toBe('npx nx run cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4629')?.command).toBe('npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4628')?.command).toBe('npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4627')?.command).toBe('npx nx run cockpit-chat-generative-ui-python:smoke && node scripts/react-cockpit/serve.mjs chat-generative-ui --no-parent'); @@ -376,6 +378,7 @@ describe('Website Playwright configuration', () => { expect.objectContaining({command:'npx nx run cockpit-chat-tool-calls-python:smoke && node scripts/react-cockpit/serve.mjs chat-tool-calls --no-parent',url:'http://127.0.0.1:4623'}), expect.objectContaining({command:'npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent',url:'http://127.0.0.1:4628'}), expect.objectContaining({command:'npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent',url:'http://127.0.0.1:4629'}), + expect.objectContaining({command:'npx nx run cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent',url:'http://127.0.0.1:4630'}), expect.objectContaining({command:'npx nx run cockpit-chat-generative-ui-python:smoke && node scripts/react-cockpit/serve.mjs chat-generative-ui --no-parent',url:'http://127.0.0.1:4627'}), expect.objectContaining({command:'npx nx run cockpit-chat-timeline-python:smoke && node scripts/react-cockpit/serve.mjs chat-timeline --no-parent',url:'http://127.0.0.1:4626'}), expect.objectContaining({command:'npx nx run cockpit-chat-threads-python:smoke && node scripts/react-cockpit/serve.mjs chat-threads --no-parent',url:'http://127.0.0.1:4625'}), diff --git a/cockpit/deep-agents/subagents/python/project.json b/cockpit/deep-agents/subagents/python/project.json index 95a57c1be..cf6b5ed6e 100644 --- a/cockpit/deep-agents/subagents/python/project.json +++ b/cockpit/deep-agents/subagents/python/project.json @@ -19,6 +19,17 @@ "executor": "nx:run-commands", "options": { "command": "npx tsx -e \"import { deepAgentsSubagentsPythonModule } from './cockpit/deep-agents/subagents/python/src/index.ts'; const mod = deepAgentsSubagentsPythonModule; if (mod.id !== 'deep-agents-subagents-python') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Subagents (Python)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" + }, + "dependsOn": [ + "test" + ] + }, + "test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "cwd": "cockpit/deep-agents/subagents/python", + "command": "uv run --frozen --python 3.12 python -m unittest discover -s tests" } } }, diff --git a/cockpit/deep-agents/subagents/python/tests/test_streaming.py b/cockpit/deep-agents/subagents/python/tests/test_streaming.py new file mode 100644 index 000000000..2447bd51d --- /dev/null +++ b/cockpit/deep-agents/subagents/python/tests/test_streaming.py @@ -0,0 +1,165 @@ +"""Offline contracts against the unchanged builder and pinned SubAgentMiddleware.""" +import asyncio +from copy import deepcopy +import importlib.util +import json +import os +from pathlib import Path +import socket +import unittest +from unittest.mock import patch + +os.environ["LANGSMITH_TRACING"] = "false" +os.environ["LANGCHAIN_TRACING_V2"] = "false" +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langgraph.checkpoint.memory import InMemorySaver +import deepagents + +SCENARIOS = ("single", "parallel", "duplicates", "no-task", "tool-error", "general-purpose", "default-tool", "root-tool-error") +def call(name, args, identity): + return {"name": name, "args": args, "id": identity, "type": "tool_call"} + +class LocalModel(BaseChatModel): + scenario: str = "single" + @property + def _llm_type(self): + return "local-subagents-contract" + def bind_tools(self, tools, **kwargs): + return self + def answer(self, messages): + human = next(m for m in reversed(messages) if m.type == "human") + turn = messages[messages.index(human):] + tools = [m for m in turn if m.type == "tool"] + system = " ".join(str(m.content) for m in messages if m.type == "system") + field = "You research airport field data" in system + weather = "You read weather for a dispatch desk" in system + # General-purpose receives the inherited parent's system instructions; its + # seeded description is explicitly distinct from an actual parent human. + general = human.content == "General-purpose local report." + identity = human.id or "seed" + if field or weather: + airport = next(a for a in ("KSFO", "KASE", "KDEN") if a in human.content) + if not tools: + names = ["lookup_weather"] if weather else ["lookup_field_elevation", "lookup_runway_length"] + args = {} if "Invalid lookup" in human.content else {"airport": airport} + return AIMessage(content="", tool_calls=[call(n, args, identity+"-"+n) for n in names]) + return AIMessage(content="Observed lookup: " + " | ".join(str(t.content) for t in tools)) + if general: + return AIMessage(content="General-purpose local report ready.") + if tools: + return AIMessage(id=identity+"-final", content="Dispatch assessment: " + " | ".join(str(t.content) for t in tools)) + if self.scenario == "no-task": + return AIMessage(id=identity+"-final", content="No delegation requested.") + if self.scenario == "default-tool": + return AIMessage(id=identity+"-dispatch", content="", tool_calls=[call("ls", {"path":"/"}, identity+"-ls")]) + if self.scenario == "root-tool-error": + return AIMessage(id=identity+"-dispatch", content="", tool_calls=[ + call("task", {"subagent_type": "field-researcher"}, identity+"-invalid-task") + ]) + if self.scenario == "general-purpose": + tasks = [("general-purpose", "General-purpose local report.")] + elif self.scenario in ("parallel", "duplicates"): + tasks = [(role, "Research "+airport+" dispatch.") for airport in ("KASE", "KDEN") for role in ("field-researcher", "weather-analyst")] + if self.scenario == "duplicates": + tasks = [("field-researcher", "Research KASE dispatch.")] * 4 + elif self.scenario == "tool-error": + tasks = [("field-researcher", "Invalid lookup KSFO dispatch.")] + else: + tasks = [("field-researcher", "Research KSFO dispatch.")] + return AIMessage(id=identity+"-dispatch", content="", tool_calls=[call("task", {"description":desc,"subagent_type":role}, identity+"-task-"+str(i)) for i,(role,desc) in enumerate(tasks)]) + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + return ChatResult(generations=[ChatGeneration(message=self.answer(messages))]) + def _stream(self, messages, stop=None, run_manager=None, **kwargs): + answer = self.answer(messages) + for start in range(0, len(answer.content), 17): + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content=answer.content[start:start+17])) + for i, tc in enumerate(answer.tool_calls): + args=json.dumps(tc["args"]) + for part, fragment in enumerate((args[:3], args[3:])): + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id,content="",tool_call_chunks=[{"index":i,"id":tc["id"] if part==0 else None,"name":tc["name"] if part==0 else None,"args":fragment}])) + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id,content="",chunk_position="last")) + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + # Delays belong only to the deterministic model. Actual child tools and + # graph scheduling remain untouched; faster weather finishes first. + system=" ".join(str(m.content) for m in messages if m.type=="system") + if "You research airport field data" in system: + await asyncio.sleep(.04) + for chunk in self._stream(messages, stop, run_manager, **kwargs): + yield chunk + + +def build(model): + with patch("langchain_openai.ChatOpenAI", return_value=model): + path=Path(__file__).parents[1]/"src/graph.py" + spec=importlib.util.spec_from_file_location("local_subagents_contract",path) + module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module.build_subagents_agent().builder.compile(checkpointer=InMemorySaver()) + +class SubagentsStreamingTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + network=self.enterContext(patch.object(socket.socket,"connect",side_effect=AssertionError("Remote socket forbidden"))) + self.addCleanup(network.assert_not_called) + self.model=LocalModel(); self.graph=build(self.model) + self.config={"configurable":{"thread_id":"subagents-contract"}} + async def send(self, scenario, identity="turn"): + self.model.scenario=scenario + events=[deepcopy(e) async for e in self.graph.astream({"messages":[{"type":"human","id":identity,"content":"Dispatch request."}]},self.config,subgraphs=True,stream_mode=["messages","values","updates","checkpoints"])] + saved=await self.graph.aget_state(self.config) + self.assertEqual(saved.values,(await self.graph.aget_state(saved.config)).values) + history=[s async for s in self.graph.aget_state_history(self.config)] + self.assertEqual(history[0].values,saved.values) + root=[data for ns,kind,data in events if not ns and kind=="values"] + self.assertEqual({m.id: m for m in root[-1]["messages"]},{m.id: m for m in saved.values["messages"]}) + self.assertEqual(saved.next,()); self.assertEqual(saved.tasks,()) + return saved,events + async def test_single_real_lookup_child_and_parent_report(self): + saved,events=await self.send("single") + self.assertIn("13 ft",saved.values["messages"][-1].content) + self.assertIn("11870 ft",saved.values["messages"][-1].content) + children=[data for ns,kind,data in events if ns and kind=="values"] + self.assertTrue(children) + self.assertTrue(any(m.type=="tool" for d in children for m in d["messages"])) + self.assertTrue(any(m.type=="human" for d in children for m in d["messages"])) + async def test_four_parallel_calls_with_reversed_completion(self): + saved,events=await self.send("parallel") + dispatch=next(m for m in saved.values["messages"] if m.type=="ai" and m.tool_calls) + self.assertEqual(len(dispatch.tool_calls),4) + self.assertEqual({t["args"]["subagent_type"] for t in dispatch.tool_calls},{"field-researcher","weather-analyst"}) + namespaces={ns for ns,kind,data in events if ns} + self.assertEqual(len(namespaces),4) + for text in ("7820 ft","5434 ft","mountain wave","gusting 31"): + self.assertIn(text,saved.values["messages"][-1].content) + finishes=[(ns,d) for ns,k,d in events if ns and k=="values" and d["messages"][-1].type=="ai" and not d["messages"][-1].tool_calls] + self.assertTrue(any(m.type=="tool" and m.name=="lookup_weather" for m in finishes[0][1]["messages"])) + async def test_identical_descriptions_and_repeated_turns(self): + for identity in ("first","second"): + saved,events=await self.send("duplicates",identity) + self.assertEqual(len({ns for ns,k,d in events if ns}),4) + calls=[m for m in saved.values["messages"] if m.id==identity+"-dispatch"][0].tool_calls + self.assertEqual(len({c["id"] for c in calls}),4) + self.assertEqual(len({c["args"]["description"] for c in calls}),1) + self.assertEqual(len([m for m in saved.values["messages"] if m.type=="human"]),2) + async def test_real_root_task_schema_error_is_reported_then_parent_finishes(self): + saved, events = await self.send("root-tool-error") + dispatch = next(m for m in saved.values["messages"] if m.type == "ai" and m.tool_calls) + self.assertEqual(dispatch.tool_calls[0]["args"], {"subagent_type": "field-researcher"}) + reports = [m for m in saved.values["messages"] if m.type == "tool"] + self.assertEqual(len(reports), 1) + self.assertEqual(reports[0].status, "error") + self.assertEqual(reports[0].tool_call_id, dispatch.tool_calls[0]["id"]) + self.assertIn("description", reports[0].content) + self.assertFalse(any(ns for ns, kind, data in events)) + self.assertEqual(saved.values["messages"][-1].type, "ai") + + async def test_no_task_general_purpose_default_tool_and_legitimate_error(self): + for scenario in ("no-task","general-purpose","default-tool","tool-error"): + saved,events=await self.send(scenario,scenario) + if scenario=="no-task": self.assertFalse(any(ns for ns,k,d in events)) + if scenario=="default-tool": self.assertTrue(any(m.type=="tool" and m.name=="ls" and m.status=="success" for m in saved.values["messages"])) + if scenario=="general-purpose": self.assertIn("General-purpose local report ready",saved.values["messages"][-1].content) + if scenario=="tool-error": + self.assertTrue(any(m.type=="tool" and m.status=="error" for ns,k,d in events if ns and k=="values" for m in d["messages"])) + +if __name__=="__main__": unittest.main() diff --git a/cockpit/deep-agents/subagents/react/README.md b/cockpit/deep-agents/subagents/react/README.md new file mode 100644 index 000000000..83f07aa0d --- /dev/null +++ b/cockpit/deep-agents/subagents/react/README.md @@ -0,0 +1,13 @@ +# Deep Agents Subagents — React preview + +Aviation lookups use fixed sample data, including weather. + +Explicit Send lazily creates an owned native LangGraph conversation using selector `subagents`. Suggestions fill the draft. Parent task cards preserve canonical call order and literal tool arguments/results; the roster is field-researcher and weather-analyst. Other tools and observed general-purpose dispatches remain literal observations. + +Root Response saved requires successful native delivery plus matching owned raw current state, fresh history head and exact checkpoint, followed by canonical native load before another Send. A resolved root tool error can still be saved, and its raw error label remains visible. Incomplete authority disables Send until New. + +Child observations use full namespace arrays and retain their own user/assistant/tool transcripts through parent reload. They are local to this open conversation, are not restored child history, and are not associated with individual parent task cards. Reload and New clear them. Stop ends local delivery without promising backend rollback. + +Task descriptions/results and child transcripts are bounded to 65,536 UTF-16 units each. The shared display ledger caps 100 task observations, 100 child namespaces and 1,048,576 UTF-16 code units of display text; unavailable data is visibly incomplete. + +Run `npx nx test cockpit-deep-agents-subagents-react`, `lint`, `build`, `fixture-test`, or `e2e` with the same project name. E2E uses the actual compiled Python graph and installed native SDK, with deterministic model construction only. No fixture controls or proof endpoints ship in the isolated production build. diff --git a/cockpit/deep-agents/subagents/react/e2e/playwright.config.ts b/cockpit/deep-agents/subagents/react/e2e/playwright.config.ts new file mode 100644 index 000000000..c3b472d0a --- /dev/null +++ b/cockpit/deep-agents/subagents/react/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + outputDir: 'test-results', + reporter: [['list']], + workers: 1, + fullyParallel: false, + timeout: 60000, + use: { baseURL: 'http://127.0.0.1:4630', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'node scripts/react-cockpit/serve.mjs deep-agents-subagents', + cwd: '../../../../..', + url: 'http://127.0.0.1:4630', + reuseExistingServer: false, + timeout: 15000, + }, +}); diff --git a/cockpit/deep-agents/subagents/react/e2e/subagents.spec.ts b/cockpit/deep-agents/subagents/react/e2e/subagents.spec.ts new file mode 100644 index 000000000..ceeb7bfce --- /dev/null +++ b/cockpit/deep-agents/subagents/react/e2e/subagents.spec.ts @@ -0,0 +1,322 @@ +import { + test, + expect, + type Page, + type APIRequestContext, + type TestInfo, +} from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +const base = '/deep-agents/subagents/react/'; +async function configure( + request: APIRequestContext, + scenario = 'single', + extra: Record = {} +) { + expect( + ( + await request.post('/__configure', { + data: { + scenario, + holdStream: false, + holdState: false, + holdCheckpoint: false, + failCreation: false, + failStream: false, + failCheckpoint: false, + ...extra, + }, + }) + ).ok() + ).toBe(true); +} +async function send(page: Page, text = 'What is the field data for KSFO?') { + await page.getByRole('textbox', { name: 'Message' }).fill(text); + await page.getByRole('button', { name: 'Send', exact: true }).click(); +} +async function saved(page: Page) { + await expect(page.getByRole('status')).toHaveText('Response saved.', { + timeout: 45000, + }); +} +async function proof( + request: APIRequestContext, + name: string, + testInfo: TestInfo +) { + mkdirSync(testInfo.outputDir, { recursive: true }); + for (const endpoint of ['requests', 'graph-proof', 'raw-events']) + writeFileSync( + testInfo.outputPath(`${name}-${endpoint}.json`), + JSON.stringify( + await (await request.get(`/__${endpoint}`)).json(), + null, + 2 + ) + ); +} +async function canonicalCards(page: Page, request: APIRequestContext) { + const runs = (await (await request.get('/__raw-events')).json()) as { + events: [ + string[], + string, + { + messages?: { + id: string; + type: string; + content: string; + tool_call_id?: string; + tool_calls?: { id: string; name: string; args: unknown }[]; + }[]; + } + ][]; + }[]; + const messages = runs + .at(-1) + ?.events.filter((e) => e[0].length === 0 && e[1] === 'values') + .at(-1)?.[2].messages; + if (!messages) throw Error('Missing actual root events'); + const calls = messages.flatMap((m) => m.tool_calls ?? []); + expect( + await page + .locator('.tool-observation') + .evaluateAll((cards) => cards.map((c) => c.getAttribute('data-call-id'))) + ).toEqual(calls.map((c) => c.id)); + for (const call of calls) { + const result = messages.find( + (m) => m.type === 'tool' && m.tool_call_id === call.id + ); + if (result) + await expect( + page.locator(`.tool-observation[data-call-id="${call.id}"] pre`).last() + ).toHaveText(result.content); + } +} +test('actual compiled single dispatch and inert draft lifecycle', async ({ + page, + request, +}, testInfo) => { + await configure(request); + const before = await (await request.get('/__requests')).json(); + await page.goto(base); + await expect(page.getByRole('textbox')).toBeEnabled(); + await page.getByRole('button', { name: 'Field data for KSFO' }).click(); + await expect(page.getByRole('textbox')).toHaveValue( + 'What is the field data for KSFO?' + ); + expect(await (await request.get('/__requests')).json()).toEqual(before); + await page.reload(); + await expect(page.getByRole('textbox')).toHaveValue(''); + await send(page); + await saved(page); + await expect(page.getByLabel('Observed dispatch count')).toHaveText( + '1 observed task dispatches' + ); + await expect(page.locator('.tool-observation')).toHaveCount(1); + await expect(page.locator('.tool-observation')).toContainText('KSFO'); + await page.getByText('Child response observations', { exact: true }).click(); + await expect(page.locator('.child-observations section')).toHaveCount(1); + await expect(page.locator('.child-observations')).toContainText('user'); + await expect(page.locator('.child-observations')).toContainText('tool'); + await canonicalCards(page, request); + await proof(request, 'single', testInfo); + await page.screenshot({ + path: testInfo.outputPath('single-desktop.png'), + fullPage: true, + }); +}); +test('parallel actual native observations survive canonical load and followup', async ({ + page, + request, +}, testInfo) => { + await configure(request, 'parallel'); + await page.goto(base); + await send( + page, + 'Brief me on KASE and KDEN: I need field data for both and the current weather at both.' + ); + await saved(page); + await expect(page.getByLabel('Observed dispatch count')).toHaveText( + '4 observed task dispatches' + ); + await expect(page.locator('.tool-observation')).toHaveCount(4); + await page.getByText('Child response observations', { exact: true }).click(); + await expect(page.locator('.child-observations section')).toHaveCount(4); + await expect(page.locator('.child-observations')).toContainText( + 'Observed response' + ); + await page.screenshot({ + path: testInfo.outputPath('parallel-desktop.png'), + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('parallel-mobile.png'), + fullPage: true, + }); + await send(page, 'Brief me on KASE and KDEN again.'); + await saved(page); + await expect(page.getByLabel('Observed dispatch count')).toHaveText( + '8 observed task dispatches' + ); + await expect(page.locator('.child-observations section')).toHaveCount(8); + await canonicalCards(page, request); + await proof(request, 'canonical-followup', testInfo); +}); +for (const [scenario, count] of [ + ['duplicates', 4], + ['no-task', 0], + ['general-purpose', 1], + ['default-tool', 0], + ['root-tool-error', 1], + ['tool-error', 1], +] as const) { + test(`actual ${scenario} preserves resolved authority and literal fields`, async ({ + page, + request, + }, testInfo) => { + await configure(request, scenario); + await page.goto(base); + await send(page, 'Dispatch request.'); + await saved(page); + await expect(page.getByLabel('Observed dispatch count')).toHaveText( + `${count} observed task dispatches` + ); + if (scenario === 'root-tool-error') { + await page.screenshot({ + path: testInfo.outputPath('root-tool-error-desktop.png'), + fullPage: true, + }); + await expect(page.getByText('Tool result: error')).toBeVisible(); + await expect(page.getByText('Task fields unavailable')).toBeVisible(); + } + if (scenario === 'tool-error') { + await expect(page.locator('.tool-observation')).toContainText( + "Error invoking tool 'lookup_field_elevation'" + ); + await page + .getByText('Child response observations', { exact: true }) + .click(); + await expect(page.locator('.child-observations')).toContainText( + 'airport: Field required' + ); + await expect(page.locator('.child-observations')).toContainText( + 'Observed response' + ); + await expect(page.locator('.child-observations')).not.toContainText( + 'Saved' + ); + await page.screenshot({ + path: testInfo.outputPath('child-tool-error-desktop.png'), + fullPage: true, + }); + } + if (scenario === 'duplicates') + await expect(page.locator('.tool-observation')).toHaveCount(4); + await canonicalCards(page, request); + await proof(request, scenario, testInfo); + }); +} +test('Stop and New invalidate held delivery without hidden replay', async ({ + page, + request, +}, testInfo) => { + await configure(request, 'parallel', { holdStream: true }); + await page.goto(base); + const before = (await (await request.get('/__requests')).json()).length; + await send(page, 'Dispatch request.'); + await expect( + page.getByRole('button', { name: 'Stop', exact: true }) + ).toBeVisible(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('stopped'); + await page.getByRole('button', { name: 'New conversation' }).click(); + await expect(page.getByRole('textbox')).toBeEnabled(); + await request.post('/__release', { data: {} }); + await expect(page.locator('.tool-observation')).toHaveCount(0); + const requests = (await (await request.get('/__requests')).json()).slice( + before + ); + expect( + requests.filter((r: { path: string }) => r.path.endsWith('/runs/stream')) + ).toHaveLength(1); + await proof(request, 'stop-new', testInfo); +}); +test('exact checkpoint transport failure blocks followup', async ({ + page, + request, +}, testInfo) => { + await configure(request, 'single', { failCheckpoint: true }); + await page.goto(base); + await send(page); + await expect(page.getByRole('alert')).toBeVisible({ timeout: 45000 }); + await expect(page.getByRole('textbox')).toBeDisabled(); + await proof(request, 'checkpoint-failure', testInfo); +}); + +test('active New isolates a held saved checkpoint and cannot resurrect it', async ({ + page, + request, +}, testInfo) => { + await configure(request, 'single', { holdCheckpoint: true }); + await page.goto(base); + await send(page); + await expect(page.getByRole('status')).toHaveText( + 'Confirming saved conversation…' + ); + await page.getByRole('button', { name: 'New conversation' }).click(); + await expect(page.getByRole('textbox')).toBeEnabled(); + await request.post('/__release', { data: {} }); + await expect(page.locator('.tool-observation')).toHaveCount(0); + await expect(page.getByRole('status')).toHaveText('Ready.'); + await proof(request, 'new-pending-checkpoint', testInfo); +}); +test('oversized literal draft is rejected without a backend thread', async ({ + page, + request, +}) => { + await configure(request); + const before = (await (await request.get('/__requests')).json()).length; + await page.goto(base); + await send(page, ''.repeat(3000)); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); + expect((await (await request.get('/__requests')).json()).length).toBe(before); + expect(await page.locator('img').count()).toBe(0); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); +}); + +test('duplicate Send and transport failure cannot cause automatic replay', async ({ + page, + request, +}, testInfo) => { + await configure(request, 'single', { failStream: true }); + await page.goto(base); + const before = (await (await request.get('/__requests')).json()).length; + await page.getByRole('textbox').fill('What is the field data for KSFO?'); + await page + .getByRole('button', { name: 'Send', exact: true }) + .evaluate((element) => { + const button = element as HTMLButtonElement; + button.click(); + button.click(); + }); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); + const requests = (await (await request.get('/__requests')).json()).slice( + before + ); + expect( + requests.filter((r: { path: string }) => r.path.endsWith('/runs/stream')) + ).toHaveLength(1); + await proof(request, 'duplicate-send-transport-failure', testInfo); +}); diff --git a/cockpit/deep-agents/subagents/react/eslint.config.mjs b/cockpit/deep-agents/subagents/react/eslint.config.mjs new file mode 100644 index 000000000..2900f3445 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/eslint.config.mjs @@ -0,0 +1,2 @@ +import base from '../../../../eslint.config.mjs'; +export default [...base]; diff --git a/cockpit/deep-agents/subagents/react/index.html b/cockpit/deep-agents/subagents/react/index.html new file mode 100644 index 000000000..b9e2ed0a1 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/index.html @@ -0,0 +1,12 @@ + + + + + + Deep Agents subagents · React + + +
+ + + diff --git a/cockpit/deep-agents/subagents/react/project.json b/cockpit/deep-agents/subagents/react/project.json new file mode 100644 index 000000000..728dba1c6 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/project.json @@ -0,0 +1,75 @@ +{ + "name": "cockpit-deep-agents-subagents-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/deep-agents/subagents/react/src", + "projectType": "application", + "tags": [ + "scope:cockpit-examples", + "scope:cockpit-e2e", + "framework:react" + ], + "targets": { + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "build", + "projects": [ + "core", + "content", + "react" + ] + } + ], + "outputs": [ + "{workspaceRoot}/dist/cockpit/deep-agents/subagents/react" + ], + "options": { + "command": "node scripts/react-cockpit/build.mjs deep-agents-subagents" + } + }, + "fixture-test": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "test", + "projects": [ + "cockpit-deep-agents-subagents-python" + ] + } + ], + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs scripts/react-cockpit/deep-agents-subagents-configuration.spec.mjs" + } + }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": [ + "build", + "fixture-test" + ], + "options": { + "config": "cockpit/deep-agents/subagents/react/e2e/playwright.config.ts", + "skipInstall": true + } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/deep-agents/subagents/react/vite.config.mts", + "reporters": [ + "default" + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "eslintConfig": "cockpit/deep-agents/subagents/react/eslint.config.mjs" + } + } + } +} diff --git a/cockpit/deep-agents/subagents/react/src/app.spec.tsx b/cockpit/deep-agents/subagents/react/src/app.spec.tsx new file mode 100644 index 000000000..dafcd3c2d --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/app.spec.tsx @@ -0,0 +1,74 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { createMessageContent } from '@threadplane/content/messages'; +import { createConnectedApplication } from './connection'; +vi.mock('./connection', () => ({ createConnectedApplication: vi.fn() })); +import { SubagentsDemo, renderMessage } from './app'; +afterEach(cleanup); +it('readiness and prompt selection never submit and New clears the draft', () => { + const state = { + rows: [], + observations: new Map(), + taskCount: 0, + childObservations: [], + busy: false, + canSubmit: true, + viewGeneration: 0, + }; + const app = { + getSnapshot: () => state, + subscribe: () => () => undefined, + submit: vi.fn(), + newConversation: vi.fn(), + dispose: vi.fn(), + }; + vi.mocked(createConnectedApplication).mockReturnValue(app as never); + const ready = vi.fn(), + v = render( + + ); + expect(ready).toHaveBeenCalledTimes(1); + fireEvent.click(v.getByRole('button', { name: 'Field data for KSFO' })); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe( + 'What is the field data for KSFO?' + ); + expect(app.submit).not.toHaveBeenCalled(); + fireEvent.click(v.getByRole('button', { name: 'New conversation' })); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe(''); +}); +it('unknown task fields remain visibly unavailable and literal text cannot execute', () => { + const content = createMessageContent(); + const rows = content.project({ + status: 'idle', + toolCalls: [], + messages: [ + { + id: 'a', + role: 'assistant', + content: '', + delivery: { generation: 'g', phase: 'complete', outcome: 'success' }, + }, + ], + }); + const v = render( + renderMessage(rows[0], [ + { + id: 't', + name: 'task', + kind: 'task', + fields: 'unavailable', + argumentsText: '', + status: 'complete', + resultText: 'literal', + resultStatus: 'error', + }, + ]) + ); + expect(v.getByText('Task fields unavailable')).toBeTruthy(); + expect(v.container.querySelector('img')).toBeNull(); + expect(v.getByRole('region', { name: 'Observed tool error' })).toBeTruthy(); + content.dispose(); +}); diff --git a/cockpit/deep-agents/subagents/react/src/app.tsx b/cockpit/deep-agents/subagents/react/src/app.tsx new file mode 100644 index 000000000..e4b40f434 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/app.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAgent } from '@threadplane/react'; +import { + ChatInput, + ToolObservation, + MessageList, + Reasoning, + Citations, +} from '@threadplane/react/chat'; +import { Markdown } from '@threadplane/react/markdown'; +import type { MessageRow } from '@threadplane/content/messages'; +import { + createConnectedApplication, + type subagentsConnection, +} from './connection'; +import type { ToolCard } from './projection'; + +/** Stable row renderer; the application owns Markdown snapshot lifetimes. */ +export function renderMessage( + row: MessageRow, + cards: readonly ToolCard[] = [] +) { + if (row.role === 'tool') return null; + if ( + row.role === 'assistant' && + !row.message.content && + !row.reasoning && + !row.message.citations?.length && + !cards.length && + row.message.toolCallIds?.length + ) + return null; + const label = + row.role === 'user' + ? 'You' + : row.role === 'assistant' + ? 'Assistant' + : 'System'; + return ( +
+

{label}

+ {row.role === 'assistant' && row.reasoning && ( + + )} + + {cards.map((card) => ( +
+

{card.subagentType ?? card.name}

+ {card.kind === 'task' && card.fields === 'unavailable' && ( +

Task fields unavailable

+ )} + {card.description !== undefined &&

{card.description}

} +

+ {card.resultStatus === 'error' + ? 'Tool result: error' + : `Observed delivery: ${card.status}`} +

+ +
+ ))} + {row.role === 'assistant' && row.message.citations && ( + + )} +
+ ); +} +export function SubagentsDemo({ + connection, + onReady, +}: { + readonly connection: ReturnType; + readonly onReady: () => void; +}) { + const [application] = useState(() => createConnectedApplication(connection)); + const [draft, setDraft] = useState(''); + const snapshot = useAgent(application); + const renderRow = useCallback( + (row: MessageRow) => renderMessage(row, snapshot.observations.get(row.id)), + [snapshot.observations] + ); + useEffect(() => { + onReady(); + }, [onReady]); + useEffect( + () => () => { + void application.dispose(); + }, + [application] + ); + const status = snapshot.busy + ? snapshot.activity === 'creating' + ? 'Creating conversation…' + : snapshot.activity === 'confirming' + ? 'Confirming saved conversation…' + : snapshot.activity === 'replacing' + ? 'Releasing conversation…' + : 'Receiving response…' + : snapshot.outcome === 'success' + ? 'Response saved.' + : snapshot.outcome === 'aborted' + ? 'Response stopped. Start a new conversation to continue.' + : !snapshot.canSubmit + ? 'Start a new conversation to continue.' + : 'Ready.'; + return ( +
+
+
+

React · Experimental

+

Deep Agents subagents

+
+ +
+

+ Dispatch sample field research and weather tasks, and inspect separate + native child streams. Aviation lookups use fixed sample data, including + weather. Stop cancels local delivery; it does not roll back backend + work. +

+
+ {[ + { + label: 'Brief KASE and KDEN', + text: 'Brief me on KASE and KDEN: I need field data for both and the current weather at both.', + }, + { + label: 'Field data for KSFO', + text: 'What is the field data for KSFO?', + }, + ].map((prompt) => ( + + ))} +
+ +

+ {snapshot.taskCount === null + ? 'Dispatch observations incomplete' + : `${snapshot.taskCount} observed task dispatches`} +

+ + {snapshot.childObservations.length > 0 && ( +
+ Child response observations +

+ Local observations from this open conversation. These are not + restored execution history and are not linked to individual task + cards. +

+ {snapshot.childObservations.map((child, index) => ( +
+

Observed stream {index + 1}

+

{JSON.stringify(child.namespace)}

+

{child.status}

+
+ Observed transcript +
{child.text}
+
+
+ ))} +
+ )} + {snapshot.error &&

{snapshot.error}

} + void application.stop() + : undefined + } + onSubmit={(text) => { + if ( + !application.getSnapshot().canSubmit || + application.getSnapshot().busy || + !text.trim() + ) + return false; + void application.submit(text); + return true; + }} + /> +

{status}

+
+ ); +} diff --git a/cockpit/deep-agents/subagents/react/src/application.spec.ts b/cockpit/deep-agents/subagents/react/src/application.spec.ts new file mode 100644 index 000000000..df0e24e9f --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/application.spec.ts @@ -0,0 +1,763 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { CompleteOutcome, Message } from '@threadplane/core'; +import type { NativeSnapshot } from './authority'; +import type { SubagentsSession } from './application'; +import { createSubagentsApplication } from './application'; +const empty = () => ({ + status: 'idle', + messages: [], + toolCalls: [], + values: {}, + interrupts: [], + subgraphs: [], +}); +function setup() { + let state: NativeSnapshot = empty(); + const listeners = new Set<() => void>(); + let release: ((outcome: CompleteOutcome) => void) | undefined; + const session = { + getSnapshot: () => state, + subscribe: (f: () => void) => { + listeners.add(f); + return () => listeners.delete(f); + }, + submit: vi.fn( + () => + new Promise((r) => { + release = r; + }) + ), + load: vi.fn(async () => undefined), + stop: vi.fn(), + dispose: vi.fn(), + }; + const client = { + createThread: vi.fn(async () => 'owned'), + sessionFactory: vi.fn(() => session as unknown as SubagentsSession), + readCurrent: vi.fn(), + readHistory: vi.fn(), + readCheckpoint: vi.fn(), + }; + return { + app: createSubagentsApplication(client), + client, + session, + emit: (s: NativeSnapshot) => { + state = s; + listeners.forEach((f) => f()); + }, + finish: () => release?.('success'), + }; +} +describe('owned application', () => { + it('starts empty without authoring for empty drafts', async () => { + const x = setup(); + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.app.getSnapshot().canSubmit).toBe(true); + await x.app.submit(' '); + expect(x.client.createThread).not.toHaveBeenCalled(); + }); + it('admits one Send while delivery is pending', async () => { + const x = setup(); + const first = x.app.submit('Dispatch'); + await Promise.resolve(); + await x.app.submit('Dispatch'); + expect(x.client.createThread).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(x.session.submit).toHaveBeenCalledTimes(1); + await x.app.stop(); + x.finish(); + await first; + expect(x.app.getSnapshot().canSubmit).toBe(false); + }); + it('New invalidates active callbacks and resets the view', async () => { + const x = setup(); + const first = x.app.submit('Dispatch'); + await Promise.resolve(); + await Promise.resolve(); + await x.app.newConversation(); + x.emit({ + ...empty(), + messages: [{ id: 'foreign', role: 'user', content: 'late' }] as never, + }); + x.finish(); + await first; + expect(x.app.getSnapshot().threadId).toBeNull(); + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.app.getSnapshot().canSubmit).toBe(true); + }); + it('bounds admitted text before thread creation', async () => { + const x = setup(); + await x.app.submit('x'.repeat(65537)); + expect(x.client.createThread).not.toHaveBeenCalled(); + expect(x.app.getSnapshot().canSubmit).toBe(false); + }); + it('transport failure blocks another Send', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + throw Error('transport'); + }); + await x.app.submit('Dispatch'); + expect(x.app.getSnapshot().outcome).toBe('error'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + }); + it('dispose rejects stale publication', async () => { + const x = setup(); + const first = x.app.submit('Dispatch'); + await Promise.resolve(); + await Promise.resolve(); + await x.app.dispose(); + x.finish(); + await first; + expect(x.session.dispose).toHaveBeenCalledTimes(1); + expect(x.client.readCurrent).not.toHaveBeenCalled(); + }); +}); + +// Repository-contained actual Task1 native/raw compiled-graph payload. +const grounded = { + before: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + loaded: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '740bc16f-5b17-48cd-95dd-ef8114c70112', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + saved: { + values: { + messages: [ + { + content: 'Dispatch request.', + additional_kwargs: {}, + response_metadata: {}, + type: 'human', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + }, + { + content: '', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + tool_calls: [ + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + type: 'tool_call', + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + artifact: null, + status: 'success', + }, + { + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + }, + ], + files: {}, + }, + next: [], + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80ce-6646-8004-c0215aa81e30', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80c5-66e0-8003-d25c44495dab', + }, + created_at: '2026-10-11T15:54:51.478785+00:00', + metadata: { + source: 'loop', + step: 4, + parents: {}, + counters_since_delta_snapshot: { messages: [4, 6], files: [1, 6] }, + }, + tasks: [], + }, +}; + +function actual() { + let state: NativeSnapshot = empty(); + const callbacks = new Set<() => void>(); + let continuation: ((x: CompleteOutcome) => void) | undefined; + const emit = (v: unknown, copy = true) => { + state = (copy ? structuredClone(v) : v) as NativeSnapshot; + callbacks.forEach((f) => f()); + }; + const session = { + getSnapshot: () => state, + subscribe: (f: () => void) => { + callbacks.add(f); + return () => callbacks.delete(f); + }, + submit: vi.fn(async (): Promise => { + emit(grounded.before); + return 'success' as const; + }), + load: vi.fn(async () => emit(grounded.loaded)), + stop: vi.fn(), + dispose: vi.fn(), + }; + const client = { + createThread: async () => grounded.saved.checkpoint.thread_id, + sessionFactory: () => session as unknown as SubagentsSession, + readCurrent: vi.fn(async () => structuredClone(grounded.saved)), + readHistory: async () => [structuredClone(grounded.saved)], + readCheckpoint: async () => structuredClone(grounded.saved), + }; + return { + app: createSubagentsApplication(client), + emit, + session, + client, + pending: () => { + session.submit.mockImplementation( + () => + new Promise((r) => { + continuation = r; + }) + ); + }, + finish: () => continuation?.('success'), + }; +} +it('actual canonical checkpoint grants followup and observes second-turn incremental publication', async () => { + const x = actual(); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().outcome).toBe('success'); + x.pending(); + const operation = x.app.submit('Second'); + await Promise.resolve(); + const s = structuredClone(grounded.loaded) as Omit< + NativeSnapshot, + 'messages' + > & { messages: Message[] }; + s.messages = [ + ...s.messages, + { + id: 'second', + role: 'user', + content: 'Second', + delivery: { + generation: 'second-generation', + phase: 'complete', + outcome: 'success', + }, + }, + ]; + x.emit(s); + expect(x.app.getSnapshot().rows.at(-1)?.message.content).toBe('Second'); + await x.app.stop(); + x.finish(); + await operation; +}); +it('uncertain followup restores the last confirmed root rather than changed prior content', async () => { + const x = actual(); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().outcome).toBe('success'); + const previous = x.app.getSnapshot().rows.map((r) => r.message.content); + x.pending(); + const operation = x.app.submit('Second'); + await Promise.resolve(); + const s = structuredClone(grounded.loaded) as Omit< + NativeSnapshot, + 'messages' + > & { messages: Message[] }; + s.messages = [ + { ...s.messages[0], content: 'Changed saved human' }, + ...s.messages.slice(1), + ]; + s.messages = [ + ...s.messages, + { + id: 'second', + role: 'user', + content: 'Second', + delivery: { + generation: 'second-generation', + phase: 'complete', + outcome: 'success', + }, + }, + ]; + x.emit(s); + x.finish(); + await operation; + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().rows.map((r) => r.message.content)).toEqual( + previous + ); +}); + +it('changed confirmed baseline is rejected before a followup dispatch', async () => { + const x = actual(); + await x.app.submit('Dispatch request.'); + const changed = structuredClone(grounded.loaded); + changed.messages[0].content = 'Unowned prior change'; + x.emit(changed); + await x.app.submit('Second'); + expect(x.session.submit).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().rows[0].message.content).toBe('Dispatch request.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it('New invalidates a pending raw confirmation without installing its result', async () => { + const x = actual(); + let finish: ((v: typeof grounded.saved) => void) | undefined; + x.client.readCurrent.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const operation = x.app.submit('Dispatch request.'); + await Promise.resolve(); + await Promise.resolve(); + expect(x.app.getSnapshot().activity).toBe('confirming'); + await x.app.newConversation(); + finish?.(structuredClone(grounded.saved)); + await operation; + expect(x.app.getSnapshot().threadId).toBeNull(); + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.session.load).not.toHaveBeenCalled(); +}); +it('provisional incomplete catalog is confirmed using complete native and raw authority', async () => { + const x = actual(); + const final = x.session.submit.getMockImplementation(); + if (!final) throw Error('Missing test implementation'); + x.session.submit.mockImplementation(async () => { + x.emit({ ...grounded.before, toolCalls: [] }); + expect(x.app.getSnapshot().taskCount).toBeNull(); + return final(); + }); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().outcome).toBe('success'); + expect(x.app.getSnapshot().taskCount).toBe(4); +}); +it('bounded capture rejects getters without executing before Markdown projection', async () => { + const x = actual(); + let reads = 0; + x.session.submit.mockImplementation(async () => { + const bad = Object.defineProperty({}, 'messages', { + get() { + reads++; + return []; + }, + enumerable: true, + }); + x.emit(bad, false); + return 'success'; + }); + await x.app.submit('Dispatch request.'); + expect(reads).toBe(0); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().rows).toEqual([]); +}); +it('a resolved observed tool cannot change arguments and recover before authority', async () => { + const x = actual(); + const final = x.session.submit.getMockImplementation(); + if (!final) throw Error('Missing fixture'); + x.session.submit.mockImplementation(async () => { + x.emit(grounded.before); + const changed = structuredClone(grounded.before); + changed.toolCalls[0].args.description += ' changed'; + x.emit(changed); + return final(); + }); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().outcome).toBe('error'); +}); +it('native success alone cannot save a foreign raw checkpoint', async () => { + const x = actual(); + const raw = structuredClone(grounded.saved); + raw.checkpoint.thread_id = 'foreign'; + x.client.readCurrent.mockResolvedValue(raw); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().outcome).toBe('error'); +}); +it('still-owned source mutation at publication ends with visible uncertainty', async () => { + const x = actual(); + x.session.load.mockImplementation(async () => { + x.emit(grounded.loaded); + const stable = structuredClone(grounded.loaded) as NativeSnapshot; + let reads = 0; + x.session.getSnapshot = () => (++reads === 1 ? stable : { ...stable }); + }); + await x.app.submit('Dispatch request.'); + expect(x.app.getSnapshot().outcome).toBe('error'); + expect(x.app.getSnapshot().error).toContain('could not be confirmed'); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); diff --git a/cockpit/deep-agents/subagents/react/src/application.ts b/cockpit/deep-agents/subagents/react/src/application.ts new file mode 100644 index 000000000..833b8f482 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/application.ts @@ -0,0 +1,527 @@ +import type { + AgentSession, + AgentStatus, + CompleteOutcome, +} from '@threadplane/core'; +import { + createMessageContent, + type MessageRow, +} from '@threadplane/content/messages'; +import { + captureAuthority, + captureBaseline, + captureTurn, + copyData, + record, + sameData, + LIMITS, + ConversationBudget, + type Baseline, + type Turn, + type Checkpoint, + type NativeSnapshot, +} from './authority'; +import { + projectParentObservations, + type ParentProjection, + type ToolCard, +} from './projection'; +import { ChildObservations, type ChildRow } from './children'; +export interface SubagentsSession extends Omit { + getSnapshot(): NativeSnapshot; + load(options?: { readonly signal?: AbortSignal }): Promise; +} +export interface SubagentsClient { + createThread(signal: AbortSignal): Promise; + sessionFactory(threadId: string): SubagentsSession; + readCurrent(threadId: string, signal: AbortSignal): Promise; + readHistory( + threadId: string, + signal: AbortSignal + ): Promise; + readCheckpoint(checkpoint: Checkpoint, signal: AbortSignal): Promise; +} +export interface SubagentsSnapshot { + readonly threadId: string | null; + readonly rows: readonly MessageRow[]; + readonly observations: ReadonlyMap; + readonly taskCount: number | null; + readonly childObservations: readonly ChildRow[]; + readonly activity: + | 'idle' + | 'creating' + | 'running' + | 'confirming' + | 'replacing'; + readonly busy: boolean; + readonly canSubmit: boolean; + readonly outcome: CompleteOutcome | null; + readonly error: string | null; + readonly viewGeneration: number; +} +const initial = (viewGeneration = 0): SubagentsSnapshot => + Object.freeze({ + threadId: null, + rows: [], + observations: new Map(), + taskCount: 0, + childObservations: [], + activity: 'idle', + busy: false, + canSubmit: true, + outcome: null, + error: null, + viewGeneration, + }); +/** A local owner may author again only after exact raw authority and canonical load. */ +export function createSubagentsApplication(client: SubagentsClient) { + type Owner = { + id: string; + key: string; + session: SubagentsSession; + content: ReturnType; + budget: ConversationBudget; + children: ChildObservations; + baseline: Baseline; + release?: () => void; + closed: boolean; + unsafe: boolean; + loading: boolean; + turn?: Turn; + nativeGeneration?: string; + text?: string; + serial: string; + completed: Map; + resolvedTools: Map; + projection?: ParentProjection; + confirmed?: NativeSnapshot; + rawStatuses?: Readonly>; + }; + let owner: Owner | undefined, + operation: AbortController | undefined, + serial = 0, + disposed = false; + let snapshot = initial(); + const listeners = new Set<() => void>(), + cleanups = new Set>(); + let disposal: Promise | undefined; + const publish = (update: Partial) => { + if (disposed) return; + snapshot = Object.freeze({ ...snapshot, ...update }); + for (const f of listeners) f(); + }; + function bounded(work: () => void | Promise) { + let timer: ReturnType; + return Promise.race([ + Promise.resolve() + .then(work) + .catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, 2000); + }), + ]).finally(() => clearTimeout(timer)); + } + function close(o: Owner) { + if (o.closed) return Promise.resolve(); + o.closed = true; + o.release?.(); + o.content.dispose(); + const p = bounded(() => o.session.dispose()); + cleanups.add(p); + void p.finally(() => cleanups.delete(p)); + return p; + } + function detach() { + const o = owner; + owner = undefined; + return o ? close(o) : Promise.resolve(); + } + function blocked(o?: Owner) { + o?.children.finish('error'); + if (o?.confirmed) { + const projection = projectParentObservations( + o.confirmed.messages, + o.confirmed.toolCalls, + o.budget, + 'parents', + o.rawStatuses + ); + if (projection.status === 'available') + publish({ + rows: o.content.project({ + status: o.confirmed.status as AgentStatus, + messages: o.confirmed.messages, + toolCalls: o.confirmed.toolCalls, + }), + observations: projection.cards, + taskCount: projection.taskCount, + }); + } + publish({ + canSubmit: false, + outcome: 'error', + error: + 'This conversation could not be confirmed. Start a new conversation to continue.', + ...(o ? { childObservations: o.children.rows() } : {}), + }); + } + function read(o: Owner, valid: () => boolean): NativeSnapshot | undefined { + if (!valid() || o.loading) return; + try { + const source = o.session.getSnapshot(); + if (!valid()) return; + const s = copyData(source) as NativeSnapshot; + if ( + !record(s) || + !Array.isArray(s.messages) || + !Array.isArray(s.toolCalls) || + !Array.isArray(s.subgraphs) || + !Array.isArray(s.interrupts) + ) + throw Error('Unavailable'); + if (o.confirmed) { + const prior = o.confirmed; + if ( + s.messages.length < prior.messages.length || + prior.messages.some( + (m, i) => + !sameData( + { ...m, delivery: undefined }, + { ...s.messages[i], delivery: undefined } + ) + ) || + prior.toolCalls.some( + (t) => + !sameData( + t, + s.toolCalls.find((x) => x.id === t.id) + ) + ) + ) + throw Error('Changed confirmed root'); + } + if ( + o.text !== undefined && + s.messages.length > o.baseline.messages.length + ) { + const human = s.messages[o.baseline.messages.length]; + if ( + human.role !== 'user' || + human.content !== o.text || + !human.delivery?.generation || + s.messages + .slice(o.baseline.messages.length + 1) + .some((m) => m.role === 'user') + ) + throw Error('Unowned turn'); + if (!o.turn) { + o.turn = captureTurn(o.baseline, { + owner: o.key, + generation: o.serial, + threadId: o.id, + humanId: human.id, + humanContent: o.text, + }); + o.nativeGeneration = human.delivery.generation; + if (!o.turn || !o.children.begin(o.serial, human.delivery.generation)) + throw Error('Unavailable turn'); + } + if ( + human.id !== o.turn.humanId || + human.delivery.generation !== o.nativeGeneration || + s.messages + .slice(o.baseline.messages.length) + .some((m) => m.delivery?.generation !== o.nativeGeneration) + ) + throw Error('Foreign generation'); + if ( + [...o.completed.keys()].some( + (id) => !s.messages.some((m) => m.id === id) + ) + ) + throw Error('Missing completed message'); + for (const tool of s.toolCalls) { + const prior = o.resolvedTools.get(tool.id); + if (prior && !sameData(prior, tool)) + throw Error('Changed resolved tool'); + if (tool.status === 'complete' || tool.status === 'error') + o.resolvedTools.set(tool.id, tool); + } + if ( + [...o.resolvedTools.keys()].some( + (id) => !s.toolCalls.some((t) => t.id === id) + ) + ) + throw Error('Missing resolved tool'); + for (const m of s.messages) { + const semantic = { ...m, delivery: undefined }; + const prior = o.completed.get(m.id); + if (prior && !sameData(prior, semantic)) + throw Error('Changed completed message'); + if (m.delivery.phase === 'complete') o.completed.set(m.id, semantic); + } + if ( + !o.children.observe(s.subgraphs, { + owner: o.key, + turn: o.serial, + generation: human.delivery.generation, + }) + ) + throw Error('Incomplete children'); + } else if (s.subgraphs.length) throw Error('Unowned children'); + const projection = projectParentObservations( + s.messages, + s.toolCalls, + o.budget + ); + if (projection.status === 'available') { + const rows = o.content.project({ + status: s.status as AgentStatus, + messages: s.messages, + toolCalls: s.toolCalls, + }); + if (!valid()) return; + o.projection = projection; + publish({ + rows, + observations: projection.cards, + taskCount: projection.taskCount, + childObservations: o.children.rows(), + }); + } else publish({ taskCount: null, childObservations: o.children.rows() }); + if (!valid()) return; + if (o.session.getSnapshot() !== source) throw Error('Changed source'); + return s; + } catch { + if (valid()) { + o.unsafe = true; + blocked(o); + } + return; + } + } + async function submit(text: string) { + if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim()) + return; + if (text.length > LIMITS.fieldText) { + blocked(owner); + return; + } + const admitted = new AbortController(); + operation = admitted; + const current = () => + !disposed && operation === admitted && !admitted.signal.aborted; + publish({ + busy: true, + canSubmit: false, + activity: owner ? 'running' : 'creating', + outcome: null, + error: null, + }); + try { + if (!current()) return; + if (!owner) { + const id = await client.createThread(admitted.signal); + if (!current()) return; + if (typeof id !== 'string' || !id || id.length > LIMITS.fieldText) + throw Error('Unknown thread'); + const session = client.sessionFactory(id), + budget = new ConversationBudget(), + key = String(snapshot.viewGeneration); + const o: Owner = { + id, + key, + session, + budget, + children: new ChildObservations(key, budget), + content: createMessageContent(), + baseline: { messages: [] }, + closed: false, + unsafe: false, + loading: false, + serial: '', + completed: new Map(), + resolvedTools: new Map(), + }; + if (!current()) { + void close(o); + return; + } + owner = o; + o.release = session.subscribe(() => { + const active = operation; + if (active && !o.loading) + read( + o, + () => + !disposed && + operation === active && + !active.signal.aborted && + owner === o && + !o.closed + ); + }); + publish({ threadId: id, activity: 'running' }); + } + const o = owner, + valid = () => current() && owner === o && !o.closed; + o.serial = String(++serial); + o.text = text; + o.turn = undefined; + o.nativeGeneration = undefined; + o.completed = new Map(); + o.resolvedTools = new Map(); + if (!valid()) return; + const before = read(o, valid); + if (!valid()) return; + if ( + !before || + before.messages.length !== o.baseline.messages.length || + before.subgraphs.length || + o.unsafe + ) + throw Error('Unowned baseline'); + const outcome = await o.session.submit(text, { signal: admitted.signal }); + if (!valid()) return; + const live = read(o, valid); + if (!valid()) return; + if (!live || o.unsafe || outcome !== 'success' || !o.turn) + throw Error('Unconfirmed delivery'); + publish({ activity: 'confirming' }); + if (!valid()) return; + const observedState = copyData( + await client.readCurrent(o.id, admitted.signal), + false + ); + if (!valid()) return; + const history = copyData( + await client.readHistory(o.id, admitted.signal), + false + ); + if (!valid()) return; + if (!Array.isArray(history) || !record(history[0])) + throw Error('Unavailable history'); + const requested = copyData(history[0].checkpoint, false) as Checkpoint; + const loadedCheckpoint = copyData( + await client.readCheckpoint(requested, admitted.signal), + false + ); + if (!valid()) return; + o.children.finish(outcome); + o.loading = true; + await o.session.load({ signal: admitted.signal }); + if (!valid()) return; + const source = o.session.getSnapshot(), + loaded = copyData(source) as NativeSnapshot; + if (!valid()) return; + const authority = captureAuthority({ + turn: o.turn, + owner: o.key, + generation: o.serial, + threadId: o.id, + outcome, + observedState, + history, + loadedCheckpoint, + snapshot: live, + loadedSnapshot: loaded, + }); + if (authority.kind !== 'terminal' || o.children.incomplete) + throw Error('Unconfirmed authority'); + const projection = projectParentObservations( + loaded.messages, + loaded.toolCalls, + o.budget, + 'parents', + authority.rawToolStatuses + ); + if (projection.status !== 'available') + throw Error('Unavailable final display'); + const rows = o.content.project({ + status: loaded.status as AgentStatus, + messages: loaded.messages, + toolCalls: loaded.toolCalls, + }); + if (!valid()) return; + const latest = o.session.getSnapshot(); + if (!valid()) return; + if (latest !== source) throw Error('Changed source before publication'); + o.confirmed = loaded; + o.rawStatuses = authority.rawToolStatuses; + const baseline = captureBaseline({ messages: authority.messages }); + if (!baseline) throw Error('Unavailable baseline'); + o.baseline = baseline; + o.text = undefined; + o.turn = undefined; + o.loading = false; + o.projection = projection; + publish({ + rows, + observations: projection.cards, + taskCount: projection.taskCount, + childObservations: o.children.rows(), + outcome, + canSubmit: true, + }); + } catch { + if (current()) { + if (owner) owner.unsafe = true; + blocked(owner); + } + } finally { + if (current()) { + operation = undefined; + publish({ busy: false, activity: 'idle' }); + } + } + } + async function stop() { + if (disposed || !operation) return; + operation.abort(); + operation = undefined; + const o = owner; + o?.children.finish('aborted'); + const children = o?.children.rows() ?? snapshot.childObservations; + const p = detach(); + publish({ + busy: false, + canSubmit: false, + activity: 'idle', + outcome: 'aborted', + error: null, + childObservations: children, + }); + await Promise.all([p, bounded(() => o?.session.stop())]); + } + async function newConversation() { + if (disposed) return; + operation?.abort(); + operation = undefined; + owner?.children.finish('new'); + const p = detach(); + snapshot = initial(snapshot.viewGeneration + 1); + publish({}); + await p; + } + function dispose() { + if (disposal) return disposal; + disposed = true; + operation?.abort(); + operation = undefined; + void detach(); + listeners.clear(); + return (disposal = Promise.all([...cleanups]).then(() => undefined)); + } + return { + getSnapshot: () => snapshot, + subscribe: (f: () => void) => { + if (disposed) return () => undefined; + listeners.add(f); + return () => listeners.delete(f); + }, + submit, + stop, + newConversation, + dispose, + }; +} diff --git a/cockpit/deep-agents/subagents/react/src/authority.spec.ts b/cockpit/deep-agents/subagents/react/src/authority.spec.ts new file mode 100644 index 000000000..af0996001 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/authority.spec.ts @@ -0,0 +1,2407 @@ +import { describe, it, expect } from 'vitest'; +import { + copyData, + literal, + LIMITS, + captureBaseline, + captureTurn, + captureAuthority, +} from './authority'; + +// Whole actual native aggregates, including retained child data and load history metadata. +const actualWholeNative = { + before: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [ + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:7c55ba25-2ad6-2fdf-878c-87f2c7badf1f'], + messages: [ + { + id: '78d9e8f6-addd-4eb6-8e2a-8c77ba1a7be8', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-0a0b9934a80d', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'weather-analyst', + toolCallIds: [ + '78d9e8f6-addd-4eb6-8e2a-8c77ba1a7be8-lookup_weather', + ], + }, + { + id: '2931c1f4-5185-4cb0-ba07-51429c05488c', + role: 'tool', + content: + 'KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_weather', + toolCallId: '78d9e8f6-addd-4eb6-8e2a-8c77ba1a7be8-lookup_weather', + }, + { + id: 'lc_run--01a12bac-8e7b-7c82-aa7e-95346cd6ba52', + role: 'assistant', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'weather-analyst', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:54051cee-1cab-ad48-5707-15aa636e63a8'], + messages: [ + { + id: 'e296b319-4ea9-4879-bd36-35fd6649610d', + role: 'user', + content: 'Research KDEN dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-0a18caceabca', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + 'e296b319-4ea9-4879-bd36-35fd6649610d-lookup_field_elevation', + 'e296b319-4ea9-4879-bd36-35fd6649610d-lookup_runway_length', + ], + }, + { + id: 'bb8cd008-2387-4e62-b4e0-650377895238', + role: 'tool', + content: 'KDEN field elevation is 5434 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + 'e296b319-4ea9-4879-bd36-35fd6649610d-lookup_field_elevation', + }, + { + id: '7e8e152c-1cad-4152-9df0-6b5511505d06', + role: 'tool', + content: 'KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: + 'e296b319-4ea9-4879-bd36-35fd6649610d-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab5751d144be', + role: 'assistant', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:f6286351-3cde-5693-05ef-2db7dd706a50'], + messages: [ + { + id: 'a87e5aa7-ad53-4908-8888-421747bf1925', + role: 'user', + content: 'Research KDEN dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-0a29ca74b6f5', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'weather-analyst', + toolCallIds: [ + 'a87e5aa7-ad53-4908-8888-421747bf1925-lookup_weather', + ], + }, + { + id: '8ddfe54c-1a71-4b55-b4a4-4b47238ced9c', + role: 'tool', + content: + 'KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_weather', + toolCallId: 'a87e5aa7-ad53-4908-8888-421747bf1925-lookup_weather', + }, + { + id: 'lc_run--01a12bac-8e7b-7c82-aa7e-954b534b8a6e', + role: 'assistant', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'weather-analyst', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, + ], + }, + loaded: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '740bc16f-5b17-48cd-95dd-ef8114c70112', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + history: [ + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80ce-6646-8004-c0215aa81e30', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80c5-66e0-8003-d25c44495dab', + }, + created_at: '2026-10-11T15:54:51.478785+00:00', + next: [], + }, + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80c5-66e0-8003-d25c44495dab', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fbf-67b4-8002-ca27aa03d951', + }, + created_at: '2026-10-11T15:54:51.475114+00:00', + next: ['model'], + }, + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fbf-67b4-8002-ca27aa03d951', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fac-6d94-8001-4ec96ca4e6a8', + }, + created_at: '2026-10-11T15:54:51.367815+00:00', + next: ['tools', 'tools', 'tools', 'tools'], + }, + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fac-6d94-8001-4ec96ca4e6a8', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fab-68e0-8000-bb80f4baf800', + }, + created_at: '2026-10-11T15:54:51.360189+00:00', + next: ['model'], + }, + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7fab-68e0-8000-bb80f4baf800', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7faa-63be-bfff-73d5a0e3e1dd', + }, + created_at: '2026-10-11T15:54:51.359660+00:00', + next: ['PatchToolCallsMiddleware.before_agent'], + }, + { + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-7faa-63be-bfff-73d5a0e3e1dd', + }, + parent_checkpoint: null, + created_at: '2026-10-11T15:54:51.359119+00:00', + next: ['__start__'], + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, +}; +it('accepts whole actual native aggregates including children and native history', () => { + const input = evidence(); + Object.assign(input.snapshot, actualWholeNative.before); + Object.assign(input.loadedSnapshot, actualWholeNative.loaded); + expect(captureAuthority(input).kind).toBe('terminal'); +}); +// Actual second native turn and saved canonical root; earlier raw messages are the fixed baseline. +const followupGrounded = { + before: { + status: 'idle', + messages: [ + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf', + role: 'user', + content: 'First dispatch.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + ], + }, + { + id: '316ee532-76f3-491e-86f2-5e30a0755ac9', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + }, + { + id: 'dd91cf3a-15bd-4f0a-8bb8-218383336d71', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + }, + { + id: '89719f15-35db-4361-9856-400d10fbbb36', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + }, + { + id: '7dba766a-1b6b-4b0b-bbca-4ff98e4e7131', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da', + role: 'user', + content: 'Second dispatch.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + ], + }, + { + id: 'c1cb085a-e8ca-4e53-a7ee-7df63550c4e8', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + }, + { + id: '268e4423-96ef-4587-825d-7f7a1a9dc262', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + }, + { + id: 'd841616e-d619-4d6b-80ff-fefc937102a7', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + }, + { + id: '194f7d46-d81c-4c21-a592-66d915e321d7', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + loaded: { + status: 'idle', + messages: [ + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf', + role: 'user', + content: 'First dispatch.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + ], + }, + { + id: '316ee532-76f3-491e-86f2-5e30a0755ac9', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + }, + { + id: 'dd91cf3a-15bd-4f0a-8bb8-218383336d71', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + }, + { + id: '89719f15-35db-4361-9856-400d10fbbb36', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + }, + { + id: '7dba766a-1b6b-4b0b-bbca-4ff98e4e7131', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: '96a9ecbe-f540-41ff-b2a5-44c0b58baca2', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da', + role: 'user', + content: 'Second dispatch.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + ], + }, + { + id: 'c1cb085a-e8ca-4e53-a7ee-7df63550c4e8', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + }, + { + id: '268e4423-96ef-4587-825d-7f7a1a9dc262', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + }, + { + id: 'd841616e-d619-4d6b-80ff-fefc937102a7', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + }, + { + id: '194f7d46-d81c-4c21-a592-66d915e321d7', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'd9728335-d494-4f1c-918c-470804fe9e84', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + saved: { + values: { + messages: [ + { + content: 'First dispatch.', + additional_kwargs: {}, + response_metadata: {}, + type: 'human', + name: null, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf', + }, + { + content: '', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-dispatch', + tool_calls: [ + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + type: 'tool_call', + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '316ee532-76f3-491e-86f2-5e30a0755ac9', + tool_call_id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-0', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '7dba766a-1b6b-4b0b-bbca-4ff98e4e7131', + tool_call_id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-3', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '89719f15-35db-4361-9856-400d10fbbb36', + tool_call_id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-2', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'dd91cf3a-15bd-4f0a-8bb8-218383336d71', + tool_call_id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-task-1', + artifact: null, + status: 'success', + }, + { + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '196e8ca8-c4f4-4fa3-a30e-2340c73b35bf-final', + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: 'Second dispatch.', + additional_kwargs: {}, + response_metadata: {}, + type: 'human', + name: null, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da', + }, + { + content: '', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-dispatch', + tool_calls: [ + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + type: 'tool_call', + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '268e4423-96ef-4587-825d-7f7a1a9dc262', + tool_call_id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-1', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '194f7d46-d81c-4c21-a592-66d915e321d7', + tool_call_id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-3', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'c1cb085a-e8ca-4e53-a7ee-7df63550c4e8', + tool_call_id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-0', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'd841616e-d619-4d6b-80ff-fefc937102a7', + tool_call_id: '56a53c7b-b734-4de7-b2c2-4e73347130da-task-2', + artifact: null, + status: 'success', + }, + { + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '56a53c7b-b734-4de7-b2c2-4e73347130da-final', + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + }, + ], + files: {}, + }, + next: [], + checkpoint: { + thread_id: 'bc91f40b-72f9-4b26-8b99-c4654213ff97', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-91d5-6aac-800a-22660de7d885', + }, + parent_checkpoint: { + thread_id: 'bc91f40b-72f9-4b26-8b99-c4654213ff97', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-91cb-6e80-8009-9985ead95340', + }, + created_at: '2026-10-11T15:54:53.264340+00:00', + metadata: { + source: 'loop', + step: 10, + parents: {}, + counters_since_delta_snapshot: { messages: [8, 12], files: [2, 12] }, + }, + tasks: [], + }, +}; +describe('followup canonical boundaries', () => { + function followup() { + const input = evidence(followupGrounded); + const saved = input.loadedCheckpoint.values.messages; + const prefix = saved.slice(0, 7); + const human = saved[7]; + input.turn = captureTurn(captureBaseline({ messages: prefix }), { + owner: input.owner, + generation: input.generation, + threadId: input.threadId, + humanId: human.id, + humanContent: human.content, + }); + return input; + } + it('rejects actual followup captured without canonical reload of its prior prefix', () => { + expect(captureAuthority(followup()).kind).toBe('unconfirmed'); + }); + it('accepts a synthetic canonical-reload variant of the grounded duplicate followup', () => { + const input = followup(); + for (const s of [input.snapshot, input.loadedSnapshot]) { + const byId = new Map(s.messages.map((m) => [m.id, m])); + s.messages.splice( + 0, + 7, + ...input.loadedCheckpoint.values.messages.slice(0, 7).map((w) => { + const m = byId.get(w.id); + if (!m) throw new Error('Fixture missing native message'); + return m; + }) + ); + } + expect(captureAuthority(input).kind).toBe('terminal'); + }); + it('rejects prior result reorder even when all three raw sources and native snapshots agree', () => { + const input = followup(); + for (const r of [ + input.observedState, + input.loadedCheckpoint, + input.history[0], + ]) + [r.values.messages[2], r.values.messages[3]] = [ + r.values.messages[3], + r.values.messages[2], + ]; + for (const s of [input.snapshot, input.loadedSnapshot]) + [s.messages[2], s.messages[3]] = [s.messages[3], s.messages[2]]; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('rejects native prior-prefix reorder independently of a valid saved prefix', () => { + const input = followup(); + [input.snapshot.messages[2], input.snapshot.messages[3]] = [ + input.snapshot.messages[3], + input.snapshot.messages[2], + ]; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('rejects changed prior args and literal results in every raw source', () => { + for (const mutation of ['args', 'result']) { + const input = followup(); + for (const r of [ + input.observedState, + input.loadedCheckpoint, + input.history[0], + ]) { + if (mutation === 'args') + ( + r.values.messages[1].tool_calls?.[0].args as Record + ).description = 'changed'; + else r.values.messages[2].content = 'changed'; + } + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + }); + it('keeps raw root ToolMessage error status separate from native complete delivery', () => { + const result = captureAuthority(evidence(rootError)); + expect(result.kind).toBe('terminal'); + if (result.kind === 'terminal') + expect(Object.values(result.rawToolStatuses)).toEqual(['error']); + }); + it('does not evaluate getters in raw authority', () => { + let reads = 0; + const input = evidence(); + Object.defineProperty(input.loadedCheckpoint, 'values', { + get() { + reads++; + return {}; + }, + enumerable: true, + }); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + expect(reads).toBe(0); + }); + it('rejects extra humans, duplicate raw IDs, nonterminal tasks and native interrupted delivery', () => { + const a = evidence(); + a.loadedCheckpoint.values.messages[2].id = + a.loadedCheckpoint.values.messages[0].id; + expect(captureAuthority(a).kind).toBe('unconfirmed'); + const b = evidence(); + b.snapshot.messages[0].delivery.outcome = 'interrupted'; + expect(captureAuthority(b).kind).toBe('unconfirmed'); + const c = evidence(); + (c.loadedCheckpoint.tasks as unknown[]).push({ id: 'pending' }); + expect(captureAuthority(c).kind).toBe('unconfirmed'); + }); +}); +// Grounded selected payloads from Task1 unchanged pinned Deep Agents/native SDK run. +const grounded = { + before: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + loaded: { + status: 'idle', + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '740bc16f-5b17-48cd-95dd-ef8114c70112', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + saved: { + values: { + messages: [ + { + content: 'Dispatch request.', + additional_kwargs: {}, + response_metadata: {}, + type: 'human', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + }, + { + content: '', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + tool_calls: [ + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + type: 'tool_call', + }, + { + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + type: 'tool_call', + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + artifact: null, + status: 'success', + }, + { + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + tool_call_id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + artifact: null, + status: 'success', + }, + { + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + }, + ], + files: {}, + }, + next: [], + checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80ce-6646-8004-c0215aa81e30', + }, + parent_checkpoint: { + thread_id: '18829a51-4b57-449c-a83c-38b510f6b81e', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-80c5-66e0-8003-d25c44495dab', + }, + created_at: '2026-10-11T15:54:51.478785+00:00', + metadata: { + source: 'loop', + step: 4, + parents: {}, + counters_since_delta_snapshot: { messages: [4, 6], files: [1, 6] }, + }, + tasks: [], + }, +}; +const rootError = { + before: { + status: 'idle', + messages: [ + { + id: 'e2bf0109-d910-499c-b861-838aa540e081', + role: 'user', + content: 'Invalid root task arguments.', + delivery: { + generation: '9a54cd7e-67aa-4b33-8166-eba8b89d1e33', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: '9a54cd7e-67aa-4b33-8166-eba8b89d1e33', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: ['e2bf0109-d910-499c-b861-838aa540e081-invalid-task'], + }, + { + id: 'c9b1b13a-98a6-4ebe-8cc3-8f501a964acc', + role: 'tool', + content: + "Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + delivery: { + generation: '9a54cd7e-67aa-4b33-8166-eba8b89d1e33', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + }, + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-final', + role: 'assistant', + content: + "Dispatch assessment: Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + delivery: { + generation: '9a54cd7e-67aa-4b33-8166-eba8b89d1e33', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + name: 'task', + args: { subagent_type: 'field-researcher' }, + status: 'complete', + result: + "Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + loaded: { + status: 'idle', + messages: [ + { + id: 'e2bf0109-d910-499c-b861-838aa540e081', + role: 'user', + content: 'Invalid root task arguments.', + delivery: { + generation: 'e2bf0109-d910-499c-b861-838aa540e081', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'e2bf0109-d910-499c-b861-838aa540e081-dispatch', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: ['e2bf0109-d910-499c-b861-838aa540e081-invalid-task'], + }, + { + id: 'c9b1b13a-98a6-4ebe-8cc3-8f501a964acc', + role: 'tool', + content: + "Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + delivery: { + generation: 'c9b1b13a-98a6-4ebe-8cc3-8f501a964acc', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + }, + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-final', + role: 'assistant', + content: + "Dispatch assessment: Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + delivery: { + generation: 'e2bf0109-d910-499c-b861-838aa540e081-final', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + toolCalls: [ + { + id: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + name: 'task', + args: { subagent_type: 'field-researcher' }, + status: 'complete', + result: + "Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + }, + ], + values: { files: {} }, + interrupts: [], + subgraphs: [], + }, + saved: { + values: { + messages: [ + { + content: 'Invalid root task arguments.', + additional_kwargs: {}, + response_metadata: {}, + type: 'human', + name: null, + id: 'e2bf0109-d910-499c-b861-838aa540e081', + }, + { + content: '', + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: 'e2bf0109-d910-499c-b861-838aa540e081-dispatch', + tool_calls: [ + { + name: 'task', + args: { subagent_type: 'field-researcher' }, + id: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + type: 'tool_call', + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + }, + { + content: + "Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + additional_kwargs: {}, + response_metadata: {}, + type: 'tool', + name: 'task', + id: 'c9b1b13a-98a6-4ebe-8cc3-8f501a964acc', + tool_call_id: 'e2bf0109-d910-499c-b861-838aa540e081-invalid-task', + artifact: null, + status: 'error', + }, + { + content: + "Dispatch assessment: Error invoking tool 'task' with kwargs {'subagent_type': 'field-researcher'} with error:\n description: Field required\n Please fix the error and try again.", + additional_kwargs: {}, + response_metadata: {}, + type: 'ai', + name: null, + id: 'e2bf0109-d910-499c-b861-838aa540e081-final', + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + }, + ], + files: {}, + }, + next: [], + checkpoint: { + thread_id: '2d0613e3-29a4-451b-8498-71d377d9b437', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-a047-675c-8004-a7c9a009ea06', + }, + parent_checkpoint: { + thread_id: '2d0613e3-29a4-451b-8498-71d377d9b437', + checkpoint_ns: '', + checkpoint_id: '1f1c58c1-a040-6c22-8003-4a2ac5f525c1', + }, + created_at: '2026-10-11T15:54:54.778961+00:00', + metadata: { + source: 'loop', + step: 4, + parents: {}, + counters_since_delta_snapshot: { messages: [4, 6], files: [0, 6] }, + }, + tasks: [], + }, +}; +const clone = (value: T): T => structuredClone(value); +function evidence(f: typeof grounded | typeof rootError = grounded) { + const saved = { ...clone(f.saved), next: clone(f.saved.next) as string[] }; + const human = saved.values.messages[0]; + return { + owner: 'view', + generation: 'operation', + threadId: saved.checkpoint.thread_id, + outcome: 'success', + turn: captureTurn(captureBaseline(), { + owner: 'view', + generation: 'operation', + threadId: saved.checkpoint.thread_id, + humanId: human.id, + humanContent: human.content, + }), + observedState: clone(saved), + history: [clone(saved)], + loadedCheckpoint: clone(saved), + snapshot: clone(f.before) as typeof grounded.before, + loadedSnapshot: clone(f.loaded) as typeof grounded.loaded, + }; +} +describe('bounded data capture', () => { + it('captures before literal serialization without evaluating getters', () => { + let reads = 0; + const value = { + get text() { + reads++; + return 'bad'; + }, + }; + expect(() => literal(value)).toThrow(); + expect(reads).toBe(0); + }); + it('bounds encoded control characters and preserves exact literal JSON', () => { + expect(() => + literal({ text: '\u0000'.repeat(LIMITS.fieldText) }) + ).toThrow(); + const value = { + quote: '"\\😀\ud800', + array: [undefined, 0, null], + optional: undefined, + }; + expect(literal(value)).toBe(JSON.stringify(value, null, 2)); + }); + it('accepts exact depth and node boundaries before rejecting overflow', () => { + let v: unknown = 0; + for (let i = 0; i < 32; i++) v = { v }; + expect(() => copyData(v)).not.toThrow(); + expect(() => copyData(new Array(LIMITS.nodes - 1).fill(0))).not.toThrow(); + expect(() => copyData(new Array(4294967295))).toThrow(); + }); + it('owns optional undefined native fields but rejects them in JSON', () => { + expect(copyData({ a: undefined })).toEqual({ a: undefined }); + expect(() => copyData({ a: undefined }, false)).toThrow(); + }); + it('rejects getters without running them', () => { + let reads = 0; + const value = { + get bad() { + reads++; + return 'bad'; + }, + }; + expect(() => copyData(value)).toThrow(); + expect(reads).toBe(0); + }); + it.each([ + new Date(), + Object.assign([], { x: 1 }), + Object.assign(new Array(2), { 1: 1 }), + { [Symbol('x')]: 1 }, + ])('rejects nonplain or sparse data', (value) => + expect(() => copyData(value)).toThrow() + ); + it('rejects cycles', () => { + const value: unknown[] = []; + value.push(value); + expect(() => copyData(value)).toThrow(); + }); + it('bounds depth and nodes before recursive copy', () => { + let value: unknown = 0; + for (let i = 0; i < 33; i++) value = { value }; + expect(() => copyData(value)).toThrow(); + expect(() => copyData(new Array(10001).fill(0))).toThrow(); + }); + it('bounds text including keys in UTF16 units', () => { + expect(copyData('😀'.repeat(LIMITS.copyText / 2))).toBe( + '😀'.repeat(LIMITS.copyText / 2) + ); + expect(() => copyData('😀'.repeat(LIMITS.copyText / 2) + 'x')).toThrow(); + expect(() => copyData({ ['x'.repeat(LIMITS.copyText)]: 'x' })).toThrow(); + }); +}); +describe('actual saved root authority', () => { + it('rejects malformed hand-built owner identity even when both inputs agree', () => { + const input = evidence(); + if (!input.turn) throw new Error('Fixture missing turn'); + input.owner = 123 as unknown as string; + input.turn = { ...input.turn, owner: input.owner }; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('accepts actual current result permutation and independently loaded native root', () => { + const input = evidence(); + const result = captureAuthority(input); + expect(result.kind).toBe('terminal'); + if (result.kind === 'terminal') + expect(result.messages).toEqual(input.loadedCheckpoint.values.messages); + }); + it('accepts actual resolved raw ToolMessage error with missing task description', () => { + expect(captureAuthority(evidence(rootError)).kind).toBe('terminal'); + }); + it.each(['owner', 'generation', 'threadId'])('rejects stale %s', (key) => { + const input = evidence(); + (input as Record)[key] = 'foreign'; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it.each(['error', 'aborted', 'interrupted', 'paused'])( + 'requires native success %s', + (outcome) => { + const input = evidence(); + input.outcome = outcome; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + ); + it.each(['observedState', 'loadedCheckpoint', 'history'])( + 'requires independent identical raw evidence %s', + (key) => { + const input = evidence(); + if (key === 'history') + input.history[0].values.messages[0].content = 'changed'; + else + input[ + key as 'observedState' | 'loadedCheckpoint' + ].values.messages[0].content = 'changed'; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + ); + it('rejects duplicate checkpoint IDs, foreign namespaces and nonterminal raw controls', () => { + const a = evidence(); + a.history.push(clone(a.history[0])); + expect(captureAuthority(a).kind).toBe('unconfirmed'); + const b = evidence(); + b.loadedCheckpoint.checkpoint.checkpoint_ns = 'child'; + expect(captureAuthority(b).kind).toBe('unconfirmed'); + const c = evidence(); + c.observedState.next.push('running'); + expect(captureAuthority(c).kind).toBe('unconfirmed'); + }); + it.each(['snapshot', 'loadedSnapshot'])( + 'rejects native content, args, IDs or declaration changes in %s', + (key) => { + for (const mutation of ['content', 'args', 'id', 'calls']) { + const input = evidence(); + const s = input[key as 'snapshot' | 'loadedSnapshot']; + if (mutation === 'content') s.messages[2].content += 'changed'; + if (mutation === 'id') s.messages[2].id = 'synthetic'; + if (mutation === 'args') s.toolCalls[0].args.description = 'changed'; + if (mutation === 'calls') s.messages[1].toolCallIds?.reverse(); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + } + ); + it('rejects moved results across assistant batches and incomplete ownership', () => { + const input = evidence(); + [input.snapshot.messages[1], input.snapshot.messages[2]] = [ + input.snapshot.messages[2], + input.snapshot.messages[1], + ]; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + const b = evidence(); + b.snapshot.toolCalls.pop(); + expect(captureAuthority(b).kind).toBe('unconfirmed'); + }); + it('requires strict previously confirmed canonical raw prefix', () => { + const input = evidence(); + const old = clone(input.loadedCheckpoint.values.messages); + input.turn = captureTurn(captureBaseline({ messages: old }), { + owner: 'view', + generation: 'operation', + threadId: input.threadId, + humanId: 'followup', + humanContent: 'Next', + }); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('does not accept synthetic-only or missing human IDs', () => { + const input = evidence(); + input.turn = captureTurn(captureBaseline(), { + owner: 'view', + generation: 'operation', + threadId: input.threadId, + humanId: 'history-message-0', + humanContent: 'Dispatch request.', + }); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); +}); diff --git a/cockpit/deep-agents/subagents/react/src/authority.ts b/cockpit/deep-agents/subagents/react/src/authority.ts new file mode 100644 index 000000000..9cac11256 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/authority.ts @@ -0,0 +1,745 @@ +import type { Message, ToolCall } from '@threadplane/core'; + +/** All limits count UTF16 code units, including property keys. */ +export const LIMITS = Object.freeze({ + depth: 32, + nodes: 10000, + copyText: 4 * 1024 * 1024, + displayText: 1024 * 1024, + fieldText: 65536, + tasks: 100, + namespaces: 100, +}); +export const record = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); +export const text = (v: unknown): v is string => + typeof v === 'string' && v.length > 0; + +/** Bounded capture before recursion or serialization. Never evaluates an accessor. + * Enumerable keys are counted before the reflective own-key scan; descriptors + * are inspected individually rather than allocating an unbounded descriptor map. */ +export function copyData(value: unknown, allowUndefined = true): unknown { + let nodes = 0, + units = 0; + const parents = new Set(); + const charge = (s: string) => { + units += s.length; + if (units > LIMITS.copyText) throw new Error('Data text limit'); + }; + function visit(v: unknown, depth: number): unknown { + if (++nodes > LIMITS.nodes || depth > LIMITS.depth) + throw new Error('Data structure limit'); + if (v === null || typeof v === 'boolean') return v; + if (typeof v === 'string') { + charge(v); + return v; + } + if (v === undefined && allowUndefined) return undefined; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v !== 'object' || v === null || parents.has(v)) + throw new Error('Nonplain data'); + const array = Array.isArray(v), + proto = Object.getPrototypeOf(v); + if ( + array + ? proto !== Array.prototype + : proto !== Object.prototype && proto !== null + ) + throw new Error('Nonplain prototype'); + const length = array + ? Object.getOwnPropertyDescriptor(v, 'length')?.value + : undefined; + if ( + array && + (!Number.isInteger(length) || length > LIMITS.nodes - nodes || length < 0) + ) + throw new Error('Array limit'); + let enumerable = 0; + for (const k in v) + if (Object.hasOwn(v, k) && ++enumerable > LIMITS.nodes - nodes) + throw new Error('Key limit'); + const keys = Reflect.ownKeys(v); + if (keys.length > LIMITS.nodes - nodes + (array ? 1 : 0)) + throw new Error('Key limit'); + if (array && keys.length !== length + 1) + throw new Error('Sparse or decorated array'); + const out: Record | unknown[] = array + ? [] + : Object.create(null); + parents.add(v); + for (const k of keys) { + if (typeof k !== 'string') throw new Error('Symbol data'); + if (array && k === 'length') continue; + if (array && (!/^(0|[1-9][0-9]*)$/.test(k) || Number(k) >= length)) + throw new Error('Decorated array'); + charge(k); + const d = Object.getOwnPropertyDescriptor(v, k); + if (!d || !('value' in d) || !d.enumerable) + throw new Error('Accessor or hidden data'); + Object.defineProperty(out, k, { + value: visit(d.value, depth + 1), + enumerable: true, + }); + } + parents.delete(v); + return Object.freeze(out); + } + return visit(value, 0); +} + +/** Stable semantic equality for already-owned bounded plain data. */ +export function sameData(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((v, i) => sameData(v, b[i])); + if (!record(a) || !record(b)) return false; + const ak = Object.keys(a), + bk = Object.keys(b); + return ( + ak.length === bk.length && + ak.every((k) => Object.hasOwn(b, k) && sameData(a[k], b[k])) + ); +} + +/** One ledger per local conversation. Retained scopes replace their own charge; + * followups reuse this ledger, so repeated snapshots are never counted twice. */ +export class ConversationBudget { + private entries = new Map< + string, + { tasks: number; namespaces: number; units: number } + >(); + replace( + key: string, + tasks: number, + namespaces: number, + units: number + ): boolean { + if ( + !text(key) || + key.length > LIMITS.fieldText || + [tasks, namespaces, units].some((n) => !Number.isInteger(n) || n < 0) + ) + return false; + let t = tasks, + n = namespaces, + u = units; + for (const [k, v] of this.entries) + if (k !== key) { + t += v.tasks; + n += v.namespaces; + u += v.units; + } + if ( + t > LIMITS.tasks || + n > LIMITS.namespaces || + u > LIMITS.displayText || + (!this.entries.has(key) && + this.entries.size >= LIMITS.tasks + LIMITS.namespaces) + ) + return false; + this.entries.set(key, { tasks, namespaces, units }); + return true; + } +} +export function displayUnits(strings: readonly string[]): number { + let total = 0; + for (const s of strings) { + if (typeof s !== 'string') throw new Error('Unavailable display text'); + if (s.length > LIMITS.fieldText) throw new Error('Display field limit'); + total += s.length; + if (total > LIMITS.displayText) throw new Error('Display aggregate limit'); + } + return total; +} +/** Already-owned optional native metadata stays literal and contract-shaped. */ +export function validMessageMetadata(m: Record): boolean { + if ( + ['name', 'reasoning', 'toolCallId'].some( + (k) => m[k] !== undefined && typeof m[k] !== 'string' + ) + ) + return false; + if ( + m.toolCallIds !== undefined && + (!Array.isArray(m.toolCallIds) || + m.toolCallIds.some((v) => !text(v)) || + new Set(m.toolCallIds).size !== m.toolCallIds.length) + ) + return false; + if ( + m.citations !== undefined && + (!Array.isArray(m.citations) || + m.citations.some( + (c) => + !record(c) || + !text(c.id) || + typeof c.index !== 'number' || + !Number.isFinite(c.index) || + ['title', 'url', 'snippet', 'sourceType', 'iconUrl'].some( + (k) => c[k] !== undefined && typeof c[k] !== 'string' + ) || + (c.publishedAt !== undefined && + typeof c.publishedAt !== 'string' && + typeof c.publishedAt !== 'number') + )) + ) + return false; + return true; +} +/** Preflight encoded JSON size before allocating its representation. */ +export function literal( + value: unknown, + maximum: number = LIMITS.fieldText +): string { + const owned = copyData(value); + if (!Number.isInteger(maximum) || maximum < 0 || maximum > LIMITS.displayText) + throw new Error('Display limit'); + if (typeof owned === 'string') { + if (owned.length > maximum) throw new Error('Display field limit'); + return owned; + } + let units = 0; + const charge = (n: number) => { + units += n; + if (units > maximum) throw new Error('Display field limit'); + }; + const stringSize = (s: string) => { + charge(s.length + 2); + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 34 || c === 92) charge(1); + else if (c < 32) charge([8, 9, 10, 12, 13].includes(c) ? 1 : 5); + else if (c >= 0xd800 && c <= 0xdbff) { + const next = s.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) i++; + else charge(5); + } else if (c >= 0xdc00 && c <= 0xdfff) charge(5); + } + }; + function size(v: unknown, depth: number): void { + if (typeof v === 'string') { + stringSize(v); + return; + } + if (Array.isArray(v)) { + charge(2); + if (v.length) { + charge( + v.length + 1 + 2 * (depth + 1) * v.length + 2 * depth + v.length - 1 + ); + for (const entry of v) + size(entry === undefined ? null : entry, depth + 1); + } + } else if (record(v)) { + const keys = Object.keys(v).filter((k) => v[k] !== undefined); + charge(2); + if (keys.length) { + charge( + keys.length + + 1 + + 2 * (depth + 1) * keys.length + + 2 * depth + + keys.length - + 1 + ); + for (const k of keys) { + stringSize(k); + charge(2); + size(v[k], depth + 1); + } + } + } else charge(v === undefined ? 'Unavailable'.length : String(v).length); + } + size(owned, 0); + return JSON.stringify(owned, null, 2) ?? 'Unavailable'; +} + +export interface Baseline { + readonly messages: readonly unknown[]; +} +export interface TurnIdentity { + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly humanId: string; + readonly humanContent: string; +} +export interface Turn extends TurnIdentity { + readonly baseline: Baseline; +} +export interface Checkpoint { + readonly thread_id: string; + readonly checkpoint_ns: ''; + readonly checkpoint_id: string; + readonly checkpoint_map?: Readonly>; +} +export interface NativeSnapshot { + readonly status: string; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly values?: unknown; + readonly interrupts: readonly unknown[]; + readonly subgraphs: readonly unknown[]; + readonly error?: unknown; +} +export interface AuthorityInput { + readonly turn: Turn | undefined; + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly outcome: string; + /** Current root from the owned raw client, never child values or native synthesis. */ + readonly observedState: unknown; + /** Fresh raw history from that same client and thread, newest first. */ + readonly history: readonly unknown[]; + /** Exact getState(thread, history[0].checkpoint), not a second current lookup. */ + readonly loadedCheckpoint: unknown; + readonly snapshot: NativeSnapshot; + readonly loadedSnapshot: NativeSnapshot; +} +export type Authority = + | { readonly kind: 'unconfirmed' } + | { + readonly kind: 'terminal'; + readonly checkpoint: Checkpoint; + readonly messages: readonly unknown[]; + readonly values: Readonly>; + readonly rawToolStatuses: Readonly>; + readonly turn: Turn; + }; +export function captureBaseline( + input: unknown = { messages: [] } +): Baseline | undefined { + try { + const v = copyData(input, false); + if (!record(v) || !Array.isArray(v.messages)) return undefined; + return Object.freeze({ messages: v.messages }); + } catch { + return undefined; + } +} +export function captureTurn( + baseline: Baseline | undefined, + input: TurnIdentity +): Turn | undefined { + try { + const b = copyData(baseline, false), + identity = copyData(input, false); + if ( + !record(b) || + !Array.isArray(b.messages) || + !record(identity) || + ['owner', 'generation', 'threadId', 'humanId'].some( + (k) => + !text(identity[k]) || + (identity[k] as string).length > LIMITS.fieldText + ) || + typeof identity.humanContent !== 'string' || + identity.humanContent.length > LIMITS.fieldText || + b.messages.some((m) => record(m) && m.id === identity.humanId) + ) + return undefined; + return Object.freeze({ ...identity, baseline: b }) as unknown as Turn; + } catch { + return undefined; + } +} +function checkpointSource( + value: unknown, + threadId: string +): Checkpoint | undefined { + if ( + !record(value) || + value.thread_id !== threadId || + value.checkpoint_ns !== '' || + !text(value.checkpoint_id) || + value.checkpoint_id.length > LIMITS.fieldText || + Object.keys(value).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) + ) + return undefined; + if ( + value.checkpoint_map !== undefined && + (!record(value.checkpoint_map) || + Object.keys(value.checkpoint_map).length !== 1 || + value.checkpoint_map[''] !== value.checkpoint_id) + ) + return undefined; + return value as unknown as Checkpoint; +} +const empty = (v: unknown) => + v === undefined || (Array.isArray(v) && v.length === 0); +const clean = (v: Record) => + (v.error === undefined || v.error === null) && + empty(v.interrupts) && + empty(v.subgraphs) && + empty(v.__interrupt__); + +/** Canonical root semantics independent of native delivery bookkeeping. */ +function normalizedRaw(raw: readonly unknown[]): + | { + messages: Record[]; + tools: Record[]; + statuses: Record; + } + | undefined { + const messages: Record[] = [], + tools = new Map>(), + ids = new Set(), + pending = new Set(), + statuses: Record = Object.create(null); + for (const w of raw) { + if ( + !record(w) || + !text(w.id) || + ids.has(w.id) || + typeof w.content !== 'string' || + !clean(w) || + !empty(w.invalid_tool_calls) || + !['human', 'ai', 'tool'].includes(w.type as string) + ) + return undefined; + ids.add(w.id); + const extra = w.additional_kwargs ?? {}; + if ( + !record(extra) || + !empty(extra.tool_calls) || + extra.function_call !== undefined + ) + return undefined; + const reasoning = + typeof w.reasoning === 'string' + ? w.reasoning + : typeof extra.reasoning_content === 'string' + ? extra.reasoning_content + : undefined; + // Supported citation shape is normalized explicitly; no private runtime imports. + const source = extra.citations ?? extra.sources; + let citations: unknown; + if (Array.isArray(source)) + citations = source.map((s, i) => { + if (typeof s === 'string') + return { id: `c${i + 1}`, index: i + 1, url: s }; + const r = record(s) ? s : {}, + out: Record = { + id: + [r.id, r.refId].find((x) => typeof x === 'string') ?? `c${i + 1}`, + index: + typeof r.index === 'number' && Number.isFinite(r.index) + ? r.index + : i + 1, + }; + for (const [k, aliases] of Object.entries({ + title: ['title', 'name'], + url: ['url', 'href', 'source'], + snippet: ['snippet', 'content', 'excerpt'], + sourceType: ['sourceType'], + iconUrl: ['iconUrl'], + })) { + const v = aliases.map((a) => r[a]).find((x) => typeof x === 'string'); + if (v !== undefined) out[k] = v; + } + if ( + typeof r.publishedAt === 'string' || + typeof r.publishedAt === 'number' + ) + out.publishedAt = r.publishedAt; + if (record(r.extra)) out.extra = r.extra; + return out; + }); + const m: Record = { + id: w.id, + role: + w.type === 'human' ? 'user' : w.type === 'ai' ? 'assistant' : 'tool', + content: w.content, + ...(typeof w.name === 'string' ? { name: w.name } : {}), + }; + if (reasoning !== undefined) m.reasoning = reasoning; + if (citations !== undefined) m.citations = citations; + if (w.type === 'ai') { + if (pending.size || w.tool_call_id !== undefined) return undefined; + const calls = w.tool_calls ?? []; + if (!Array.isArray(calls)) return undefined; + m.toolCallIds = []; + for (const c of calls) { + if ( + !record(c) || + !text(c.id) || + !text(c.name) || + tools.has(c.id) || + !Object.hasOwn(c, 'args') || + (c.type !== undefined && c.type !== 'tool_call') || + Object.keys(c).some( + (k) => !['id', 'name', 'args', 'type'].includes(k) + ) + ) + return undefined; + (m.toolCallIds as string[]).push(c.id); + tools.set(c.id, { + id: c.id, + name: c.name, + args: c.args, + status: 'pending', + }); + pending.add(c.id); + } + } else if (w.type === 'tool') { + const t = + typeof w.tool_call_id === 'string' + ? tools.get(w.tool_call_id) + : undefined; + if ( + !t || + !pending.delete(w.tool_call_id as string) || + !['success', 'error'].includes(w.status as string) || + (w.name !== t.name && + !( + w.status === 'error' && + (w.name === null || w.name === undefined) + )) || + !empty(w.tool_calls) + ) + return undefined; + m.toolCallId = w.tool_call_id; + t.status = 'complete'; + t.result = w.content; + statuses[w.tool_call_id as string] = w.status as 'success' | 'error'; + } else if ( + pending.size || + !empty(w.tool_calls) || + w.tool_call_id !== undefined + ) + return undefined; + messages.push(m); + } + if ( + pending.size || + messages[0]?.role !== 'user' || + messages.at(-1)?.role !== 'assistant' || + !text(messages.at(-1)?.content) || + (messages.at(-1)?.toolCallIds as unknown[])?.length + ) + return undefined; + return { messages, tools: [...tools.values()], statuses }; +} +function nativeSemantic( + input: NativeSnapshot +): + | { messages: Record[]; tools: readonly ToolCall[] } + | undefined { + if ( + input.status !== 'idle' || + (input.error !== undefined && input.error !== null) || + !Array.isArray(input.interrupts) || + input.interrupts.length || + !Array.isArray(input.subgraphs) || + !Array.isArray(input.messages) || + !Array.isArray(input.toolCalls) + ) + return undefined; + const messages: Record[] = [], + ids = new Set(); + for (const m of input.messages) { + if ( + !record(m) || + !text(m.id) || + ids.has(m.id) || + typeof m.content !== 'string' || + !validMessageMetadata(m) || + !record(m.delivery) || + !text(m.delivery.generation) || + m.delivery.phase !== 'complete' || + m.delivery.outcome !== 'success' || + Object.keys(m).some( + (k) => + ![ + 'id', + 'role', + 'content', + 'delivery', + 'reasoning', + 'citations', + 'name', + 'toolCallId', + 'toolCallIds', + ].includes(k) + ) + ) + return undefined; + ids.add(m.id); + const out: Record = { + id: m.id, + role: m.role, + content: m.content, + }; + for (const k of ['name', 'reasoning', 'citations', 'toolCallId']) + if (m[k as keyof Message] !== undefined) out[k] = m[k as keyof Message]; + if (m.role === 'assistant') { + if (m.toolCallIds !== undefined && !Array.isArray(m.toolCallIds)) + return undefined; + out.toolCallIds = m.toolCallIds ?? []; + } else if (!empty(m.toolCallIds)) return undefined; + messages.push(out); + } + return { messages, tools: input.toolCalls }; +} +/** Only contiguous, complete result batches declared by a current assistant may + * permute. Prior canonical history and all human/assistant boundaries stay strict. */ +function matchesRoot( + raw: ReturnType, + native: ReturnType, + prefix: number +): boolean { + if ( + !raw || + !native || + raw.messages.length !== native.messages.length || + raw.tools.length !== native.tools.length + ) + return false; + const catalog = new Map(); + for (const t of native.tools) { + if ( + !record(t) || + !text(t.id) || + catalog.has(t.id) || + t.status !== 'complete' + ) + return false; + catalog.set(t.id, t); + } + for (const t of raw.tools) + if (!sameData(t, catalog.get(t.id as string))) return false; + for (let i = 0; i < raw.messages.length; i++) { + const r = raw.messages[i], + n = native.messages[i]; + if (!sameData(r, n)) return false; + const callIds = r.role === 'assistant' ? (r.toolCallIds as string[]) : []; + if (i >= prefix && callIds.length) { + const batch = raw.messages.slice(i + 1, i + 1 + callIds.length), + observed = native.messages.slice(i + 1, i + 1 + callIds.length); + if ( + batch.length !== callIds.length || + observed.length !== callIds.length || + batch.some((m) => m.role !== 'tool') || + observed.some((m) => m.role !== 'tool') + ) + return false; + const byId = new Map(observed.map((m) => [m.toolCallId, m])); + if ( + byId.size !== callIds.length || + batch.some( + (m) => + !callIds.includes(m.toolCallId as string) || + !sameData(m, byId.get(m.toolCallId)) + ) + ) + return false; + i += callIds.length; + } + } + return true; +} + +/** A failure yields no replacement: callers retain previously confirmed authority. + * The caller captures its owned client and turn once before asynchronous reads. */ +export function captureAuthority(input: unknown): Authority { + const unavailable: Authority = Object.freeze({ kind: 'unconfirmed' }); + try { + const captured = copyData(input) as AuthorityInput; + const turn = captured.turn; + if ( + !turn || + ['owner', 'generation', 'threadId', 'humanId'].some( + (k) => + !text((turn as unknown as Record)[k]) || + ((turn as unknown as Record)[k] as string).length > + LIMITS.fieldText + ) || + typeof turn.humanContent !== 'string' || + turn.humanContent.length > LIMITS.fieldText || + captured.outcome !== 'success' || + turn.owner !== captured.owner || + turn.generation !== captured.generation || + turn.threadId !== captured.threadId || + !Array.isArray(turn.baseline?.messages) + ) + return unavailable; + const observed = copyData(captured.observedState, false), + loaded = copyData(captured.loadedCheckpoint, false), + history = copyData(captured.history, false); + if ( + !record(observed) || + !record(loaded) || + !Array.isArray(history) || + !history.length || + !sameData(observed, loaded) || + !sameData(history[0], loaded) || + !clean(loaded) || + !Array.isArray(loaded.next) || + loaded.next.length || + !Array.isArray(loaded.tasks) || + loaded.tasks.length || + !record(loaded.values) || + !clean(loaded.values) || + !Array.isArray(loaded.values.messages) + ) + return unavailable; + const cp = checkpointSource(loaded.checkpoint, captured.threadId); + if (!cp) return unavailable; + const checkpointIds = new Set(); + for (const h of history) { + if (!record(h)) return unavailable; + const c = checkpointSource(h.checkpoint, captured.threadId); + if (!c || checkpointIds.has(c.checkpoint_id)) return unavailable; + checkpointIds.add(c.checkpoint_id); + } + const messages = loaded.values.messages, + prefix = turn.baseline.messages; + if (!sameData(prefix, messages.slice(0, prefix.length))) return unavailable; + const human = messages[prefix.length]; + if ( + !record(human) || + human.type !== 'human' || + human.id !== turn.humanId || + human.content !== turn.humanContent || + messages.filter((m) => record(m) && m.id === turn.humanId).length !== 1 || + messages + .slice(prefix.length + 1) + .some((m) => record(m) && m.type === 'human') + ) + return unavailable; + const raw = normalizedRaw(messages), + before = nativeSemantic(captured.snapshot), + after = nativeSemantic(captured.loadedSnapshot); + const otherValues: Record = {}; + for (const k of Object.keys(loaded.values)) + if (k !== 'messages' && k !== '__interrupt__') + otherValues[k] = loaded.values[k]; + if ( + !raw || + !matchesRoot(raw, before, prefix.length) || + !matchesRoot(raw, after, prefix.length) || + !sameData(captured.snapshot.values, otherValues) || + !sameData(captured.loadedSnapshot.values, otherValues) + ) + return unavailable; + return Object.freeze({ + kind: 'terminal', + checkpoint: cp, + messages, + values: loaded.values, + rawToolStatuses: Object.freeze(raw.statuses), + turn, + }); + } catch { + return unavailable; + } +} diff --git a/cockpit/deep-agents/subagents/react/src/children.spec.ts b/cockpit/deep-agents/subagents/react/src/children.spec.ts new file mode 100644 index 000000000..8dc0df134 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/children.spec.ts @@ -0,0 +1,916 @@ +import { describe, it, expect } from 'vitest'; +import { ConversationBudget, LIMITS } from './authority'; +import { ChildObservations } from './children'; + +// Actual incremental native child snapshots: finalized call IDs grow during streaming. +const actualSequence = [ + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: 'Observed lookup: ', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: 'Observed lookup: KASE field elevat', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: 'Observed lookup: KASE field elevation is 7820 ft. |', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest run', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'streaming', + }, + name: 'field-researcher', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, + { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], + }, +]; +it('accepts every source-grounded incremental child update through parent closure', () => { + const t = tracker(); + for (const c of actualSequence) expect(t.observe([c], context)).toBe(true); + t.finish('success'); + expect(t.rows()[0].status).toBe('Observed response'); +}); +describe('child causal and exact limits', () => { + it('rejects malformed optional metadata rather than coercing it to display text', () => { + const t = tracker(); + const c = clone(grounded); + Object.assign(c.messages[0], { name: 123 }); + expect(t.observe([c], context)).toBe(false); + }); + it('marks active empty child disappearance incomplete while retaining observations', () => { + const t = tracker(); + t.observe([grounded], context); + expect(t.observe([], context)).toBe(false); + expect(t.rows()[0].status).toBe('Incomplete'); + }); + it('accepts overlapping IDs between different namespaces', () => { + const t = tracker(); + const c = clone(grounded); + c.namespace = ['other|namespace']; + expect(t.observe([grounded, c], context)).toBe(true); + expect(t.rows()).toHaveLength(2); + }); + it('marks missing namespace in a nonempty update incomplete', () => { + const t = tracker(); + const c = clone(grounded); + c.namespace = ['other']; + expect(t.observe([grounded, c], context)).toBe(true); + expect(t.observe([grounded], context)).toBe(false); + expect(t.rows()[0].status).toBe('Incomplete'); + }); + it('never labels an unfinished child transcript observed response solely from parent success', () => { + const t = tracker(); + const c = clone(grounded); + (c.messages[4].delivery as { phase: string; outcome?: string }).phase = + 'streaming'; + delete (c.messages[4].delivery as { outcome?: string }).outcome; + expect(t.observe([c], context)).toBe(true); + t.finish('success'); + expect(t.rows()[0].status).toBe('Incomplete'); + }); + it('bounds joined transcript exactly including role labels', () => { + const t = tracker(); + const c = clone(grounded); + c.messages = c.messages.slice(0, 1); + c.messages[0].content = 'x'.repeat(LIMITS.fieldText - 'user: '.length); + expect(t.observe([c], context)).toBe(true); + const next = tracker(); + c.messages[0].content += 'x'; + expect(next.observe([c], context)).toBe(false); + }); + it('bounds child data before evaluating getters and retains a visible incomplete state', () => { + const t = tracker(); + let reads = 0; + const c = clone(grounded); + Object.defineProperty(c, 'messages', { + get() { + reads++; + return []; + }, + enumerable: true, + }); + expect(t.observe([c], context)).toBe(false); + expect(reads).toBe(0); + expect(t.incomplete).toBe(true); + }); + it('preserves prior rows when New begins an independent turn', () => { + const t = tracker(); + t.observe([grounded], context); + expect(t.begin('next', 'next')).toBe(true); + expect(t.rows()[0].status).toBe('Incomplete'); + expect(t.observe([grounded], context)).toBe(false); + }); +}); +// Actual native child namespace with user/assistant/tool roles and files values. +const grounded = { + namespace: ['tools:206fa38f-48d9-a519-b32a-e7a283a02320'], + messages: [ + { + id: '0db062fb-a2e1-463c-9c7c-d7e2976600a9', + role: 'user', + content: 'Research KASE dispatch.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: 'lc_run--01a12bac-8e75-7cc0-8ace-09f351b5bdbf', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [ + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + ], + }, + { + id: 'e6f430b3-5f62-45af-9c74-2affb942feaa', + role: 'tool', + content: 'KASE field elevation is 7820 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_field_elevation', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_field_elevation', + }, + { + id: '10b395bf-3545-443d-9686-2daeebcf989f', + role: 'tool', + content: 'KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'lookup_runway_length', + toolCallId: '0db062fb-a2e1-463c-9c7c-d7e2976600a9-lookup_runway_length', + }, + { + id: 'lc_run--01a12bac-8ea6-7da2-bac4-ab408f26f214', + role: 'assistant', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'field-researcher', + toolCallIds: [], + }, + ], + values: { files: {} }, + interrupts: [], +}; +const clone = (x: T): T => structuredClone(x); +const context = { + owner: 'view', + turn: 'turn', + generation: grounded.messages[0].delivery.generation, +}; +function tracker(budget = new ConversationBudget()) { + const t = new ChildObservations('view', budget); + expect(t.begin(context.turn, context.generation)).toBe(true); + return t; +} +describe('retained child observations', () => { + it('captures actual roles and literal transcript without guessing task association', () => { + const t = tracker(); + expect(t.observe([grounded], context)).toBe(true); + const row = t.rows()[0]; + expect(row.namespace).toEqual(grounded.namespace); + expect(row.text).toContain('Research KASE dispatch.'); + expect(row.text).toContain('KASE field elevation'); + expect(row.messages).toHaveLength(5); + expect(row).not.toHaveProperty('taskId'); + t.finish('success'); + expect(t.rows()[0].status).toBe('Observed response'); + }); + it('retains observations when session.load clears native children', () => { + const t = tracker(); + t.observe([grounded], context); + t.finish('success'); + expect(t.observe([], context)).toBe(false); + expect(t.rows()).toHaveLength(1); + }); + it('scopes same namespace/message IDs independently across turns and child/root namespaces', () => { + const t = tracker(); + t.observe([grounded], context); + t.finish('success'); + t.begin('second', 'second-generation'); + const c = clone(grounded); + c.messages.forEach((m) => (m.delivery.generation = 'second-generation')); + expect( + t.observe([c], { + owner: 'view', + turn: 'second', + generation: 'second-generation', + }) + ).toBe(true); + expect(t.rows()).toHaveLength(2); + expect(t.rows()[0].key).not.toBe(t.rows()[1].key); + }); + it('ignores stale owner/generation updates without contaminating current observations', () => { + const t = tracker(); + expect(t.observe([grounded], { ...context, generation: 'stale' })).toBe( + false + ); + expect(t.incomplete).toBe(false); + expect(t.observe([grounded], context)).toBe(true); + }); + it.each(['aborted', 'interrupted', 'error', 'new'])( + 'marks interrupted %s delivery visibly', + (outcome) => { + const t = tracker(); + t.observe([grounded], context); + t.finish(outcome); + expect(t.rows()[0].status).toBe( + outcome === 'aborted' ? 'Stopped' : 'Incomplete' + ); + } + ); + it('rejects shortened and completed-mutated transcripts retaining an incomplete row', () => { + for (const mutation of ['shortened', 'changed']) { + const t = tracker(); + t.observe([grounded], context); + const c = clone(grounded); + if (mutation === 'shortened') c.messages.pop(); + else c.messages[4].content += 'changed'; + expect(t.observe([c], context)).toBe(false); + expect(t.rows()[0].status).toBe('Incomplete'); + } + }); + it('does not upgrade child errors by parent success', () => { + const t = tracker(); + const c = { ...clone(grounded), error: { message: 'child failed' } }; + t.observe([c], context); + t.finish('success'); + expect(t.rows()[0].status).toBe('Incomplete'); + }); + it('supports streaming prefix growth and rejects rewrites', () => { + const t = tracker(); + const c = clone(grounded); + c.messages = c.messages.slice(0, 1); + (c.messages[0].delivery as { phase: string; outcome?: string }).phase = + 'streaming'; + delete (c.messages[0].delivery as { outcome?: string }).outcome; + c.messages[0].content = 'Research'; + expect(t.observe([c], context)).toBe(true); + c.messages[0].content = 'Research KASE'; + expect(t.observe([c], context)).toBe(true); + c.messages[0].content = 'Other'; + expect(t.observe([c], context)).toBe(false); + }); + it('bounds namespace component/depth, duplicate paths, transcript and cumulative namespaces', () => { + for (const namespace of [new Array(17).fill('x'), ['x'.repeat(1025)]]) { + const t = tracker(); + expect(t.observe([{ ...grounded, namespace }], context)).toBe(false); + expect(t.incomplete).toBe(true); + } + const t = tracker(); + expect(t.observe([grounded, grounded], context)).toBe(false); + const b = new ConversationBudget(); + b.replace('old', 0, 100, 0); + expect(tracker(b).observe([grounded], context)).toBe(false); + }); + it('shares the one MiB conversation display budget with parents', () => { + const b = new ConversationBudget(); + b.replace('parents', 1, 0, LIMITS.displayText); + expect(tracker(b).observe([grounded], context)).toBe(false); + }); +}); diff --git a/cockpit/deep-agents/subagents/react/src/children.ts b/cockpit/deep-agents/subagents/react/src/children.ts new file mode 100644 index 000000000..f8129131a --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/children.ts @@ -0,0 +1,293 @@ +import type { Message } from '@threadplane/core'; +import { + ConversationBudget, + copyData, + displayUnits, + LIMITS, + literal, + record, + sameData, + text, + validMessageMetadata, +} from './authority'; + +export type ChildStatus = + | 'Receiving' + | 'Observed response' + | 'Stopped' + | 'Incomplete'; +export interface ChildRow { + readonly key: string; + readonly namespace: readonly string[]; + readonly text: string; + readonly messages: readonly Message[]; + readonly status: ChildStatus; +} +export interface ChildContext { + readonly owner: string; + readonly turn: string; + readonly generation: string; +} +interface Entry { + readonly namespace: readonly string[]; + readonly messages: readonly Message[]; + readonly text: string; + readonly interrupted: boolean; +} +interface TurnEntry { + readonly turn: string; + readonly generation: string; + entries: Map; + status: ChildStatus; + incomplete: boolean; + ended: boolean; +} + +/** App-owned observations survive native load. Namespace tuples are scoped by + * local owner/turn/generation; no parent call identity is inferred. */ +export class ChildObservations { + private turns: TurnEntry[] = []; + private current?: TurnEntry; + constructor( + private readonly owner: string, + private readonly budget = new ConversationBudget() + ) {} + get incomplete(): boolean { + return this.turns.some( + (t) => t.incomplete || [...t.entries.values()].some((e) => e.interrupted) + ); + } + begin(turn: string, generation: string): boolean { + if ( + !text(this.owner) || + !text(turn) || + !text(generation) || + [this.owner, turn, generation].some((s) => s.length > LIMITS.fieldText) || + this.turns.length >= LIMITS.namespaces || + this.turns.some((t) => t.turn === turn || t.generation === generation) + ) + return false; + if (this.current && !this.current.ended) this.finish('new'); + const next: TurnEntry = { + turn, + generation, + entries: new Map(), + status: 'Receiving', + incomplete: false, + ended: false, + }; + this.turns.push(next); + this.current = next; + return true; + } + observe(input: unknown, context: ChildContext): boolean { + const current = this.current; + if ( + !current || + current.ended || + current.incomplete || + context.owner !== this.owner || + context.turn !== current.turn || + context.generation !== current.generation + ) + return false; + try { + const children = copyData(input); + if (!Array.isArray(children) || children.length > LIMITS.namespaces) + throw new Error('Child observation limit'); + // Root loading happens only after finish; while active, disappearance is + // uncertainty. An initial empty native snapshot is still a valid no-op. + if (!children.length) { + if (current.entries.size) throw new Error('Missing active children'); + return true; + } + const next = new Map(current.entries), + batch = new Set(); + for (const child of children) { + if ( + !record(child) || + !Array.isArray(child.namespace) || + !child.namespace.length || + child.namespace.length > 16 || + child.namespace.some((s) => !text(s) || s.length > 1024) || + !Array.isArray(child.messages) || + !Array.isArray(child.interrupts) || + (child.values !== undefined && !record(child.values)) + ) + throw new Error('Unavailable child data'); + const namespace = child.namespace as string[], + key = JSON.stringify(namespace); + if (batch.has(key)) throw new Error('Duplicate namespace'); + batch.add(key); + const prior = current.entries.get(key), + messages = child.messages as Message[], + ids = new Set(); + for (const [index, m] of messages.entries()) { + if ( + !record(m) || + !text(m.id) || + ids.has(m.id) || + !['user', 'assistant', 'tool', 'system'].includes(m.role) || + typeof m.content !== 'string' || + !validMessageMetadata(m) || + !record(m.delivery) || + m.delivery.generation !== current.generation || + !['streaming', 'complete'].includes(m.delivery.phase) || + (m.delivery.phase === 'complete' && + ![ + 'success', + 'error', + 'aborted', + 'interrupted', + 'paused', + ].includes(m.delivery.outcome)) || + (m.delivery.phase === 'streaming' && + Object.hasOwn(m.delivery, 'outcome') && + (m.delivery as Record)['outcome'] !== undefined) + ) + throw new Error('Unavailable child message'); + ids.add(m.id); + displayUnits([ + m.id, + m.content, + m.name ?? '', + m.reasoning ?? '', + m.toolCallId ?? '', + ...(m.toolCallIds ?? []), + ]); + const old = prior?.messages[index]; + if (old) { + const grows = ( + a: readonly unknown[] | undefined, + b: readonly unknown[] | undefined + ) => + a === undefined || + (b !== undefined && + b.length >= a.length && + a.every((v, i) => sameData(v, b[i]))); + const stable = (a: unknown, b: unknown) => + a === undefined || sameData(a, b); + if ( + old.delivery.phase === 'complete' + ? !sameData(old, m) + : old.id !== m.id || + old.role !== m.role || + !stable(old.name, m.name) || + !stable(old.toolCallId, m.toolCallId) || + !grows(old.toolCallIds, m.toolCallIds) || + !grows(old.citations, m.citations) || + (old.reasoning !== undefined && + (m.reasoning === undefined || + !m.reasoning.startsWith(old.reasoning))) || + !m.content.startsWith(old.content) + ) + throw new Error('Changed child observation'); + } + } + if (prior && messages.length < prior.messages.length) + throw new Error('Shortened child observation'); + const transcript = messages + .map( + (m) => + `${m.role}${m.name ? ` (${m.name})` : ''}: ${m.content}${ + m.reasoning ? `\nReasoning: ${m.reasoning}` : '' + }${m.citations ? `\nCitations: ${literal(m.citations)}` : ''}` + ) + .join('\n\n'); + if (transcript.length > LIMITS.fieldText) + throw new Error('Child transcript limit'); + next.set( + key, + Object.freeze({ + namespace, + messages, + text: transcript, + interrupted: + !!child.error || + child.interrupts.length > 0 || + messages.some( + (m) => + m.delivery.phase === 'complete' && + m.delivery.outcome !== 'success' + ), + }) + ); + } + for (const key of current.entries.keys()) + if (!batch.has(key)) throw new Error('Missing active namespace'); + let namespaces = 0, + units = 0; + for (const turn of this.turns) + for (const entry of (turn === current ? next : turn.entries).values()) { + namespaces++; + const rowKey = JSON.stringify([ + this.owner, + turn.turn, + turn.generation, + entry.namespace, + ]); + units += displayUnits([ + rowKey, + ...entry.namespace, + entry.text, + ...entry.messages.flatMap((m) => [ + m.id, + m.toolCallId ?? '', + ...(m.toolCallIds ?? []), + ]), + ]); + // All native message metadata kept in row is plain and capture-bounded; + // display text is the transcript plus explicit namespace/key/status. + units += Math.max('Observed response'.length, 'Incomplete'.length); + } + if (!this.budget.replace(`children:${this.owner}`, 0, namespaces, units)) + throw new Error('Conversation observation limit'); + current.entries = next; + return true; + } catch { + current.incomplete = true; + current.status = 'Incomplete'; + return false; + } + } + finish(outcome: string): void { + const current = this.current; + if (!current || current.ended) return; + current.ended = true; + const unfinished = [...current.entries.values()].some( + (e) => + !e.messages.length || + e.messages.some((m) => m.delivery.phase !== 'complete') + ); + current.status = + current.incomplete || (outcome === 'success' && unfinished) + ? 'Incomplete' + : outcome === 'success' + ? 'Observed response' + : outcome === 'aborted' + ? 'Stopped' + : 'Incomplete'; + if (current.status === 'Incomplete') current.incomplete = true; + } + rows(): readonly ChildRow[] { + const rows: ChildRow[] = []; + for (const turn of this.turns) + for (const entry of turn.entries.values()) + rows.push( + Object.freeze({ + key: JSON.stringify([ + this.owner, + turn.turn, + turn.generation, + entry.namespace, + ]), + namespace: entry.namespace, + text: entry.text, + messages: entry.messages, + status: + turn.incomplete || entry.interrupted ? 'Incomplete' : turn.status, + }) + ); + return Object.freeze(rows); + } +} diff --git a/cockpit/deep-agents/subagents/react/src/connection.spec.ts b/cockpit/deep-agents/subagents/react/src/connection.spec.ts new file mode 100644 index 000000000..20f15374a --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/connection.spec.ts @@ -0,0 +1,105 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +const construct = vi.fn(); +const create = vi.fn(), + getState = vi.fn(), + getHistory = vi.fn(); +vi.mock('@langchain/langgraph-sdk', () => ({ + Client: class { + threads = { create, getState, getHistory }; + constructor(options: unknown) { + construct(options); + } + }, +})); +vi.mock('@threadplane/langgraph', () => ({ createSession: vi.fn() })); +import { createSession } from '@threadplane/langgraph'; +import { createSubagentsClient } from './connection'; +beforeEach(() => { + vi.clearAllMocks(); +}); +it('reads only threads it explicitly created and owns immutable credentials', async () => { + const headers = { 'x-api-key': 'original' }; + const c = createSubagentsClient({ apiUrl: 'https://example.test', headers }); + headers['x-api-key'] = 'changed'; + expect(construct.mock.calls[0][0]).toMatchObject({ + apiUrl: 'https://example.test', + defaultHeaders: { 'x-api-key': 'original' }, + callerOptions: { maxRetries: 0 }, + }); + const signal = new AbortController().signal; + await expect(c.readCurrent('foreign', signal)).rejects.toThrow(); + expect(getState).not.toHaveBeenCalled(); + create.mockImplementation(async ({ threadId }) => ({ thread_id: threadId })); + const id = await c.createThread(signal); + vi.mocked(createSession).mockReturnValue({ + getSnapshot: () => ({ + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }), + subscribe: vi.fn(), + submit: vi.fn(), + load: vi.fn(), + stop: vi.fn(), + dispose: vi.fn(), + } as never); + c.sessionFactory(id); + expect(createSession).toHaveBeenCalledWith({ + assistantId: 'subagents', + threadId: id, + apiUrl: 'https://example.test', + clientOptions: { + maxRetries: 0, + defaultHeaders: { 'x-api-key': 'original' }, + }, + }); + getHistory.mockResolvedValue([]); + await c.readHistory(id, signal); + expect(getHistory).toHaveBeenCalledWith(id, { limit: 1, signal }); + await expect( + c.readCheckpoint( + { thread_id: id, checkpoint_ns: 'child' as '', checkpoint_id: 'bad' }, + signal + ) + ).rejects.toThrow(); + expect(getState).not.toHaveBeenCalled(); +}); +it('captures checkpoint getters without evaluating them', async () => { + let reads = 0; + const c = createSubagentsClient({ + apiUrl: 'https://example.test', + headers: {}, + }); + const signal = new AbortController().signal; + const bad = Object.defineProperty({}, 'thread_id', { + get() { + reads++; + return 'bad'; + }, + enumerable: true, + }); + await expect(c.readCheckpoint(bad as never, signal)).rejects.toThrow(); + expect(reads).toBe(0); +}); + +it('aborted pending owned read cannot return authority', async () => { + const c = createSubagentsClient({ + apiUrl: 'https://example.test', + headers: {}, + }); + const controller = new AbortController(); + create.mockImplementation(async ({ threadId }) => ({ thread_id: threadId })); + const id = await c.createThread(controller.signal); + let finish: ((value: unknown) => void) | undefined; + getState.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const pending = c.readCurrent(id, controller.signal); + controller.abort(); + finish?.({ checkpoint: { thread_id: id } }); + await expect(pending).rejects.toThrow('Conversation unavailable'); +}); diff --git a/cockpit/deep-agents/subagents/react/src/connection.ts b/cockpit/deep-agents/subagents/react/src/connection.ts new file mode 100644 index 000000000..74d23657b --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/connection.ts @@ -0,0 +1,150 @@ +import { Client } from '@langchain/langgraph-sdk'; +import { createSession } from '@threadplane/langgraph'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Internal bridge is copied by the isolated build. +import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { + createSubagentsApplication, + type SubagentsClient, + type SubagentsSession, +} from './application'; +import { copyData, record, text, LIMITS, type Checkpoint } from './authority'; +export interface SubagentsConnection { + readonly apiUrl: string; + readonly headers: Record; +} +export function subagentsConnection( + configuration: RuntimeBridgeConfiguration +): SubagentsConnection { + if ( + configuration.status === 'error' || + (configuration.status === 'configured' && + configuration.target.kind === 'ag-ui') + ) + throw Error('Runtime configuration failed.'); + const target = + configuration.status === 'configured' && + configuration.target.kind === 'langsmith' + ? configuration.target + : null; + return { + apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href, + headers: target ? { 'x-api-key': target.apiKey } : {}, + }; +} +export function createSubagentsClient( + input: SubagentsConnection +): SubagentsClient { + const connection = Object.freeze({ + apiUrl: input.apiUrl, + headers: Object.freeze({ ...input.headers }), + }), + known = new Set(); + const client = new Client({ + apiUrl: connection.apiUrl, + apiKey: null, + defaultHeaders: connection.headers, + callerOptions: { maxRetries: 0 }, + }); + function owned(id: string, signal: AbortSignal) { + if (!known.has(id) || signal.aborted) + throw Error('Conversation unavailable.'); + } + return { + async createThread(signal) { + if (signal.aborted) throw Error('Aborted'); + const id = crypto.randomUUID(), + created = await client.threads.create({ + threadId: id, + ifExists: 'raise', + signal, + }); + if (signal.aborted || created.thread_id !== id) + throw Error('Conversation creation was not confirmed.'); + known.add(id); + return id; + }, + sessionFactory(id) { + if (!known.has(id)) throw Error('Unknown conversation.'); + const session = createSession({ + assistantId: 'subagents', + threadId: id, + apiUrl: connection.apiUrl, + clientOptions: { maxRetries: 0, defaultHeaders: connection.headers }, + }); + try { + const state = session.getSnapshot(); + if ( + ![ + 'getSnapshot', + 'subscribe', + 'submit', + 'load', + 'stop', + 'dispose', + ].every( + (k) => typeof session[k as keyof typeof session] === 'function' + ) || + ![ + state.messages, + state.toolCalls, + state.interrupts, + state.subgraphs, + ].every((x) => Array.isArray(x) && x.length === 0) + ) + throw Error('Rejected session'); + return session as SubagentsSession; + } catch { + void Promise.resolve(session.dispose()).catch(() => undefined); + throw Error('Runtime session is unavailable.'); + } + }, + async readCurrent(id, signal) { + owned(id, signal); + const value = await client.threads.getState(id, undefined, { signal }); + owned(id, signal); + return value; + }, + async readHistory(id, signal) { + owned(id, signal); + const value = await client.threads.getHistory(id, { limit: 1, signal }); + owned(id, signal); + return value; + }, + async readCheckpoint(requested, signal) { + const v = copyData(requested, false); + if ( + !record(v) || + !text(v.thread_id) || + v.checkpoint_ns !== '' || + !text(v.checkpoint_id) || + v.checkpoint_id.length > LIMITS.fieldText || + Object.keys(v).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) || + (v.checkpoint_map !== undefined && + (!record(v.checkpoint_map) || + Object.keys(v.checkpoint_map).length !== 1 || + v.checkpoint_map[''] !== v.checkpoint_id)) + ) + throw Error('Checkpoint unavailable.'); + const c = v as unknown as Checkpoint; + owned(c.thread_id, signal); + const value = await client.threads.getState( + c.thread_id, + c as Parameters[1], + { signal } + ); + owned(c.thread_id, signal); + return value; + }, + }; +} +export function createConnectedApplication(connection: SubagentsConnection) { + return createSubagentsApplication(createSubagentsClient(connection)); +} diff --git a/cockpit/deep-agents/subagents/react/src/main.tsx b/cockpit/deep-agents/subagents/react/src/main.tsx new file mode 100644 index 000000000..de0ad29f3 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/main.tsx @@ -0,0 +1,45 @@ +import { createRoot } from 'react-dom/client'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package. +import { + GENERATED_RUNTIME_PARENT_ORIGINS, + installRuntimeBridge, +} from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { SubagentsDemo } from './app'; +import { subagentsConnection } from './connection'; +import '@threadplane/react/chat/styles.css'; +import './styles.css'; + +const element = document.getElementById('root'); +if (!element) throw new Error('Application root is missing'); +const bridge = installRuntimeBridge(undefined, { + allowedParentOrigins: GENERATED_RUNTIME_PARENT_ORIGINS, +}); +const root = createRoot(element); +let active = true; +window.addEventListener( + 'pagehide', + () => { + active = false; + root.unmount(); + bridge.dispose(); + }, + { once: true } +); +void bridge + .awaitConfiguration() + .then((configuration) => { + if (!active) return; + root.render( + bridge.markReady()} + /> + ); + }) + .catch(() => { + if (!active) return; + bridge.markError('bootstrap_failed'); + root.render( +

The Deep Agents subagents example could not start.

+ ); + }); diff --git a/cockpit/deep-agents/subagents/react/src/projection.spec.ts b/cockpit/deep-agents/subagents/react/src/projection.spec.ts new file mode 100644 index 000000000..2dd57ec54 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/projection.spec.ts @@ -0,0 +1,359 @@ +import { describe, it, expect } from 'vitest'; +import { ConversationBudget, LIMITS } from './authority'; +import { projectParentObservations } from './projection'; + +describe('literal safety and cumulative display', () => { + it.each([undefined, '', 'foreign-call'])( + 'rejects an orphan tool row with call ID %j', + (toolCallId) => { + const f = clone(grounded); + const orphan = clone(f.messages[2]); + Object.assign(orphan, { + id: 'orphan-tool', + role: 'tool', + content: 'Unowned tool response', + name: 'task', + toolCallId, + delivery: { + generation: 'native', + phase: 'complete', + outcome: 'success', + }, + }); + if (toolCallId === undefined) Reflect.deleteProperty(orphan, 'toolCallId'); + f.messages.push(orphan); + expect(project(f).status).toBe('incomplete'); + } + ); + it('rejects a streaming delivery with a defined outcome', () => { + const f = clone(grounded); + f.messages[0].delivery = { + generation: 'native', + phase: 'streaming', + outcome: 'error', + }; + expect(project(f).status).toBe('incomplete'); + }); + it('accepts streaming delivery with an optional own undefined outcome', () => { + const f = clone(grounded); + Object.assign(f.messages[0].delivery, { + phase: 'streaming', + outcome: undefined, + }); + expect(project(f).status).toBe('available'); + }); + it('preserves resolved raw root errors with unavailable description and missing native name', () => { + const f = clone(grounded); + const tool = f.tools[0], + result = resultFor(f, tool.id); + Object.assign(tool, { + args: { subagent_type: 'field-researcher' }, + result: 'Error invoking task: description: Field required', + }); + result.content = tool.result; + delete result.name; + const p = projectParentObservations( + f.messages, + f.tools, + new ConversationBudget(), + 'parents', + { [tool.id]: 'error' } + ); + expect(p.status).toBe('available'); + expect(p.taskCount).toBe(4); + expect(p.cards.get(f.messages[1].id)?.[0]).toMatchObject({ + fields: 'unavailable', + status: 'complete', + resultStatus: 'error', + resultText: tool.result, + }); + }); + it('marks malformed optional message metadata unavailable', () => { + const f = clone(grounded); + Object.assign(f.messages[0], { reasoning: 123 }); + expect(project(f).status).toBe('incomplete'); + }); + it('rejects malformed native delivery and unowned result fields', () => { + const f = clone(grounded); + f.messages[0].delivery.phase = 'bad'; + expect(project(f).status).toBe('incomplete'); + }); + it('charges displayed root transcript text together with cards and children', () => { + const f = clone(grounded); + f.messages[0].content = 'x'.repeat(LIMITS.fieldText + 1); + expect(project(f).status).toBe('incomplete'); + }); + it('does not charge repeated identical snapshots twice', () => { + const b = new ConversationBudget(); + expect(project(clone(grounded), b).status).toBe('available'); + expect(project(clone(grounded), b).status).toBe('available'); + }); + it('preserves literal HTML and grounded raw resolved error semantics', () => { + const f = clone(grounded); + f.tools[0].result = ''; + resultFor(f, f.tools[0].id).content = f.tools[0].result; + const statuses = { [f.tools[0].id]: 'error' }; + const p = projectParentObservations( + f.messages, + f.tools, + new ConversationBudget(), + 'parents', + statuses + ); + expect(p.cards.get(f.messages[1].id)?.[0].resultText).toBe( + '' + ); + expect(p.cards.get(f.messages[1].id)?.[0].resultStatus).toBe('error'); + }); + it('bounds result text in UTF16 units', () => { + const f = clone(grounded); + f.tools[0].result = '😀'.repeat(LIMITS.fieldText / 2); + resultFor(f, f.tools[0].id).content = f.tools[0].result; + expect(project(f).status).toBe('available'); + f.tools[0].result += 'x'; + resultFor(f, f.tools[0].id).content = f.tools[0].result; + expect(project(f).status).toBe('incomplete'); + }); + it('rejects getters before displaying arguments', () => { + let reads = 0; + const f = clone(grounded); + Object.defineProperty(f.tools[0], 'args', { + get() { + reads++; + return {}; + }, + enumerable: true, + }); + expect(project(f).status).toBe('incomplete'); + expect(reads).toBe(0); + }); +}); +// Actual streamed declaration/catalog/result payload; catalog order is not authority. +const grounded = { + messages: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6', + role: 'user', + content: 'Dispatch request.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-dispatch', + role: 'assistant', + content: '', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [ + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + ], + }, + { + id: '76e8ce47-7bb5-4d82-8f75-b3900eb6b8e7', + role: 'tool', + content: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + }, + { + id: '740bc16f-5b17-48cd-95dd-ef8114c70112', + role: 'tool', + content: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + }, + { + id: 'ecc5857c-bbaf-48bb-8e1c-a1d7b0c2d915', + role: 'tool', + content: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + }, + { + id: '94a6f331-90c8-45f9-9274-4c128b8c9a38', + role: 'tool', + content: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + name: 'task', + toolCallId: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-final', + role: 'assistant', + content: + 'Dispatch assessment: Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft. | Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory. | Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft. | Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + delivery: { + generation: 'ba81fd62-6a59-4711-a978-9e07478cd668', + phase: 'complete', + outcome: 'success', + }, + toolCallIds: [], + }, + ], + tools: [ + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-0', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KASE field elevation is 7820 ft. | KASE longest runway is 8006 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-1', + name: 'task', + args: { + description: 'Research KASE dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KASE: Few 12000 ft, visibility 10 SM, wind 300 at 6, mountain wave advisory.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-2', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'field-researcher', + }, + status: 'complete', + result: + 'Observed lookup: KDEN field elevation is 5434 ft. | KDEN longest runway is 16000 ft.', + }, + { + id: '5c43bb54-c8cf-46a6-9a7e-d162356047e6-task-3', + name: 'task', + args: { + description: 'Research KDEN dispatch.', + subagent_type: 'weather-analyst', + }, + status: 'complete', + result: + 'Observed lookup: KDEN: Clear, visibility 10 SM, wind 350 at 22 gusting 31.', + }, + ], +}; +const clone = (x: T): T => structuredClone(x); +function resultFor(f: typeof grounded, id: string) { + const m = f.messages.find((m) => m.toolCallId === id); + if (!m) throw new Error('Fixture missing result'); + return m; +} +const project = (x = grounded, budget = new ConversationBudget()) => + projectParentObservations(x.messages, x.tools, budget); +describe('parent task observations', () => { + it('uses declaration order with duplicates and counts canonical task IDs once', () => { + const f = clone(grounded); + f.tools.reverse(); + const p = project(f); + expect(p.status).toBe('available'); + expect(p.taskCount).toBe(4); + expect(p.cards.get(f.messages[1].id)?.map((c) => c.id)).toEqual( + f.messages[1].toolCallIds + ); + expect(p.cards.get(f.messages[1].id)?.[0].description).toBe( + p.cards.get(f.messages[1].id)?.[1].description + ); + }); + it('shows unknown literal types and malformed args without losing valid task identity', () => { + for (const args of [ + { description: 'Task', subagent_type: 'general-purpose' }, + { description: 'Task', subagent_type: 'unknown' }, + { subagent_type: 'field-researcher' }, + 123, + ]) { + const f = clone(grounded); + (f.tools[0] as { args: unknown }).args = args; + const p = project(f); + expect(p.taskCount).toBe(4); + const c = p.cards.get(f.messages[1].id)?.[0]; + expect(c).toBeDefined(); + expect(c?.fields).toBe( + args === 123 || !('description' in Object(args)) + ? 'unavailable' + : (args as { subagent_type: string }).subagent_type === 'unknown' + ? 'unavailable' + : 'available' + ); + } + }); + it('keeps generic middleware observations out of parent task count', () => { + const f = clone(grounded); + f.tools[0].name = 'ls'; + resultFor(f, f.tools[0].id).name = 'ls'; + expect(project(f).taskCount).toBe(3); + expect(project(f).cards.get(f.messages[1].id)?.[0].kind).toBe('tool'); + }); + it.each(['catalog', 'owner', 'declaration', 'result'])( + 'marks duplicate %s visibly incomplete', + (kind) => { + const f = clone(grounded); + if (kind === 'catalog') f.tools.push(clone(f.tools[0])); + if (kind === 'owner') + f.messages.push({ ...clone(f.messages[1]), id: 'duplicate-owner' }); + if (kind === 'declaration') + f.messages[1].toolCallIds?.push(f.tools[0].id); + if (kind === 'result') + f.messages.push({ ...clone(f.messages[2]), id: 'duplicate-result' }); + expect(project(f).status).toBe('incomplete'); + } + ); + it('marks mismatched results and malformed delivery incomplete', () => { + const f = clone(grounded); + f.tools[0].result = 'changed'; + expect(project(f).status).toBe('incomplete'); + }); + it('bounds each description and result without hiding the overflow', () => { + const f = clone(grounded); + f.tools[0].args.description = '😀'.repeat(LIMITS.fieldText / 2); + expect(project(f).status).toBe('available'); + f.tools[0].args.description += 'x'; + expect(project(f).status).toBe('incomplete'); + }); + it('shares aggregate budget with retained observations and across followups', () => { + const b = new ConversationBudget(); + expect(b.replace('children', 0, 1, LIMITS.displayText - 1)).toBe(true); + expect(project(clone(grounded), b).status).toBe('incomplete'); + }); + it('bounds cumulative canonical parent tasks', () => { + const b = new ConversationBudget(); + expect(b.replace('old', 100, 0, 0)).toBe(true); + expect(project(clone(grounded), b).status).toBe('incomplete'); + }); +}); diff --git a/cockpit/deep-agents/subagents/react/src/projection.ts b/cockpit/deep-agents/subagents/react/src/projection.ts new file mode 100644 index 000000000..bf38074c0 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/projection.ts @@ -0,0 +1,261 @@ +import { + ConversationBudget, + copyData, + displayUnits, + LIMITS, + literal, + record, + text, + validMessageMetadata, +} from './authority'; + +export interface ToolCard { + readonly id: string; + readonly name: string; + readonly kind: 'task' | 'tool'; + readonly description?: string; + readonly subagentType?: string; + readonly fields: 'available' | 'unavailable'; + readonly argumentsText: string; + readonly resultText?: string; + readonly resultStatus?: 'success' | 'error'; + /** Native complete describes resolved delivery, not raw execution success. */ + readonly status: string; +} +export interface ParentProjection { + readonly status: 'available' | 'incomplete'; + readonly reason?: string; + readonly taskCount: number; + readonly cards: ReadonlyMap; +} + +/** Literal parent observations ordered by the one declaring assistant's call IDs. + * Use one conversation ledger and one stable parent scope across followups. */ +export function projectParentObservations( + observedMessages: unknown, + observedTools: unknown, + budget: ConversationBudget, + scope = 'parents', + rawStatuses?: unknown +): ParentProjection { + const cards = new Map(); + let taskCount = 0; + try { + const data = copyData({ + messages: observedMessages, + tools: observedTools, + rawStatuses, + }) as { messages: unknown; tools: unknown; rawStatuses: unknown }; + if ( + !Array.isArray(data.messages) || + !Array.isArray(data.tools) || + (data.rawStatuses !== undefined && !record(data.rawStatuses)) + ) + throw new Error('Unavailable parent data'); + const messages = data.messages, + tools = data.tools, + ids = new Set(), + catalog = new Map>(), + owners = new Map(), + results = new Map< + string, + { message: Record; index: number } + >(); + let units = 0; + for (const [index, m] of messages.entries()) { + if ( + !record(m) || + !text(m.id) || + ids.has(m.id) || + typeof m.content !== 'string' || + !validMessageMetadata(m) || + !['user', 'assistant', 'tool', 'system'].includes(m.role as string) + ) + throw new Error('Invalid parent message identity'); + ids.add(m.id); + if ( + !record(m.delivery) || + !text(m.delivery.generation) || + !['streaming', 'complete'].includes(m.delivery.phase as string) || + (m.delivery.phase === 'streaming' && + m.delivery.outcome !== undefined) || + (m.delivery.phase === 'complete' && + !['success', 'error', 'aborted', 'interrupted', 'paused'].includes( + m.delivery.outcome as string + )) + ) + throw new Error('Unavailable parent delivery'); + units += displayUnits([ + m.id, + m.role as string, + m.content, + typeof m.name === 'string' ? m.name : '', + typeof m.reasoning === 'string' ? m.reasoning : '', + ...(m.citations === undefined ? [] : [literal(m.citations)]), + ]); + if (m.role === 'assistant') { + const calls = m.toolCallIds ?? []; + if (!Array.isArray(calls)) throw new Error('Unavailable declaration'); + for (const id of calls) { + if (!text(id) || owners.has(id)) + throw new Error('Ambiguous tool ownership'); + owners.set(id, { id: m.id, index }); + } + } else if ( + m.toolCallIds !== undefined && + (!Array.isArray(m.toolCallIds) || m.toolCallIds.length) + ) + throw new Error('Unexpected declaration'); + if (m.role === 'tool' || m.toolCallId !== undefined) { + if ( + m.role !== 'tool' || + !text(m.toolCallId) || + results.has(m.toolCallId) + ) + throw new Error('Ambiguous result'); + results.set(m.toolCallId, { message: m, index }); + } + } + for (const t of tools) { + if ( + !record(t) || + !text(t.id) || + !text(t.name) || + catalog.has(t.id) || + !Object.hasOwn(t, 'args') || + !['pending', 'running', 'complete', 'error'].includes( + t.status as string + ) + ) + throw new Error('Invalid tool catalog'); + catalog.set(t.id, t); + } + if ( + catalog.size !== owners.size || + [...owners.keys()].some((id) => !catalog.has(id)) + ) + throw new Error('Incomplete tool ownership'); + for (const m of messages) { + if (!record(m) || m.role !== 'assistant') continue; + const list: ToolCard[] = []; + for (const id of (m.toolCallIds ?? []) as string[]) { + const t = catalog.get(id), + owner = owners.get(id), + result = results.get(id); + if (!t || !owner) throw new Error('Incomplete tool ownership'); + const rawStatus = record(data.rawStatuses) + ? data.rawStatuses[id] + : undefined; + if ( + result && + (result.index <= owner.index || + messages + .slice(owner.index + 1, result.index) + .some( + (v) => + record(v) && (v.role === 'user' || v.role === 'assistant') + ) || + (result.message.name !== t.name && + !(rawStatus === 'error' && result.message.name === undefined))) + ) + throw new Error('Result crossed declaration batch'); + let resultText: string | undefined; + if (t.status === 'complete') { + if ( + !result || + typeof t.result !== 'string' || + result.message.content !== t.result + ) + throw new Error('Unavailable resolved result'); + resultText = t.result; + } else if (t.status === 'error') { + if (typeof t.error !== 'string' || result) + throw new Error('Unavailable tool error'); + resultText = t.error; + } else if ( + result || + (Object.hasOwn(t, 'result') && t.result !== undefined) + ) + throw new Error('Contradictory unresolved result'); + const kind = t.name === 'task' ? 'task' : 'tool'; + if (kind === 'task') taskCount++; + const args = t.args, + description = + record(args) && typeof args.description === 'string' + ? args.description + : undefined, + subagentType = + record(args) && typeof args.subagent_type === 'string' + ? args.subagent_type + : undefined; + const fields = + kind === 'task' && + record(args) && + Object.keys(args).length === 2 && + text(description) && + ['field-researcher', 'weather-analyst', 'general-purpose'].includes( + subagentType ?? '' + ) + ? 'available' + : 'unavailable'; + // Arguments can contain multiple individually bounded fields. The whole + // literal representation remains subject to the conversation budget. + const argumentsText = literal(args, LIMITS.displayText); + if (argumentsText.length > LIMITS.displayText) + throw new Error('Arguments display limit'); + if ( + rawStatus !== undefined && + rawStatus !== 'success' && + rawStatus !== 'error' + ) + throw new Error('Unavailable raw result status'); + const card: ToolCard = Object.freeze({ + id, + name: t.name as string, + kind, + fields, + argumentsText, + status: t.status as string, + ...(description === undefined ? {} : { description }), + ...(subagentType === undefined ? {} : { subagentType }), + ...(resultText === undefined + ? {} + : { resultText: literal(resultText) }), + ...(rawStatus === undefined ? {} : { resultStatus: rawStatus }), + }); + units += + argumentsText.length + + displayUnits([ + id, + t.name as string, + description ?? '', + subagentType ?? '', + resultText ?? '', + card.fields, + card.status, + card.kind, + (rawStatus as string) ?? '', + ]); + list.push(card); + } + if (list.length) { + units += displayUnits([m.id as string]); + cards.set(m.id as string, Object.freeze(list)); + } + } + if ( + [...results.keys()].some((id) => !catalog.has(id)) || + !budget.replace(scope, taskCount, 0, units) + ) + throw new Error('Conversation observation limit'); + return Object.freeze({ status: 'available', taskCount, cards }); + } catch (e) { + // A count from a partial or ambiguous catalog must not appear complete. + return Object.freeze({ + status: 'incomplete', + reason: e instanceof Error ? e.message : 'Unavailable parent data', + taskCount: 0, + cards: new Map(), + }); + } +} diff --git a/cockpit/deep-agents/subagents/react/src/styles.css b/cockpit/deep-agents/subagents/react/src/styles.css new file mode 100644 index 000000000..774888d23 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/styles.css @@ -0,0 +1,129 @@ +:root { + font-family: system-ui, sans-serif; + color: #202832; + background: #fafafa; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.subagents-demo { + max-width: 56rem; + margin: auto; + padding: 1.5rem; + min-height: 100dvh; + display: flex; + flex-direction: column; + gap: 1rem; +} +.subagents-demo header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} +.subagents-demo h1 { + font-size: 1.5rem; + margin: 0; +} +.subagents-demo p { + margin: 0; +} +.eyebrow { + color: #606b76; + font-size: 0.8rem; + margin-bottom: 0.4rem !important; +} +.subagents-demo button { + font: inherit; + cursor: pointer; +} +.subagents-demo button:disabled { + cursor: default; +} +.subagents-demo .tp-chat-list { + flex: 1; + min-height: 12rem; + max-height: none; +} +[role='alert'] { + color: #a52323; +} +[role='status'] { + font-size: 0.85rem; + color: #606b76; +} +@media (max-width: 32rem) { + .subagents-demo { + padding: 1rem; + } + .subagents-demo header { + align-items: flex-start; + } +} + +.seed-prompts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.tool-observation { + padding: 1rem; + margin-block: 0.75rem; + border: 1px solid var(--ds-border-default, #cbd5e1); + border-radius: 0.75rem; + min-width: 0; +} + +.child-observations { + border: 1px solid #cbd5e1; + border-radius: 12px; + padding: 1rem; + margin: 1rem 0; + min-width: 0; +} +.child-observations summary { + cursor: pointer; + font-weight: 600; +} +.child-observations section { + border-top: 1px solid #cbd5e1; + margin-top: 1rem; + padding-top: 0.5rem; + min-width: 0; +} +.child-observations pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; +} +.child-observation-status { + font-size: 0.85rem; + color: #475569; +} +.tool-observation h3 { + font-size: 0.95rem; +} + +.subagents-demo, +.subagents-demo * { + min-width: 0; +} +.subagents-demo { + overflow-wrap: anywhere; +} +.namespace, +.tool-observation pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.tool-observation h3 { + font-family: monospace; +} +@media (max-width: 32rem) { + .subagents-demo header { + flex-wrap: wrap; + } +} diff --git a/cockpit/deep-agents/subagents/react/src/styles.d.ts b/cockpit/deep-agents/subagents/react/src/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/cockpit/deep-agents/subagents/react/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/cockpit/deep-agents/subagents/react/tsconfig.json b/cockpit/deep-agents/subagents/react/tsconfig.json new file mode 100644 index 000000000..7bfcb1799 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/tsconfig.json @@ -0,0 +1,54 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": [ + "node", + "vitest/globals" + ], + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "composite": false, + "emitDeclarationOnly": false, + "declarationMap": false, + "paths": { + "@threadplane/core": [ + "libs/core/src/index.ts" + ], + "@threadplane/core/tools": [ + "libs/core/src/tools/index.ts" + ], + "@threadplane/content": [ + "libs/content/src/index.ts" + ], + "@threadplane/content/messages": [ + "libs/content/src/messages/index.ts" + ], + "@threadplane/content/markdown": [ + "libs/content/src/markdown/index.ts" + ], + "@threadplane/react": [ + "libs/react/src/index.ts" + ], + "@threadplane/react/chat": [ + "libs/react/src/chat/index.ts" + ], + "@threadplane/react/markdown": [ + "libs/react/src/markdown/index.ts" + ], + "@threadplane/langgraph": [ + "libs/langgraph/src/runtime/create-session.ts" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "e2e/**/*.ts" + ] +} diff --git a/cockpit/deep-agents/subagents/react/vite.config.mts b/cockpit/deep-agents/subagents/react/vite.config.mts new file mode 100644 index 000000000..16eb2a3b7 --- /dev/null +++ b/cockpit/deep-agents/subagents/react/vite.config.mts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [nxViteTsPaths()], + resolve: { + alias: { + '@threadplane/langgraph': fileURLToPath( + new URL( + '../../../../libs/langgraph/src/runtime/create-session.ts', + import.meta.url + ) + ), + }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + maxWorkers: 2, + }, +}); +import { fileURLToPath } from 'node:url'; diff --git a/libs/cockpit-registry/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index 9c95cae5e..5c708a2a5 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -58,7 +58,7 @@ export const capabilities: readonly Capability[] = [ { id: 'langgraph-client-tools', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'client-tools', angularProject: 'cockpit-langgraph-client-tools-angular', port: 4308, pythonPort: 5308, pythonDir: 'cockpit/langgraph/client-tools/python', graphName: 'client-tools', react: { project: 'cockpit-langgraph-client-tools-react', port: 4603 } }, { id: 'da-planning', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'planning', angularProject: 'cockpit-deep-agents-planning-angular', port: 4310, react: { project: 'cockpit-deep-agents-planning-react', port: 4628 }, pythonPort: 5310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }, { id: 'da-filesystem', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, react: { project: 'cockpit-deep-agents-filesystem-react', port: 4629 }, pythonPort: 5311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'da-filesystem' }, - { id: 'da-subagents', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, + { id: 'da-subagents', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, react: { project: 'cockpit-deep-agents-subagents-react', port: 4630 }, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, { id: 'da-memory', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonPort: 5313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, // Render capabilities diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index c74b2627e..096cb4834 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -44,6 +44,26 @@ import { describe, expect, it } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { capabilityModules, getFrontendCapabilityDescriptor } from './content-descriptors'; import { cockpitManifest } from './manifest'; +it('registers one frozen native Subagents variant with the canonical backend and exact product sources', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-subagents-python'); + if (!canonical) throw new Error('Missing canonical Subagents'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react'); + expect(react).toBeDefined(); + expect(react?.manifestIdentity).toEqual(canonical.manifestIdentity); + expect(react?.backendAssetPaths).toEqual(deepAgentsSubagentsPythonModule.backendAssetPaths); + expect(react?.promptAssetPaths).toEqual(deepAgentsSubagentsPythonModule.promptAssetPaths); + expect(react?.runtimeUrl).toBe('deep-agents/subagents/react'); + expect(react?.devPort).toBe(4630); + expect(react?.codeAssetPaths).toEqual([...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'projection.ts', 'children.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/subagents/react/src/${file}`), 'cockpit/deep-agents/subagents/react/project.json']); + expect(Object.isFrozen(react)).toBe(true); + expect(Object.isFrozen(react?.codeAssetPaths)).toBe(true); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'angular')).toBe(canonical); + expect(capabilityModules.filter(entry => entry.id === canonical.id)).toHaveLength(1); + for (const path of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) { + expect(path).not.toMatch(/fixture|wire\.py|\.spec\.|proof/); + expect(existsSync(new URL('../../../../' + path, import.meta.url))).toBe(true); + } +}); it('registers one frozen native Filesystem variant with the canonical backend and exact product sources', () => { const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); if (!canonical) throw new Error('Missing canonical Filesystem'); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 7bee91f4e..340d3679a 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1284,11 +1284,30 @@ const deepAgentsFilesystemReact = (() => { }); })(); +const deepAgentsSubagentsReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'deep-agents-subagents-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-subagents-react'); + if (!canonical || !frontend) throw new Error('Subagents frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'deep-agents-subagents-python-react', + frontend: 'react', + title: 'Subagents (React preview)', + codeAssetPaths: [ + ...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'projection.ts', 'children.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/subagents/react/src/${file}`), + 'cockpit/deep-agents/subagents/react/project.json', + ], + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, deepAgentsPlanningReact, deepAgentsFilesystemReact, + deepAgentsSubagentsReact, chatThreadsReact, chatTimelineReact, chatGenerativeUiReact, diff --git a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts index f5c32927b..0be2ec94f 100644 --- a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts @@ -8,13 +8,26 @@ import { import type { CockpitManifestIdentity } from './manifest.types'; import { capabilities, getCockpitFrontends } from './capability-registry'; +it('keeps Subagents as one canonical backend with Angular default and two exact frontend deployments', () => { + const subagents = capabilities.filter(entry => entry.product === 'deep-agents' && entry.topic === 'subagents'); + expect(subagents).toHaveLength(1); + expect(subagents[0]).toMatchObject({ angularProject: 'cockpit-deep-agents-subagents-angular', pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents', pythonPort: 5312 }); + const frontends = getCockpitFrontends(); + expect(frontends.filter(entry => entry.frontend === 'angular')).toHaveLength(41); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(31); + expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'subagents')).toEqual([ + { frontend: 'angular', project: 'cockpit-deep-agents-subagents-angular', port: 4312, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents', buildOutput: 'dist/cockpit/deep-agents/subagents/angular' }, + { frontend: 'react', project: 'cockpit-deep-agents-subagents-react', port: 4630, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents/react', buildOutput: 'dist/cockpit/deep-agents/subagents/react' }, + ]); +}); + it('keeps Planning as one canonical backend with Angular default and two exact frontend deployments', () => { const planning = capabilities.filter(entry => entry.product === 'deep-agents' && entry.topic === 'planning'); expect(planning).toHaveLength(1); expect(planning[0]).toMatchObject({ angularProject: 'cockpit-deep-agents-planning-angular', pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }); const frontends = getCockpitFrontends(); expect(frontends.filter(entry => entry.frontend === 'angular')).toHaveLength(41); - expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(30); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(31); expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'planning')).toEqual([ { frontend: 'angular', project: 'cockpit-deep-agents-planning-angular', port: 4310, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning', buildOutput: 'dist/cockpit/deep-agents/planning/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-planning-react', port: 4628, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning/react', buildOutput: 'dist/cockpit/deep-agents/planning/react' }, diff --git a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts index 724c4af3b..43ca51e59 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -14,6 +14,14 @@ const streaming: CockpitManifestIdentity = { page: 'overview', language: 'python', }; +it('selects the Subagents React frontend without changing the Angular or Python deployment', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-subagents-python'); + if (!canonical) throw new Error('Missing canonical Subagents'); + expect(getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-subagents-react')).toMatchObject({port: 4630, runtimePath: 'deep-agents/subagents/react'}); + expect(getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-subagents-angular')).toMatchObject({port: 4312}); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'react')).toMatchObject({runtimeUrl: 'deep-agents/subagents/react', devPort: 4630}); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'angular')).toBe(canonical); +}); it('selects the Filesystem React frontend without changing the Angular or Python deployment', () => { const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); if (!canonical) throw new Error('Missing canonical Filesystem'); @@ -259,8 +267,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toEqual(['graph.py','dashboard_tools.py','server.py'].map(file=>`cockpit/ag-ui/json-render/python/src/${file}`)); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/json-render'); expect(getFrontendCapabilityDescriptor({...identity,topic:'testing'},'react')).toBeUndefined(); - expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(30); - expect(getCockpitFrontends()).toHaveLength(71); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(31); + expect(getCockpitFrontends()).toHaveLength(72); for(const asset of [...(react?.codeAssetPaths??[]),...(react?.backendAssetPaths??[])])expect(existsSync(new URL('../../../../'+asset,import.meta.url))).toBe(true); }); it('resolves AG-UI Tool Views with authored weather policy and unchanged server-tool endpoint', () => { @@ -289,9 +297,9 @@ describe('registered React public previews', () => { expect(getFrontendCapabilityDescriptor(identity, 'angular')?.runtimeUrl).toBe('ag-ui/interrupts'); expect(getFrontendCapabilityDescriptor({ ...identity, product: 'langgraph' }, 'react')?.runtimeUrl).toBe('langgraph/interrupts/react'); const frontends = getCockpitFrontends(); - expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(30); - expect(frontends).toHaveLength(71); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(71); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(31); + expect(frontends).toHaveLength(72); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(72); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -306,8 +314,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toContain('cockpit/ag-ui/streaming/python/src/server.py'); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/streaming'); expect(getFrontendCapabilityDescriptor(streaming,'react')?.runtimeUrl).toBe('langgraph/streaming/react'); - expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(30); - expect(getCockpitFrontends()).toHaveLength(71); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(31); + expect(getCockpitFrontends()).toHaveLength(72); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -335,8 +343,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(30); - expect(getCockpitFrontends()).toHaveLength(71); + ).toHaveLength(31); + expect(getCockpitFrontends()).toHaveLength(72); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -596,11 +604,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - expect(frontends).toHaveLength(71); - expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(71); - expect(new Set(frontends.map((entry) => entry.project)).size).toBe(71); - expect(new Set(frontends.map((entry) => entry.port)).size).toBe(71); - expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(71); + expect(frontends).toHaveLength(72); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(72); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(72); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(72); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(72); expect( frontends.find( (entry) => entry.project === 'cockpit-langgraph-memory-react' diff --git a/scripts/cockpit-frontend-matrix.spec.mjs b/scripts/cockpit-frontend-matrix.spec.mjs index 703f72cbb..f432c53e7 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -21,6 +21,21 @@ test('Planning owns an exact closed native React identity', async () => { for (const key of ['planning', 'deep-agents/planning', '../deep-agents-planning', 'deep-agents-planning/../memory']) assert.throws(() => reactCockpitConfiguration(key), /Unsupported React cockpit topic/); }); +test('Subagents backend changes select exactly both canonical frontends', () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-deep-agents-subagents-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-subagents-angular', 'cockpit-deep-agents-subagents-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/subagents/python')); +}); +test('Subagents owns an exact closed native React identity', async () => { + const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); + assert.deepEqual(reactCockpitConfiguration('deep-agents-subagents'), { + topic: 'subagents', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/subagents/react', + base: '/deep-agents/subagents/react/', port: 4630, project: 'cockpit-deep-agents-subagents-react', + }); + for (const key of ['subagents', 'deep-agents/subagents', '../deep-agents-subagents', 'deep-agents-subagents/../memory']) + assert.throws(() => reactCockpitConfiguration(key), /Unsupported React cockpit topic/); +}); test('Filesystem backend changes select exactly both canonical frontends', () => { const root = fileURLToPath(new URL('..', import.meta.url)); const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-deep-agents-filesystem-python']), { fullFleet: false }); @@ -42,7 +57,7 @@ test('Generative UI backend changes select both canonical frontends registered b assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-generative-ui-angular', 'cockpit-chat-generative-ui-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/generative-ui/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 71); + assert.equal(frontends.length, 72); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-generative-ui-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Timeline backend changes select both canonical frontends registered by the website', () => { @@ -51,7 +66,7 @@ test('Timeline backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-timeline-angular', 'cockpit-chat-timeline-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/timeline/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 71); + assert.equal(frontends.length, 72); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-timeline-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Chat Timeline owns an exact native React identity', async () => { @@ -70,7 +85,7 @@ test('Threads backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-threads-angular', 'cockpit-chat-threads-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/threads/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 71); + assert.equal(frontends.length, 72); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-threads-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Chat Threads owns an exact native React identity', async () => { @@ -389,7 +404,7 @@ test('CI discovers native React previews across protocols with their own Python const root = mkdtempSync(join(tmpdir(), 'cockpit-react-topics-')); t.after(() => rmSync(root, { recursive: true, force: true })); // All writes in this test target this isolated temporary root. - for (const topic of ['planning', 'filesystem']) for (const frontend of ['react', 'python']) { + for (const topic of ['planning', 'filesystem', 'subagents']) for (const frontend of ['react', 'python']) { const directory = join(root, 'cockpit/deep-agents', topic, frontend); mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, 'project.json'), JSON.stringify({ @@ -432,7 +447,7 @@ test('CI discovers native React previews across protocols with their own Python ); } const caps = deriveCockpitCaps(root); - assert.equal(caps.length, 30); + assert.equal(caps.length, 31); const agUi = selectCockpitCaps( caps, new Set(['cockpit-ag-ui-streaming-python']), diff --git a/scripts/examples/e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts index 141a6c06d..bb6e2fa7d 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -13,6 +13,19 @@ it('wires native Planning build, fixture and website coverage to the shared Pyth expect(config).toContain('cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent'); expect(config).toContain("url: 'http://127.0.0.1:4628'"); }); +it('wires native Subagents build, fixture and website coverage to the shared Python graph', () => { + const read = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), 'utf8')); + const react = read('cockpit/deep-agents/subagents/react/project.json'); + expect(react.tags).toEqual(expect.arrayContaining(['framework:react', 'scope:cockpit-e2e', 'scope:cockpit-examples'])); + expect(react.targets.build.options.command).toBe('node scripts/react-cockpit/build.mjs deep-agents-subagents'); + expect(react.targets.e2e.dependsOn).toEqual(['build', 'fixture-test']); + expect(react.targets['fixture-test'].dependsOn).toContainEqual({ target: 'test', projects: ['cockpit-deep-agents-subagents-python'] }); + const website = read('apps/website/project.json'); + expect(website.targets.e2e.dependsOn[0].projects).toContain('cockpit-deep-agents-subagents-react'); + const config = readFileSync(join(repoRoot, 'apps/website/playwright.config.ts'), 'utf8'); + expect(config).toContain('cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent'); + expect(config).toContain("url: 'http://127.0.0.1:4630'"); +}); it('wires native Filesystem build, fixture and website coverage to the shared Python graph', () => { const read = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), 'utf8')); const react = read('cockpit/deep-agents/filesystem/react/project.json'); diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index e3b8eb853..922983b7d 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,7 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'deep-agents-subagents') + return Object.freeze({topic:'subagents',library:'deep-agents',adapter:'langgraph',appPath:'cockpit/deep-agents/subagents/react',base:'/deep-agents/subagents/react/',port:4630,project:'cockpit-deep-agents-subagents-react'}); if (topic === 'deep-agents-filesystem') return Object.freeze({topic:'filesystem',library:'deep-agents',adapter:'langgraph',appPath:'cockpit/deep-agents/filesystem/react',base:'/deep-agents/filesystem/react/',port:4629,project:'cockpit-deep-agents-filesystem-react'}); if (topic === 'deep-agents-planning') diff --git a/scripts/react-cockpit/deep-agents-subagents-configuration.spec.mjs b/scripts/react-cockpit/deep-agents-subagents-configuration.spec.mjs new file mode 100644 index 000000000..3265fd64b --- /dev/null +++ b/scripts/react-cockpit/deep-agents-subagents-configuration.spec.mjs @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { reactCockpitConfiguration } from './configuration.mjs'; +test('closed native Subagents selection', () => { + const c = reactCockpitConfiguration('deep-agents-subagents'); + assert.equal(c.library, 'deep-agents'); + assert.equal(c.topic, 'subagents'); + assert.equal(c.port, 4630); + assert.equal(c.base, '/deep-agents/subagents/react/'); + assert.equal(c.project, 'cockpit-deep-agents-subagents-react'); + assert.ok(Object.isFrozen(c)); + assert.throws(() => reactCockpitConfiguration('../subagents')); +}); +import { readFileSync } from 'node:fs'; +test('native fixture participates in awaited serve shutdown', () => { + const source = readFileSync(new URL('./serve.mjs', import.meta.url), 'utf8'); + assert.match(source, /if \(deepAgentsSubagentsFixture \|\|/); + assert.match(source, /await \(deepAgentsSubagentsFixture \?\?/); +}); diff --git a/scripts/react-cockpit/deep-agents-subagents-fixture.mjs b/scripts/react-cockpit/deep-agents-subagents-fixture.mjs new file mode 100644 index 000000000..f4d6f33cb --- /dev/null +++ b/scripts/react-cockpit/deep-agents-subagents-fixture.mjs @@ -0,0 +1,589 @@ +/** Loopback-only SDK transport backed by the actual compiled Subagents graph. */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +function graphWorker(spawnWorker, timeout) { + const child = spawnWorker(); + const pending = new Map(); + let sequence = 0, + dead = false, + closing; + const ended = new Promise((resolve) => child.once('close', resolve)); + const maxResponseBytes = 16 * 1024 * 1024; + let frame = Buffer.alloc(0), + frameBytes = 0; + child.stderr.resume(); + function fail() { + dead = true; + frame = Buffer.alloc(0); + frameBytes = 0; + for (const item of pending.values()) { + clearTimeout(item.timer); + item.reject(Error('Subagents graph worker unavailable.')); + } + pending.clear(); + } + const terminate = () => { + fail(); + child.kill('SIGTERM'); + }; + child.once('error', fail); + child.once('close', fail); + child.stdin.on('error', terminate); + child.stdout.once('error', terminate); + child.stdout.once('end', () => { + if (!dead) terminate(); + }); + child.stdout.on('data', (bytes) => { + if (dead) return; + try { + let start = 0; + while (start < bytes.length && !dead) { + const newline = bytes.indexOf(0x0a, start); + const end = newline < 0 ? bytes.length : newline; + const needed = frameBytes + end - start; + // Count each raw frame before copying or decoding, including frames + // whose delimiter has not arrived. Never grow storage beyond the cap. + if (needed > maxResponseBytes) throw Error('Oversized response'); + if (needed > frame.length) { + const grown = Buffer.allocUnsafe( + Math.min(maxResponseBytes, Math.max(needed, frame.length * 2, 4096)) + ); + frame.copy(grown, 0, 0, frameBytes); + frame = grown; + } + bytes.copy(frame, frameBytes, start, end); + frameBytes = needed; + if (newline < 0) return; + const result = JSON.parse(frame.toString('utf8', 0, frameBytes)); + frameBytes = 0; + const item = pending.get(result.requestId); + if (!item) throw Error('Unowned response'); + pending.delete(result.requestId); + clearTimeout(item.timer); + if (result.error) + item.reject(Error('Subagents graph operation failed.')); + else item.resolve(result); + start = newline + 1; + } + } catch { + terminate(); + } + }); + return { + pid: child.pid, + send(value) { + if (dead) return Promise.reject(Error('Subagents graph worker closed.')); + return new Promise((resolve, reject) => { + const requestId = ++sequence; + const wire = JSON.stringify({ ...value, requestId }) + '\n'; + if (Buffer.byteLength(wire) > 1024 * 1024) { + reject(Error('Oversized request')); + return; + } + const timer = setTimeout(terminate, timeout); + pending.set(requestId, { resolve, reject, timer }); + child.stdin.write(wire); + }); + }, + close() { + return (closing ??= (async () => { + fail(); + child.stdin.end(); + const timer = setTimeout(() => child.kill('SIGKILL'), 1500); + await ended; + clearTimeout(timer); + })()); + }, + }; +} + +export function createDeepAgentsSubagentsFixture({ + workerTimeoutMs = 30000, + spawnWorker = () => + spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/subagents/python', + 'python', + 'scripts/react-cockpit/deep-agents-subagents-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-subagents-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ), +} = {}) { + const sourceSha256 = Object.fromEntries( + ['src/graph.py', 'prompts/subagents.md', 'uv.lock'].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync( + new URL( + '../../cockpit/deep-agents/subagents/python/' + path, + import.meta.url + ) + ) + ) + .digest('hex'), + ]) + ); + let worker, + closed = false, + closing, + queue = Promise.resolve(); + // Real execution is buffered before delivery; transport cancellation does not roll back server graph work. + const threads = new Set(), + runs = new Map(), + held = new Set(), + rawEvents = [], + proofs = [], + requests = []; + const options = { + scenario: 'single', + holdStream: false, + holdState: false, + holdCheckpoint: false, + failCreation: false, + failStream: false, + failCheckpoint: false, + }; + const json = (res, value, status = 200) => { + if (!res.destroyed && !res.writableEnded) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); + } + }; + const event = (kind, data) => + `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`; + const only = (value, keys) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).every((k) => keys.includes(k)); + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function operate(value) { + const result = queue.then(async () => { + if (closed) throw Error('Closed fixture'); + worker ??= graphWorker(spawnWorker, workerTimeoutMs); + const result = await worker.send(value), + proof = result.proof; + if ( + closed || + proof?.actualCompiledGraph !== true || + proof.networkConnectAttempts !== 0 || + proof.op !== value.op || + proof.threadId !== (value.threadId ?? null) || + Object.keys(proof.sourceSha256 ?? {}).length !== + Object.keys(sourceSha256).length || + Object.entries(sourceSha256).some( + ([path, hash]) => proof.sourceSha256[path] !== hash + ) + ) + throw Error('Invalid graph proof'); + proofs.push(proof); + return result; + }); + queue = result.catch(() => undefined); + return result; + } + function hold(response, finish, run) { + const item = { response, finish, run }; + held.add(item); + response.once('close', () => { + if (held.delete(item) && run) run.status = 'interrupted'; + }); + } + const handle = async (request, response, pathname) => { + if (!pathname.startsWith('/api/') && !pathname.startsWith('/__')) + return false; + try { + if (closed) throw Error('Closed fixture'); + if ( + !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes( + request.socket.remoteAddress + ) + ) { + json(response, {}, 403); + return true; + } + const chunks = []; + let byteLength = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.length; + if (byteLength > 1024 * 1024) { + json(response, {}, 413); + return true; + } + chunks.push(bytes); + } + const input = Buffer.concat(chunks, byteLength).toString('utf8'); + const body = input ? JSON.parse(input) : {}; + if (!only(body, Object.keys(body ?? {}))) { + json(response, {}, 400); + return true; + } + if (pathname === '/__raw-events') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, rawEvents); + return true; + } + if (pathname === '/__graph-proof') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, proofs); + return true; + } + if (pathname === '/__requests') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, requests); + return true; + } + if (pathname === '/__lifetime') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, { + workers: worker ? 1 : 0, + workerPid: worker?.pid, + held: held.size, + }); + return true; + } + if (pathname === '/__configure') { + if (request.method !== 'POST') { + json(response, {}, 405); + return true; + } + if ( + !only(body, [ + 'scenario', + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ]) || + ('scenario' in body && + ![ + 'single', + 'parallel', + 'duplicates', + 'no-task', + 'tool-error', + 'general-purpose', + 'default-tool', + 'root-tool-error', + ].includes(body.scenario)) || + [ + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ].some((k) => k in body && typeof body[k] !== 'boolean') + ) { + json(response, {}, 400); + return true; + } + if ('scenario' in body) + await operate({ op: 'configure', scenario: body.scenario }); + Object.assign(options, body); + json(response, {}); + return true; + } + if (pathname === '/__release') { + if (request.method !== 'POST' || !only(body, [])) { + json(response, {}, 400); + return true; + } + for (const item of held) { + held.delete(item); + item.finish(); + } + json(response, {}); + return true; + } + if (pathname.startsWith('/__')) { + json(response, {}, 404); + return true; + } + if (requests.length >= 2000 || threads.size >= 100 || runs.size >= 1000) { + json(response, {}, 429); + return true; + } + requests.push( + structuredClone({ path: pathname, method: request.method, body }) + ); + if (pathname === '/api/threads' && request.method === 'POST') { + if (options.failCreation) { + options.failCreation = false; + json(response, { error: 'Controlled creation failure.' }, 500); + return true; + } + if ( + !uuid.test(body.thread_id ?? '') || + threads.has(body.thread_id) || + !only(body, ['thread_id', 'if_exists', 'metadata']) || + body.if_exists !== 'raise' || + (body.metadata !== undefined && !only(body.metadata, [])) + ) { + json(response, {}, 400); + return true; + } + await operate({ op: 'create', threadId: body.thread_id }); + threads.add(body.thread_id); + json(response, { thread_id: body.thread_id }); + return true; + } + const route = /^\/api\/threads\/([^/]+)(.*)$/.exec(pathname); + if (!route || !threads.has(route[1])) { + json(response, {}, 404); + return true; + } + const [, threadId, suffix] = route; + if ( + (suffix === '/state' && request.method === 'GET') || + (suffix === '/state/checkpoint' && request.method === 'POST') + ) { + if (suffix === '/state' && input.length !== 0) { + json(response, {}, 400); + return true; + } + if (suffix === '/state/checkpoint') { + if (options.failCheckpoint) { + options.failCheckpoint = false; + json(response, { error: 'Controlled exact read failure.' }, 500); + return true; + } + if ( + !only(body, ['checkpoint']) || + !only(body.checkpoint, [ + 'thread_id', + 'checkpoint_id', + 'checkpoint_ns', + 'checkpoint_map', + ]) || + body.checkpoint.thread_id !== threadId || + typeof body.checkpoint.checkpoint_id !== 'string' || + typeof body.checkpoint.checkpoint_ns !== 'string' + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + if ( + !history.some( + (s) => + s.checkpoint.checkpoint_id === body.checkpoint.checkpoint_id && + s.checkpoint.checkpoint_ns === body.checkpoint.checkpoint_ns + ) || + (body.checkpoint.checkpoint_map !== undefined && + (!only(body.checkpoint.checkpoint_map, [ + body.checkpoint.checkpoint_ns, + ]) || + Object.values(body.checkpoint.checkpoint_map).some( + (id) => id !== body.checkpoint.checkpoint_id + ))) + ) { + json(response, {}, 400); + return true; + } + } + const { state } = await operate({ + op: 'state', + threadId, + ...(suffix === '/state/checkpoint' + ? { checkpoint: body.checkpoint } + : {}), + }); + const finish = () => json(response, state); + if ( + options.holdState || + (options.holdCheckpoint && suffix === '/state/checkpoint') + ) + hold(response, finish); + else finish(); + return true; + } + if (suffix === '/history' && request.method === 'POST') { + if ( + !only(body, ['limit']) || + (body.limit !== undefined && + (!Number.isInteger(body.limit) || + body.limit < 1 || + body.limit > 100)) + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + json(response, history.slice(0, body.limit ?? 10)); + return true; + } + const runRoute = /^\/runs\/([^/]+)(\/cancel)?$/.exec(suffix); + if ( + runRoute && + ((!runRoute[2] && request.method === 'GET') || + (runRoute[2] && request.method === 'POST')) + ) { + if (input.length !== 0) { + json(response, {}, 400); + return true; + } + const run = runs.get(runRoute[1]); + if (run?.thread_id !== threadId) { + json(response, {}, 404); + return true; + } + if (runRoute[2]) { + const search = new URL(request.url, 'http://localhost').searchParams; + if (search.get('action') !== 'interrupt') { + json(response, {}, 400); + return true; + } + for (const item of held) + if (item.run === run) { + held.delete(item); + run.status = 'interrupted'; + item.response.end(); + } + json(response, {}); + } else json(response, run); + return true; + } + if (suffix === '/runs/stream' && request.method === 'POST') { + if (options.failStream) { + options.failStream = false; + json(response, { error: 'Controlled stream failure.' }, 500); + return true; + } + const message = body.input?.messages?.[0]; + if ( + !only(body, [ + 'assistant_id', + 'input', + 'command', + 'stream_mode', + 'stream_subgraphs', + 'stream_resumable', + 'on_disconnect', + ]) || + !( + body.command === undefined && + only(body.input, ['messages']) && + body.input.messages?.length === 1 && + only(message, ['type', 'id', 'content']) && + message.type === 'human' && + typeof message.id === 'string' && + message.id.trim() && + typeof message.content === 'string' + ) || + body.assistant_id !== 'subagents' || + !Array.isArray(body.stream_mode) || + !body.stream_mode.length || + new Set(body.stream_mode).size !== body.stream_mode.length || + body.stream_mode.some( + (mode) => + ![ + 'messages-tuple', + 'values', + 'updates', + 'checkpoints', + 'custom', + ].includes(mode) + ) || + (body.stream_subgraphs !== undefined && + body.stream_subgraphs !== true) || + (body.stream_resumable !== undefined && + body.stream_resumable !== true) || + (body.on_disconnect !== undefined && + body.on_disconnect !== 'continue') + ) { + json(response, {}, 400); + return true; + } + const run = { + run_id: randomUUID(), + thread_id: threadId, + status: 'running', + }; + runs.set(run.run_id, run); + const wire = await operate({ + op: 'submit', + threadId, + messages: body.input.messages, + }); + rawEvents.push({ threadId, runId: run.run_id, events: wire.events }); + if (response.destroyed) { + run.status = 'interrupted'; + return true; + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'content-location': `/threads/${threadId}/runs/${run.run_id}`, + }); + response.write(event('metadata', { run_id: run.run_id })); + const modes = new Set( + body.stream_mode.map((mode) => + mode === 'messages-tuple' ? 'messages' : mode + ) + ); + const finish = () => { + if (response.destroyed || closed) return; + for (const [namespace, kind, data] of wire.events) + if ( + modes.has(kind) && + (!namespace.length || body.stream_subgraphs === true) + ) + response.write(event([kind, ...namespace].join('|'), data)); + run.status = 'success'; + response.end(); + }; + if (options.holdStream) hold(response, finish, run); + else finish(); + return true; + } + json(response, {}, 404); + return true; + } catch { + json(response, { error: 'Subagents graph fixture failed.' }, 500); + return true; + } + }; + handle.close = () => + (closing ??= (async () => { + closed = true; + for (const item of held) item.response.end(); + held.clear(); + await worker?.close(); + })()); + return handle; +} diff --git a/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs new file mode 100644 index 000000000..7cb24a808 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs @@ -0,0 +1,726 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { randomUUID, createHash } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { Client } from '@langchain/langgraph-sdk'; +import { + emitCandidate, + packCandidate, +} from '../react-parity/langgraph-candidate-package.mjs'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const { createDeepAgentsSubagentsFixture } = await import( + './deep-agents-subagents-fixture.mjs' +).catch(() => ({})); +const captureDirectory = mkdtempSync( + join(tmpdir(), 'threadplane-subagents-fixture-captures-') +); +console.log('Subagents fixture capture directory: ' + captureDirectory); +const capture = (name, value) => + writeFileSync( + join(captureDirectory, name + '.json'), + JSON.stringify(value, null, 2) + '\n' + ); +let native; +function nativeRuntime() { + return (native ??= (async () => { + const directory = mkdtempSync( + join(tmpdir(), 'threadplane-subagents-native-') + ); + const candidate = join(directory, 'candidate'); + mkdirSync(candidate); + const emission = emitCandidate(root, candidate); + const packed = packCandidate(candidate, directory); + const core = JSON.parse( + execFileSync( + 'npm', + [ + 'pack', + join(root, 'dist/libs/core'), + '--pack-destination', + directory, + '--json', + ], + { encoding: 'utf8' } + ) + )[0]; + const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'))); + const consumer = join(directory, 'consumer'); + mkdirSync(consumer); + writeFileSync( + join(consumer, 'package.json'), + JSON.stringify({ + private: true, + type: 'module', + dependencies: { + '@threadplane/core': 'file:' + join(directory, core.filename), + '@threadplane/langgraph': 'file:' + packed.tarball, + // SDK stream projection imports this runtime peer; Nx propagates the + // repo's legacy-peer-deps policy, so declare the locked peer explicitly. + '@langchain/core': + lock.packages['node_modules/@langchain/core'].version, + '@langchain/langgraph-sdk': + lock.packages['node_modules/@langchain/langgraph-sdk'].version, + }, + }) + ); + execFileSync( + 'npm', + ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund'], + { cwd: consumer, stdio: 'pipe' } + ); + const installed = join( + consumer, + 'node_modules/@threadplane/langgraph/runtime/create-session.js' + ); + capture('installed-runtime', { + directory, + installed, + emission, + installedSha256: createHash('sha256') + .update(readFileSync(installed)) + .digest('hex'), + }); + return import(pathToFileURL(installed).href); + })()); +} +async function fixture(t, options) { + assert.equal( + typeof createDeepAgentsSubagentsFixture, + 'function', + 'Missing native Subagents worker fixture' + ); + const handle = createDeepAgentsSubagentsFixture(options); + const server = createServer(async (req, res) => { + if ( + !(await handle(req, res, new URL(req.url, 'http://localhost').pathname)) + ) { + res.writeHead(404); + res.end(); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(r)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const client = new Client({ + apiUrl: base + '/api', + callerOptions: { maxRetries: 0 }, + }); + const thread = randomUUID(); + await client.threads.create({ threadId: thread, ifExists: 'raise' }); + const control = async (body) => + assert.equal( + ( + await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify(body), + }) + ).status, + 200 + ); + const stream = (id = randomUUID()) => + client.runs.stream(thread, 'subagents', { + input: { + messages: [{ type: 'human', id, content: 'Dispatch request.' }], + }, + streamMode: ['messages-tuple', 'values', 'updates', 'checkpoints'], + streamSubgraphs: true, + streamResumable: true, + onDisconnect: 'continue', + }); + const submit = async (id) => { + const events = []; + for await (const e of stream(id)) events.push(e); + return events; + }; + return { base, client, thread, control, submit, stream, handle }; +} +const rootCalls = (state) => + state.values.messages + .filter((m) => m.type === 'ai') + .flatMap((m) => m.tool_calls ?? []); +const results = (state) => + state.values.messages.filter((m) => m.type === 'tool'); +for (const scenario of [ + 'single', + 'parallel', + 'duplicates', + 'no-task', + 'general-purpose', + 'default-tool', + 'tool-error', + 'root-tool-error', +]) { + test( + `installed SDK real graph ${scenario} keeps native owner, namespace and saved checkpoints`, + { timeout: 30000 }, + async (t) => { + const f = await fixture(t); + await f.control({ scenario }); + const events = await f.submit('human-' + scenario); + const saved = await f.client.threads.getState(f.thread); + assert.deepEqual( + await f.client.threads.getState(f.thread, saved.checkpoint), + saved + ); + assert.deepEqual( + (await f.client.threads.getHistory(f.thread, { limit: 100 }))[0], + saved + ); + assert.deepEqual(saved.next, []); + assert.deepEqual(saved.tasks, []); + const run = events.find((e) => e.event === 'metadata').data.run_id; + assert.equal((await f.client.runs.get(f.thread, run)).status, 'success'); + const children = events.filter((e) => e.event.includes('|')); + const paths = new Set( + children.map((e) => e.event.split('|').slice(1).join('|')) + ); + assert.equal( + paths.size, + ['parallel', 'duplicates'].includes(scenario) + ? 4 + : ['no-task', 'default-tool', 'root-tool-error'].includes(scenario) + ? 0 + : 1 + ); + assert.equal( + rootCalls(saved).length, + ['parallel', 'duplicates'].includes(scenario) + ? 4 + : scenario === 'no-task' + ? 0 + : 1 + ); + assert.equal(results(saved).length, rootCalls(saved).length); + for (const c of rootCalls(saved)) + assert(results(saved).some((m) => m.tool_call_id === c.id)); + if (scenario === 'duplicates') + assert.equal( + new Set(rootCalls(saved).map((c) => c.args.description)).size, + 1 + ); + if (scenario === 'default-tool') + assert.equal(rootCalls(saved)[0].name, 'ls'); + if (scenario === 'tool-error') + assert( + children.some( + (e) => + e.event.startsWith('values|') && + e.data.messages.some( + (m) => m.type === 'tool' && m.status === 'error' + ) + ) + ); + const raw = await (await fetch(f.base + '/__raw-events')).json(); + if (scenario === 'parallel') { + const spans = [...paths].map((path) => { + const indices = raw[0].events.flatMap(([ns], i) => + ns.join('|') === path ? [i] : [] + ); + return [Math.min(...indices), Math.max(...indices)]; + }); + assert( + spans.some(([a, b], i) => + spans.some(([c, d], j) => i !== j && a < c && c < b && b < d) + ), + 'actual parallel child namespace events interleave' + ); + } + const proofs = await (await fetch(f.base + '/__graph-proof')).json(); + assert( + proofs.every( + (p) => p.actualCompiledGraph && p.networkConnectAttempts === 0 + ) + ); + capture('sdk-' + scenario, { events, raw, saved, proofs }); + if (scenario === 'duplicates') { + await f.submit('second-human'); + const second = await f.client.threads.getState(f.thread); + assert.equal( + second.values.messages.filter((m) => m.type === 'human').length, + 2 + ); + assert.equal(rootCalls(second).length, 8); + } + } + ); +} +test( + 'installed neutral createSession captures child human/tool/assistant and extra values before load', + { timeout: 60000 }, + async (t) => { + const { createSession } = await nativeRuntime(); + const f = await fixture(t); + await f.control({ scenario: 'parallel' }); + const session = createSession({ + apiUrl: f.base + '/api', + threadId: f.thread, + assistantId: 'subagents', + }); + t.after(() => session.dispose()); + const during = []; + const unsub = session.subscribe(() => { + const snap = session.getSnapshot(); + if (snap.subgraphs.length) during.push(structuredClone(snap)); + }); + const outcome = await session.submit('Dispatch request.'); + unsub(); + const before = structuredClone(session.getSnapshot()); + const saved = await f.client.threads.getState(f.thread); + const raw = await (await fetch(f.base + '/__raw-events')).json(); + capture('native-before-load', { outcome, before, during, saved, raw }); + assert.equal(outcome, 'success'); + assert.equal(before.subgraphs.length, 4); + for (const child of before.subgraphs) { + assert(Array.isArray(child.namespace)); + assert(child.namespace.length); + assert(child.messages.some((m) => m.role === 'user')); + assert(child.messages.some((m) => m.role === 'tool')); + assert(child.messages.some((m) => m.role === 'assistant')); + assert(child.values && Object.hasOwn(child.values, 'files')); + assert.equal(child.interrupts.length, 0); + } + await session.load(); + const after = structuredClone(session.getSnapshot()); + capture('native-after-load', { afterLoad: after }); + assert.equal( + after.subgraphs.length, + 0, + 'explicit root history load does not restore ephemeral child namespace streams' + ); + } +); +test( + 'native duplicate descriptions remain unlinked namespaces and reset each current session turn', + { timeout: 30000 }, + async (t) => { + const { createSession } = await nativeRuntime(); + const f = await fixture(t); + await f.control({ scenario: 'duplicates' }); + const session = createSession({ + apiUrl: f.base + '/api', + threadId: f.thread, + assistantId: 'subagents', + }); + t.after(() => session.dispose()); + assert.equal(await session.submit('First dispatch.'), 'success'); + const first = structuredClone(session.getSnapshot()); + assert.equal(first.subgraphs.length, 4); + assert.equal( + new Set( + first.subgraphs.map( + (c) => c.messages.find((m) => m.role === 'user').content + ) + ).size, + 1 + ); + assert.equal( + new Set(first.subgraphs.map((c) => JSON.stringify(c.namespace))).size, + 4 + ); + assert.equal(await session.submit('Second dispatch.'), 'success'); + const second = structuredClone(session.getSnapshot()); + assert.equal(second.subgraphs.length, 4); + assert( + second.subgraphs.every( + (c) => + !first.subgraphs.some( + (previous) => + JSON.stringify(c.namespace) === JSON.stringify(previous.namespace) + ) + ) + ); + const saved = await f.client.threads.getState(f.thread); + assert.equal(rootCalls(saved).length, 8); + await f.control({ scenario: 'no-task' }); + assert.equal(await session.submit('No delegation now.'), 'success'); + const noTask = structuredClone(session.getSnapshot()); + assert.equal(noTask.subgraphs.length, 0); + capture('native-duplicates', { + first, + second, + saved, + noTask, + raw: await (await fetch(f.base + '/__raw-events')).json(), + }); + } +); + +test( + 'root-tool-error is a real resolved task schema error on successful native run', + { timeout: 30000 }, + async (t) => { + const { createSession } = await nativeRuntime(); + const f = await fixture(t); + await f.control({ scenario: 'root-tool-error' }); + const session = createSession({ + apiUrl: f.base + '/api', + threadId: f.thread, + assistantId: 'subagents', + }); + t.after(() => session.dispose()); + assert.equal( + await session.submit('Invalid root task arguments.'), + 'success' + ); + const beforeLoad = structuredClone(session.getSnapshot()); + const saved = await f.client.threads.getState(f.thread); + const calls = rootCalls(saved); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'task'); + assert.deepEqual(calls[0].args, { subagent_type: 'field-researcher' }); + const reports = results(saved); + assert.equal(reports.length, 1); + assert.equal(reports[0].status, 'error'); + assert.equal(reports[0].tool_call_id, calls[0].id); + assert.match(reports[0].content, /description/); + assert.equal(beforeLoad.subgraphs.length, 0); + assert( + beforeLoad.messages.some( + (m) => m.role === 'tool' && m.toolCallId === calls[0].id + ) + ); + await session.load(); + const afterLoad = structuredClone(session.getSnapshot()); + assert.deepEqual( + await f.client.threads.getState(f.thread, saved.checkpoint), + saved + ); + capture('native-root-tool-error', { + beforeLoad, + afterLoad, + saved, + raw: await (await fetch(f.base + '/__raw-events')).json(), + }); + } +); + +test('unknown owner, selector, route, body and checkpoint fail closed', async (t) => { + const f = await fixture(t); + await f.submit(); + const saved = await f.client.threads.getState(f.thread); + await assert.rejects(f.client.threads.getState(randomUUID())); + await assert.rejects( + f.client.threads.getState(f.thread, { + ...saved.checkpoint, + thread_id: randomUUID(), + }) + ); + await assert.rejects( + f.client.threads.getState(f.thread, { + ...saved.checkpoint, + checkpoint_id: randomUUID(), + }) + ); + await assert.rejects(async () => { + for await (const e of f.client.runs.stream(f.thread, 'da-subagents', { + input: { messages: [{ type: 'human', id: 'bad', content: 'bad' }] }, + streamMode: ['values'], + })) { + } + }); + assert.equal( + (await fetch(f.base + `/api/threads/${f.thread}/unknown`)).status, + 404 + ); + assert.equal( + ( + await fetch(f.base + '/__configure', { + method: 'POST', + body: JSON.stringify({ scenario: 'injected' }), + }) + ).status, + 400 + ); + assert.equal( + ( + await fetch(f.base + '/api/threads', { + method: 'POST', + body: JSON.stringify({ thread_id: randomUUID() }), + }) + ).status, + 400 + ); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values, + saved.values + ); +}); +test('buffered delivery cancellation preserves actual saved graph and closes child', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'parallel', holdStream: true }); + const stream = f.stream(); + const metadata = await stream.next(); + const run = metadata.value.data.run_id; + await f.client.runs.cancel(f.thread, run, true, 'interrupt'); + assert.equal((await stream.next()).done, true); + assert.equal((await f.client.runs.get(f.thread, run)).status, 'interrupted'); + assert.equal(rootCalls(await f.client.threads.getState(f.thread)).length, 4); + const life = await (await fetch(f.base + '/__lifetime')).json(); + await f.handle.close(); + assert.throws(() => process.kill(life.workerPid, 0), { code: 'ESRCH' }); +}); +test( + 'worker unterminated oversized frame rejects before the long operation timeout', + { timeout: 12000 }, + async (t) => { + let child; + const timeout = 6000; + const handle = createDeepAgentsSubagentsFixture({ + workerTimeoutMs: timeout, + spawnWorker: () => + (child = spawn( + process.execPath, + [ + '-e', + 'process.stdin.once("data",()=>process.stdout.write(Buffer.alloc(16*1024*1024+1,0x78)));setInterval(()=>{},1000);', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + )), + }); + const server = createServer((req, res) => + handle(req, res, new URL(req.url, 'http://localhost').pathname) + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const client = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + const started = performance.now(); + await assert.rejects( + client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + const elapsed = performance.now() - started; + await handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + assert( + elapsed < timeout / 2, + `Oversized unterminated frame waited ${elapsed}ms toward ${timeout}ms operation timeout` + ); + } +); + +test( + 'worker parser accepts fragmented UTF8 exact-limit frames and resets bytes per response', + { timeout: 15000 }, + async (t) => { + // Injected responses test framing only; all graph-authority tests use the real worker. + const sourceSha256 = Object.fromEntries( + ['src/graph.py', 'prompts/subagents.md', 'uv.lock'].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync( + join(root, 'cockpit/deep-agents/subagents/python', path) + ) + ) + .digest('hex'), + ]) + ); + const source = ` + const {createInterface}=require('node:readline'); + const lines=createInterface({input:process.stdin}); + lines.on('line',async line=>{ + const r=JSON.parse(line); + const response={requestId:r.requestId,proof:{actualCompiledGraph:true,op:r.op,threadId:r.threadId??null,networkConnectAttempts:0,sourceSha256:${JSON.stringify( + sourceSha256 + )}},padding:'é✈️'}; + response.padding+=' '.repeat(16*1024*1024-Buffer.byteLength(JSON.stringify(response))); + const bytes=Buffer.from(JSON.stringify(response)+'\\n'); + const split=bytes.indexOf(Buffer.from('é'))+1; + process.stdout.write(bytes.subarray(0,split)); + await new Promise(resolve=>setImmediate(resolve)); + for(let start=split;startprocess.stdout.once('drain',resolve)); + } + });`; + let child; + const f = await fixture(t, { + spawnWorker: () => + (child = spawn(process.execPath, ['-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + const other = randomUUID(); + assert.equal( + (await f.client.threads.create({ threadId: other, ifExists: 'raise' })) + .thread_id, + other + ); + await f.handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + } +); + +test('worker parser processes each coalesced frame and rejects the unowned second response', async (t) => { + // The second response is deliberately not pending, even when both frames share a chunk. + const sourceSha256 = Object.fromEntries( + ['src/graph.py', 'prompts/subagents.md', 'uv.lock'].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync(join(root, 'cockpit/deep-agents/subagents/python', path)) + ) + .digest('hex'), + ]) + ); + const source = `process.stdin.once('data',data=>{const r=JSON.parse(data);process.stdout.write(JSON.stringify({requestId:r.requestId,proof:{actualCompiledGraph:true,op:r.op,threadId:r.threadId,networkConnectAttempts:0,sourceSha256:${JSON.stringify( + sourceSha256 + )}}})+'\\n'+JSON.stringify({requestId:999})+'\\n');});setInterval(()=>{},1000);`; + let child; + const f = await fixture(t, { + spawnWorker: () => + (child = spawn(process.execPath, ['-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + await assert.rejects( + f.client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await f.handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); +}); + +for (const [name, source] of [ + ['timeout', 'process.stdin.resume();setInterval(()=>{},1000);'], + [ + 'malformed', + 'process.stdin.once("data",()=>process.stdout.write("bad\\n"));setInterval(()=>{},1000);', + ], + [ + 'oversized response', + 'process.stdin.once("data",()=>process.stdout.write("x".repeat(16*1024*1024+1)+"\\n"));setInterval(()=>{},1000);', + ], + [ + 'foreign response', + 'process.stdin.once("data",()=>process.stdout.write(JSON.stringify({requestId:999})+"\\n"));setInterval(()=>{},1000);', + ], + [ + 'invalid proof', + 'process.stdin.once("data",d=>process.stdout.write(JSON.stringify({requestId:JSON.parse(d).requestId,proof:{actualCompiledGraph:false}})+"\\n"));setInterval(()=>{},1000);', + ], +]) { + test(`worker ${name} closes owned process`, async (t) => { + let child; + const handle = createDeepAgentsSubagentsFixture({ + workerTimeoutMs: 1000, + spawnWorker: () => + (child = spawn(process.execPath, ['-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + const server = createServer((req, res) => + handle(req, res, new URL(req.url, 'http://localhost').pathname) + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(r)); + }); + const client = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + await assert.rejects( + client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + }); +} +test( + 'stdio worker independently rejects malformed wire, replay and foreign checkpoint', + { timeout: 30000 }, + async (t) => { + const child = spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/subagents/python', + 'python', + 'scripts/react-cockpit/deep-agents-subagents-wire.py', + ], + { + cwd: root, + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-genui-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + child.stderr.resume(); + const lines = createInterface({ input: child.stdout }); + t.after(async () => { + const end = once(child, 'close'); + child.stdin.end(); + await end; + lines.close(); + }); + let n = 0; + const send = async (v) => { + const line = once(lines, 'line'); + child.stdin.write(JSON.stringify({ ...v, requestId: ++n }) + '\n'); + return JSON.parse((await line)[0]); + }; + assert.equal( + (await send({ op: 'state', threadId: 'unknown' })).error, + 'Graph operation failed' + ); + await send({ op: 'create', threadId: 'a' }); + await send({ op: 'create', threadId: 'b' }); + const input = { + op: 'submit', + threadId: 'a', + messages: [{ type: 'human', id: 'a-human', content: 'Dispatch' }], + }; + const wire = await send(input); + assert(wire.events.some(([ns]) => ns.length)); + assert.equal((await send(input)).error, 'Graph operation failed'); + assert.equal( + ( + await send({ + op: 'state', + threadId: 'b', + checkpoint: wire.state.checkpoint, + }) + ).error, + 'Graph operation failed' + ); + assert.equal( + (await send({ op: 'configure', scenario: 'remote' })).error, + 'Graph operation failed' + ); + assert.equal( + (await send({ op: 'state', threadId: 'a', extra: true })).error, + 'Graph operation failed' + ); + const bad = once(lines, 'line'); + child.stdin.write('{invalid}\n'); + assert.equal(JSON.parse((await bad)[0]).error, 'Graph operation failed'); + } +); diff --git a/scripts/react-cockpit/deep-agents-subagents-wire.py b/scripts/react-cockpit/deep-agents-subagents-wire.py new file mode 100644 index 000000000..3164f2281 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-subagents-wire.py @@ -0,0 +1,152 @@ +"""Bounded newline-JSON worker running the unchanged compiled Subagents builder.""" +import asyncio +from dataclasses import asdict, is_dataclass +from contextlib import redirect_stdout +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import socket +import sys +import traceback +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +PROJECT = ROOT / "cockpit/deep-agents/subagents/python" +sys.path.insert(0, str(PROJECT / "tests")) +from test_streaming import LocalModel, build, SCENARIOS + +SOURCES = ("src/graph.py", "prompts/subagents.md", "uv.lock") +MAX_REQUEST = 1024 * 1024 +MAX_RESPONSE = 16 * 1024 * 1024 + + +def encode(value): + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + if is_dataclass(value): + return asdict(value) + raise TypeError("Unsupported graph wire value") + + +def checkpoint(state): + return { + "values": state.values, "next": list(state.next), + "checkpoint": {k: v for k, v in state.config["configurable"].items() + if k in ("thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map")}, + "parent_checkpoint": dict(state.parent_config["configurable"]) if state.parent_config else None, + "created_at": state.created_at, "metadata": state.metadata, + "tasks": [{"id": t.id, "name": t.name, + **({"error": t.error} if t.error is not None else {}), + **({"state": t.state} if t.state is not None else {}), + "interrupts": [{"id": item.id, "value": item.value} for item in t.interrupts]} + for t in state.tasks], + } + + +async def main(): + model = LocalModel() + scenario = "single" + owners = set() + seen = set() + request_ids = set() + provenance = {} + with patch.object(socket.socket, "connect", side_effect=AssertionError("Remote socket forbidden")) as network: + with redirect_stdout(sys.stderr): + graph = build(model) + while line := sys.stdin.buffer.readline(MAX_REQUEST + 1): + request = {} + try: + if len(line) > MAX_REQUEST or not line.endswith(b"\n"): + raise ValueError("Oversized request") + request = json.loads(line) + if not isinstance(request, dict) or set(request) - {"requestId", "op", "threadId", "scenario", "checkpoint", "messages"}: + raise ValueError("Malformed request") + if type(request.get("requestId")) is not int or request["requestId"] <= 0 or request["requestId"] in request_ids or len(request_ids) >= 2000: + raise ValueError("Invalid/replayed request id or lifetime limit") + request_ids.add(request["requestId"]) + with redirect_stdout(sys.stderr): + config = {"configurable": {"thread_id": request.get("threadId", "")}} + op = request["op"] + fields = {"create": {"threadId"}, "configure": {"scenario"}, "state": {"threadId", "checkpoint"}, + "history": {"threadId"}, "submit": {"threadId", "messages"}, "resume": {"threadId"}} + if op not in fields or set(request) - ({"requestId", "op"} | fields[op]): + raise ValueError("Unsupported operation fields") + if op == "create": + if not isinstance(request.get("threadId"), str) or not request["threadId"] or request["threadId"] in owners or len(owners) >= 100: + raise ValueError("Invalid or duplicate owner") + owners.add(request["threadId"]) + elif op != "configure" and request.get("threadId") not in owners: + raise ValueError("Unknown owner") + if "checkpoint" in request: + requested_checkpoint = request["checkpoint"] + if (not isinstance(requested_checkpoint, dict) + or requested_checkpoint.get("thread_id") != config["configurable"]["thread_id"] + or set(requested_checkpoint) - {"thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map"} + or not isinstance(requested_checkpoint.get("checkpoint_id"), str) + or not requested_checkpoint["checkpoint_id"] + or not isinstance(requested_checkpoint.get("checkpoint_ns"), str)): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + if "checkpoint_map" in requested_checkpoint and ( + not isinstance(requested_checkpoint["checkpoint_map"], dict) + or set(requested_checkpoint["checkpoint_map"]) != {requested_checkpoint["checkpoint_ns"]} + or requested_checkpoint["checkpoint_map"][requested_checkpoint["checkpoint_ns"]] != requested_checkpoint["checkpoint_id"]): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + known = [s async for s in graph.aget_state_history(config)] + if not any(s.config["configurable"].get("checkpoint_id") == requested_checkpoint.get("checkpoint_id") and s.config["configurable"].get("checkpoint_ns", "") == requested_checkpoint.get("checkpoint_ns") for s in known): + raise ValueError("Unknown checkpoint") + config["configurable"].update({k: v for k, v in requested_checkpoint.items() if k != "thread_id"}) + op = request["op"] + if op == "configure": + scenario = request["scenario"] + if scenario not in SCENARIOS: + raise ValueError("Unsupported scenario") + result = {} + elif op == "create": + result = {} + elif op == "state": + result = {"state": checkpoint(await graph.aget_state(config))} + elif op == "history": + result = {"history": [checkpoint(s) async for s in graph.aget_state_history(config)]} + elif op == "submit": + current = await graph.aget_state(config) + if any(t.interrupts for t in current.tasks): + raise ValueError("Cannot submit over paused batch") + messages = request.get("messages") + if not isinstance(messages, list) or len(messages) != 1: + raise ValueError("Expected one human message") + message = messages[0] + if not isinstance(message, dict) or set(message) != {"type", "id", "content"} or message["type"] != "human" or not isinstance(message["id"], str) or not message["id"].strip() or not isinstance(message["content"], str): + raise ValueError("Invalid human message") + identity = message["id"] + if (request["threadId"], identity) in seen: + raise ValueError("Replayed human message") + seen.add((request["threadId"], identity)) + model.scenario = scenario + provenance[request["threadId"]] = scenario + events = [] + async for namespace, kind, event in graph.astream( + {"messages": request["messages"]}, config, + subgraphs=True, stream_mode=["messages", "values", "updates", "checkpoints"]): + events.append([list(namespace), kind, deepcopy(event)]) + result = {"events": events, "state": checkpoint(await graph.aget_state(config))} + else: + raise ValueError("Unsupported operation") + network.assert_not_called() + result["proof"] = { + "actualCompiledGraph": True, "op": op, + "threadId": request.get("threadId"), "scenario": provenance.get(request.get("threadId"), scenario), + "sourceSha256": {p: hashlib.sha256((PROJECT / p).read_bytes()).hexdigest() for p in SOURCES}, + "networkConnectAttempts": network.call_count, + } + result["requestId"] = request["requestId"] + wire = json.dumps(result, default=encode) + if len(wire.encode()) > MAX_RESPONSE: + raise ValueError("Oversized response") + print(wire, flush=True) + except Exception: + traceback.print_exc(file=sys.stderr) + print(json.dumps({"requestId": (request.get("requestId") if isinstance(request, dict) else None), "error": "Graph operation failed"}), flush=True) + + +asyncio.run(main()) diff --git a/scripts/react-cockpit/serve.mjs b/scripts/react-cockpit/serve.mjs index fd3840e02..f320ced0e 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -28,7 +28,10 @@ import { createAgUiSubagentsFixture } from './ag-ui-subagents-fixture.mjs'; import { createDeepAgentsFilesystemFixture } from './deep-agents-filesystem-fixture.mjs'; import { createDeepAgentsPlanningFixture } from './deep-agents-planning-fixture.mjs'; +import { createDeepAgentsSubagentsFixture } from './deep-agents-subagents-fixture.mjs'; + const configuration = reactCockpitConfiguration(process.argv[2]); +const deepAgentsSubagentsFixture = configuration.library === 'deep-agents' && configuration.topic === 'subagents' ? createDeepAgentsSubagentsFixture() : null; const deepAgentsFilesystemFixture = configuration.library === 'deep-agents' && configuration.topic === 'filesystem' ? createDeepAgentsFilesystemFixture() : null; const deepAgentsPlanningFixture = configuration.library === 'deep-agents' && configuration.topic === 'planning' ? createDeepAgentsPlanningFixture() : null; let chatGenerativeUiFixture = configuration.library === 'chat' && configuration.topic === 'generative-ui' ? createChatGenerativeUiFixture() : null; @@ -108,6 +111,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (deepAgentsSubagentsFixture && (await deepAgentsSubagentsFixture(request, response, pathname))) return; if (deepAgentsFilesystemFixture && (await deepAgentsFilesystemFixture(request, response, pathname))) return; if (deepAgentsPlanningFixture && (await deepAgentsPlanningFixture(request, response, pathname))) return; if (chatGenerativeUiFixture && pathname === '/__reset') { @@ -340,11 +344,11 @@ const server = createServer(async (request, response) => { server.listen(configuration.port, '127.0.0.1'); const parentServer = !process.argv.includes('--no-parent') ? createServer(server.listeners('request')[0]).listen(3000, '127.0.0.1') : null; -if (deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (deepAgentsSubagentsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (deepAgentsSubagentsFixture ?? deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); await Promise.all([server, parentServer].filter(Boolean).map((owned) => new Promise((resolve) => { owned.close(resolve); owned.closeAllConnections(); diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 8f5cabf71..ba786973a 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,9 +1,9 @@ { "schemaVersion": 1, - "baselineHead": "a3e5452c45369e4caba69cb51b0c1c8b186216c1", + "baselineHead": "49d0b92652f290f586b5b8cb3d9c9d9748834531", "sourceState": { "modified": [ - "apps/website/content/docs/deep-agents/capabilities/filesystem.mdx", + "apps/website/content/docs/deep-agents/capabilities/subagents.mdx", "libs/cockpit-registry/src/lib/capability-registry.ts", "libs/cockpit-registry/src/lib/content-descriptors.ts" ], @@ -2704,7 +2704,7 @@ "id": "doc:apps/website/content/docs/deep-agents/capabilities/subagents.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/capabilities/subagents.mdx", - "sha256": "19e8785ec483298a26172b0e30b1da110090d874bb1f725cff080e645b4f6ab2" + "sha256": "366cdd8205a105b636c1a0073913180ffbaeb08e14e3d894253e53944b2ce8b7" }, { "id": "doc:apps/website/content/docs/deep-agents/getting-started/introduction.mdx", @@ -13465,13 +13465,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "b0be0c0dca65b7ba110f6b0412c6dea4048e3fd2cc33ea3e4b5cccf941bc93ac" + "sha256": "e59447b7d2de74b83b953bb1adb07d8c01a7bb3ea61c7657ff9fab35956f8ee0" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "b84d95ce712bab88fad1625ebe3ef58e96e9df47f06256dcee1cc4f6c6760930" + "sha256": "98d6fae452f3d5919ddb70463703ce34a5d8b09a31f7b73437a11fad0dd0b593" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts", From e4909ec6fdd0c900ab52b283f530374de453bb83 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 11 Oct 2026 11:01:35 -0700 Subject: [PATCH 2/2] test(react): verify Subagents fixture installs from a cold cache --- .../deep-agents-subagents-fixture.spec.mjs | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs index 7cb24a808..94327bd19 100644 --- a/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs +++ b/scripts/react-cockpit/deep-agents-subagents-fixture.spec.mjs @@ -13,6 +13,7 @@ import { Client } from '@langchain/langgraph-sdk'; import { emitCandidate, packCandidate, + installCandidateConsumer, } from '../react-parity/langgraph-candidate-package.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const { createDeepAgentsSubagentsFixture } = await import( @@ -53,27 +54,17 @@ function nativeRuntime() { const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'))); const consumer = join(directory, 'consumer'); mkdirSync(consumer); - writeFileSync( - join(consumer, 'package.json'), - JSON.stringify({ - private: true, - type: 'module', - dependencies: { - '@threadplane/core': 'file:' + join(directory, core.filename), - '@threadplane/langgraph': 'file:' + packed.tarball, - // SDK stream projection imports this runtime peer; Nx propagates the - // repo's legacy-peer-deps policy, so declare the locked peer explicitly. - '@langchain/core': - lock.packages['node_modules/@langchain/core'].version, - '@langchain/langgraph-sdk': - lock.packages['node_modules/@langchain/langgraph-sdk'].version, - }, - }) - ); - execFileSync( - 'npm', - ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund'], - { cwd: consumer, stdio: 'pipe' } + // Reuse the package verifier's exact locked graph and tarball-byte checks. + // A clean npm-ci cache contains tarballs but need not contain packuments. + const installation = installCandidateConsumer( + consumer, + { private: true, type: 'module' }, + { + '@threadplane/core': 'file:' + join(directory, core.filename), + '@threadplane/langgraph': 'file:' + packed.tarball, + }, + lock, + 'node' ); const installed = join( consumer, @@ -83,6 +74,7 @@ function nativeRuntime() { directory, installed, emission, + installation, installedSha256: createHash('sha256') .update(readFileSync(installed)) .digest('hex'),