diff --git a/apps/website/content/docs/deep-agents/capabilities/planning.mdx b/apps/website/content/docs/deep-agents/capabilities/planning.mdx index 6e8b8ba6b..4e843cd4f 100644 --- a/apps/website/content/docs/deep-agents/capabilities/planning.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/planning.mdx @@ -1,6 +1,6 @@ --- title: Planning -description: TodoListMiddleware puts a todos array on the graph state, and an Angular panel renders the plan as the agent revises it mid-run. +description: Render evolving todo plans with TodoListMiddleware. Angular is the default; the native React preview shows live and saved plans on the same Python graph. --- # Planning diff --git a/apps/website/e2e/react-deep-agents-planning-preview.spec.ts b/apps/website/e2e/react-deep-agents-planning-preview.spec.ts new file mode 100644 index 000000000..994295761 --- /dev/null +++ b/apps/website/e2e/react-deep-agents-planning-preview.spec.ts @@ -0,0 +1,227 @@ +import { test, expect, type Page } from '@playwright/test'; +import { fromPageOrReactPreview } from './fixtures/react-preview-requests'; +const route = '/docs/deep-agents/capabilities/planning'; +const reactFrame = /(?:localhost:4628|deep-agents\/planning\/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); +} +test('native Deep Agents Planning 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 Planning preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/deep-agents/planning/react/src/application.ts"]' + ) + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-planning-docs-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-planning-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', + 'plan-state.ts', + 'plan-panel.tsx', + 'tool-evidence.ts', + 'terminal.ts', + 'main.tsx', + 'styles.css', + ]) + await expect( + page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name, exact: true }) + ).toBeVisible(); + await expect(page.getByRole('tabpanel')).toContainText( + 'createConnectedApplication' + ); + const graph = page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name: 'graph.py', exact: true }); + await graph.click(); + await expect(page.getByRole('tabpanel')).toContainText('TodoListMiddleware'); + await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: /^Run(?:,|$)/ }) + .click(); + await emptyRun(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-planning-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-planning-run-mobile.png'), + }); +}); +test('the canonical Planning 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 Planning preview', + exact: true, + }) + ).toHaveCount(0); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Planning 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: 'Dispatch brief: KSFO to KASE', exact: true }) + .click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue( + 'Plan a dispatch brief for a flight from KSFO to KASE: check field elevation, runway length, and weather at both ends, then tell me if there is anything the crew should know.' + ); + await frame + .getByRole('button', { name: 'New conversation', exact: true }) + .click(); + await emptyRun(page); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 140e864b4..45369912b 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -402,6 +402,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4628', + 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 506bc3bd8..ae4fd77fa 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -102,7 +102,8 @@ "cockpit-chat-subagents-react", "cockpit-chat-threads-react", "cockpit-chat-timeline-react", - "cockpit-chat-generative-ui-react" + "cockpit-chat-generative-ui-react", + "cockpit-deep-agents-planning-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 a46dfd1db..05be00c60 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,19 @@ import { ReactTimeTravelPreview } from '../../../../../components/docs/ReactTime import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; import DocsPage, { generateMetadata } from './page'; +it('selects Planning-specific native React Docs and sources while retaining Angular Docs', async () => { + const tree = await route('deep-agents', 'capabilities', 'planning'); + 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('ReactDeepAgentsPlanningPreview'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/planning/react/src/plan-state.ts'); + expect(article?.props.exampleCode?.sources?.['cockpit/deep-agents/planning/react/src/terminal.ts']).toContain('checkpoint'); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths.some(path => path.includes('/planning/angular/'))).toBe(true); + expect(findElement(workspace?.props.reactDocsSlot, ReactStreamingPreview as ComponentType)).toBeNull(); +}); + interface ElementProps { children?: ReactNode; docsSlot?: ReactNode; 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 56b3dfbef..847be9717 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 { ReactDeepAgentsPlanningPreview } from '../../../../../components/docs/ReactDeepAgentsPlanningPreview'; import { ReactChatTimelinePreview } from '../../../../../components/docs/ReactChatTimelinePreview'; import { ReactChatThreadsPreview } from '../../../../../components/docs/ReactChatThreadsPreview'; import { ReactChatSubagentsPreview } from '../../../../../components/docs/ReactChatSubagentsPreview'; @@ -282,10 +283,13 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || + (library === 'deep-agents' && section === 'capabilities' && slug === 'planning') || (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 === 'render' ? ( + library === 'deep-agents' && section === 'capabilities' && slug === 'planning' ? ( + + ) : library === 'render' ? ( slug === 'provide-render' ? : slug === 'render-spec-component' ? : slug === 'registry' ? : diff --git a/apps/website/src/components/docs/ReactDeepAgentsPlanningPreview.tsx b/apps/website/src/components/docs/ReactDeepAgentsPlanningPreview.tsx new file mode 100644 index 000000000..dac2fdb64 --- /dev/null +++ b/apps/website/src/components/docs/ReactDeepAgentsPlanningPreview.tsx @@ -0,0 +1,62 @@ +import { MdxRenderer } from './MdxRenderer'; +import type { ExampleCodeContext } from '../../lib/example-code'; + +const source = `# React Deep Agents Planning preview + +This experimental example composes native React chat components with a read-only plan panel. The React, core, content and native LangGraph candidates are private packages built from source. Angular remains the default on this canonical Planning guide. Both frontends use the same Python graph, planning prompt and shared FREE runtime. + +## Start a dispatch brief + +Choose **Run**, type a message and choose **Send** or press **Enter**. **Dispatch brief: KSFO to KASE** fills the draft with the existing aviation suggestion; it does not send. Airport elevation, runway and weather lookups use fixed tables in the unchanged graph. + +The first Send creates the conversation. **New conversation** clears the local view and waits for Send to create another conversation. A failed creation can be retried. Opening Docs, Code or Run makes no thread request. Mode navigation and browser history preserve the mounted frame and unsent draft; reloading clears local state. + + + + +## Watch the current plan + +Each valid todos update replaces the whole observed list while the response is running. The panel preserves duplicate tasks and their order, and an empty list clears it. Empty task text has a visible placeholder. Validation allows at most 50 items and 2000 UTF-16 code units per task. An invalid update retains the last valid observed plan and shows a notice; a later valid update clears that notice. + +The read-only panel shows the backend's pending, in-progress and completed statuses and derives progress from them. Finishing a response does not mark unfinished tasks completed. **Live observed plan** and **Last saved plan** are separate: a visible streamed update has not yet been confirmed as saved. + + + + +## Confirm what was saved + +A tool marked complete means its result was received; it does not prove the plan was applied. Tool arguments, received results and errors remain inspectable beside the conversation. Saved confirmation separately checks the raw tool result status. The example confirms a saved plan only after the actual run succeeds and its exact saved checkpoint agrees with the current human request, retained conversation prefix, canonical native messages and successful write_todos evidence. Tool arguments and checkpoint todos must match exactly, including order and duplicates. Prose and printed Python representations do not supply plan authority. + + + + + +## Stop and recover + +**Stop** keeps the last saved plan visible and requires **New conversation** before another Send. A first native failure before any saved checkpoint also requires New conversation. A later unconfirmed request keeps the saved plan separate; a later valid write can recover against the current conversation prefix. A response with no write can retain an unchanged, previously confirmed plan, but cannot adopt changed, unconfirmed todos. These are local display and submission rules, not a claim that stopping rolls back server state. There is no automatic replay or resume. + +## Build from source + +From a Threadplane checkout with dependencies installed: + +~~~sh +npx nx build cockpit-deep-agents-planning-react +npx nx e2e cockpit-deep-agents-planning-react +~~~ + +Browser verification uses the real SDK, compiled Python graph and saved checkpoints with deterministic providers. Verification endpoints are excluded from public output. +`; + +export function ReactDeepAgentsPlanningPreview({ + 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 960d97820..0bd217b34 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; -import { capabilityModules } from '@threadplane/cockpit-registry'; +import { capabilityModules, getFrontendCapabilityDescriptor } from '@threadplane/cockpit-registry'; import { describe, expect, it } from 'vitest'; import { resolveExampleFile, @@ -28,6 +28,21 @@ const findWorkspaceRoot = (): string => { const WORKSPACE_ROOT = findWorkspaceRoot(); const CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content'); +it('resolves every native Planning article include from its registered sources and shared backend', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-planning-python')!; + 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/ReactDeepAgentsPlanningPreview.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(7); + 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); +}); + interface MappedPage { readonly docsPath: string; readonly assetPaths: readonly string[]; diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 03ebc775a..dd3cc7cf6 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(28); - expect(new Set(prerequisites).size).toBe(28); + expect(prerequisites).toHaveLength(29); + expect(new Set(prerequisites).size).toBe(29); 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 twenty-eight owned React servers after exact fresh Nx prerequisites', () => { + it('starts twenty-nine 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[01234567])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(28); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[012345678])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(29); 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(28); - expect(new Set(projects).size).toBe(28); + expect(projects).toHaveLength(29); + expect(new Set(projects).size).toBe(29); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -87,6 +87,8 @@ describe('Website Playwright configuration', () => { expect(projects).toContain('cockpit-chat-messages-react'); expect(projects).toContain('cockpit-chat-input-react'); expect(projects).toContain('cockpit-chat-threads-react'); + expect(projects).toContain('cockpit-deep-agents-planning-react'); + 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'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4626')?.command).toBe('npx nx run cockpit-chat-timeline-python:smoke && node scripts/react-cockpit/serve.mjs chat-timeline --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4625')?.command).toBe('npx nx run cockpit-chat-threads-python:smoke && node scripts/react-cockpit/serve.mjs chat-threads --no-parent'); @@ -370,6 +372,7 @@ describe('Website Playwright configuration', () => { expect.objectContaining({command:'npx nx run cockpit-chat-input-python:smoke && node scripts/react-cockpit/serve.mjs chat-input --no-parent',url:'http://127.0.0.1:4621'}), expect.objectContaining({command:'npx nx run cockpit-chat-interrupts-python:smoke && node scripts/react-cockpit/serve.mjs chat-interrupts --no-parent',url:'http://127.0.0.1:4622'}), 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-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/planning/python/project.json b/cockpit/deep-agents/planning/python/project.json index cedb04948..4122c0980 100644 --- a/cockpit/deep-agents/planning/python/project.json +++ b/cockpit/deep-agents/planning/python/project.json @@ -6,25 +6,29 @@ "targets": { "build": { "executor": "@nx/js:tsc", - "outputs": [ - "{workspaceRoot}/dist/cockpit/deep-agents/planning/python" - ], + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/planning/python"], "options": { "outputPath": "dist/cockpit/deep-agents/planning/python", "main": "cockpit/deep-agents/planning/python/src/index.ts", "tsConfig": "cockpit/deep-agents/planning/python/tsconfig.json" } }, + "test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "cwd": "cockpit/deep-agents/planning/python", + "command": "uv run --frozen --python 3.12 python -m unittest discover -s tests" + } + }, "smoke": { "executor": "nx:run-commands", + "cache": false, + "dependsOn": ["test"], "options": { "command": "npx tsx -e \"import { deepAgentsPlanningPythonModule } from './cockpit/deep-agents/planning/python/src/index.ts'; const mod = deepAgentsPlanningPythonModule; if (mod.id !== 'deep-agents-planning-python') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Planning (Python)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } }, - "tags": [ - "scope:cockpit-e2e", - "scope:cockpit-examples", - "scope:cockpit-smoke" - ] + "tags": ["scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke"] } diff --git a/cockpit/deep-agents/planning/python/tests/test_streaming.py b/cockpit/deep-agents/planning/python/tests/test_streaming.py new file mode 100644 index 000000000..4166e91d9 --- /dev/null +++ b/cockpit/deep-agents/planning/python/tests/test_streaming.py @@ -0,0 +1,163 @@ +"""Contracts of the actual Planning builder and installed TodoListMiddleware.""" +import importlib.util +import json +import os +from pathlib import Path +import socket +import unittest +from copy import deepcopy +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, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langgraph.checkpoint.memory import InMemorySaver +# Load provider type checks before the construction-only patch below. +import deepagents + + +def todo(content, status="pending"): + return {"content": content, "status": status} + + +def call(todos, identity="write"): + return {"name": "write_todos", "args": {"todos": todos}, "id": identity, "type": "tool_call"} + + +class LocalModel(BaseChatModel): + responses: list[AIMessage] = [] + cursor: int = 0 + + @property + def _llm_type(self): + return "local-planning-contract" + + def bind_tools(self, tools, **kwargs): + return self + + def answer(self): + answer = self.responses[self.cursor] + self.cursor += 1 + return answer + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + return ChatResult(generations=[ChatGeneration(message=self.answer())]) + + def _stream(self, messages, stop=None, run_manager=None, **kwargs): + answer = self.answer() + for start in range(0, len(answer.content), 4): + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content=answer.content[start:start + 4])) + for index, 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": index, "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")) + + +def build(model): + """Patch only model construction; recompile the actual builder with persistence.""" + path = Path(__file__).parents[1] / "src/graph.py" + with patch("langchain_openai.ChatOpenAI", return_value=model): + spec = importlib.util.spec_from_file_location("local_planning_contract", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.build_planning_agent().builder.compile(checkpointer=InMemorySaver()) + + +def responses(batches, identity="turn"): + return [AIMessage(id=f"{identity}-model-{i}", content="", tool_calls=batch) + for i, batch in enumerate(batches)] + [AIMessage(id=f"{identity}-answer", content="Flight assessment ready.")] + + +class PlanningStreamingTests(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": "planning-contract"}} + + async def send(self, batches, identity="turn"): + self.model.responses = responses(batches, identity) + self.model.cursor = 0 + events = [deepcopy(e) async for e in self.graph.astream( + {"messages": [{"type": "human", "id": identity, "content": identity}]}, + self.config, stream_mode=["messages", "values", "updates", "checkpoints"])] + saved = await self.graph.aget_state(self.config) + self.assertFalse(saved.next) + self.assertFalse(saved.tasks) + self.assertEqual([v for k, v in events if k == "values"][-1], saved.values) + checkpoints = [v for k, v in events if k == "checkpoints"] + self.assertEqual(checkpoints[-1]["config"]["configurable"]["checkpoint_id"], saved.config["configurable"]["checkpoint_id"]) + self.assertEqual(checkpoints[-1]["values"], saved.values) + self.assertTrue(all(not task.interrupts for task in saved.tasks)) + return saved, events + + async def test_whole_list_replacement_preserves_order_duplicates_and_empty(self): + first = [todo("Old"), todo("Removed")] + replacement = [todo("Duplicate", "completed"), todo("Duplicate", "in_progress"), todo("First", "pending")] + saved, events = await self.send([[call(first, "first")], [call(replacement, "replacement")]]) + self.assertEqual(saved.values["todos"], replacement) + observed = [v.get("todos") for k, v in events if k == "values"] + self.assertIn(first, observed) + self.assertIn(replacement, observed) + tools = [m for m in saved.values["messages"] if isinstance(m, ToolMessage)] + self.assertEqual([(m.tool_call_id, m.name, m.status) for m in tools], [("first", "write_todos", "success"), ("replacement", "write_todos", "success")]) + self.assertTrue(tools[-1].content.startswith("Updated todo list to [")) + cleared, _ = await self.send([[call([], "clear")]], "clear-turn") + self.assertEqual(cleared.values["todos"], []) + + async def test_parallel_rejection_retains_prior_plan_and_later_valid_write_recovers(self): + prior = [todo("Retain")] + await self.send([[call(prior, "seed")]], "seed") + rejected, _ = await self.send([[call([todo("Wrong A")], "a"), call([todo("Wrong B")], "b")]], "rejected") + self.assertEqual(rejected.values["todos"], prior) + errors = [m for m in rejected.values["messages"] if isinstance(m, ToolMessage) and m.tool_call_id in ("a", "b")] + self.assertEqual([(m.tool_call_id, m.name, m.status) for m in errors], [("a", None, "error"), ("b", None, "error")]) + self.assertTrue(all("multiple times in parallel" in m.content for m in errors)) + valid = [todo("Recovered", "in_progress")] + saved, events = await self.send([[call(prior, "c"), call(prior, "d")], [call(valid, "recover")]], "recovery") + self.assertEqual(saved.values["todos"], valid) + self.assertEqual(saved.values["messages"][-1].content, "Flight assessment ready.") + self.assertEqual(saved.values["todos"][0]["status"], "in_progress") + tools = [m for m in saved.values["messages"] if isinstance(m, ToolMessage)] + self.assertEqual(tools[-1].status, "success") + + async def test_invalid_tool_schema_rejected_without_replacing_plan(self): + prior = [todo("Safe", "completed")] + await self.send([[call(prior, "seed")]], "seed") + for identity, args in (("bad-status", {"todos": [todo("Bad", "invented")]}), ("missing-todos", {})): + malformed = {"name": "write_todos", "args": args, "id": identity, "type": "tool_call"} + saved, _ = await self.send([[malformed]], identity) + self.assertEqual(saved.values["todos"], prior) + result = next(m for m in saved.values["messages"] if isinstance(m, ToolMessage) and m.tool_call_id == identity) + self.assertEqual((result.name, result.status), ("write_todos", "error")) + self.assertIn("Error", result.content) + + async def test_saved_checkpoint_history_correlates_canonical_messages_without_run_metadata(self): + saved, events = await self.send([[call([todo("Work", "in_progress")])]]) + checkpoints = [e for k, e in events if k == "checkpoints"] + terminal = checkpoints[-1] + self.assertEqual(terminal["config"]["configurable"]["thread_id"], "planning-contract") + exact = await self.graph.aget_state(terminal["config"]) + self.assertEqual(exact.values, saved.values) + self.assertEqual(terminal["next"], []) + self.assertEqual(terminal["tasks"], []) + self.assertNotIn("run_id", exact.metadata) + self.assertEqual(exact.values["messages"][0].id, "turn") + history = [s async for s in self.graph.aget_state_history(self.config)] + self.assertEqual(history[0].config, saved.config) + self.assertTrue(any(s.next for s in history)) + self.assertTrue(any(s.tasks for s in history)) + self.assertFalse(any(t.interrupts for s in history for t in s.tasks)) + self.assertNotIn("completed_turn_id", saved.values) + self.assertNotIn("operation_receipt", saved.values) + + +if __name__ == "__main__": + unittest.main() diff --git a/cockpit/deep-agents/planning/react/README.md b/cockpit/deep-agents/planning/react/README.md new file mode 100644 index 000000000..691a4866a --- /dev/null +++ b/cockpit/deep-agents/planning/react/README.md @@ -0,0 +1,50 @@ +# Deep Agents Planning · React + +Native React chat with a read-only projection of the existing `da-planning` +graph's `todos`. The KSFO-to-KASE dispatch suggestion fills the draft; only Send +creates a conversation. Aviation lookups use fixed example data. + +The full ordered list is replaced when valid root values arrive, including an +explicit empty list. Duplicate rows keep their positions. The display accepts +at most 50 items and 2000 UTF-16 code units per item with the middleware's three +statuses. Unsupported updates show a notice and retain the prior valid display. + +Live observation and last saved authority are separate. A saved plan requires +native submit outcome `success`, the current native authored human, canonical +history parity, exact owned root checkpoint state with no pending execution, +and authoritative raw `write_todos` result status and arguments. A core tool +status `complete` means its result arrived; it does not prove the write applied. +The exact raw checkpoint read supplies both the candidate values and raw loaded +state; independent corroboration comes from native submit, messages, tool calls, +values, and history. No tool-result prose is parsed. + +Response completion never completes unfinished todos. A saved `in_progress` +item records plan status; it does not imply continued execution. Stop retains +the last saved plan and requires New conversation. New clears the view without +creating a thread. Failed creation can retry. A first native response failure +before any checkpoint requires New; later unconfirmed checkpoint reads can +recover on a subsequent valid request, using the current canonical prefix and +the separately retained saved plan. A no-write response cannot borrow an +unconfirmed changed plan. + +## Local verification + +From the workspace root: + +```sh +npx nx test cockpit-deep-agents-planning-react +npx nx lint cockpit-deep-agents-planning-react +npx nx build cockpit-deep-agents-planning-react +npx nx fixture-test cockpit-deep-agents-planning-react +npx nx e2e cockpit-deep-agents-planning-react +``` + +The loopback proof server runs on port 4628 at +`/deep-agents/planning/react/`. Its fixture executes the actual Python graph, +middleware, tools, and in-memory checkpointer with only model construction +controlled. It forbids remote socket connections and reports source hashes. +The graph buffers actual events before local SSE delivery: holding or stopping +delivery does **not** prove cancellation or rollback of a production graph. +Local transport fault/hold controls exist only in the proof fixture and are +never assembled into public server routes. Browser tests verify desktop/mobile, +keyboard use, reduced motion, literal text, and horizontal overflow. diff --git a/cockpit/deep-agents/planning/react/e2e/planning.spec.ts b/cockpit/deep-agents/planning/react/e2e/planning.spec.ts new file mode 100644 index 000000000..dbc36e247 --- /dev/null +++ b/cockpit/deep-agents/planning/react/e2e/planning.spec.ts @@ -0,0 +1,274 @@ +import { + expect, + test, + type Page, + type APIRequestContext, +} from '@playwright/test'; +const base = '/deep-agents/planning/react/'; +async function configure( + request: APIRequestContext, + values: Record +) { + const response = await request.post('/__configure', { + data: { + holdStream: false, + holdState: false, + holdCheckpoint: false, + failCreation: false, + failStream: false, + failCheckpoint: false, + scenario: 'normal', + ...values, + }, + }); + expect(response.ok()).toBe(true); +} +async function send(page: Page, text = 'Dispatch brief: KSFO to KASE') { + const box = page.getByRole('textbox', { name: 'Message' }); + await box.fill(text); + await box.press('Enter'); +} +const plan = (page: Page) => page.getByRole('complementary', { name: 'Plan' }); +test.beforeEach(async ({ page, request }) => { + await configure(request, {}); + await page.goto(base); +}); +test('mount and suggestion are inert; native live replacement becomes saved with unfinished task', async ({ + page, + request, +}) => { + const before = (await (await request.get('/__requests')).json()).length; + await page.reload(); + await expect(page.getByText('Ready.', { exact: true })).toBeVisible(); + expect((await (await request.get('/__requests')).json()).length).toBe(before); + await page + .getByRole('button', { name: 'Dispatch brief: KSFO to KASE' }) + .click(); + expect((await (await request.get('/__requests')).json()).length).toBe(before); + await configure(request, { holdCheckpoint: true }); + await page.getByRole('textbox', { name: 'Message' }).press('Enter'); + await expect(page.getByText('Confirming saved plan…')).toBeVisible(); + await expect(plan(page).getByText('3 of 4 completed')).toBeVisible(); + await expect( + plan(page).getByText('Live plan · waiting for confirmation') + ).toBeVisible(); + await request.post('/__release'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect( + plan(page).locator('[data-plan-status="in_progress"]') + ).toHaveCount(1); + await expect( + plan(page).getByText('Saved plan', { exact: true }) + ).toBeVisible(); + if (process.env.PLANNING_CAPTURE_SCREENSHOTS === '1') + await page.screenshot({ + path: '/tmp/threadplane-planning-task3-desktop.png', + fullPage: true, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + const proof = await (await request.get('/__graph-proof')).json(); + expect( + proof.every( + (row: { actualCompiledGraph: boolean; networkConnectAttempts: number }) => + row.actualCompiledGraph && row.networkConnectAttempts === 0 + ) + ).toBe(true); +}); +test('failed creation retries; native stream failure retains saved plan', async ({ + page, + request, +}) => { + await configure(request, { failCreation: true }); + await send(page); + await expect( + page.getByText('Response failed.', { exact: true }) + ).toBeVisible(); + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + await configure(request, { failStream: true }); + await send(page, 'Again'); + await expect( + page.getByText('Response failed.', { exact: true }) + ).toBeVisible(); + await expect( + plan(page).getByText('Last saved plan', { exact: true }) + ).toBeVisible(); + await expect(plan(page).getByText('3 of 4 completed')).toBeVisible(); +}); +test('uncertain changed checkpoint and no-write cannot borrow plan; later legitimate write recovers', async ({ + page, + request, +}) => { + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + await configure(request, { scenario: 'duplicates', failCheckpoint: true }); + await send(page, 'Change'); + await expect(page.getByText('Plan update unconfirmed.')).toBeVisible(); + await expect( + plan(page).getByText('Last saved plan', { exact: true }) + ).toBeVisible(); + await configure(request, { scenario: 'no-write' }); + await send(page, 'Just answer'); + await expect(page.getByText('Plan update unconfirmed.')).toBeVisible(); + await configure(request, { scenario: 'recovery' }); + await send(page, 'Recover'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect( + plan(page).getByText('Recovered', { exact: true }) + ).toBeVisible(); + await expect(plan(page).getByText('0 of 1 completed')).toBeVisible(); +}); +test('stop holds saved authority, ignores late stream, and New clears without creating thread', async ({ + page, + request, +}) => { + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + await configure(request, { scenario: 'duplicates', holdStream: true }); + await send(page, 'Change'); + await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible(); + await expect + .poll(async () => (await (await request.get('/__lifetime')).json()).held) + .toBe(1); + await page.getByRole('button', { name: 'Stop' }).click(); + await expect( + page.getByText('Response stopped.', { exact: true }) + ).toBeVisible(); + await request.post('/__release'); + await expect(plan(page).getByText('3 of 4 completed')).toBeVisible(); + const before = (await (await request.get('/__requests')).json()).filter( + (row: { path: string }) => row.path === '/api/threads' + ).length; + await page.getByRole('button', { name: 'New conversation' }).click(); + await expect(plan(page).getByText(/Ask the assistant/)).toBeVisible(); + expect( + (await (await request.get('/__requests')).json()).filter( + (row: { path: string }) => row.path === '/api/threads' + ).length + ).toBe(before); +}); +test('New during held checkpoint ignores stale owner; new send owns its conversation', async ({ + page, + request, +}) => { + await configure(request, { holdCheckpoint: true }); + await send(page); + await expect(page.getByText('Confirming saved plan…')).toBeVisible(); + await page.getByRole('button', { name: 'New conversation' }).click(); + await request.post('/__release'); + await expect(plan(page).getByText(/Ask the assistant/)).toBeVisible(); + await expect(page.getByRole('article')).toHaveCount(0); + await configure(request, { scenario: 'empty' }); + await send(page, 'Clear'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect(plan(page).getByText('The plan is empty.')).toBeVisible(); +}); +test('whole-list duplicates and empty replacement remain ordered and read-only', async ({ + page, + request, +}) => { + await configure(request, { scenario: 'duplicates' }); + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + const items = plan(page).locator('.plan-items > li'); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toContainText('Duplicate'); + await expect(items.nth(1)).toContainText('Duplicate'); + await expect(items.nth(2)).toContainText('First'); + await expect(plan(page).getByRole('checkbox')).toHaveCount(0); + await configure(request, { scenario: 'empty' }); + await send(page, 'Clear'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect(plan(page).getByText('0 of 0 completed')).toBeVisible(); + await expect(items).toHaveCount(0); +}); +test('actual rejected write results remain received tool traces and recover without implying application', async ({ + page, + request, +}) => { + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + for (const scenario of ['parallel', 'schema']) { + await configure(request, { scenario }); + await send(page, 'Rejected write'); + await expect(page.getByText('Plan update unconfirmed.')).toBeVisible(); + await expect( + plan(page).getByText('Last saved plan', { exact: true }) + ).toBeVisible(); + expect( + await page.locator('.tool-inspection > summary').allTextContents() + ).toContain('write_todos · result received'); + expect( + (await page.locator('.tool-inspection > summary').allTextContents()).some( + (text) => /applied|success/i.test(text) + ) + ).toBe(false); + } + await configure(request, { scenario: 'recovery' }); + await send(page, 'Recover'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect( + plan(page).getByText('Recovered', { exact: true }) + ).toBeVisible(); +}); +test('bounds-invalid actual write retains valid display and shows notice; valid recovery clears notice', async ({ + page, + request, +}) => { + await send(page); + await expect(page.getByText('Response saved.')).toBeVisible(); + await configure(request, { scenario: 'oversized' }); + await send(page, 'Too many'); + await expect(page.getByText('Plan update unconfirmed.')).toBeVisible(); + await expect( + plan(page).getByText(/Plans support up to 50 items/) + ).toBeVisible(); + await expect(plan(page).getByText('3 of 4 completed')).toBeVisible(); + await configure(request, { scenario: 'empty-content' }); + await send(page, 'Recover'); + await expect(page.getByText('Response saved.')).toBeVisible(); + await expect(plan(page).getByText('Untitled item')).toBeVisible(); + await expect(plan(page).getByText(/Plans support/)).toHaveCount(0); +}); +test('mobile, keyboard and reduced motion preserve readable literal plan without horizontal overflow', async ({ + page, + request, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page + .getByRole('button', { name: 'Dispatch brief: KSFO to KASE' }) + .focus(); + await page.keyboard.press('Enter'); + await page.getByRole('textbox', { name: 'Message' }).focus(); + await page.keyboard.press('Enter'); + await expect(page.getByText('Response saved.')).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await expect(plan(page).getByText('3 of 4 completed')).toBeVisible(); + if (process.env.PLANNING_CAPTURE_SCREENSHOTS === '1') + await page.screenshot({ + path: '/tmp/threadplane-planning-task3-mobile.png', + fullPage: true, + }); + await configure(request, { scenario: 'long-content' }); + await send(page, 'Long literal plan'); + await expect(page.getByText('Response saved.')).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-reduced-motion: reduce)').matches + ) + ).toBe(true); +}); diff --git a/cockpit/deep-agents/planning/react/e2e/playwright.config.ts b/cockpit/deep-agents/planning/react/e2e/playwright.config.ts new file mode 100644 index 000000000..edc5d356d --- /dev/null +++ b/cockpit/deep-agents/planning/react/e2e/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + workers: 1, + fullyParallel: false, + timeout: 30000, + use: { baseURL: 'http://127.0.0.1:4628', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent', + cwd: '../../../../..', + url: 'http://127.0.0.1:4628', + reuseExistingServer: false, + timeout: 15000, + }, +}); diff --git a/cockpit/deep-agents/planning/react/index.html b/cockpit/deep-agents/planning/react/index.html new file mode 100644 index 000000000..83e0eaf35 --- /dev/null +++ b/cockpit/deep-agents/planning/react/index.html @@ -0,0 +1,13 @@ + + + + + + + Deep Agents Planning · React + + +
+ + + diff --git a/cockpit/deep-agents/planning/react/project.json b/cockpit/deep-agents/planning/react/project.json new file mode 100644 index 000000000..5fe3a3da1 --- /dev/null +++ b/cockpit/deep-agents/planning/react/project.json @@ -0,0 +1,50 @@ +{ + "name": "cockpit-deep-agents-planning-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/deep-agents/planning/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/planning/react"], + "options": { + "command": "node scripts/react-cockpit/build.mjs deep-agents-planning" + } + }, + "fixture-test": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "test", + "projects": ["cockpit-deep-agents-planning-python"] + } + ], + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-planning-fixture.spec.mjs scripts/react-cockpit/deep-agents-planning-configuration.spec.mjs" + } + }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": ["build", "fixture-test"], + "options": { + "config": "cockpit/deep-agents/planning/react/e2e/playwright.config.ts", + "skipInstall": true + } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/deep-agents/planning/react/vite.config.mts", + "reporters": ["default"] + } + }, + "lint": { "executor": "@nx/eslint:lint" } + } +} diff --git a/cockpit/deep-agents/planning/react/src/app.spec.tsx b/cockpit/deep-agents/planning/react/src/app.spec.tsx new file mode 100644 index 000000000..f773a4462 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/app.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createConnectedApplication } from './connection'; +import type { PlanningApplicationSnapshot } from './application'; +vi.mock('./connection', () => ({ createConnectedApplication: vi.fn() })); +import { PlanningDemo, connectionFingerprint } from './app'; +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); +function harness() { + let snapshot: PlanningApplicationSnapshot = { + threadId: null, + rows: [], + messages: [], + toolCalls: [], + observedPlan: { kind: 'missing' }, + savedPlan: { kind: 'missing' }, + phase: 'idle', + busy: false, + canSubmit: true, + outcome: null, + notice: null, + error: null, + viewGeneration: 0, + }; + const listeners = new Set<() => void>(); + const update = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + listeners.forEach((fn) => fn()); + }; + const app = { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + submit: vi.fn(async () => { + update({ busy: true, phase: 'working' }); + return true; + }), + stop: vi.fn(async () => undefined), + newConversation: vi.fn(async () => { + update({ + viewGeneration: snapshot.viewGeneration + 1, + busy: false, + phase: 'idle', + rows: [], + }); + }), + dispose: vi.fn(async () => undefined), + }; + vi.mocked(createConnectedApplication).mockReturnValue(app); + const onReady = vi.fn(); + const connection = { apiUrl: 'https://authored.invalid', headers: {} }; + const view = render( + + ); + return { app, update, view, onReady, connection }; +} +it('inert mount and suggestion are draft-only; keyboard send and New remain available', () => { + const h = harness(); + expect(h.onReady).toHaveBeenCalledOnce(); + expect(h.app.submit).not.toHaveBeenCalled(); + fireEvent.click( + h.view.getByRole('button', { name: 'Dispatch brief: KSFO to KASE' }) + ); + const box = h.view.getByRole('textbox', { + name: 'Message', + }) as HTMLTextAreaElement; + expect(box.value).toMatch(/KSFO to KASE/); + expect(h.app.submit).not.toHaveBeenCalled(); + fireEvent.keyDown(box, { key: 'Enter', code: 'Enter' }); + expect(h.app.submit).toHaveBeenCalledOnce(); + expect(box.value).toBe(''); + fireEvent.click(h.view.getByRole('button', { name: 'Stop' })); + expect(h.app.stop).toHaveBeenCalledOnce(); + fireEvent.click(h.view.getByRole('button', { name: 'New conversation' })); + expect(h.app.newConversation).toHaveBeenCalledOnce(); +}); +it('connection fingerprint normalizes header order and replaces/disposes old owner on value change', () => { + expect( + connectionFingerprint({ apiUrl: 'a', headers: { z: '1', a: '2' } }) + ).toBe(connectionFingerprint({ apiUrl: 'a', headers: { a: '2', z: '1' } })); + const h = harness(); + h.view.rerender( + + ); + expect(h.app.dispose).toHaveBeenCalledOnce(); + expect(createConnectedApplication).toHaveBeenCalledTimes(2); +}); +it('saved phase still shows unfinished rows and does not claim all tasks completed', () => { + const h = harness(); + act(() => + h.update({ + phase: 'saved', + observedPlan: { + kind: 'valid', + items: [{ content: 'Still unfinished', status: 'in_progress' }], + }, + savedPlan: { + kind: 'valid', + items: [{ content: 'Still unfinished', status: 'in_progress' }], + }, + }) + ); + expect(h.view.getByText('Still unfinished')).toBeTruthy(); + expect(h.view.getByText('0 of 1 completed')).toBeTruthy(); + expect(h.view.getByText('Response saved.')).toBeTruthy(); +}); diff --git a/cockpit/deep-agents/planning/react/src/app.tsx b/cockpit/deep-agents/planning/react/src/app.tsx new file mode 100644 index 000000000..649fb0902 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/app.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAgent } from '@threadplane/react'; +import { + ChatInput, + MessageList, + Reasoning, + ToolObservation, +} from '@threadplane/react/chat'; +import { Markdown } from '@threadplane/react/markdown'; +import type { MessageRow } from '@threadplane/content/messages'; +import { + createConnectedApplication, + type PlanningConnection, +} from './connection'; +import type { PlanningApplicationSnapshot } from './application'; +import { PlanPanel } from './plan-panel'; +const literal = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value, null, 2); +export function renderMessage( + row: MessageRow, + snapshot: PlanningApplicationSnapshot +) { + const label = + row.role === 'user' + ? 'You' + : row.role === 'assistant' + ? 'Assistant' + : row.role === 'tool' + ? 'Tool result' + : 'System'; + return ( +
+

{label}

+ {row.role === 'assistant' && row.reasoning && ( + + )} + {row.role === 'tool' ? ( +
+ Tool result received +
{row.message.content}
+
+ ) : ( + + )} + {snapshot.toolCalls + .filter((call) => row.message.toolCallIds?.includes(call.id)) + .map((call) => ( +
+ + {call.name} ·{' '} + {call.status === 'complete' ? 'result received' : call.status} + + +
+ ))} +
+ ); +} +export function connectionFingerprint(connection: PlanningConnection) { + return JSON.stringify([ + connection.apiUrl, + Object.entries(connection.headers).sort(([a], [b]) => a.localeCompare(b)), + ]); +} +type Props = { + readonly connection: PlanningConnection; + readonly onReady: () => void; +}; +export function PlanningDemo(props: Props) { + const fingerprint = connectionFingerprint(props.connection), + identity = useRef({ fingerprint, version: 0 }); + if (identity.current.fingerprint !== fingerprint) + identity.current = { fingerprint, version: identity.current.version + 1 }; + return ; +} +function PlanningOwner({ connection, onReady }: Props) { + const [application] = useState(() => createConnectedApplication(connection)), + [draft, setDraft] = useState(''), + snapshot = useAgent(application); + useEffect(() => { + onReady(); + }, [onReady]); + useEffect( + () => () => { + void application.dispose(); + }, + [application] + ); + const renderRow = useCallback( + (row: MessageRow) => renderMessage(row, snapshot), + [snapshot] + ); + const currentView = () => + application.getSnapshot().viewGeneration === snapshot.viewGeneration; + const status = snapshot.busy + ? snapshot.phase === 'confirming' + ? 'Confirming saved plan…' + : 'Receiving response…' + : snapshot.phase === 'saved' + ? 'Response saved.' + : snapshot.phase === 'stopped' + ? 'Response stopped.' + : snapshot.phase === 'unconfirmed' + ? 'Plan update unconfirmed.' + : snapshot.phase === 'failed' + ? 'Response failed.' + : 'Ready.'; + return ( +
+
+
+

React · Deep Agents

+

A plan for the flight ahead.

+
+ +
+

+ Build a dispatch brief using fixed aviation lookup data. Watch the + assistant build and revise its plan alongside the conversation. +

+
+
+
+ {[ + { + label: 'Dispatch brief: KSFO to KASE', + text: 'Plan a dispatch brief for a flight from KSFO to KASE: check field elevation, runway length, and weather at both ends, then tell me if there is anything the crew should know.', + }, + ].map((prompt) => ( + + ))} +
+ {!snapshot.rows.length && ( +
+ +

Start with what you want to do.

+

+ A message creates your conversation. Suggestions just fill the + draft. +

+
+ )} + + {snapshot.error && ( +

+ {snapshot.error} +

+ )} + { + if (currentView()) setDraft(text); + }} + placeholder="Ask for a dispatch brief…" + hint="Enter to send. Shift+Enter adds a new line." + busy={snapshot.busy} + disabled={snapshot.busy || !snapshot.canSubmit} + onStop={ + snapshot.busy + ? () => { + if (currentView()) void application.stop(); + } + : undefined + } + onSubmit={(text) => { + const current = application.getSnapshot(); + if ( + !currentView() || + current.busy || + !current.canSubmit || + !text.trim() + ) + return false; + void application.submit(text); + return currentView() && application.getSnapshot().busy; + }} + /> +

+ {status} +

+
+ +
+
+ ); +} diff --git a/cockpit/deep-agents/planning/react/src/application.spec.ts b/cockpit/deep-agents/planning/react/src/application.spec.ts new file mode 100644 index 000000000..8ac1cda08 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/application.spec.ts @@ -0,0 +1,448 @@ +import { expect, it, vi } from 'vitest'; +import { + staticDelivery, + type CompleteOutcome, + type Message, +} from '@threadplane/core'; +import { createPlanningApplication, type PlanningSession } from './application'; +import type { PlanningSnapshot } from './terminal'; + +interface WireCall { + id: string; + name: string; + args: { todos: { content: string; status: string }[] }; + type: string; +} +interface WireMessage { + id: string; + type: string; + content: string; + name?: string; + tool_call_id?: string; + tool_calls?: WireCall[]; + status?: string; +} +interface Raw { + values: { + messages: WireMessage[]; + files?: Record; + todos?: { content: string; status: string }[]; + }; + checkpoint?: { + thread_id: string; + checkpoint_ns: string; + checkpoint_id: string; + }; + next: unknown[]; + tasks: unknown[]; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +function setup() { + let state: PlanningSnapshot = { + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }; + let raw: Raw = { values: { messages: [] }, next: [], tasks: [] }; + let turn = 0; + const listeners = new Set<() => void>(); + const emit = (next: PlanningSnapshot) => { + state = next; + for (const l of listeners) l(); + }; + const complete = ( + text: string, + items = [{ content: 'Unfinished', status: 'in_progress' }], + write = true + ) => { + const n = ++turn, + prefix = raw.values.messages; + const calls = write + ? [ + { + id: `call${n}`, + name: 'write_todos', + args: { todos: items }, + type: 'tool_call', + }, + ] + : []; + const messages = [ + ...prefix, + { id: `human${n}`, type: 'human', content: text }, + ...(write + ? [ + { id: `ai${n}`, type: 'ai', content: '', tool_calls: calls }, + { + id: `tool${n}`, + type: 'tool', + content: 'Result received', + name: 'write_todos', + tool_call_id: `call${n}`, + status: 'success', + }, + ] + : []), + { + id: `answer${n}`, + type: 'ai', + content: 'Here is the response', + tool_calls: [], + }, + ]; + const checkpoint = { + thread_id: 'thread', + checkpoint_ns: '', + checkpoint_id: `cp${n}`, + }; + raw = { + checkpoint, + values: { + messages, + files: {}, + ...(write + ? { todos: items } + : raw.values.todos + ? { todos: raw.values.todos } + : {}), + }, + next: [], + tasks: [], + }; + const projected = messages.map((m: WireMessage) => ({ + id: m.id, + content: m.content, + role: + m.type === 'human' ? 'user' : m.type === 'ai' ? 'assistant' : 'tool', + delivery: staticDelivery(m.id), + ...(m.name ? { name: m.name } : {}), + ...(m.tool_call_id ? { toolCallId: m.tool_call_id } : {}), + ...(m.tool_calls + ? { toolCallIds: m.tool_calls.map((c: WireCall) => c.id) } + : {}), + })) as Message[]; + const toolCalls = messages.flatMap((m: WireMessage) => + (m.tool_calls ?? []).map((c: WireCall) => ({ + id: c.id, + name: c.name, + args: c.args, + result: 'Result received', + status: 'complete' as const, + })) + ); + emit({ + status: 'idle', + messages: projected, + toolCalls, + values: { + files: {}, + ...(raw.values.todos ? { todos: raw.values.todos } : {}), + }, + history: [{ checkpoint, next: [] }], + interrupts: [], + subgraphs: [], + }); + }; + const session = { + getSnapshot: () => state, + subscribe: (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + submit: vi.fn(async (text: string): Promise => { + complete(text); + return 'success'; + }), + load: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + } as unknown as PlanningSession; + const client = { + createThread: vi.fn(async () => 'thread'), + sessionFactory: vi.fn(() => session), + readCurrent: vi.fn(async () => raw), + readCheckpoint: vi.fn(async () => raw), + }; + return { + app: createPlanningApplication(client), + client, + session, + emit, + complete, + get raw() { + return raw; + }, + deferred, + }; +} +it('mount is inert and first send creates one lazy thread and confirms unfinished plan', async () => { + const f = setup(); + expect(f.client.createThread).not.toHaveBeenCalled(); + expect(f.client.readCurrent).not.toHaveBeenCalled(); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'idle', + rows: [], + savedPlan: { kind: 'missing' }, + }); + await f.app.submit('Plan'); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'saved', + busy: false, + savedPlan: { kind: 'valid', items: [{ status: 'in_progress' }] }, + }); + expect(f.client.createThread).toHaveBeenCalledOnce(); + await f.app.dispose(); +}); +it('failed creation can retry without borrowing an unconfirmed id', async () => { + const f = setup(); + f.client.createThread.mockRejectedValueOnce(Error('private')); + await f.app.submit('Plan'); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'failed', + canSubmit: true, + threadId: null, + }); + await f.app.submit('Plan'); + expect(f.app.getSnapshot().phase).toBe('saved'); + await f.app.dispose(); +}); +it('observed replacement appears live but saved authority waits for checkpoint', async () => { + const f = setup(), + held = deferred(); + f.client.readCheckpoint.mockImplementation(() => held.promise); + const pending = f.app.submit('Plan'); + await vi.waitFor(() => expect(f.app.getSnapshot().phase).toBe('confirming')); + expect(f.app.getSnapshot().observedPlan.kind).toBe('valid'); + expect(f.app.getSnapshot().savedPlan.kind).toBe('missing'); + held.resolve(f.raw); + await pending; + expect(f.app.getSnapshot().phase).toBe('saved'); + await f.app.dispose(); +}); +for (const outcome of ['error', 'aborted', 'paused'] as const) + it(`native ${outcome} cannot promote plan`, async () => { + const f = setup(); + vi.mocked(f.session.submit).mockImplementation(async (text) => { + f.complete(text as string); + return outcome; + }); + await f.app.submit('Plan'); + expect(f.app.getSnapshot().savedPlan.kind).toBe('missing'); + expect(f.client.readCheckpoint).not.toHaveBeenCalled(); + expect(f.app.getSnapshot().phase).toBe( + outcome === 'aborted' + ? 'stopped' + : outcome === 'error' + ? 'failed' + : 'unconfirmed' + ); + await f.app.dispose(); + }); +it('uncertain checkpoint retains saved authority and later successful request recovers', async () => { + const f = setup(); + await f.app.submit('Plan'); + const saved = f.app.getSnapshot().savedPlan; + f.client.readCheckpoint.mockRejectedValueOnce(Error('private')); + await f.app.submit('Again'); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'unconfirmed', + savedPlan: saved, + }); + await f.app.submit('Recover'); + expect(f.app.getSnapshot().phase).toBe('saved'); + expect(f.app.getSnapshot().messages).toHaveLength(12); + await f.app.dispose(); +}); +it('invalid live values retain prior valid display and notice', async () => { + const f = setup(); + await f.app.submit('Plan'); + const observed = f.app.getSnapshot().observedPlan; + vi.mocked(f.session.submit).mockImplementation(async () => { + f.emit({ + ...f.session.getSnapshot(), + values: { todos: [{ content: 'bad', status: 'wrong' }] }, + }); + return 'error'; + }); + await f.app.submit('Bad'); + expect(f.app.getSnapshot().observedPlan).toEqual(observed); + expect(f.app.getSnapshot().notice).toMatch(/50|2000/); + await f.app.dispose(); +}); +it('New clears all state without creation and old checkpoint completion cannot republish', async () => { + const f = setup(), + held = deferred(); + f.client.readCheckpoint.mockImplementation(() => held.promise); + const pending = f.app.submit('Plan'); + await vi.waitFor(() => expect(f.app.getSnapshot().phase).toBe('confirming')); + await f.app.newConversation(); + held.resolve(f.raw); + await pending; + expect(f.app.getSnapshot()).toMatchObject({ + threadId: null, + rows: [], + phase: 'idle', + observedPlan: { kind: 'missing' }, + savedPlan: { kind: 'missing' }, + }); + expect(f.client.createThread).toHaveBeenCalledOnce(); + await f.app.dispose(); +}); +it('stop invalidates late streams and confirmation while retaining saved plan', async () => { + const f = setup(); + await f.app.submit('Plan'); + const held = deferred(); + vi.mocked(f.session.submit).mockImplementation(() => held.promise); + const pending = f.app.submit('Again'); + await vi.waitFor(() => expect(f.session.submit).toHaveBeenCalledTimes(2)); + await f.app.stop(); + const stopped = f.app.getSnapshot(); + f.complete('Again'); + held.resolve('success'); + await pending; + expect(f.app.getSnapshot()).toBe(stopped); + expect(stopped.phase).toBe('stopped'); + expect(stopped.savedPlan.kind).toBe('valid'); + await f.app.dispose(); +}); +it('dispose suppresses pending creation and late publications', async () => { + const f = setup(), + held = deferred(); + f.client.createThread.mockImplementation(() => held.promise); + const pending = f.app.submit('Plan'); + await f.app.dispose(); + const snapshot = f.app.getSnapshot(); + held.resolve('thread'); + await pending; + expect(f.client.sessionFactory).not.toHaveBeenCalled(); + expect(f.app.getSnapshot()).toBe(snapshot); +}); +it('double send is rejected while busy and empty text is inert', async () => { + const f = setup(), + held = deferred(); + f.client.createThread.mockImplementation(() => held.promise); + await f.app.submit(' '); + expect(f.client.createThread).not.toHaveBeenCalled(); + const pending = f.app.submit('Plan'); + await f.app.submit('Other'); + expect(f.client.createThread).toHaveBeenCalledOnce(); + held.resolve('thread'); + await pending; + await f.app.dispose(); +}); +it('a human first introduced by checkpoint load cannot attest native submit ownership', async () => { + const f = setup(); + vi.mocked(f.session.submit).mockResolvedValue('success'); + vi.mocked(f.session.load).mockImplementation(async () => { + f.complete('Plan'); + }); + await f.app.submit('Plan'); + expect(f.app.getSnapshot().phase).toBe('unconfirmed'); + expect(f.app.getSnapshot().savedPlan.kind).toBe('missing'); + await f.app.dispose(); +}); +it('rejects a foreign current-state checkpoint before another native submit', async () => { + const f = setup(); + await f.app.submit('Plan'); + f.client.readCurrent.mockResolvedValueOnce({ + ...f.raw, + checkpoint: { + checkpoint_ns: '', + checkpoint_id: 'foreign-cp', + thread_id: 'foreign', + }, + }); + await f.app.submit('Again'); + expect(f.session.submit).toHaveBeenCalledOnce(); + expect(f.app.getSnapshot().phase).toBe('failed'); + await f.app.dispose(); +}); +it('valid recovery clears the current invalid-update notice', async () => { + const f = setup(); + await f.app.submit('Plan'); + vi.mocked(f.session.submit).mockImplementationOnce(async () => { + f.emit({ ...f.session.getSnapshot(), values: { todos: 'bad' } }); + return 'error'; + }); + await f.app.submit('Bad'); + expect(f.app.getSnapshot().notice).toBeTruthy(); + await f.app.submit('Recover'); + expect(f.app.getSnapshot()).toMatchObject({ phase: 'saved', notice: null }); + await f.app.dispose(); +}); +it('New invalidates a pending current-prefix read before another native send', async () => { + const f = setup(); + await f.app.submit('Plan'); + const held = deferred(); + f.client.readCurrent.mockImplementation(() => held.promise); + const pending = f.app.submit('Again'); + await vi.waitFor(() => expect(f.client.readCurrent).toHaveBeenCalledOnce()); + await f.app.newConversation(); + held.resolve(f.raw); + await pending; + expect(f.session.submit).toHaveBeenCalledOnce(); + expect(f.app.getSnapshot()).toMatchObject({ + threadId: null, + rows: [], + phase: 'idle', + }); + await f.app.dispose(); +}); +it('native successful promise without live human cannot borrow a previously saved human', async () => { + const f = setup(); + await f.app.submit('Plan'); + const saved = f.app.getSnapshot().savedPlan; + vi.mocked(f.session.submit).mockResolvedValueOnce('success'); + await f.app.submit('Plan'); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'unconfirmed', + savedPlan: saved, + }); + await f.app.dispose(); +}); +it('read-only status changes preserve unfinished items after assistant completes', async () => { + const f = setup(); + await f.app.submit('Plan'); + expect(f.app.getSnapshot().phase).toBe('saved'); + expect(f.app.getSnapshot().observedPlan).toEqual({ + kind: 'valid', + items: [{ content: 'Unfinished', status: 'in_progress' }], + }); + f.emit({ + ...f.session.getSnapshot(), + status: 'running', + values: { todos: [{ content: 'Late', status: 'completed' }] }, + }); + expect(f.app.getSnapshot().observedPlan).toEqual({ + kind: 'valid', + items: [{ content: 'Unfinished', status: 'in_progress' }], + }); + await f.app.dispose(); +}); +it('synchronous owner replacement during native subscription releases the rejected subscription', async () => { + const f = setup(), + release = vi.fn(); + let notifications = 0; + vi.spyOn(f.session, 'subscribe').mockImplementation((notify) => { + notify(); + return release; + }); + f.app.subscribe(() => { + if (++notifications === 2) void f.app.newConversation(); + }); + await f.app.submit('Plan'); + expect(release).toHaveBeenCalledOnce(); + expect(f.session.submit).not.toHaveBeenCalled(); + expect(f.app.getSnapshot()).toMatchObject({ phase: 'idle', threadId: null }); + await f.app.dispose(); +}); diff --git a/cockpit/deep-agents/planning/react/src/application.ts b/cockpit/deep-agents/planning/react/src/application.ts new file mode 100644 index 000000000..6aaa6366e --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/application.ts @@ -0,0 +1,430 @@ +import type { + AgentSession, + AgentSnapshot, + CompleteOutcome, + Message, + ToolCall, +} from '@threadplane/core'; +import { + createMessageContent, + type MessageRow, +} from '@threadplane/content/messages'; +import { copyJson, plainRecord, planState, type PlanState } from './plan-state'; +import { + captureBaseline, + captureTurn, + captureTerminal, + checkpointSource, + type Checkpoint, + type PlanningSnapshot, + type Turn, +} from './terminal'; + +export interface PlanningSession extends Omit { + getSnapshot(): AgentSnapshot & PlanningSnapshot; + load(options?: { readonly signal?: AbortSignal }): Promise; +} +export interface PlanningClient { + createThread(signal: AbortSignal): Promise; + sessionFactory(threadId: string): PlanningSession; + readCurrent(threadId: string, signal: AbortSignal): Promise; + readCheckpoint(source: Checkpoint, signal: AbortSignal): Promise; +} +export interface PlanningApplicationSnapshot { + readonly threadId: string | null; + readonly rows: readonly MessageRow[]; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly observedPlan: Exclude; + readonly savedPlan: Exclude; + readonly phase: + | 'idle' + | 'working' + | 'confirming' + | 'saved' + | 'stopped' + | 'failed' + | 'unconfirmed'; + readonly busy: boolean; + readonly canSubmit: boolean; + readonly outcome: CompleteOutcome | null; + readonly notice: string | null; + readonly error: string | null; + readonly viewGeneration: number; +} +const empty = (): PlanningApplicationSnapshot => + Object.freeze({ + threadId: null, + rows: Object.freeze([]), + messages: Object.freeze([]), + toolCalls: Object.freeze([]), + observedPlan: Object.freeze({ kind: 'missing' }), + savedPlan: Object.freeze({ kind: 'missing' }), + phase: 'idle', + busy: false, + canSubmit: true, + outcome: null, + notice: null, + error: null, + viewGeneration: 0, + }); + +/** One lazy native owner. Live observation never grants saved authority. */ +export function createPlanningApplication(client: PlanningClient) { + type Owner = { + id: string; + identity: string; + session: PlanningSession; + content: ReturnType; + release?: () => void; + closed: boolean; + attempted: boolean; + checkpointKnown: boolean; + }; + type Attempt = { + controller: AbortController; + owner?: Owner; + generation: string; + turn?: Turn; + baseline?: ReturnType; + text: string; + view: number; + submitting?: boolean; + saving?: boolean; + previousHumanIds?: ReadonlySet; + previousGenerations?: ReadonlySet; + }; + let snapshot = empty(), + owner: Owner | undefined, + attempt: Attempt | undefined, + disposed = false, + disposal: Promise | undefined; + const listeners = new Set<() => void>(), + cleanups = new Set>(); + const current = (run: Attempt, selected = run.owner) => + !disposed && + attempt === run && + !run.controller.signal.aborted && + snapshot.viewGeneration === run.view && + (!selected || (owner === selected && !selected.closed)); + function publish(update: Partial) { + if (disposed) return; + snapshot = Object.freeze({ ...snapshot, ...update }); + for (const notify of [...listeners]) notify(); + } + function close(selected: Owner) { + if (selected.closed) return Promise.resolve(); + selected.closed = true; + selected.release?.(); + selected.content.dispose(); + let timer: ReturnType; + const cleanup = Promise.race([ + Promise.resolve() + .then(() => selected.session.dispose()) + .catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, 2000); + }), + ]).finally(() => clearTimeout(timer)); + cleanups.add(cleanup); + void cleanup.then(() => cleanups.delete(cleanup)); + return cleanup; + } + function observe(run: Attempt): PlanningSnapshot | undefined { + const selected = run.owner; + if (!selected || !current(run)) return; + const source = selected.session.getSnapshot(); + if (!current(run)) return; + const state = copyJson(source, true) as ReturnType< + PlanningSession['getSnapshot'] + >; + if ( + !Array.isArray(state.messages) || + !Array.isArray(state.toolCalls) || + !Array.isArray(state.interrupts) || + !Array.isArray(state.subgraphs) + ) + throw Error('Unavailable native state'); + if (!run.turn && run.baseline && run.submitting && !run.saving) { + // Native submit authors the ID. Bind its unique new user from the owned + // live projection before any history load; loaded history cannot attest + // what this submit authored. Raw prefix parity is checked separately. + const humans = state.messages.filter( + (message) => + message.role === 'user' && !run.previousHumanIds?.has(message.id) + ); + const human = humans.length === 1 ? humans[0] : undefined; + if ( + human?.content === run.text && + human.delivery.generation && + !run.previousGenerations?.has(human.delivery.generation) + ) + run.turn = captureTurn(run.baseline, { + owner: selected.identity, + generation: run.generation, + threadId: selected.id, + humanId: human.id, + humanContent: run.text, + }); + } + const plan = planState(state.values), + rows = selected.content.project(state); + if (!current(run)) return; + publish({ + rows, + messages: state.messages, + toolCalls: state.toolCalls, + ...(plan.kind === 'valid' ? { observedPlan: plan, notice: null } : {}), + ...(plan.kind === 'invalid' + ? { + notice: + 'This plan update could not be displayed. Plans support up to 50 items, 2000 characters per item, and pending, in progress, or completed status. The prior valid plan is retained.', + } + : {}), + }); + return current(run) ? state : undefined; + } + function finish( + run: Attempt, + phase: PlanningApplicationSnapshot['phase'], + outcome: CompleteOutcome | null, + error: string | null, + canSubmit = true + ) { + if (!current(run)) return; + attempt = undefined; + publish({ busy: false, phase, outcome, error, canSubmit }); + } + async function submit(text: string) { + if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim()) + return false; + const run: Attempt = { + controller: new AbortController(), + generation: crypto.randomUUID(), + text, + view: snapshot.viewGeneration, + }; + attempt = run; + publish({ + busy: true, + canSubmit: false, + phase: 'working', + outcome: null, + error: null, + }); + let nativeSucceeded = false; + try { + if (!current(run)) return false; + if (!owner) { + const id = await client.createThread(run.controller.signal); + if (!current(run)) return false; + if (!id || typeof id !== 'string') throw Error('Unconfirmed creation'); + const selected: Owner = { + id, + identity: crypto.randomUUID(), + session: client.sessionFactory(id), + content: createMessageContent(), + closed: false, + attempted: false, + checkpointKnown: false, + }; + if (!current(run)) { + void close(selected); + return false; + } + owner = selected; + run.owner = selected; + selected.release = selected.session.subscribe(() => { + const active = attempt; + if (active?.owner === selected && current(active)) { + try { + observe(active); + } catch { + /* Terminal confirmation will fail closed. */ + } + } + }); + if (selected.closed) selected.release(); + if (!current(run)) return false; + publish({ threadId: id }); + } else run.owner = owner; + const selected = run.owner; + if (!selected || !current(run)) return false; + // Read only after authored Send. An uncertain prior turn may advance the + // exact canonical prefix without advancing separately retained authority. + const prior = selected.attempted + ? await client.readCurrent(selected.id, run.controller.signal) + : undefined; + if (!current(run)) return false; + const values = + prior === undefined + ? undefined + : plainRecord(prior) + ? prior.values + : undefined; + if (prior !== undefined && !plainRecord(values)) + throw Error('Unavailable prefix'); + if ( + plainRecord(prior) && + prior.checkpoint !== undefined && + !checkpointSource(prior.checkpoint, selected.id) + ) + throw Error('Foreign current state'); + if (plainRecord(prior) && checkpointSource(prior.checkpoint, selected.id)) + selected.checkpointKnown = true; + run.baseline = captureBaseline(values, snapshot.savedPlan); + if (!run.baseline) throw Error('Unavailable prefix'); + selected.attempted = true; + const before = selected.session.getSnapshot(); + if (!current(run)) return false; + run.previousHumanIds = new Set( + before.messages + .filter((message) => message.role === 'user') + .map((message) => message.id) + ); + run.previousGenerations = new Set( + before.messages.map((message) => message.delivery.generation) + ); + run.submitting = true; + const outcome = await selected.session.submit(text, { + signal: run.controller.signal, + }); + if (!current(run)) return false; + const live = observe(run); + if (!current(run)) return false; + if (outcome !== 'success') { + finish( + run, + outcome === 'aborted' + ? 'stopped' + : outcome === 'error' + ? 'failed' + : 'unconfirmed', + outcome, + outcome === 'error' + ? selected.checkpointKnown + ? 'The response failed. The last saved plan is retained.' + : 'The response failed before a saved conversation could be confirmed. Start a new conversation to continue.' + : 'The response was not confirmed. The last saved plan is retained.', + outcome === 'error' && selected.checkpointKnown + ); + return false; + } + nativeSucceeded = true; + if (!run.turn) throw Error('Native human was not observed'); + run.saving = true; + publish({ phase: 'confirming' }); + if (!current(run)) return false; + await selected.session.load({ signal: run.controller.signal }); + if (!current(run)) return false; + const saved = observe(run), + checkpoint = + saved && + checkpointSource( + saved.history?.[0] && plainRecord(saved.history[0]) + ? saved.history[0].checkpoint + : undefined, + selected.id + ); + if (!saved || !live || !checkpoint) throw Error('Unavailable checkpoint'); + selected.checkpointKnown = true; + const raw = await client.readCheckpoint( + checkpoint, + run.controller.signal + ); + if (!current(run)) return false; + const latest = observe(run); + if (!current(run) || !latest) return false; + // Raw observedValues and loadedCheckpoint deliberately share this exact + // owned SDK read. Independent corroboration is native success, current + // human/prefix, canonical messages/tools, native values and history. + const confirmation = captureTerminal({ + turn: run.turn, + owner: selected.identity, + generation: run.generation, + threadId: selected.id, + submittedSuccessfully: outcome === 'success', + observedCheckpoint: checkpoint, + observedValues: plainRecord(raw) ? raw.values : undefined, + loadedCheckpoint: raw, + snapshot: latest, + }); + if (!current(run)) return false; + if (confirmation.kind !== 'confirmed') + throw Error('Unconfirmed saved plan'); + publish({ + savedPlan: confirmation.plan, + observedPlan: confirmation.plan, + }); + finish(run, 'saved', outcome, null); + return true; + } catch { + if (current(run)) + finish( + run, + nativeSucceeded ? 'unconfirmed' : 'failed', + nativeSucceeded ? 'success' : 'error', + nativeSucceeded + ? 'The saved plan could not be confirmed. The last saved plan is retained.' + : run.owner?.attempted && !run.owner.checkpointKnown + ? 'The request failed before a saved conversation could be confirmed. Start a new conversation to continue.' + : 'The request failed. You can try again.', + nativeSucceeded || !run.owner?.attempted || run.owner.checkpointKnown + ); + return false; + } + } + async function stop() { + const run = attempt; + if (disposed || !run) return; + attempt = undefined; + run.controller.abort(); + publish({ + busy: false, + canSubmit: false, + phase: 'stopped', + outcome: 'aborted', + error: + 'Response stopped. The last saved plan is retained. Start a new conversation to continue.', + }); + try { + await run.owner?.session.stop(); + } catch { + /* Cancellation is not save authority. */ + } + } + async function newConversation() { + if (disposed) return; + attempt?.controller.abort(); + attempt = undefined; + const previous = owner; + owner = undefined; + publish({ ...empty(), viewGeneration: snapshot.viewGeneration + 1 }); + if (previous) await close(previous); + } + function dispose() { + if (disposal) return disposal; + disposed = true; + attempt?.controller.abort(); + attempt = undefined; + const previous = owner; + owner = undefined; + if (previous) void close(previous); + listeners.clear(); + return (disposal = Promise.all([...cleanups]).then(() => undefined)); + } + return { + getSnapshot: () => snapshot, + subscribe(notify: () => void) { + if (disposed) return () => undefined; + listeners.add(notify); + return () => { + listeners.delete(notify); + }; + }, + submit, + stop, + newConversation, + dispose, + }; +} diff --git a/cockpit/deep-agents/planning/react/src/connection.integration.spec.ts b/cockpit/deep-agents/planning/react/src/connection.integration.spec.ts new file mode 100644 index 000000000..1653d1030 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/connection.integration.spec.ts @@ -0,0 +1,183 @@ +import { + createServer, + type Server, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createPlanningClient } from './connection'; +import { createPlanningApplication } from './application'; +import { plainRecord } from './plan-state'; +/* eslint-disable @nx/enforce-module-boundaries -- Local graph proof transport is never bundled. */ +// @ts-expect-error Local proof-only JavaScript has no published declaration. +import { createDeepAgentsPlanningFixture as createFixture } from '../../../../../scripts/react-cockpit/deep-agents-planning-fixture.mjs'; +/* eslint-enable @nx/enforce-module-boundaries */ +interface LocalFixture { + ( + request: IncomingMessage, + response: ServerResponse, + pathname: string + ): Promise; + close(): Promise; +} +const createDeepAgentsPlanningFixture = createFixture as () => LocalFixture; +let server: Server | undefined, + fixture: ReturnType | undefined, + application: ReturnType | undefined; +afterEach(async () => { + await application?.dispose(); + await fixture?.close(); + if (server) + await new Promise((resolve) => { + server?.close(() => resolve()); + server?.closeAllConnections(); + }); +}); +async function setup() { + const handle = createDeepAgentsPlanningFixture(); + fixture = handle; + const http = createServer(async (req, res) => { + if ( + !(await handle( + req, + res, + new URL(req.url ?? '/', 'http://localhost').pathname + )) + ) { + res.writeHead(404); + res.end(); + } + }); + server = http; + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + const address = http.address(); + if (!address || typeof address === 'string') throw Error('No address'); + const base = `http://127.0.0.1:${address.port}`, + client = createPlanningClient({ apiUrl: base + '/api', headers: {} }); + const read = vi.spyOn(client, 'readCheckpoint'), + app = createPlanningApplication(client); + application = app; + const configure = async ( + scenario: string, + extra: Record = {} + ) => { + const response = await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify({ scenario, ...extra }), + }); + expect(response.ok).toBe(true); + }; + const release = () => fetch(base + '/__release', { method: 'POST' }); + return { app, client, read, base, configure, release }; +} +it('first native transport failure before any checkpoint honestly requires New; a fresh conversation succeeds', async () => { + const f = await setup(); + await f.configure('normal', { failStream: true }); + expect(await f.app.submit('First')).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'failed', + canSubmit: false, + }); + expect(await f.app.submit('Retry')).toBe(false); + await f.app.newConversation(); + expect(await f.app.submit('Fresh')).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); +}, 30000); +it('native session confirms actual middleware writes, exact checkpoint, unfinished rows, and second-send empty clear', async () => { + const f = await setup(); + expect(await (await fetch(f.base + '/__requests')).json()).toEqual([]); + expect(await f.app.submit('Plan a thoughtful project launch')).toBe(true); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'saved', + savedPlan: { + kind: 'valid', + items: expect.arrayContaining([ + { content: expect.any(String), status: 'in_progress' }, + ]), + }, + }); + await f.configure('empty'); + expect(await f.app.submit('Clear the plan')).toBe(true); + expect(f.app.getSnapshot().savedPlan).toEqual({ kind: 'valid', items: [] }); + const proof: unknown[] = await ( + await fetch(f.base + '/__graph-proof') + ).json(); + expect(proof.length).toBeGreaterThan(0); + expect( + proof.every( + (p) => + plainRecord(p) && + p.actualCompiledGraph === true && + p.networkConnectAttempts === 0 + ) + ).toBe(true); +}, 30000); +for (const scenario of ['parallel', 'schema']) + it(`actual ${scenario} rejection is a received core result, never saved authority; later native recovery succeeds`, async () => { + const f = await setup(); + await f.app.submit('Initial'); + const saved = f.app.getSnapshot().savedPlan; + await f.configure(scenario); + expect(await f.app.submit('Rejected update')).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'unconfirmed', + savedPlan: saved, + }); + expect( + f.app + .getSnapshot() + .toolCalls.some( + (call) => call.status === 'complete' && call.name === 'write_todos' + ) + ).toBe(true); + await f.configure('recovery'); + expect(await f.app.submit('Recover with a valid write')).toBe(true); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'saved', + savedPlan: { + kind: 'valid', + items: [{ content: 'Recovered', status: 'in_progress' }], + }, + }); + }, 30000); +it('actual changed unconfirmed prefix cannot lend its plan to no-write; subsequent legitimate replacement recovers', async () => { + const f = await setup(); + await f.app.submit('Initial'); + const saved = f.app.getSnapshot().savedPlan; + await f.configure('duplicates'); + f.read.mockRejectedValueOnce(Error('Unavailable exact read')); + expect(await f.app.submit('Changed live')).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'unconfirmed', + savedPlan: saved, + }); + await f.configure('no-write'); + expect(await f.app.submit('Just answer')).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'unconfirmed', + savedPlan: saved, + }); + await f.configure('recovery'); + expect(await f.app.submit('Recover')).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); +}, 30000); +it('stop rejects late owned checkpoint publications and preserves prior saved display', async () => { + const f = await setup(); + await f.app.submit('Initial'); + const saved = f.app.getSnapshot().savedPlan; + await f.configure('duplicates', { holdCheckpoint: true }); + const pending = f.app.submit('Changed'); + await vi.waitFor(() => expect(f.app.getSnapshot().phase).toBe('confirming'), { + timeout: 10000, + }); + await f.app.stop(); + const stopped = f.app.getSnapshot(); + await f.release(); + await pending; + expect(f.app.getSnapshot()).toBe(stopped); + expect(stopped).toMatchObject({ + phase: 'stopped', + savedPlan: saved, + canSubmit: false, + }); +}, 30000); diff --git a/cockpit/deep-agents/planning/react/src/connection.spec.ts b/cockpit/deep-agents/planning/react/src/connection.spec.ts new file mode 100644 index 000000000..d3bf4a6e9 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/connection.spec.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +// eslint-disable-next-line @nx/enforce-module-boundaries -- Test validates the internal bridge contract. +import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { afterEach, expect, it, vi } from 'vitest'; +const sdk = vi.hoisted(() => ({ + create: vi.fn(), + getState: vi.fn(), + native: vi.fn(), + constructor: vi.fn(), +})); +vi.mock('@langchain/langgraph-sdk', () => ({ + Client: class { + threads = { create: sdk.create, getState: sdk.getState }; + constructor(options: unknown) { + sdk.constructor(options); + } + }, +})); +vi.mock('@threadplane/langgraph', () => ({ createSession: sdk.native })); +import { planningConnection, createPlanningClient } from './connection'; +afterEach(() => vi.resetAllMocks()); +it('resolves shared proxy and developer target, rejecting incompatible configuration', () => { + expect( + planningConnection({ + status: 'unconfigured', + } as unknown as RuntimeBridgeConfiguration).apiUrl + ).toMatch(/\/api$/); + expect( + planningConnection({ + status: 'configured', + target: { + kind: 'langsmith', + apiUrl: 'https://authored.invalid', + apiKey: 'secret', + }, + } as unknown as RuntimeBridgeConfiguration) + ).toEqual({ + apiUrl: 'https://authored.invalid', + headers: { 'x-api-key': 'secret' }, + }); + expect(() => + planningConnection({ + status: 'error', + } as unknown as RuntimeBridgeConfiguration) + ).toThrow('Runtime'); + expect(() => + planningConnection({ + status: 'configured', + target: { kind: 'ag-ui' }, + } as unknown as RuntimeBridgeConfiguration) + ).toThrow('Runtime'); +}); +it('client construction makes no request; confirmed creation owns native sessions and state reads', async () => { + const headers = { 'x-api-key': 'secret' }, + client = createPlanningClient({ + apiUrl: 'https://authored.invalid', + headers, + }); + headers['x-api-key'] = 'changed'; + expect(sdk.create).not.toHaveBeenCalled(); + expect(sdk.getState).not.toHaveBeenCalled(); + expect(() => client.sessionFactory('unknown')).toThrow(); + sdk.create.mockImplementation(async ({ threadId }: { threadId: string }) => ({ + thread_id: threadId, + })); + const controller = new AbortController(); + const id = await client.createThread(controller.signal); + sdk.native.mockReturnValue({ + getSnapshot: () => ({ + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }), + subscribe: () => () => undefined, + submit: async () => 'success', + load: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + }); + client.sessionFactory(id); + expect(sdk.native.mock.calls[0][0]).toMatchObject({ + assistantId: 'da-planning', + threadId: id, + clientOptions: { maxRetries: 0, defaultHeaders: { 'x-api-key': 'secret' } }, + }); + sdk.getState.mockResolvedValue({}); + await client.readCurrent(id, controller.signal); + expect(sdk.getState.mock.calls[0][1]).toBeUndefined(); + await client.readCheckpoint( + { thread_id: id, checkpoint_ns: '', checkpoint_id: 'cp' }, + controller.signal + ); + expect(sdk.getState.mock.calls[1][1]).toMatchObject({ checkpoint_id: 'cp' }); +}); +it('failed/mismatched/aborted creation never installs ownership and can retry', async () => { + const client = createPlanningClient({ + apiUrl: 'https://authored.invalid', + headers: {}, + }), + signal = new AbortController().signal; + sdk.create.mockResolvedValue({ thread_id: 'wrong' }); + await expect(client.createThread(signal)).rejects.toThrow('not confirmed'); + expect(() => client.sessionFactory('wrong')).toThrow(); + sdk.create.mockImplementation(async ({ threadId }: { threadId: string }) => ({ + thread_id: threadId, + })); + expect(await client.createThread(signal)).toBeTruthy(); + const abort = new AbortController(); + abort.abort(); + const count = sdk.create.mock.calls.length; + await expect(client.createThread(abort.signal)).rejects.toThrow(); + expect(sdk.create).toHaveBeenCalledTimes(count); +}); +it('unknown, malformed, or aborted checkpoint reads remain inert', async () => { + const client = createPlanningClient({ + apiUrl: 'https://authored.invalid', + headers: {}, + }), + signal = new AbortController().signal; + await expect(client.readCurrent('unknown', signal)).rejects.toThrow(); + await expect( + client.readCheckpoint( + { thread_id: 'unknown', checkpoint_ns: '', checkpoint_id: 'cp' }, + signal + ) + ).rejects.toThrow(); + expect(sdk.getState).not.toHaveBeenCalled(); +}); diff --git a/cockpit/deep-agents/planning/react/src/connection.ts b/cockpit/deep-agents/planning/react/src/connection.ts new file mode 100644 index 000000000..116cb0016 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/connection.ts @@ -0,0 +1,136 @@ +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 { + createPlanningApplication, + type PlanningClient, + type PlanningSession, +} from './application'; +import { copyJson } from './plan-state'; +import { checkpointSource, type Checkpoint } from './terminal'; +export interface PlanningConnection { + readonly apiUrl: string; + readonly headers: Record; +} +export function planningConnection( + configuration: RuntimeBridgeConfiguration +): PlanningConnection { + 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 createPlanningClient( + input: PlanningConnection +): PlanningClient { + 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(threadId: string, signal: AbortSignal) { + if (!known.has(threadId) || signal.aborted) + throw Error('Conversation is unavailable.'); + } + return { + async createThread(signal) { + try { + 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('Unconfirmed'); + known.add(id); + return id; + } catch { + throw Error('Conversation creation was not confirmed.'); + } + }, + sessionFactory(threadId) { + if (!known.has(threadId)) throw Error('Unknown conversation.'); + let session: ReturnType | undefined; + try { + session = createSession({ + assistantId: 'da-planning', + threadId, + apiUrl: connection.apiUrl, + clientOptions: { maxRetries: 0, defaultHeaders: connection.headers }, + }); + const state = session.getSnapshot(); + if ( + !state || + !Array.isArray(state.messages) || + !Array.isArray(state.toolCalls) || + !Array.isArray(state.interrupts) || + !Array.isArray(state.subgraphs) || + ![ + 'getSnapshot', + 'subscribe', + 'submit', + 'load', + 'stop', + 'dispose', + ].every( + (method) => + typeof session?.[method as keyof typeof session] === 'function' + ) + ) + throw Error('Rejected session'); + return session as PlanningSession; + } catch { + try { + void Promise.resolve(session?.dispose()).catch(() => undefined); + } catch { + /* Rejected session is never installed. */ + } + throw Error('Runtime session is unavailable.'); + } + }, + async readCurrent(threadId, signal) { + owned(threadId, signal); + const result = await client.threads.getState(threadId, undefined, { + signal, + }); + owned(threadId, signal); + return result; + }, + async readCheckpoint(requested, signal) { + const captured = copyJson(requested) as Checkpoint, + source = checkpointSource(captured, captured.thread_id); + if (!source) throw Error('Checkpoint is unavailable.'); + owned(source.thread_id, signal); + const result = await client.threads.getState( + source.thread_id, + source as Parameters[1], + { signal } + ); + owned(source.thread_id, signal); + return result; + }, + }; +} +export function createConnectedApplication(connection: PlanningConnection) { + return createPlanningApplication(createPlanningClient(connection)); +} diff --git a/cockpit/deep-agents/planning/react/src/main.tsx b/cockpit/deep-agents/planning/react/src/main.tsx new file mode 100644 index 000000000..0d57ca119 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/main.tsx @@ -0,0 +1,43 @@ +import { createRoot } from 'react-dom/client'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this private bridge. +import { + GENERATED_RUNTIME_PARENT_ORIGINS, + installRuntimeBridge, +} from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { PlanningDemo } from './app'; +import { planningConnection } 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 planning example could not start.

); + }); diff --git a/cockpit/deep-agents/planning/react/src/plan-panel.spec.tsx b/cockpit/deep-agents/planning/react/src/plan-panel.spec.tsx new file mode 100644 index 000000000..2b451c717 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/plan-panel.spec.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react'; +import { afterEach, expect, it } from 'vitest'; +import { PlanPanel } from './plan-panel'; +afterEach(cleanup); +it('renders inert plan explanation and never invents rows', () => { + const view = render( + + ); + expect(view.getByText(/Ask the assistant/)).toBeTruthy(); + expect(view.queryAllByRole('listitem')).toHaveLength(0); +}); +it('shows exact ordered duplicate rows and progress from displayed rows', () => { + const items = [ + { content: 'Same', status: 'pending' }, + { content: 'Same', status: 'completed' }, + { content: '', status: 'in_progress' }, + ] as const; + const view = render( + + ); + expect(view.getAllByRole('listitem')).toHaveLength(3); + expect(view.getAllByText('Same')).toHaveLength(2); + expect(view.getByText('Untitled item')).toBeTruthy(); + expect(view.getByText('1 of 3 completed')).toBeTruthy(); + expect(items[2].content).toBe(''); +}); +it('valid empty is a deliberate cleared plan', () => { + const view = render( + + ); + expect(view.getByText('The plan is empty.')).toBeTruthy(); + expect(view.getByText('0 of 0 completed')).toBeTruthy(); +}); +it('terminal unfinished status is read-only and distinct from active execution', () => { + const view = render( + + ); + expect(view.getByText(/Unconfirmed update/)).toBeTruthy(); + expect(view.getByText(/does not mean/)).toBeTruthy(); + expect(view.getByText('Last saved plan')).toBeTruthy(); + expect(view.getByRole('status').textContent).toBe('Unsupported update'); + expect(view.queryByRole('checkbox')).toBeNull(); +}); diff --git a/cockpit/deep-agents/planning/react/src/plan-panel.tsx b/cockpit/deep-agents/planning/react/src/plan-panel.tsx new file mode 100644 index 000000000..7255924a8 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/plan-panel.tsx @@ -0,0 +1,103 @@ +import type { PlanningApplicationSnapshot } from './application'; +import { planProgress, type PlanItem, type PlanState } from './plan-state'; +type Valid = Exclude; +const labels = { + pending: 'Pending', + in_progress: 'In progress', + completed: 'Completed', +}; +function PlanRows({ items }: { readonly items: readonly PlanItem[] }) { + return ( +
    + {items.map((item, index) => ( +
  1. + +
    +

    {item.content || 'Untitled item'}

    + {labels[item.status]} +
    +
  2. + ))} +
+ ); +} +export function PlanPanel({ + observed, + saved, + phase, + notice, +}: { + readonly observed: Valid; + readonly saved: Valid; + readonly phase: PlanningApplicationSnapshot['phase']; + readonly notice: string | null; +}) { + const progress = + observed.kind === 'valid' ? planProgress(observed.items) : undefined; + const status = + phase === 'saved' + ? 'Saved plan' + : phase === 'working' || phase === 'confirming' + ? 'Live plan · waiting for confirmation' + : phase === 'idle' + ? 'Your plan' + : phase === 'stopped' + ? 'Stopped · last update not confirmed' + : phase === 'failed' + ? 'Failed response · last update not confirmed' + : 'Unconfirmed update · last saved plan retained'; + return ( + + ); +} diff --git a/cockpit/deep-agents/planning/react/src/plan-state.spec.ts b/cockpit/deep-agents/planning/react/src/plan-state.spec.ts new file mode 100644 index 000000000..a312de26f --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/plan-state.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { planProgress, planState } from './plan-state'; + +const row = (content = 'Task', status = 'pending') => ({ content, status }); +describe('root todo projection', () => { + it('treats an inert undefined root as not yet observed', () => { + expect(planState(undefined)).toEqual({ kind: 'missing' }); + }); + it('copies and freezes ordered duplicate and empty text rows', () => { + const todos = [ + row(''), + row('Same'), + row('Same', 'completed'), + row('Work', 'in_progress'), + ]; + const result = planState({ todos }); + expect(result).toEqual({ kind: 'valid', items: todos }); + if (result.kind !== 'valid') throw new Error('Expected valid projection'); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.items)).toBe(true); + expect(result.items.every(Object.isFrozen)).toBe(true); + todos[0].content = 'Changed'; + expect(result.items[0].content).toBe(''); + expect(planProgress(result.items)).toEqual({ completed: 1, total: 4 }); + }); + it('accepts empty list as an explicit clear', () => { + expect(planState({ todos: [] })).toEqual({ kind: 'valid', items: [] }); + expect(planProgress([])).toEqual({ completed: 0, total: 0 }); + }); + it('distinguishes missing from invalid', () => { + expect(planState({ files: {} })).toEqual({ kind: 'missing' }); + expect(planState({ todos: null }).kind).toBe('invalid'); + }); + it('accepts exact bounds in UTF16 units', () => { + expect( + planState({ + todos: Array.from({ length: 50 }, () => row('😀'.repeat(1000))), + }).kind + ).toBe('valid'); + }); + it.each([ + null, + [], + new Date(), + Object.create({ todos: [] }), + { todos: '[]' }, + { todos: Array(1) }, + { todos: [null] }, + { todos: [row('x', 'done')] }, + { todos: [row('x', 'Pending')] }, + { todos: [{ content: 1, status: 'pending' }] }, + { todos: [{ content: 'x' }] }, + { todos: [{ ...row(), id: 'fake' }] }, + { todos: [Object.create(row())] }, + { todos: Array.from({ length: 51 }, () => row()) }, + { todos: [row('😀'.repeat(1000) + 'a')] }, + ])('rejects malformed, inherited and out-of-bound input %#', (input) => { + expect(planState(input).kind).toBe('invalid'); + }); + it('rejects getters without invoking them', () => { + let reads = 0; + const input = Object.defineProperty({}, 'todos', { + enumerable: true, + get() { + reads++; + return []; + }, + }); + expect(planState(input).kind).toBe('invalid'); + expect(reads).toBe(0); + }); +}); diff --git a/cockpit/deep-agents/planning/react/src/plan-state.ts b/cockpit/deep-agents/planning/react/src/plan-state.ts new file mode 100644 index 000000000..dc866738c --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/plan-state.ts @@ -0,0 +1,128 @@ +export interface PlanItem { + readonly content: string; + readonly status: 'pending' | 'in_progress' | 'completed'; +} +export type PlanState = + | { readonly kind: 'valid'; readonly items: readonly PlanItem[] } + | { readonly kind: 'missing' } + | { readonly kind: 'invalid' }; + +export function plainRecord(value: unknown): value is Record { + return ( + value !== null && + typeof value === 'object' && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** Capture own JSON data without invoking accessors. Reject unsupported values, + * cycles, sparse arrays and excessive depth; never coerce or truncate. */ +export function copyJson( + input: unknown, + omitOptionalUndefined = false +): unknown { + const path = new Set(); + let nodes = 0; + function copy(value: unknown, depth: number): unknown { + if (++nodes > 100000 || depth > 100) + throw new Error('Unsupported JSON size'); + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) + return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (!Array.isArray(value) && !plainRecord(value)) + throw new Error('Unsupported JSON value'); + const object = value as object; + if (path.has(object)) throw new Error('Cyclic data'); + path.add(object); + const keys = Reflect.ownKeys(object); + const array = Array.isArray(value); + if (array && Object.getPrototypeOf(value) !== Array.prototype) + throw new Error('Unsupported array'); + const entries = keys.filter((k) => !(array && k === 'length')); + if (array && entries.length !== value.length) + throw new Error('Sparse or extended array'); + const output: Record = {}; + for (const key of entries) { + if (typeof key !== 'string') throw new Error('Symbol key'); + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) + throw new Error('Non-JSON property'); + // Native contracts have optional own fields; raw graph evidence does not. + if (!array && omitOptionalUndefined && descriptor.value === undefined) + continue; + if (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)) + throw new Error('Extended array'); + Object.defineProperty(output, key, { + value: copy(descriptor.value, depth + 1), + enumerable: true, + }); + } + path.delete(object); + return Object.freeze( + array + ? Array.from({ length: value.length }, (_, i) => output[String(i)]) + : output + ); + } + return copy(input, 0); +} + +export function sameJson(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) + return ( + left.length === right.length && + left.every((v, i) => sameJson(v, right[i])) + ); + if (!plainRecord(left) || !plainRecord(right)) return false; + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every((k) => Object.hasOwn(right, k) && sameJson(left[k], right[k])) + ); +} + +export function planState(input: unknown): PlanState { + if (input === undefined) return Object.freeze({ kind: 'missing' }); + try { + const root = copyJson(input); + if (!plainRecord(root)) return Object.freeze({ kind: 'invalid' }); + if (!Object.hasOwn(root, 'todos')) + return Object.freeze({ kind: 'missing' }); + const todos = root.todos; + if ( + !Array.isArray(todos) || + todos.length > 50 || + todos.some( + (row) => + !plainRecord(row) || + Object.keys(row).length !== 2 || + !Object.hasOwn(row, 'content') || + !Object.hasOwn(row, 'status') || + typeof row.content !== 'string' || + row.content.length > 2000 || + !['pending', 'in_progress', 'completed'].includes( + row.status as string + ) + ) + ) + return Object.freeze({ kind: 'invalid' }); + return Object.freeze({ + kind: 'valid', + items: todos as readonly PlanItem[], + }); + } catch { + return Object.freeze({ kind: 'invalid' }); + } +} + +export function planProgress(items: readonly PlanItem[]) { + return Object.freeze({ + completed: items.filter((row) => row.status === 'completed').length, + total: items.length, + }); +} diff --git a/cockpit/deep-agents/planning/react/src/styles.css b/cockpit/deep-agents/planning/react/src/styles.css new file mode 100644 index 000000000..919eec8f6 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/styles.css @@ -0,0 +1,282 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #24312e; + background: #f4f5f0; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +textarea { + font: inherit; +} +button { + cursor: pointer; + border: 1px solid #bdcbc2; + border-radius: 8px; + background: #fff; + color: #234735; + padding: 0.65rem 0.9rem; +} +button:disabled { + cursor: default; + opacity: 0.55; +} +button:focus-visible, +summary:focus-visible, +textarea:focus-visible { + outline: 3px solid #468667; + outline-offset: 3px; +} +.planning-demo { + max-width: 1240px; + margin: 0 auto; + padding: 52px 32px 28px; +} +.planning-demo > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +h1 { + font-size: clamp(1.9rem, 3.2vw, 2.7rem); + letter-spacing: -0.045em; + margin: 8px 0 12px; + line-height: 1.12; + font-weight: 600; +} +.eyebrow { + text-transform: uppercase; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.12em; + margin: 0; + color: #456753; +} +.introduction { + max-width: 700px; + line-height: 1.6; + color: #596861; + margin: 6px 0 30px; +} +.planning-layout { + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(280px, 1fr); + gap: 24px; + align-items: start; +} +.conversation-panel { + min-width: 0; + background: #fff; + border: 1px solid #d8dfd5; + border-radius: 16px; + padding: 24px; +} +.seed-prompts { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} +.seed-prompts button { + font-size: 0.84rem; + background: #f4f7f2; +} +.conversation-empty { + min-height: 210px; + padding: 38px 12px; + text-align: center; + color: #667269; +} +.conversation-empty > span { + font-size: 2rem; + color: #44825c; +} +.conversation-empty h2 { + font-size: 1.15rem; + font-weight: 550; + color: #354a3e; +} +.conversation-empty p { + font-size: 0.9rem; + line-height: 1.6; +} +.tp-chat-message, +.tp-chat-message pre, +.tool-inspection { + min-width: 0; + overflow-wrap: anywhere; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-width: 100%; + font-size: 0.8rem; +} +.tp-chat-message--tool { + padding: 10px 12px; + background: #f4f5f0; + font-size: 0.82rem; + border-radius: 8px; +} +.tool-inspection { + font-size: 0.8rem; + margin-top: 10px; +} +summary { + cursor: pointer; + padding: 6px 0; + overflow-wrap: anywhere; +} +.plan-panel { + min-width: 0; + background: #edf2e8; + border: 1px solid #d1dbc9; + border-radius: 16px; + padding: 26px; +} +.plan-heading h2 { + font-size: 1.3rem; + letter-spacing: -0.025em; + margin: 9px 0 12px; + font-weight: 600; +} +.plan-authority { + color: #566a58; + font-size: 0.79rem; + line-height: 1.5; +} +.plan-progress p { + font-size: 0.83rem; + margin: 24px 0 9px; +} +progress { + display: block; + width: 100%; + height: 6px; + border: none; + border-radius: 8px; + overflow: hidden; + background: #d6dfd0; + accent-color: #42805b; +} +progress::-webkit-progress-bar { + background: #d6dfd0; +} +progress::-webkit-progress-value { + background: #42805b; +} +.plan-items { + padding: 0; + list-style: none; + margin: 22px 0 0; +} +.plan-items li { + display: flex; + gap: 12px; + padding: 14px 0; + border-bottom: 1px solid #d5dece; + min-width: 0; +} +.plan-items li > div { + min-width: 0; +} +.plan-items p { + margin: 0 0 6px; + line-height: 1.5; + font-size: 0.9rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.plan-marker { + flex: 0 0 25px; + height: 25px; + display: grid; + place-items: center; + border: 1px solid #a9b9a2; + border-radius: 50%; + font-size: 0.72rem; + color: #547153; +} +[data-plan-status='completed'] .plan-marker { + background: #3f7450; + color: white; + border-color: #3f7450; +} +.plan-item-status { + font-size: 0.72rem; + color: #5c7059; +} +.plan-empty { + font-size: 0.92rem; + color: #65755f; + line-height: 1.7; + padding: 30px 0; +} +.plan-explanation { + font-size: 0.75rem; + color: #64745e; + line-height: 1.6; + margin: 20px 0 0; +} +.saved-plan { + margin-top: 18px; + font-size: 0.82rem; +} +.plan-notice, +.request-error { + font-size: 0.82rem; + line-height: 1.6; + color: #7d4c24; + background: #fff5de; + border-radius: 8px; + padding: 12px; +} +.response-status { + font-size: 0.78rem; + color: #627067; + margin-bottom: 0; +} +@media (max-width: 720px) { + .planning-demo { + padding: 28px 16px; + } + .planning-demo > header { + align-items: flex-start; + gap: 12px; + } + .planning-demo > header button { + font-size: 0.75rem; + padding: 0.55rem 0.65rem; + flex-shrink: 0; + } + .planning-layout { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + .conversation-panel, + .plan-panel { + padding: 18px; + } + h1 { + font-size: 1.85rem; + } + .introduction { + font-size: 0.9rem; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation: none !important; + transition: none !important; + } +} diff --git a/cockpit/deep-agents/planning/react/src/styles.d.ts b/cockpit/deep-agents/planning/react/src/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/cockpit/deep-agents/planning/react/src/terminal.spec.ts b/cockpit/deep-agents/planning/react/src/terminal.spec.ts new file mode 100644 index 000000000..ff10e31ac --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/terminal.spec.ts @@ -0,0 +1,519 @@ +import { describe, expect, it } from 'vitest'; +import { staticDelivery, type Message } from '@threadplane/core'; +import { captureBaseline, captureTerminal, captureTurn } from './terminal'; + +const todos = [{ content: 'Unfinished', status: 'in_progress' }]; +const ref = { thread_id: 'thread', checkpoint_ns: '', checkpoint_id: 'cp' }; +const identity = { + owner: 'owner', + generation: 'local-generation', + threadId: 'thread', + humanId: 'human', + humanContent: 'Plan', +}; +function example(write = true, items = todos) { + const calls = write + ? [ + { + id: 'call', + name: 'write_todos', + args: { todos: items }, + type: 'tool_call', + }, + ] + : []; + const messages: { + id: string; + type: string; + name: string | null; + content: string; + tool_calls?: typeof calls; + invalid_tool_calls?: unknown[]; + tool_call_id?: string; + status?: string; + }[] = [ + { id: 'human', type: 'human', name: null, content: 'Plan' }, + ...(write + ? [ + { + id: 'ai', + type: 'ai', + name: null, + content: '', + tool_calls: calls, + invalid_tool_calls: [], + }, + { + id: 'tool', + type: 'tool', + name: 'write_todos', + content: 'Python repr prose', + tool_call_id: 'call', + status: 'success', + }, + ] + : []), + { + id: 'answer', + type: 'ai', + name: null, + content: 'Here is the answer', + tool_calls: [], + invalid_tool_calls: [], + }, + ]; + const values = { messages, files: {}, todos: items }; + const raw = { + checkpoint: ref, + values, + metadata: { source: 'loop', step: 4, parents: {} }, + next: [], + tasks: [], + }; + const projected = messages.map((m) => ({ + id: m.id, + role: (m.type === 'human' + ? 'user' + : m.type === 'ai' + ? 'assistant' + : 'tool') as Message['role'], + content: m.content, + delivery: staticDelivery(m.id), + ...(m.name ? { name: m.name } : {}), + ...('tool_call_id' in m ? { toolCallId: m.tool_call_id } : {}), + ...('tool_calls' in m + ? { toolCallIds: m.tool_calls?.map((c) => c.id) } + : {}), + })); + const input = { + turn: captureTurn( + captureBaseline({ + messages: [], + files: {}, + ...(write ? {} : { todos: items }), + }), + identity + ), + owner: 'owner', + generation: 'local-generation', + threadId: 'thread', + submittedSuccessfully: true, + observedCheckpoint: ref, + observedValues: values, + loadedCheckpoint: raw, + snapshot: { + status: 'idle', + interrupts: [], + subgraphs: [], + messages: projected, + toolCalls: calls.map((c) => ({ + id: c.id, + name: c.name, + args: c.args, + status: 'complete' as const, + result: 'Python repr prose', + })), + values: { files: {}, todos: items }, + history: [raw], + }, + }; + return input; +} +describe('owned terminal saved plan', () => { + it('captures an inert first-turn baseline without saved-state fields', () => { + expect(captureBaseline(undefined)).toEqual({ + messages: [], + plan: { kind: 'missing' }, + }); + }); + it('accepts optional undefined native fields while raw data stays JSON-only', () => { + const input = example(); + Object.assign(input.snapshot, { error: undefined }); + Object.assign(input.snapshot.messages[0], { + name: undefined, + reasoning: undefined, + citations: undefined, + }); + expect(captureTerminal(input).kind).toBe('confirmed'); + }); + it('accepts the actual compact native history entry without raw values/tasks', () => { + const input = example(); + const history = [ + { + checkpoint: ref, + parent_checkpoint: null, + created_at: '2026-10-10T00:43:51Z', + next: [], + }, + ]; + expect( + captureTerminal({ ...input, snapshot: { ...input.snapshot, history } }) + .kind + ).toBe('confirmed'); + }); + it('confirms actual state with unfinished todos and no run metadata', () => { + const result = captureTerminal(example()); + expect(result.kind).toBe('confirmed'); + if (result.kind !== 'confirmed') throw new Error('Expected confirmation'); + expect(result.plan).toEqual({ kind: 'valid', items: todos }); + expect(result.update).toBe('replacement'); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.values.messages)).toBe(true); + }); + it('confirms valid empty clear', () => { + expect(captureTerminal(example(true, []))).toMatchObject({ + plan: { kind: 'valid', items: [] }, + }); + }); + it('retains consistent baseline on a no-write response', () => { + expect(captureTerminal(example(false))).toMatchObject({ + update: 'retained', + }); + }); + it('allows a missing plan on a first no-write response', () => { + const input = example(false); + input.turn = captureTurn( + captureBaseline({ messages: [], files: {} }), + identity + ); + for (const values of [ + input.observedValues, + input.loadedCheckpoint.values, + input.snapshot.values, + ]) + delete (values as { todos?: unknown }).todos; + expect(captureTerminal(input)).toMatchObject({ plan: { kind: 'missing' } }); + }); + it('captures immutable baseline without mount I/O, then binds actual human id', () => { + const source = { messages: [], todos }; + const baseline = captureBaseline(source); + const turn = captureTurn(baseline, identity); + if (!turn || turn.baseline.plan.kind !== 'valid') + throw new Error('Expected valid turn'); + expect(turn.baseline.plan.items).toEqual(todos); + expect(Object.isFrozen(turn.baseline)).toBe(true); + expect(turn.humanId).toBe('human'); + expect(captureBaseline({ messages: [], todos: null })).toBeUndefined(); + }); + it.each(['owner', 'generation', 'threadId'])('rejects foreign %s', (key) => { + const input = example(); + expect(captureTerminal({ ...input, [key]: 'foreign' }).kind).toBe( + 'unconfirmed' + ); + }); + it('requires a successful native submission', () => { + expect( + captureTerminal({ ...example(), submittedSuccessfully: false }).kind + ).toBe('unconfirmed'); + }); + it('never coerces a nonboolean submission outcome into success', () => { + expect( + captureTerminal({ + ...example(), + submittedSuccessfully: 'true' as unknown as boolean, + }).kind + ).toBe('unconfirmed'); + }); + it('confirms a recovered replacement after raw nameless parallel errors', () => { + const input = example(); + const rejected = ['a', 'b'].map((id) => ({ + id, + name: 'write_todos', + args: { todos }, + type: 'tool_call', + })); + const text = 'Error: never call write_todos multiple times in parallel'; + const rawErrors = [ + { + id: 'parallel-ai', + type: 'ai', + name: null, + content: '', + tool_calls: rejected, + invalid_tool_calls: [], + }, + ...rejected.map((call) => ({ + id: `error-${call.id}`, + type: 'tool', + name: null, + content: text, + tool_call_id: call.id, + status: 'error', + })), + ]; + input.loadedCheckpoint.values.messages.splice(1, 0, ...rawErrors); + input.snapshot.messages.splice( + 1, + 0, + { + id: 'parallel-ai', + role: 'assistant', + content: '', + delivery: staticDelivery('parallel-ai'), + toolCallIds: ['a', 'b'], + }, + ...rejected.map((call) => ({ + id: `error-${call.id}`, + role: 'tool' as const, + content: text, + delivery: staticDelivery(`error-${call.id}`), + toolCallId: call.id, + })) + ); + input.snapshot.toolCalls.unshift( + ...rejected.map((call) => ({ + id: call.id, + name: call.name, + args: call.args, + status: 'complete' as const, + result: text, + })) + ); + expect(captureTerminal(input)).toMatchObject({ + kind: 'confirmed', + update: 'replacement', + plan: { kind: 'valid', items: todos }, + }); + }); + it('retains prior saved plan with an unchanged baseline canonical prefix', () => { + const input = example(false); + const prefix = [ + { + id: 'old-human', + type: 'human', + name: null, + content: 'Earlier request', + }, + { + id: 'old-answer', + type: 'ai', + name: null, + content: 'Earlier response', + tool_calls: [], + invalid_tool_calls: [], + }, + ]; + input.loadedCheckpoint.values.messages.unshift(...prefix); + input.snapshot.messages.unshift( + ...prefix.map((message) => ({ + id: message.id, + role: (message.type === 'human' + ? 'user' + : 'assistant') as Message['role'], + content: message.content, + delivery: staticDelivery(message.id), + ...(message.type === 'ai' ? { toolCallIds: [] } : {}), + })) + ); + input.turn = captureTurn( + captureBaseline({ messages: prefix, todos }), + identity + ); + expect(captureTerminal(input)).toMatchObject({ + kind: 'confirmed', + update: 'retained', + plan: { kind: 'valid', items: todos }, + }); + }); + it('recovers on a later send using current canonical prefix and separate saved authority', () => { + const input = example(); + const prefix = [ + { + id: 'failed-human', + type: 'human', + name: null, + content: 'Previous attempt', + }, + { + id: 'failed-answer', + type: 'ai', + name: null, + content: 'Unconfirmed previous answer', + tool_calls: [], + invalid_tool_calls: [], + }, + ]; + input.loadedCheckpoint.values.messages.unshift(...prefix); + input.snapshot.messages.unshift( + ...prefix.map((m) => ({ + id: m.id, + role: (m.type === 'human' ? 'user' : 'assistant') as Message['role'], + content: m.content, + delivery: staticDelivery(m.id), + ...(m.type === 'ai' ? { toolCallIds: [] } : {}), + })) + ); + const baseline = captureBaseline( + { + messages: prefix, + todos: [{ content: 'Malformed observation', status: 'unknown' }], + }, + { kind: 'missing' } + ); + expect(baseline).toMatchObject({ plan: { kind: 'missing' } }); + input.turn = captureTurn(baseline, identity); + expect(captureTerminal(input)).toMatchObject({ + kind: 'confirmed', + update: 'replacement', + plan: { kind: 'valid', items: todos }, + }); + }); + it('does not adopt changed unconfirmed state through a no-write baseline', () => { + const input = example(false); + input.turn = captureTurn( + captureBaseline({ messages: [], todos }, { kind: 'valid', items: [] }), + identity + ); + expect(captureTerminal(input).kind).toBe('unconfirmed'); + }); + it('validates and immutably captures explicit retained authority', () => { + const saved = { + kind: 'valid' as const, + items: [{ content: 'Saved', status: 'pending' as const }], + }; + const baseline = captureBaseline({ messages: [], todos: null }, saved); + expect(baseline).toMatchObject({ plan: saved }); + saved.items[0].content = 'Mutated'; + expect(baseline).toMatchObject({ + plan: { kind: 'valid', items: [{ content: 'Saved', status: 'pending' }] }, + }); + expect( + captureBaseline({ messages: [] }, { + kind: 'valid', + items: [{ content: 'bad', status: 'invented' }], + } as never) + ).toBeUndefined(); + }); + it.each(['next', 'tasks', 'interrupts', '__interrupt__', 'subgraphs'])( + 'rejects unresolved execution %s', + (key) => { + const input = example(); + expect( + captureTerminal({ + ...input, + loadedCheckpoint: { + ...input.loadedCheckpoint, + [key]: [{ id: 'pending' }], + }, + }).kind + ).toBe('unconfirmed'); + } + ); + it('rejects raw errors and nonidle native states', () => { + const input = example(); + expect( + captureTerminal({ + ...input, + loadedCheckpoint: { ...input.loadedCheckpoint, error: 'bad' }, + }).kind + ).toBe('unconfirmed'); + expect( + captureTerminal({ + ...input, + snapshot: { ...input.snapshot, status: 'running' }, + }).kind + ).toBe('unconfirmed'); + }); + it('compares backend run metadata only with actual known backend id', () => { + const input = example(); + input.loadedCheckpoint.metadata = { + ...input.loadedCheckpoint.metadata, + run_id: 'backend', + } as typeof input.loadedCheckpoint.metadata; + expect(captureTerminal({ ...input, backendRunId: 'backend' }).kind).toBe( + 'confirmed' + ); + expect(captureTerminal({ ...input, backendRunId: 'foreign' }).kind).toBe( + 'unconfirmed' + ); + expect(captureTerminal(input).kind).toBe('confirmed'); + }); + it('rejects checkpoint namespaces, foreign thread and mismatched exact checkpoint', () => { + for (const changed of [ + { checkpoint_ns: 'subgraph' }, + { thread_id: 'foreign' }, + { checkpoint_id: 'other' }, + ]) { + const input = example(); + expect( + captureTerminal({ + ...input, + observedCheckpoint: { ...ref, ...changed }, + }).kind + ).toBe('unconfirmed'); + } + }); + it('rejects history or observed values that differ from exact loaded state', () => { + const input = example(); + expect( + captureTerminal({ + ...input, + observedValues: { ...input.observedValues, todos: [] }, + }).kind + ).toBe('unconfirmed'); + expect( + captureTerminal({ + ...input, + snapshot: { + ...input.snapshot, + history: [ + { + ...input.loadedCheckpoint, + values: { ...input.observedValues, todos: [] }, + }, + ], + }, + }).kind + ).toBe('unconfirmed'); + }); + it.each(['id', 'role', 'content', 'toolCallId'])( + 'requires canonical projected message %s parity', + (key) => { + const input = example(); + input.snapshot.messages[2] = { + ...input.snapshot.messages[2], + [key]: 'foreign', + } as (typeof input.snapshot.messages)[number]; + expect(captureTerminal(input).kind).toBe('unconfirmed'); + } + ); + it('rejects changed call arguments/result data despite projected complete', () => { + const input = example(); + input.snapshot.toolCalls[0].result = 'Different'; + expect(captureTerminal(input).kind).toBe('unconfirmed'); + }); + it('does not promote a rejected write even when core projects complete', () => { + const input = example(); + Object.assign(input.loadedCheckpoint.values.messages[2], { + status: 'error', + }); + expect(captureTerminal(input).kind).toBe('unconfirmed'); + }); + it('rejects external plan changes on no-write turns', () => { + const input = example(false); + input.turn = captureTurn( + captureBaseline({ messages: [], todos: [] }), + identity + ); + expect(captureTerminal(input).kind).toBe('unconfirmed'); + }); + it('rejects changed baseline transcript or human content', () => { + const input = example(); + input.turn = captureTurn( + captureBaseline({ + messages: [{ id: 'old', type: 'human', content: 'old' }], + }), + identity + ); + expect(captureTerminal(input).kind).toBe('unconfirmed'); + expect( + captureTerminal({ + ...example(), + turn: captureTurn(captureBaseline({ messages: [] }), { + ...identity, + humanContent: 'different', + }), + }).kind + ).toBe('unconfirmed'); + }); +}); diff --git a/cockpit/deep-agents/planning/react/src/terminal.ts b/cockpit/deep-agents/planning/react/src/terminal.ts new file mode 100644 index 000000000..07188b50b --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/terminal.ts @@ -0,0 +1,449 @@ +import type { Message, ToolCall } from '@threadplane/core'; +import { + copyJson, + plainRecord, + planState, + sameJson, + type PlanState, +} from './plan-state'; +import { writeEvidence } from './tool-evidence'; + +export interface Baseline { + readonly messages: readonly unknown[]; + readonly plan: Exclude; +} +export interface TurnIdentity { + readonly owner: string; + /** Local application attempt identity, never a backend run id. */ + readonly generation: string; + readonly threadId: string; + readonly humanId: string; + readonly humanContent: string; + readonly backendRunId?: 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 PlanningSnapshot { + readonly status: string; + readonly error?: unknown; + readonly interrupts: readonly unknown[]; + readonly subgraphs: readonly unknown[]; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly values?: unknown; + readonly history?: readonly unknown[]; +} +export interface TerminalInput { + readonly turn: Turn | undefined; + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly backendRunId?: string; + /** Only true for the native submit result outcome === 'success'. A resolved + * promise alone also covers failed, paused and cancelled outcomes. */ + readonly submittedSuccessfully: boolean; + /** Root checkpoint identity and values actually observed for this attempt. */ + readonly observedCheckpoint: unknown; + readonly observedValues: unknown; + /** Result of loading that exact root checkpoint, never a current-state guess. */ + readonly loadedCheckpoint: unknown; + readonly snapshot: PlanningSnapshot; +} +export type Confirmation = + | { readonly kind: 'unconfirmed' } + | { + readonly kind: 'confirmed'; + readonly update: 'replacement' | 'retained'; + readonly plan: Baseline['plan']; + readonly checkpoint: Checkpoint; + readonly values: Readonly>; + }; + +/** Capture the exact current pre-send canonical prefix. Undefined is the inert + * first turn. Default plan authority is safe only for initial/already-confirmed + * values. After an unconfirmed attempt, supply the separately retained saved + * plan: current canonical messages may advance without advancing authority. + * Current malformed todos do not prevent a later successful replacement. */ +export function captureBaseline( + input: unknown, + retainedSavedPlan?: Baseline['plan'] +): Baseline | undefined { + try { + const values = copyJson(input === undefined ? { messages: [] } : input); + if (!plainRecord(values) || !Array.isArray(values.messages)) + return undefined; + const plan = + retainedSavedPlan === undefined + ? planState(values) + : savedPlanSource(retainedSavedPlan); + if (!plan || plan.kind === 'invalid') return undefined; + return Object.freeze({ messages: values.messages, plan }); + } catch { + return undefined; + } +} + +function savedPlanSource(input: unknown): Baseline['plan'] | undefined { + const value = copyJson(input); + if (!plainRecord(value)) return undefined; + if (value.kind === 'missing' && Object.keys(value).length === 1) + return Object.freeze({ kind: 'missing' }); + if ( + value.kind !== 'valid' || + Object.keys(value).length !== 2 || + !Object.hasOwn(value, 'items') + ) + return undefined; + const plan = planState({ todos: value.items }); + return plan.kind === 'valid' ? plan : undefined; +} + +/** Bind the captured baseline only after the native runtime supplies its actual + * current human id. These are application facts, not invented graph fields. */ +export function captureTurn( + baseline: Baseline | undefined, + input: TurnIdentity +): Turn | undefined { + try { + const identity = copyJson(input, true); + if ( + !baseline || + !plainRecord(identity) || + ['owner', 'generation', 'threadId', 'humanId'].some( + (k) => typeof identity[k] !== 'string' || !identity[k] + ) || + typeof identity.humanContent !== 'string' || + (identity.backendRunId !== undefined && + (typeof identity.backendRunId !== 'string' || !identity.backendRunId)) + ) + return undefined; + const copied = copyJson(baseline); + if ( + !plainRecord(copied) || + !Array.isArray(copied.messages) || + !savedPlanSource(copied.plan) || + copied.messages.some((m) => plainRecord(m) && m.id === identity.humanId) + ) + return undefined; + return Object.freeze({ ...identity, baseline: copied }) as unknown as Turn; + } catch { + return undefined; + } +} + +export function checkpointSource( + input: unknown, + threadId: string +): Checkpoint | undefined { + try { + const value = copyJson(input); + if ( + !plainRecord(value) || + value.thread_id !== threadId || + value.checkpoint_ns !== '' || + typeof value.checkpoint_id !== 'string' || + !value.checkpoint_id || + Object.keys(value).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) + ) + return undefined; + if ( + value.checkpoint_map !== undefined && + (!plainRecord(value.checkpoint_map) || + Object.entries(value.checkpoint_map).some( + ([k, v]) => k !== '' || v !== value.checkpoint_id + )) + ) + return undefined; + return value as unknown as Checkpoint; + } catch { + return undefined; + } +} +function empty(value: unknown) { + return value === undefined || (Array.isArray(value) && !value.length); +} +function clean(record: Record) { + return ( + (record.error === undefined || record.error === null) && + ['interrupts', '__interrupt__', 'subgraphs'].every((k) => empty(record[k])) + ); +} +function terminal(record: Record) { + return ( + clean(record) && + Array.isArray(record.next) && + !record.next.length && + Array.isArray(record.tasks) && + !record.tasks.length + ); +} +function projectValues(values: Record) { + return Object.fromEntries( + Object.entries(values).filter( + ([k]) => k !== 'messages' && k !== '__interrupt__' + ) + ); +} + +/** Confirmation is fail-closed. An unconfirmed result carries no replacement; + * the application retains its previous saved authority until a later success. */ +export function captureTerminal(input: TerminalInput): Confirmation { + const unconfirmed: Confirmation = Object.freeze({ kind: 'unconfirmed' }); + try { + const turn = copyJson(input.turn, true); + const state = copyJson(input.snapshot, true); + const raw = copyJson(input.loadedCheckpoint); + const observed = copyJson(input.observedValues); + if ( + !plainRecord(turn) || + !plainRecord(state) || + !plainRecord(raw) || + !plainRecord(observed) || + input.submittedSuccessfully !== true || + turn.owner !== input.owner || + turn.generation !== input.generation || + turn.threadId !== input.threadId || + state.status !== 'idle' || + !clean(state) || + !Array.isArray(state.interrupts) || + state.interrupts.length || + !Array.isArray(state.subgraphs) || + state.subgraphs.length || + !terminal(raw) + ) + return unconfirmed; + const knownRunId = input.backendRunId ?? turn.backendRunId; + if ( + turn.backendRunId !== undefined && + input.backendRunId !== undefined && + turn.backendRunId !== input.backendRunId + ) + return unconfirmed; + if ( + knownRunId !== undefined && + (typeof knownRunId !== 'string' || + !knownRunId || + (raw.run_id !== undefined && raw.run_id !== knownRunId) || + (plainRecord(raw.metadata) && + raw.metadata.run_id !== undefined && + raw.metadata.run_id !== knownRunId)) + ) + return unconfirmed; + const requested = checkpointSource( + input.observedCheckpoint, + input.threadId + ); + const checkpoint = checkpointSource(raw.checkpoint, input.threadId); + const latest = Array.isArray(state.history) ? state.history[0] : undefined; + // Native history deliberately carries only checkpoint/parent/date/next. + // Raw saved execution tasks and values are proved by the exact state read. + if ( + !requested || + !checkpoint || + !sameJson(requested, checkpoint) || + !plainRecord(latest) || + !clean(latest) || + !Array.isArray(latest.next) || + latest.next.length || + (latest.tasks !== undefined && + (!Array.isArray(latest.tasks) || latest.tasks.length)) || + !sameJson( + checkpointSource(latest.checkpoint, input.threadId), + checkpoint + ) || + (latest.values !== undefined && !sameJson(latest.values, raw.values)) + ) + return unconfirmed; + const values = raw.values; + if ( + !plainRecord(values) || + !clean(values) || + !sameJson(values, observed) || + !sameJson(projectValues(values), state.values) || + !Array.isArray(values.messages) || + !Array.isArray(state.messages) || + !Array.isArray(state.toolCalls) || + !plainRecord(turn.baseline) || + !Array.isArray(turn.baseline.messages) + ) + return unconfirmed; + const prefix = turn.baseline.messages; + if (!sameJson(prefix, values.messages.slice(0, prefix.length))) + return unconfirmed; + const human = values.messages[prefix.length]; + if ( + !plainRecord(human) || + human.type !== 'human' || + human.id !== turn.humanId || + human.content !== turn.humanContent + ) + return unconfirmed; + const evidence = writeEvidence(values.messages, turn.humanId as string); + if (evidence.kind === 'invalid' || evidence.kind === 'rejected') + return unconfirmed; + const plan = planState(values); + if ( + plan.kind === 'invalid' || + (evidence.kind === 'replacement' + ? plan.kind !== 'valid' || !sameJson(plan.items, evidence.items) + : !sameJson(plan, turn.baseline.plan)) + ) + return unconfirmed; + if (!canonicalMatches(values.messages, state.messages, state.toolCalls)) + return unconfirmed; + const last = values.messages.at(-1); + if ( + !plainRecord(last) || + last.type !== 'ai' || + typeof last.content !== 'string' || + !last.content.trim() || + !empty(last.tool_calls) + ) + return unconfirmed; + return Object.freeze({ + kind: 'confirmed', + update: evidence.kind === 'replacement' ? 'replacement' : 'retained', + plan, + checkpoint, + values, + }); + } catch { + return unconfirmed; + } +} + +function canonicalMatches( + raw: unknown[], + messages: unknown[], + tools: unknown[] +): boolean { + if (raw.length !== messages.length) return false; + const calls = new Map>(); + const ids = new Set(); + for (const tool of tools) { + if ( + !plainRecord(tool) || + typeof tool.id !== 'string' || + calls.has(tool.id) || + tool.status !== 'complete' + ) + return false; + calls.set(tool.id, tool); + } + const pending = new Set(); + const seen = new Set(); + for (const [i, wire] of raw.entries()) { + const m = messages[i]; + if ( + !plainRecord(wire) || + !clean(wire) || + !plainRecord(m) || + typeof wire.id !== 'string' || + !wire.id || + ids.has(wire.id) || + wire.id !== m.id || + typeof wire.content !== 'string' || + wire.content !== m.content || + !plainRecord(m.delivery) || + m.delivery.phase !== 'complete' || + m.delivery.outcome !== 'success' || + wire.type !== + (m.role === 'user' + ? 'human' + : m.role === 'assistant' + ? 'ai' + : m.role === 'tool' + ? 'tool' + : '') || + m.name !== (typeof wire.name === 'string' ? wire.name : undefined) || + !empty(wire.invalid_tool_calls) || + !empty(m.citations) + ) + return false; + ids.add(wire.id); + const extra = plainRecord(wire.additional_kwargs) + ? wire.additional_kwargs + : {}; + if ( + (wire.additional_kwargs !== undefined && + !plainRecord(wire.additional_kwargs)) || + extra.function_call !== undefined || + !empty(extra.tool_calls) || + !empty(extra.citations) || + !empty(extra.sources) + ) + return false; + const reasoning = + typeof wire.reasoning === 'string' + ? wire.reasoning + : typeof extra.reasoning_content === 'string' + ? extra.reasoning_content + : undefined; + if (m.reasoning !== reasoning || m.toolCallId !== wire.tool_call_id) + return false; + if (wire.type === 'ai') { + if (pending.size) return false; + const rawCalls = wire.tool_calls ?? []; + const projectedIds = m.toolCallIds ?? []; + if ( + !Array.isArray(rawCalls) || + !Array.isArray(projectedIds) || + rawCalls.length !== projectedIds.length + ) + return false; + for (const [j, call] of rawCalls.entries()) { + if ( + !plainRecord(call) || + typeof call.id !== 'string' || + !call.id || + call.id !== projectedIds[j] || + seen.has(call.id) + ) + return false; + const tool = calls.get(call.id); + if (!tool || tool.name !== call.name || !sameJson(tool.args, call.args)) + return false; + seen.add(call.id); + pending.add(call.id); + } + } else if (wire.type === 'tool') { + const tool = calls.get(wire.tool_call_id as string); + if ( + !tool || + !pending.delete(wire.tool_call_id as string) || + tool.result !== wire.content || + !['success', 'error'].includes(wire.status as string) || + (wire.name !== tool.name && + !( + wire.status === 'error' && + (wire.name === null || wire.name === undefined) + )) || + !empty(wire.tool_calls) || + !empty(m.toolCallIds) + ) + return false; + } else if ( + pending.size || + !empty(wire.tool_calls) || + !empty(m.toolCallIds) || + wire.tool_call_id !== undefined + ) + return false; + } + return !pending.size && seen.size === calls.size; +} diff --git a/cockpit/deep-agents/planning/react/src/tool-evidence.spec.ts b/cockpit/deep-agents/planning/react/src/tool-evidence.spec.ts new file mode 100644 index 000000000..0b8220906 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/tool-evidence.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { writeEvidence } from './tool-evidence'; + +const todos = [{ content: 'Still working', status: 'in_progress' }]; +const human = { id: 'h', type: 'human', content: 'Plan' }; +const ai = (ids = ['call'], args: unknown = { todos }) => ({ + id: `ai-${ids.join('-')}`, + type: 'ai', + content: '', + tool_calls: ids.map((id) => ({ + id, + name: 'write_todos', + args, + type: 'tool_call', + })), + invalid_tool_calls: [], +}); +const tool = ( + id = 'call', + status = 'success', + name: string | null = 'write_todos' +) => ({ + id: `result-${id}`, + type: 'tool', + tool_call_id: id, + name, + status, + content: "Updated todo list to [{'content': 'prose must not be parsed'}]", +}); +const answer = { + id: 'answer', + type: 'ai', + content: 'Done', + tool_calls: [], + invalid_tool_calls: [], +}; +describe('current-turn raw write evidence', () => { + it('uses whole-list call arguments and authoritative raw success', () => { + expect(writeEvidence([human, ai(), tool(), answer], 'h')).toEqual({ + kind: 'replacement', + items: todos, + callId: 'call', + }); + }); + it('accepts successful empty clear', () => { + expect( + writeEvidence( + [human, ai(['clear'], { todos: [] }), tool('clear'), answer], + 'h' + ) + ).toEqual({ kind: 'replacement', items: [], callId: 'clear' }); + }); + it('reports no write despite assistant todo prose', () => { + expect(writeEvidence([human, answer], 'h')).toEqual({ kind: 'none' }); + }); + it('recognizes nameless parallel rejection', () => { + expect( + writeEvidence( + [ + human, + ai(['a', 'b']), + tool('a', 'error', null), + tool('b', 'error', null), + answer, + ], + 'h' + ).kind + ).toBe('rejected'); + }); + it('recognizes schema rejection without validating rejected arguments', () => { + expect( + writeEvidence([human, ai(['bad'], {}), tool('bad', 'error'), answer], 'h') + .kind + ).toBe('rejected'); + }); + it('recovers after rejected parallel writes with later single success', () => { + expect( + writeEvidence( + [ + human, + ai(['a', 'b']), + tool('a', 'error', null), + tool('b', 'error', null), + ai(['recover']), + tool('recover'), + answer, + ], + 'h' + ) + ).toEqual({ kind: 'replacement', items: todos, callId: 'recover' }); + }); + it('ignores prior-turn writes', () => { + expect( + writeEvidence( + [{ ...human, id: 'old' }, ai(), tool(), { ...human, id: 'h' }, answer], + 'h' + ) + ).toEqual({ kind: 'none' }); + }); + it.each( + [ + [human, ai(), answer], + [human, ai(), tool(), tool(), answer], + [human, ai(['a', 'a']), tool('a'), answer], + [human, ai(['a', 'b']), tool('a'), tool('b'), answer], + [human, ai(), { ...tool(), status: undefined }, answer], + [human, ai(), tool('call', 'success', null), answer], + [human, ai(), tool('other'), answer], + [ + human, + ai(['bad'], { todos: [{ content: 'Bad', status: 'done' }] }), + tool('bad'), + answer, + ], + [human, ai(['bad'], { todos, extra: true }), tool('bad'), answer], + [human, { ...ai(), invalid_tool_calls: [{ id: 'bad' }] }, tool(), answer], + [human, ai(), tool(), { ...human, id: 'foreign' }, answer], + [human, ai(), { ...tool(), content: { todos } }, answer], + ].map((messages) => ({ messages })) + )('rejects pending, ambiguous or unsupported evidence %#', ({ messages }) => { + expect(writeEvidence(messages, 'h').kind).toBe('invalid'); + }); + it('rejects getters without invocation', () => { + let reads = 0; + const result = Object.defineProperty({ ...tool() }, 'status', { + enumerable: true, + get() { + reads++; + return 'success'; + }, + }); + expect(writeEvidence([human, ai(), result, answer], 'h').kind).toBe( + 'invalid' + ); + expect(reads).toBe(0); + }); +}); diff --git a/cockpit/deep-agents/planning/react/src/tool-evidence.ts b/cockpit/deep-agents/planning/react/src/tool-evidence.ts new file mode 100644 index 000000000..639af2064 --- /dev/null +++ b/cockpit/deep-agents/planning/react/src/tool-evidence.ts @@ -0,0 +1,143 @@ +import { copyJson, plainRecord, planState, type PlanItem } from './plan-state'; + +export type WriteEvidence = + | { readonly kind: 'none' | 'rejected' | 'invalid' } + | { + readonly kind: 'replacement'; + readonly items: readonly PlanItem[]; + readonly callId: string; + }; + +/** Inspect actual canonical wire messages. Core ToolCall.complete cannot + * establish success: history projection also completes raw error results. */ +export function writeEvidence(input: unknown, humanId: string): WriteEvidence { + const invalid: WriteEvidence = Object.freeze({ kind: 'invalid' }); + try { + const messages = copyJson(input); + if (!Array.isArray(messages) || !humanId) return invalid; + const messageIds = new Set(); + for (const message of messages) { + if ( + !plainRecord(message) || + typeof message.id !== 'string' || + !message.id || + messageIds.has(message.id) + ) + return invalid; + messageIds.add(message.id); + } + const start = messages.findIndex( + (m) => m.id === humanId && m.type === 'human' + ); + if (start < 0) return invalid; + const pending = new Map< + string, + { name: string; args: unknown; parallel: boolean } + >(); + // Prior IDs cannot be reused in the current turn. + const seen = new Set(); + for (const message of messages.slice(0, start)) { + if (Array.isArray(message.tool_calls)) + for (const call of message.tool_calls) { + if ( + !plainRecord(call) || + typeof call.id !== 'string' || + seen.has(call.id) + ) + return invalid; + seen.add(call.id); + } + } + let result: WriteEvidence = Object.freeze({ kind: 'none' }); + for (const [index, message] of messages.slice(start).entries()) { + if ( + typeof message.content !== 'string' || + !empty(message.invalid_tool_calls) + ) + return invalid; + const extra = message.additional_kwargs; + if ( + extra !== undefined && + (!plainRecord(extra) || + extra.function_call !== undefined || + !empty(extra.tool_calls)) + ) + return invalid; + if (message.type === 'human') { + if ( + index !== 0 || + pending.size || + !empty(message.tool_calls) || + message.tool_call_id !== undefined + ) + return invalid; + } else if (message.type === 'ai') { + if (pending.size || message.tool_call_id !== undefined) return invalid; + const calls = message.tool_calls ?? []; + if (!Array.isArray(calls)) return invalid; + const parallel = + calls.filter( + (call) => plainRecord(call) && call.name === 'write_todos' + ).length > 1; + for (const call of calls) { + if ( + !plainRecord(call) || + typeof call.id !== 'string' || + !call.id || + seen.has(call.id) || + typeof call.name !== 'string' || + !call.name || + !plainRecord(call.args) || + (call.type !== undefined && call.type !== 'tool_call') + ) + return invalid; + pending.set(call.id, { name: call.name, args: call.args, parallel }); + seen.add(call.id); + } + } else if (message.type === 'tool') { + if ( + typeof message.tool_call_id !== 'string' || + !empty(message.tool_calls) + ) + return invalid; + const call = pending.get(message.tool_call_id); + if (!call || !['success', 'error'].includes(message.status as string)) + return invalid; + if ( + message.name !== call.name && + !( + message.status === 'error' && + (message.name === null || message.name === undefined) + ) + ) + return invalid; + pending.delete(message.tool_call_id); + if (call.name === 'write_todos') { + if (message.status === 'error') + result = Object.freeze({ kind: 'rejected' }); + else { + const plan = planState(call.args); + if ( + call.parallel || + plan.kind !== 'valid' || + Object.keys(call.args as object).length !== 1 + ) + return invalid; + result = Object.freeze({ + kind: 'replacement', + items: plan.items, + callId: message.tool_call_id, + }); + } + } + } else return invalid; + } + return pending.size ? invalid : result; + } catch { + return invalid; + } +} + +function empty(value: unknown) { + return value === undefined || (Array.isArray(value) && value.length === 0); +} diff --git a/cockpit/deep-agents/planning/react/tsconfig.json b/cockpit/deep-agents/planning/react/tsconfig.json new file mode 100644 index 000000000..c99122e69 --- /dev/null +++ b/cockpit/deep-agents/planning/react/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "composite": false, + "emitDeclarationOnly": false, + "declarationMap": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/cockpit/deep-agents/planning/react/vite.config.mts b/cockpit/deep-agents/planning/react/vite.config.mts new file mode 100644 index 000000000..b0b2621d2 --- /dev/null +++ b/cockpit/deep-agents/planning/react/vite.config.mts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + resolve: { + alias: { + '@threadplane/langgraph': fileURLToPath( + new URL( + '../../../../libs/langgraph/src/runtime/create-session.ts', + import.meta.url + ) + ), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + maxWorkers: 2, + }, +}); diff --git a/libs/cockpit-registry/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index 907ebf5de..2278d60ef 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -56,7 +56,7 @@ export const capabilities: readonly Capability[] = [ { id: 'time-travel', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'time-travel', angularProject: 'cockpit-langgraph-time-travel-angular', port: 4306, react: { project: 'cockpit-langgraph-time-travel-react', port: 4607 }, pythonPort: 5306, pythonDir: 'cockpit/langgraph/time-travel/python', graphName: 'time-travel' }, { id: 'deployment-runtime', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'deployment-runtime', angularProject: 'cockpit-langgraph-deployment-runtime-angular', port: 4307, react: { project: 'cockpit-langgraph-deployment-runtime-react', port: 4608 }, pythonPort: 5307, pythonDir: 'cockpit/langgraph/deployment-runtime/python', graphName: 'deployment-runtime' }, { 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, pythonPort: 5310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }, + { 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, 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-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' }, diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index e053c4116..1bb751b13 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -44,6 +44,20 @@ 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 Planning variant with the canonical backend and exact sources', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-planning-python')!; + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react'); + expect(react).toBeDefined(); + expect(react?.manifestIdentity).toEqual(canonical.manifestIdentity); + expect(react?.backendAssetPaths).toEqual(deepAgentsPlanningPythonModule.backendAssetPaths); + expect(react?.promptAssetPaths).toEqual(deepAgentsPlanningPythonModule.promptAssetPaths); + expect(react?.runtimeUrl).toBe('deep-agents/planning/react'); + expect(react?.devPort).toBe(4628); + expect(react?.codeAssetPaths).toEqual(['app.tsx', 'application.ts', 'connection.ts', 'plan-state.ts', 'tool-evidence.ts', 'terminal.ts', 'plan-panel.tsx', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/planning/react/src/${file}`)); + expect(Object.isFrozen(react)).toBe(true); + expect(Object.isFrozen(react?.codeAssetPaths)).toBe(true); + expect(capabilityModules.filter(entry => entry.id === canonical.id)).toHaveLength(1); +}); it('keeps native Chat Generative UI attached to the original backend descriptor assets', () => { const canonical=capabilityModules.find(entry=>entry.id==='chat-generative-ui-python'); if (!canonical) throw new Error('Missing canonical Chat Generative UI'); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 0d127271e..3832a3413 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1254,9 +1254,25 @@ const computedFunctionsReact = (() => { }); })(); +const deepAgentsPlanningReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'deep-agents-planning-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-planning-react'); + if (!canonical || !frontend) throw new Error('Planning frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'deep-agents-planning-python-react', + frontend: 'react', + title: 'Planning (React preview)', + codeAssetPaths: ['app.tsx', 'application.ts', 'connection.ts', 'plan-state.ts', 'tool-evidence.ts', 'terminal.ts', 'plan-panel.tsx', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/planning/react/src/${file}`), + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, + deepAgentsPlanningReact, 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 4c328d09a..114c56682 100644 --- a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts @@ -6,6 +6,20 @@ import { type RegisteredCapabilityModule, } from './content-descriptors'; import type { CockpitManifestIdentity } from './manifest.types'; +import { capabilities, getCockpitFrontends } from './capability-registry'; + +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(29); + 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' }, + ]); +}); const identity: CockpitManifestIdentity = { product: 'langgraph', diff --git a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts index 38d1dcb63..8e3d35d23 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -251,8 +251,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(28); - expect(getCockpitFrontends()).toHaveLength(69); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(29); + expect(getCockpitFrontends()).toHaveLength(70); 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', () => { @@ -281,9 +281,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(28); - expect(frontends).toHaveLength(69); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(69); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(29); + expect(frontends).toHaveLength(70); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(70); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -298,8 +298,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(28); - expect(getCockpitFrontends()).toHaveLength(69); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(29); + expect(getCockpitFrontends()).toHaveLength(70); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -327,8 +327,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(28); - expect(getCockpitFrontends()).toHaveLength(69); + ).toHaveLength(29); + expect(getCockpitFrontends()).toHaveLength(70); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -588,11 +588,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - expect(frontends).toHaveLength(69); - expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(69); - expect(new Set(frontends.map((entry) => entry.project)).size).toBe(69); - expect(new Set(frontends.map((entry) => entry.port)).size).toBe(69); - expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(69); + expect(frontends).toHaveLength(70); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(70); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(70); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(70); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(70); 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 f1d8c90fd..d256ca1c0 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -6,13 +6,28 @@ import { test } from 'node:test'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { deriveCockpitCaps, selectCockpitCaps } from './cockpit-matrix.mjs'; +test('Planning 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-planning-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-planning-angular', 'cockpit-deep-agents-planning-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/planning/python')); +}); +test('Planning owns an exact closed native React identity', async () => { + const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); + assert.deepEqual(reactCockpitConfiguration('deep-agents-planning'), { + topic: 'planning', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/planning/react', + base: '/deep-agents/planning/react/', port: 4628, project: 'cockpit-deep-agents-planning-react', + }); + for (const key of ['planning', 'deep-agents/planning', '../deep-agents-planning', 'deep-agents-planning/../memory']) + assert.throws(() => reactCockpitConfiguration(key), /Unsupported React cockpit topic/); +}); test('Generative UI backend changes select both canonical frontends registered by the website', () => { const root = fileURLToPath(new URL('..', import.meta.url)); const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-chat-generative-ui-python']), { fullFleet: false }); 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, 69); + assert.equal(frontends.length, 70); 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', () => { @@ -21,7 +36,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, 69); + assert.equal(frontends.length, 70); 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 () => { @@ -40,7 +55,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, 69); + assert.equal(frontends.length, 70); 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 () => { @@ -355,9 +370,18 @@ test('React cockpit configuration accepts only the nine authored topics', async ); }); -test('CI discovers twelve React previews across both protocols with their own Python backends', (t) => { +test('CI discovers native React previews across protocols with their own Python backends', (t) => { 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 frontend of ['react', 'python']) { + const directory = join(root, 'cockpit/deep-agents/planning', frontend); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'project.json'), JSON.stringify({ + name: `cockpit-deep-agents-planning-${frontend}`, + targets: frontend === 'python' ? {} : { e2e: {} }, + })); + } for (const topic of [ 'streaming', 'interrupts', @@ -393,7 +417,7 @@ test('CI discovers twelve React previews across both protocols with their own Py ); } const caps = deriveCockpitCaps(root); - assert.equal(caps.length, 28); + assert.equal(caps.length, 29); 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 8a61999fd..6a1c598ee 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -1,4 +1,18 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; + +it('wires native Planning 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/planning/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-planning'); + expect(react.targets.e2e.dependsOn).toEqual(['build', 'fixture-test']); + expect(react.targets['fixture-test'].dependsOn).toContainEqual({ target: 'test', projects: ['cockpit-deep-agents-planning-python'] }); + const website = read('apps/website/project.json'); + expect(website.targets.e2e.dependsOn[0].projects).toContain('cockpit-deep-agents-planning-react'); + const config = readFileSync(join(repoRoot, 'apps/website/playwright.config.ts'), 'utf8'); + 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'"); +}); import { basename, dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { capabilities } from '@threadplane/cockpit-registry'; diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index 8c05f7fab..748492e82 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,10 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'deep-agents-planning') + return Object.freeze({ + topic: 'planning', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/planning/react', + base: '/deep-agents/planning/react/', port: 4628, project: 'cockpit-deep-agents-planning-react', + }); if (topic === 'chat-generative-ui') return Object.freeze({ topic: 'generative-ui', library: 'chat', adapter: 'langgraph', appPath: 'cockpit/chat/generative-ui/react', diff --git a/scripts/react-cockpit/deep-agents-planning-configuration.spec.mjs b/scripts/react-cockpit/deep-agents-planning-configuration.spec.mjs new file mode 100644 index 000000000..409966dda --- /dev/null +++ b/scripts/react-cockpit/deep-agents-planning-configuration.spec.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { reactCockpitConfiguration } from './configuration.mjs'; +test('Planning has a closed native React selection and fixture dispatch precedes generic fallback', () => { + assert.deepEqual(reactCockpitConfiguration('deep-agents-planning'), { + topic: 'planning', + library: 'deep-agents', + adapter: 'langgraph', + appPath: 'cockpit/deep-agents/planning/react', + base: '/deep-agents/planning/react/', + port: 4628, + project: 'cockpit-deep-agents-planning-react', + }); + assert.throws(() => reactCockpitConfiguration('deep-agents/../../foreign')); + const serve = readFileSync(new URL('./serve.mjs', import.meta.url), 'utf8'); + assert.ok( + serve.indexOf('await deepAgentsPlanningFixture(request') < + serve.indexOf("if (pathname === '/__reset')") + ); +}); diff --git a/scripts/react-cockpit/deep-agents-planning-fixture.mjs b/scripts/react-cockpit/deep-agents-planning-fixture.mjs new file mode 100644 index 000000000..b80ff4756 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-planning-fixture.mjs @@ -0,0 +1,517 @@ +/** Loopback-only SDK transport backed by the actual compiled Planning graph. */ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +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 lines = createInterface({ input: child.stdout }); + child.stderr.resume(); + function fail() { + dead = true; + for (const item of pending.values()) { + clearTimeout(item.timer); + item.reject(Error('Planning graph worker unavailable.')); + } + pending.clear(); + } + const terminate = () => { + fail(); + child.kill('SIGTERM'); + }; + child.once('error', fail); + child.once('close', () => { + fail(); + lines.close(); + }); + child.stdin.on('error', terminate); + lines.once('close', () => { + if (!dead) terminate(); + }); + lines.on('line', (line) => { + try { + if (Buffer.byteLength(line) > 16 * 1024 * 1024) + throw Error('Oversized response'); + const result = JSON.parse(line), + item = pending.get(result.requestId); + if (!item) throw Error('Unowned response'); + pending.delete(result.requestId); + clearTimeout(item.timer); + if (result.error) item.reject(Error('Planning graph operation failed.')); + else item.resolve(result); + } catch { + terminate(); + } + }); + return { + pid: child.pid, + send(value) { + if (dead) return Promise.reject(Error('Planning 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); + lines.close(); + })()); + }, + }; +} + +export function createDeepAgentsPlanningFixture({ + workerTimeoutMs = 30000, + spawnWorker = () => + spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/planning/python', + 'python', + 'scripts/react-cockpit/deep-agents-planning-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-planning-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ), +} = {}) { + const sourceSha256 = Object.fromEntries( + ['src/graph.py', 'prompts/planning.md', 'uv.lock'].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync( + new URL( + '../../cockpit/deep-agents/planning/python/' + path, + import.meta.url + ) + ) + ) + .digest('hex'), + ]) + ); + let worker, + closed = false, + closing, + queue = Promise.resolve(); + const threads = new Set(), + runs = new Map(), + held = new Set(), + proofs = [], + requests = []; + const options = { + scenario: 'normal', + 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; + } + let input = ''; + for await (const chunk of request) { + input += chunk; + if (Buffer.byteLength(input) > 1024 * 1024) { + json(response, {}, 413); + return true; + } + } + const body = input ? JSON.parse(input) : {}; + if (!only(body, Object.keys(body ?? {}))) { + json(response, {}, 400); + return true; + } + if (pathname === '/__graph-proof') { + json(response, proofs); + return true; + } + if (pathname === '/__requests') { + json(response, requests); + return true; + } + if (pathname === '/__lifetime') { + json(response, { + workers: worker ? 1 : 0, + workerPid: worker?.pid, + held: held.size, + }); + return true; + } + if (pathname === '/__configure') { + if ( + !only(body, [ + 'scenario', + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ]) || + ('scenario' in body && + ![ + 'normal', + 'empty', + 'duplicates', + 'parallel', + 'schema', + 'recovery', + 'no-write', + 'oversized', + 'empty-content', + 'long-content', + ].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') { + for (const item of held) { + held.delete(item); + item.finish(); + } + json(response, {}); + return true; + } + if (pathname.startsWith('/__')) { + json(response, {}, 404); + 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 !== undefined && body.if_exists !== 'raise') || + (body.metadata !== undefined && !only(body.metadata, [])) + ) { + json(response, {}, 400); + return true; + } + 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', 'before', 'metadata', 'checkpoint']) || + (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 && + (request.method === 'GET' || (runRoute[2] && request.method === 'POST')) + ) { + 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', + 'stream_mode', + 'stream_subgraphs', + 'stream_resumable', + 'on_disconnect', + ]) || + !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 !== 'da-planning' || + !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, + }); + 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 [kind, data] of wire.events) + if (modes.has(kind)) response.write(event(kind, 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: 'Planning 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-planning-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-planning-fixture.spec.mjs new file mode 100644 index 000000000..95aac49dd --- /dev/null +++ b/scripts/react-cockpit/deep-agents-planning-fixture.spec.mjs @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict'; +import { createServer, request } from 'node:http'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; +import { randomUUID, createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { test } from 'node:test'; +import { Client } from '@langchain/langgraph-sdk'; + +const { createDeepAgentsPlanningFixture } = await import( + './deep-agents-planning-fixture.mjs' +).catch(() => ({})); + +async function fixture(t, options) { + assert.equal( + typeof createDeepAgentsPlanningFixture, + 'function', + 'Planning compiled-graph fixture exists' + ); + const handle = createDeepAgentsPlanningFixture(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((resolve) => server.close(resolve)); + }); + 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) => { + const response = await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify(body), + }); + assert.equal(response.status, 200); + }; + const stream = ( + content = 'Plan the KSFO to KASE flight.', + id = randomUUID() + ) => + client.runs.stream(thread, 'da-planning', { + input: { messages: [{ type: 'human', id, content }] }, + streamMode: ['messages-tuple', 'values', 'updates', 'checkpoints'], + streamSubgraphs: true, + streamResumable: true, + onDisconnect: 'continue', + }); + const submit = async (content, id = randomUUID()) => { + const events = []; + for await (const event of stream(content, id)) events.push(event); + return { + events, + id, + run: events.find((e) => e.event === 'metadata').data.run_id, + }; + }; + return { handle, base, client, thread, control, stream, submit }; +} + +function toolResults(state, ids) { + return state.values.messages.filter( + (m) => m.type === 'tool' && ids.includes(m.tool_call_id) + ); +} + +test('installed SDK streams actual replacement checkpoints and exact canonical saved state', async (t) => { + const f = await fixture(t); + const { events, run, id } = await f.submit(); + const saved = await f.client.threads.getState(f.thread); + const terminal = events.filter((e) => e.event === 'checkpoints').at(-1).data; + assert.deepEqual( + events.filter((e) => e.event === 'values').at(-1).data, + saved.values + ); + assert.equal( + terminal.config.configurable.checkpoint_id, + saved.checkpoint.checkpoint_id + ); + assert.equal(terminal.config.configurable.thread_id, f.thread); + assert.deepEqual(terminal.values, saved.values); + assert.deepEqual(saved.next, []); + assert.deepEqual(saved.tasks, []); + assert.equal(saved.values.messages[0].id, id); + assert.equal(saved.values.messages.at(-1).type, 'ai'); + assert.equal(saved.values.todos.at(-1).status, 'in_progress'); + assert.equal( + saved.values.todos.filter((todo) => todo.status === 'completed').length, + 3 + ); + assert.equal(Object.hasOwn(saved.metadata, 'run_id'), false); + assert.equal(Object.hasOwn(terminal.metadata, 'run_id'), false); + assert.equal(Object.hasOwn(saved.values, 'completed_turn_id'), false); + assert.equal(Object.hasOwn(saved.values, 'operation_receipt'), false); + const exact = await f.client.threads.getState(f.thread, saved.checkpoint); + assert.deepEqual(exact, saved); + const history = await f.client.threads.getHistory(f.thread, { limit: 100 }); + assert.equal( + history[0].checkpoint.checkpoint_id, + saved.checkpoint.checkpoint_id + ); + assert(history.some((s) => s.next.length)); + assert(history.some((s) => s.tasks.length)); + assert( + history.every((s) => s.tasks.every((task) => task.interrupts.length === 0)) + ); + assert.equal((await f.client.runs.get(f.thread, run)).status, 'success'); + const writes = saved.values.messages.filter( + (m) => m.type === 'tool' && m.content.startsWith('Updated todo list to') + ); + assert.equal(writes.length, 2); + assert( + writes.every((m) => m.name === 'write_todos' && m.status === 'success') + ); + const lookup = saved.values.messages.find( + (m) => m.type === 'tool' && m.name === 'lookup_field_elevation' + ); + assert.equal(lookup.content, 'KASE field elevation is 7820 ft.'); + const proofs = await (await fetch(f.base + '/__graph-proof')).json(); + for (const proof of proofs) { + assert.equal(proof.actualCompiledGraph, true); + assert.equal(proof.networkConnectAttempts, 0); + for (const source of ['src/graph.py', 'prompts/planning.md', 'uv.lock']) { + const expected = createHash('sha256') + .update( + readFileSync( + new URL( + '../../cockpit/deep-agents/planning/python/' + source, + import.meta.url + ) + ) + ) + .digest('hex'); + assert.equal(proof.sourceSha256[source], expected); + } + } +}); + +test('SDK preserves duplicates/order, replaces the whole list, and accepts explicit empty clearing', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'duplicates' }); + await f.submit(); + const saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.todos, [ + { content: 'Duplicate', status: 'completed' }, + { content: 'Duplicate', status: 'in_progress' }, + { content: 'First', status: 'pending' }, + ]); + await f.control({ scenario: 'empty' }); + await f.submit('Clear plan.'); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values.todos, + [] + ); +}); + +test('SDK exposes middleware rejection, schema errors, and subsequent valid recovery truthfully', async (t) => { + const f = await fixture(t); + await f.submit(); + const prior = (await f.client.threads.getState(f.thread)).values.todos; + await f.control({ scenario: 'parallel' }); + const rejected = await f.submit('Parallel write.'); + let saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.todos, prior); + const errors = toolResults(saved, [ + rejected.id + '-parallel-a', + rejected.id + '-parallel-b', + ]); + assert.equal(errors.length, 2); + assert(errors.every((m) => m.status === 'error' && m.name === null)); + assert(errors.every((m) => m.content.includes('multiple times in parallel'))); + await f.control({ scenario: 'schema' }); + const invalid = await f.submit('Invalid write.'); + saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.todos, prior); + const invalidResult = toolResults(saved, [invalid.id + '-invalid'])[0]; + assert.equal(invalidResult.name, 'write_todos'); + assert.equal(invalidResult.status, 'error'); + await f.control({ scenario: 'recovery' }); + const recovered = await f.submit('Recover.'); + saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.todos, [ + { content: 'Recovered', status: 'in_progress' }, + ]); + assert.equal( + toolResults(saved, [recovered.id + '-recover'])[0].status, + 'success' + ); +}); + +test('SDK cancellation stops held delivery without forging rollback and cleanup reaps worker', async (t) => { + const f = await fixture(t); + await f.control({ holdStream: true }); + const stream = f.stream(); + const metadata = await stream.next(); + const run = metadata.value.data.run_id; + assert.equal((await f.client.runs.get(f.thread, run)).status, 'running'); + await f.client.runs.cancel(f.thread, run, true, 'interrupt'); + assert.equal((await f.client.runs.get(f.thread, run)).status, 'interrupted'); + assert.equal((await stream.next()).done, true); + // The graph executes before buffered delivery: transport cancellation is not rollback. + const saved = await f.client.threads.getState(f.thread); + assert.equal(saved.values.todos.length, 4); + const lifetime = await (await fetch(f.base + '/__lifetime')).json(); + assert.equal(lifetime.held, 0); + assert.equal(lifetime.workers, 1); + await f.handle.close(); + assert.throws(() => process.kill(lifetime.workerPid, 0), { code: 'ESRCH' }); +}); + +test('checkpoint ownership and graph-owned input cannot be overridden through SDK', 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(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 unused of f.client.runs.stream(f.thread, 'da-planning', { + input: { + messages: [{ type: 'human', id: randomUUID(), content: 'Override.' }], + todos: [], + }, + streamMode: ['values'], + })) + assert.fail('Invalid graph-owned input streamed'); + }); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values, + saved.values + ); +}); + +test('current-state GET rejects foreign checkpoint bodies between owned threads', async (t) => { + const f = await fixture(t); + const other = randomUUID(); + await f.client.threads.create({ threadId: other, ifExists: 'raise' }); + await f.submit(); + for await (const unused of f.client.runs.stream(other, 'da-planning', { + input: { + messages: [ + { type: 'human', id: 'other-human', content: 'Other thread.' }, + ], + }, + streamMode: ['values'], + })) { + /* Consume the actual other thread graph. */ + } + const foreign = await f.client.threads.getState(other); + const current = await f.client.threads.getState(f.thread); + const body = JSON.stringify({ checkpoint: foreign.checkpoint }); + const status = await new Promise((resolve, reject) => { + const req = request( + f.base + `/api/threads/${f.thread}/state`, + { + method: 'GET', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }, + (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode)); + } + ); + req.once('error', reject); + req.end(body); + }); + assert.equal( + status, + 400, + 'current-state GET must reject a checkpoint request body' + ); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values, + current.values + ); +}); + +test( + 'worker rejects checkpoint thread overrides independently of HTTP validation', + { timeout: 30000 }, + async (t) => { + const child = spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/planning/python', + 'python', + 'scripts/react-cockpit/deep-agents-planning-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + child.stderr.resume(); + const lines = createInterface({ input: child.stdout }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const ended = once(child, 'close'); + child.kill('SIGKILL'); + await ended; + } + lines.close(); + }); + const send = async (value) => { + const line = once(lines, 'line'); + child.stdin.write(JSON.stringify(value) + '\n'); + return JSON.parse((await line)[0]); + }; + const foreign = await send({ + op: 'submit', + threadId: 'worker-other', + requestId: 1, + messages: [ + { type: 'human', id: 'worker-other-human', content: 'Other owner.' }, + ], + }); + assert.equal(foreign.state.values.messages[0].id, 'worker-other-human'); + const response = await send({ + op: 'state', + threadId: 'worker-current', + requestId: 2, + checkpoint: foreign.state.checkpoint, + }); + assert.equal( + response.error, + 'Graph operation failed', + 'worker must reject foreign checkpoint owner' + ); + assert.equal(response.requestId, 2); + } +); + +for (const [name, source] of [ + ['timeout', 'process.stdin.resume(); setInterval(() => {}, 1000);'], + [ + 'malformed output', + 'process.stdin.once("data", () => { process.stdout.write("not-json\\n"); }); setInterval(() => {}, 1000);', + ], + [ + 'unowned response', + 'process.stdin.once("data", () => { process.stdout.write(JSON.stringify({requestId: 999}) + "\\n"); }); setInterval(() => {}, 1000);', + ], + [ + 'invalid graph proof', + 'process.stdin.once("data", data => { const {requestId} = JSON.parse(data); process.stdout.write(JSON.stringify({requestId, proof: {actualCompiledGraph: false}}) + "\\n"); }); setInterval(() => {}, 1000);', + ], +]) { + test(`worker ${name} fails closed and cleanup reaps the child`, async (t) => { + let child; + const f = await fixture(t, { + workerTimeoutMs: 1000, + spawnWorker: () => + (child = spawn(process.execPath, ['-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + await assert.rejects(f.client.threads.getState(f.thread)); + await f.handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + }); +} diff --git a/scripts/react-cockpit/deep-agents-planning-wire.py b/scripts/react-cockpit/deep-agents-planning-wire.py new file mode 100644 index 000000000..65759a6a3 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-planning-wire.py @@ -0,0 +1,133 @@ +"""Bounded newline-JSON worker running the unchanged compiled Planning builder.""" +import asyncio +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/planning/python" +sys.path.insert(0, str(PROJECT / "tests")) +from test_streaming import LocalModel, build, call, responses, todo + +SOURCES = ("src/graph.py", "prompts/planning.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") + 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 {}), + "interrupts": [{"id": item.id, "value": item.value} for item in t.interrupts]} + for t in state.tasks], + } + + +def batches(scenario, identity): + write = lambda items, suffix: call(items, identity + "-" + suffix) + if scenario == "no-write": + return [] + if scenario == "oversized": + return [[write([todo(f"Item {i}") for i in range(51)], "oversized")]] + if scenario == "empty-content": + return [[write([todo("")], "empty-content")]] + if scenario == "long-content": + return [[write([todo("" + "x" * 1976)], "long-content")]] + if scenario == "empty": + return [[write([], "clear")]] + if scenario == "duplicates": + return [[write([todo("Old"), todo("Removed")], "old")], + [write([todo("Duplicate", "completed"), todo("Duplicate", "in_progress"), todo("First")], "replace")]] + parallel = [write([todo("Rejected A")], "parallel-a"), write([todo("Rejected B")], "parallel-b")] + if scenario == "parallel": + return [parallel] + if scenario == "recovery": + return [parallel, [write([todo("Recovered", "in_progress")], "recover")]] + if scenario == "schema": + return [[write([todo("Invalid", "invented")], "invalid")]] + plan = [todo("Check departure weather"), todo("Check destination elevation"), + todo("Check runway"), todo("Assess mountain conditions")] + final = [todo(row["content"], "completed" if i < 3 else "in_progress") for i, row in enumerate(plan)] + lookup = {"name": "lookup_field_elevation", "args": {"airport": "KASE"}, + "id": identity + "-elevation", "type": "tool_call"} + return [[write(plan, "initial")], [lookup], [write(final, "revision")]] + + +async def main(): + model = LocalModel() + scenario = "normal" + 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) + with redirect_stdout(sys.stderr): + config = {"configurable": {"thread_id": request.get("threadId", "")}} + 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"}): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + 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 ("normal", "empty", "duplicates", "parallel", "recovery", "schema", "no-write", "oversized", "empty-content", "long-content"): + raise ValueError("Unsupported scenario") + 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": + identity = request["messages"][0]["id"] + model.responses = responses(batches(scenario, identity), identity) + model.cursor = 0 + events = [] + async for kind, event in graph.astream( + {"messages": request["messages"]}, config, + stream_mode=["messages", "values", "updates", "checkpoints"]): + events.append([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": 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"), "error": "Graph operation failed"}), flush=True) + + +asyncio.run(main()) diff --git a/scripts/react-cockpit/serve.mjs b/scripts/react-cockpit/serve.mjs index 465b61735..66d309608 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -25,8 +25,10 @@ import { createAgUiInterruptsFixture } from './ag-ui-interrupts-fixture.mjs'; import { createAgUiToolViewsFixture } from './ag-ui-tool-views-fixture.mjs'; import { createAgUiJsonRenderFixture } from './ag-ui-json-render-fixture.mjs'; import { createAgUiSubagentsFixture } from './ag-ui-subagents-fixture.mjs'; +import { createDeepAgentsPlanningFixture } from './deep-agents-planning-fixture.mjs'; const configuration = reactCockpitConfiguration(process.argv[2]); +const deepAgentsPlanningFixture = configuration.library === 'deep-agents' && configuration.topic === 'planning' ? createDeepAgentsPlanningFixture() : null; let chatGenerativeUiFixture = configuration.library === 'chat' && configuration.topic === 'generative-ui' ? createChatGenerativeUiFixture() : null; const chatTimelineFixture = configuration.library === 'chat' && configuration.topic === 'timeline' ? createChatTimelineFixture() : null; const chatThreadsFixture = configuration.library === 'chat' && configuration.topic === 'threads' ? createChatThreadsFixture() : null; @@ -104,6 +106,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (deepAgentsPlanningFixture && (await deepAgentsPlanningFixture(request, response, pathname))) return; if (chatGenerativeUiFixture && pathname === '/__reset') { if (request.method !== 'POST') return json(response, {}, 405); try { @@ -334,11 +337,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 (chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (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 2d054291e..43e4092d1 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,8 +1,10 @@ { "schemaVersion": 1, - "baselineHead": "beabb1d39164a7ff6cf8141faa18f64c7d90ee4e", + "baselineHead": "dd545f8c04c2803aae25135600a9876bc861a5dc", "sourceState": { - "modified": [], + "modified": [ + "apps/website/content/docs/deep-agents/capabilities/planning.mdx" + ], "untracked": [] }, "scope": { @@ -2688,7 +2690,7 @@ "id": "doc:apps/website/content/docs/deep-agents/capabilities/planning.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/capabilities/planning.mdx", - "sha256": "48fc7346f3aab0d9da40558432b9826ea84be15e0e4407196057f6cbaef2a8fa" + "sha256": "e1e2f1b12b08dc5d6cdce3be8318c1dd1b8d174e241e3444bb764ccd8ed051a8" }, { "id": "doc:apps/website/content/docs/deep-agents/capabilities/skills.mdx", @@ -13461,13 +13463,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "12c30dbfbb604e5705eee4b4fd4caf4987cc42bb660c9d5ea2bb1292d2b8c965" + "sha256": "4d4c6981d7c743949415e37996705246726f6742f8d64d70163d162e53b73efd" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "cc4bbfd31fc7d1bdec187b37d2007557aa3a2de1af6a4f55eea29732b2ef4b84" + "sha256": "917e033c3eed3e90323c735efa1558a2de0c3cc89846b1f87c02d82bd588f0d6" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",