From 7e38e23cebf383251f9efddf6a3c209d8517ce31 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 10 Oct 2026 11:05:26 -0700 Subject: [PATCH] Add native React Deep Agents Filesystem preview --- .../deep-agents/capabilities/filesystem.mdx | 18 +- ...act-deep-agents-filesystem-preview.spec.ts | 241 ++++++ apps/website/playwright.config.ts | 7 + apps/website/project.json | 3 +- .../[library]/[section]/[slug]/page.spec.tsx | 12 + .../docs/[library]/[section]/[slug]/page.tsx | 7 +- .../docs/ReactDeepAgentsFilesystemPreview.tsx | 78 ++ .../website/src/lib/docs-example-code.spec.ts | 26 + apps/website/src/playwright-config.spec.ts | 17 +- .../filesystem/python/project.json | 12 + .../filesystem/python/tests/test_streaming.py | 256 ++++++ .../deep-agents/filesystem/react/README.md | 7 + .../filesystem/react/e2e/filesystem.spec.ts | 72 ++ .../filesystem/react/e2e/playwright.config.ts | 17 + .../filesystem/react/eslint.config.mjs | 11 + .../deep-agents/filesystem/react/index.html | 13 + .../deep-agents/filesystem/react/project.json | 75 ++ .../filesystem/react/src/app.spec.tsx | 100 +++ .../deep-agents/filesystem/react/src/app.tsx | 258 ++++++ .../filesystem/react/src/application.spec.ts | 160 ++++ .../filesystem/react/src/application.ts | 572 +++++++++++++ .../filesystem/react/src/approval-panel.tsx | 103 +++ .../react/src/approval-state.spec.ts | 175 ++++ .../filesystem/react/src/approval-state.ts | 191 +++++ .../filesystem/react/src/authority.spec.ts | 473 +++++++++++ .../filesystem/react/src/authority.ts | 561 +++++++++++++ .../react/src/connection.integration.spec.ts | 396 +++++++++ .../filesystem/react/src/connection.spec.ts | 172 ++++ .../filesystem/react/src/connection.ts | 143 ++++ .../deep-agents/filesystem/react/src/main.tsx | 43 + .../filesystem/react/src/panels.spec.tsx | 153 ++++ .../filesystem/react/src/styles.css | 263 ++++++ .../filesystem/react/src/styles.d.ts | 1 + .../filesystem/react/src/workspace-panel.tsx | 95 +++ .../react/src/workspace-state.spec.ts | 200 +++++ .../filesystem/react/src/workspace-state.ts | 313 +++++++ .../filesystem/react/tsconfig.json | 15 + .../filesystem/react/vite.config.mts | 22 + .../src/lib/capability-registry.ts | 2 +- .../src/lib/content-descriptors.spec.ts | 20 + .../src/lib/content-descriptors.ts | 16 + .../src/lib/frontend-descriptors.spec.ts | 2 +- .../src/lib/frontend-pilot.spec.ts | 36 +- scripts/cockpit-frontend-matrix.spec.mjs | 29 +- scripts/examples/e2e-wiring.spec.ts | 13 + scripts/react-cockpit/configuration.mjs | 2 + ...p-agents-filesystem-configuration.spec.mjs | 21 + .../deep-agents-filesystem-fixture.mjs | 557 +++++++++++++ .../deep-agents-filesystem-fixture.spec.mjs | 771 ++++++++++++++++++ .../deep-agents-filesystem-wire.py | 211 +++++ scripts/react-cockpit/serve.mjs | 7 +- scripts/react-parity/baseline.json | 12 +- 52 files changed, 6934 insertions(+), 46 deletions(-) create mode 100644 apps/website/e2e/react-deep-agents-filesystem-preview.spec.ts create mode 100644 apps/website/src/components/docs/ReactDeepAgentsFilesystemPreview.tsx create mode 100644 cockpit/deep-agents/filesystem/python/tests/test_streaming.py create mode 100644 cockpit/deep-agents/filesystem/react/README.md create mode 100644 cockpit/deep-agents/filesystem/react/e2e/filesystem.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/e2e/playwright.config.ts create mode 100644 cockpit/deep-agents/filesystem/react/eslint.config.mjs create mode 100644 cockpit/deep-agents/filesystem/react/index.html create mode 100644 cockpit/deep-agents/filesystem/react/project.json create mode 100644 cockpit/deep-agents/filesystem/react/src/app.spec.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/app.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/application.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/application.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/approval-panel.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/approval-state.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/approval-state.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/authority.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/authority.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/connection.integration.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/connection.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/connection.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/main.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/panels.spec.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/styles.css create mode 100644 cockpit/deep-agents/filesystem/react/src/styles.d.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/workspace-panel.tsx create mode 100644 cockpit/deep-agents/filesystem/react/src/workspace-state.spec.ts create mode 100644 cockpit/deep-agents/filesystem/react/src/workspace-state.ts create mode 100644 cockpit/deep-agents/filesystem/react/tsconfig.json create mode 100644 cockpit/deep-agents/filesystem/react/vite.config.mts create mode 100644 scripts/react-cockpit/deep-agents-filesystem-configuration.spec.mjs create mode 100644 scripts/react-cockpit/deep-agents-filesystem-fixture.mjs create mode 100644 scripts/react-cockpit/deep-agents-filesystem-fixture.spec.mjs create mode 100644 scripts/react-cockpit/deep-agents-filesystem-wire.py diff --git a/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx index db50d7a58..e69c53160 100644 --- a/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx @@ -13,7 +13,7 @@ The Run tab shows the prebuilt `` composition beside a workspace panel. Th The scratch file under `/notes/` appears in the panel the moment the agent writes it. The report does not. A write under `/reports/` pauses the run, and an approval card appears below the tree while the target path is already listed as a dimmed, italic row badged "awaiting approval". -Accept lets the write land and the run continue. Ignore rejects it, and the agent finishes without the file. The card also offers Edit and Respond, which this example leaves unhandled. Selecting any file in the tree shows its contents in the preview underneath. +Accept approves the pending action and resumes the run. Ignore rejects that action; the agent may propose another action afterward, so rejection does not guarantee a terminal response or the absence of a file. The card also offers Edit and Respond, which this example leaves unhandled. Selecting any file in the tree shows its contents in the preview underneath. ## How it is built @@ -54,7 +54,7 @@ provideAgent({ ### The pending write, read off the interrupt -While an approval is open the file does not exist yet. It is an argument on a paused tool call, so the only place to find it is the interrupt payload, which `injectAgent()` exposes as the `langGraphInterrupts()` Signal. The payload is `{ action_requests: [{ name, args }], review_configs: [...] }`, and for `write_file` the target path is `args.file_path`. +A proposed new file is not yet saved, while a proposed replacement may target an existing file. The Angular component reads only the first action request of the first interrupt from the `langGraphInterrupts()` Signal exposed by `injectAgent()`. The payload is `{ action_requests: [{ name, args }], review_configs: [...] }`, and for `write_file` the target path is `args.file_path`. @@ -62,7 +62,7 @@ Reading it lets the tree show the file before it lands, so the reviewer sees whe ### The file map, projected into a tree -`files` is a flat map from absolute path to a file record; the text is on its `content` field, which is why the projection stringifies anything that is not already a string. `agent.value()` returns the live graph state that holds the map. The projection reads that map, adds the pending path as a ghost entry when one is open, and splits each key on its last slash to derive a directory and a name. +`files` is a flat map from absolute path to a file record. This Angular projection displays a string value directly and calls `JSON.stringify` on the whole record for any non-string value; it does not extract the record's `content` field. `agent.value()` returns the live graph state that holds the map. The projection reads that map, adds the pending path as a ghost entry when one is open, and splits each key on its last slash to derive a directory and a name. @@ -82,22 +82,28 @@ Keeping the tree and the approval in one sidebar is the point of the layout: the ### Resuming with a decision -`` emits an `InterruptAction` of `accept`, `edit`, `respond`, or `ignore`, and the component turns the two it handles into resume payloads. `HumanInTheLoopMiddleware` resumes on an object with a `decisions` list, one decision per paused tool call, each `{ "type": "approve" }`, `{ "type": "edit" }`, or `{ "type": "reject" }`. +`` emits an `InterruptAction` of `accept`, `edit`, `respond`, or `ignore`, and the component turns the two it handles into resume payloads. `HumanInTheLoopMiddleware` resumes on an object with a `decisions` list, one ordered decision per protected action request, each `{ "type": "approve" }`, `{ "type": "edit" }`, or `{ "type": "reject" }`. -The demo always sends exactly one decision, which is enough because only one write is ever paused here. The middleware rejects a resume whose decision count differs from the number of hanging tool calls, so a turn that batches two writes into one interrupt needs two decisions. +The Angular demo always sends exactly one decision. The backend can pause several protected actions in one interrupt, so this frontend does not handle every possible batch. The required decision count is the number of protected `action_requests`, in their original order, rather than every unresolved tool call. Notes and report actions can be scheduled in parallel: a mixed pause can have `files: {}` and three pending tool calls but only one protected report decision. -The consequence is visible in the tree. On Accept the write lands, the ghost row stops being pending, and the preview shows the real file content. On Ignore the interrupt clears without a file being written, so the row that only ever existed as a projection of the pending path disappears. +After either decision, subsequent graph state determines the files shown in the tree. Approval can still encounter a tool error; rejection may be followed by another proposed action. Neither button predicts final file contents or guarantees that a future write cannot occur. The middleware reads `interrupt(request)["decisions"]`, so a bare list or a bare string raises a `TypeError` on the server rather than a validation error the browser can show. The failure appears as a dead run rather than as a rejected submission, so the shape is worth getting right the first time. +## React preview + +Choose React in the Example UI selector for a native React workspace with literal UTF-8 and legacy text projection, separate live and checkpoint-confirmed files, and complete ordered Approve or Reject batch decisions. Its Docs, Code and Run modes share the selected frontend; Angular remains the default. React suggestions fill a draft and only Send creates the owned thread. + ## Permission rules A `FilesystemPermission` carries three fields: the `operations` it covers, the `paths` it matches, and the `mode` it applies. Rules are evaluated in declaration order and the first match wins; a call that matches no rule is allowed. Subagents inherit the parent rules unless they declare `permissions` of their own, which replaces the parent set entirely. +The pinned `/reports/**` rule protects report descendants. Exact `/reports` writes and edits are allowed; deletion of `/reports` or `/` is protected because the deletion subtree overlaps the rule. + The three modes are `allow`, which lets the call proceed, `deny`, which returns a permission-denied error to the model, and `interrupt`, which pauses the call for human approval. Path patterns must start with `/` and may not contain `..`. diff --git a/apps/website/e2e/react-deep-agents-filesystem-preview.spec.ts b/apps/website/e2e/react-deep-agents-filesystem-preview.spec.ts new file mode 100644 index 000000000..3ef70eb59 --- /dev/null +++ b/apps/website/e2e/react-deep-agents-filesystem-preview.spec.ts @@ -0,0 +1,241 @@ +import { test, expect, type Page } from '@playwright/test'; +import { fromPageOrReactPreview } from './fixtures/react-preview-requests'; +const route = '/docs/deep-agents/capabilities/filesystem'; +const reactFrame = /(?:localhost:4629|deep-agents\/filesystem\/react)/; +const observations = new WeakMap< + Page, + { requests: string[]; errors: string[] } +>(); +test.beforeEach(async ({ page }) => { + const observed = { requests: [] as string[], errors: [] as string[] }; + observations.set(page, observed); + page.on('request', (request) => { + if ( + ['fetch', 'xhr'].includes(request.resourceType()) && + /\/threads(?:\/|$)/.test(new URL(request.url()).pathname) && + fromPageOrReactPreview(request, reactFrame) + ) + observed.requests.push(request.method()); + }); + page.on('pageerror', (error) => observed.errors.push(error.message)); +}); +test.afterEach(async ({ page }) => { + expect(observations.get(page)?.requests).toEqual([]); + expect(observations.get(page)?.errors).toEqual([]); +}); +async function hydrated(page: Page, mode: 'docs' | 'code' | 'run') { + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true' + ); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-workspace-mode', + { docs: 'Docs', code: 'Code', run: 'Run' }[mode] + ); +} +async function emptyRun(page: Page, draft = '') { + await hydrated(page, 'run'); + await expect(page.getByLabel('Example UI')).toHaveValue('react'); + await expect(page).toHaveURL(/(?:\?|&)mode=run(?:&|$)/); + await expect(page.locator('iframe')).toBeVisible(); + await expect(page.locator('iframe')).toHaveAttribute('src', reactFrame); + const frame = page.frameLocator('iframe'); + await expect(frame.getByRole('status')).toHaveText('Ready.'); + await expect( + frame + .getByRole('region', { name: 'Conversation', exact: true }) + .locator('article') + ).toHaveCount(0); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue(draft); +} +async function noOverflow(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +} +test('native Deep Agents Filesystem 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 Filesystem preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/deep-agents/filesystem/react/src/application.ts"]' + ) + ).toBeVisible(); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-filesystem-docs-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-filesystem-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', + 'workspace-state.ts', + 'approval-state.ts', + 'authority.ts', + 'workspace-panel.tsx', + 'approval-panel.tsx', + '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' + ); + await noOverflow(page); + await page.screenshot({ path: testInfo.outputPath('react-deep-agents-filesystem-code-desktop.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ path: testInfo.outputPath('react-deep-agents-filesystem-code-mobile.png') }); + await page.setViewportSize({ width: 1280, height: 720 }); + const graph = page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name: 'graph.py', exact: true }); + await graph.click(); + await expect(page.getByRole('tabpanel')).toContainText('FilesystemPermission'); + await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: /^Run(?:,|$)/ }) + .click(); + await emptyRun(page); + await noOverflow(page); + expect(await page.frameLocator('iframe').locator('html').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-filesystem-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-filesystem-run-mobile.png'), + }); +}); +test('the canonical Filesystem 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 Filesystem preview', + exact: true, + }) + ).toHaveCount(0); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Filesystem 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: 'Runway note for KASE', exact: true }) + .click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue( + 'Work up a runway suitability note for KASE. Save your raw lookups to /notes/kase-data.md, then write the finished note to /reports/kase-runway.md.' + ); + 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 45369912b..7a09ea315 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -409,6 +409,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4629', + 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 ae4fd77fa..d1d5a87c0 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -103,7 +103,8 @@ "cockpit-chat-threads-react", "cockpit-chat-timeline-react", "cockpit-chat-generative-ui-react", - "cockpit-deep-agents-planning-react" + "cockpit-deep-agents-planning-react", + "cockpit-deep-agents-filesystem-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 05be00c60..56014563c 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -33,6 +33,18 @@ import { ReactTimeTravelPreview } from '../../../../../components/docs/ReactTime import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; import DocsPage, { generateMetadata } from './page'; +it('selects Filesystem-specific native React Docs and exact sources while retaining Angular Docs', async () => { + const tree = await route('deep-agents', 'capabilities', 'filesystem'); + const workspace = findElement(tree, WebsiteWorkspace as ComponentType); + const article = workspace?.props.reactDocsSlot as React.ReactElement | undefined; + expect(article).toBeTruthy(); + expect((article?.type as { name?: string })?.name).toBe('ReactDeepAgentsFilesystemPreview'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/filesystem/react/src/approval-state.ts'); + expect(article?.props.exampleCode?.sources?.['cockpit/deep-agents/filesystem/react/src/authority.ts']).toContain('checkpoint'); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths.some(path => path.includes('/filesystem/angular/'))).toBe(true); + expect(article?.props.exampleCode?.assetPaths.some(path => /fixture|wire\.py|\.spec\.|proof/.test(path))).toBe(false); +}); 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); 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 847be9717..472236bda 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 { ReactDeepAgentsFilesystemPreview } from '../../../../../components/docs/ReactDeepAgentsFilesystemPreview'; import { ReactDeepAgentsPlanningPreview } from '../../../../../components/docs/ReactDeepAgentsPlanningPreview'; import { ReactChatTimelinePreview } from '../../../../../components/docs/ReactChatTimelinePreview'; import { ReactChatThreadsPreview } from '../../../../../components/docs/ReactChatThreadsPreview'; @@ -283,11 +284,13 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'deep-agents' && section === 'capabilities' && slug === 'planning') || + (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem'].includes(slug)) || (library === 'render' && ((section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (section === 'api' && ['render-spec-component','provide-render'].includes(slug)))) || (library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','json-render','subagents'].includes(slug)))) || (library === 'chat' && ((section === 'guides' && ['thread-routing', 'generative-ui'].includes(slug)) || slug === 'client-tools' || (section === 'concepts' && slug === 'message-model') || (section === 'components' && ['chat-trace', 'chat-input', 'chat-interrupt-panel', 'chat-tool-calls', 'chat-subagent-card'].includes(slug))))) ? ( - library === 'deep-agents' && section === 'capabilities' && slug === 'planning' ? ( + library === 'deep-agents' && section === 'capabilities' && slug === 'filesystem' ? ( + + ) : library === 'deep-agents' && section === 'capabilities' && slug === 'planning' ? ( ) : library === 'render' ? ( slug === 'provide-render' ? : diff --git a/apps/website/src/components/docs/ReactDeepAgentsFilesystemPreview.tsx b/apps/website/src/components/docs/ReactDeepAgentsFilesystemPreview.tsx new file mode 100644 index 000000000..3fd1c5c29 --- /dev/null +++ b/apps/website/src/components/docs/ReactDeepAgentsFilesystemPreview.tsx @@ -0,0 +1,78 @@ +import { MdxRenderer } from './MdxRenderer'; +import type { ExampleCodeContext } from '../../lib/example-code'; + +const source = `# React Deep Agents Filesystem preview + +This experimental example composes native React chat components with a read-only workspace and a protected-action approval panel. The React, core, content and native LangGraph candidates are private packages built from source. Angular remains the default on this canonical Filesystem guide. Both frontends use the same Python graph, filesystem prompt and shared FREE runtime. + +## Start a runway note + +Choose **Run**, type a message and choose **Send** or press **Enter**. **Runway note for KASE** fills this exact draft without sending: + +> Work up a runway suitability note for KASE. Save your raw lookups to /notes/kase-data.md, then write the finished note to /reports/kase-runway.md. + +Airport elevation and runway lookups use fixed tables in the unchanged graph. Only Send creates the owned thread. **New conversation** clears local files, selection, approval and conversation ownership; it waits for Send to create another thread. Opening the guide, changing frontend or mode, using browser history, filling a suggestion and choosing New do not author runs. Mode navigation and history preserve the mounted frame and unsent draft; reloading clears local state. + + + + +## Read the actual workspace + +The workspace groups paths by directory in stable path order. Select a file to read its literal text. An explicit selection survives while its path exists; otherwise the panel selects the first existing path deterministically. Current UTF-8 records use their content string; valid legacy arrays of string lines join with newline. Empty text stays empty. Files are read-only and are never interpreted as HTML or Markdown. + +The panel distinguishes **Live workspace** from checkpoint-confirmed files labeled **Paused · confirmed workspace**, **Saved workspace** or **Last confirmed workspace**. Streamed values can describe a change before checkpoint confirmation. At a confirmed pause, the saved panel shows actual files from the current root checkpoint beside proposed actions. A proposed file is awaiting approval; it is not a saved file. + +The projection accepts at most 100 canonical paths of at most 1024 UTF-16 code units, with at most 65,536 code units per file and 1,048,576 total text units. Unsupported encodings, malformed records and oversized text remain visibly unavailable. Invalid workspace data retains the last confirmed workspace with a notice. The preview does not silently truncate content or label unsupported files Saved. + + + + +## Review the whole protected batch + +The unchanged graph uses StateBackend and an interrupt permission over /reports/**. In the pinned backend, write_file and edit_file at the exact path /reports are allowed; report descendants are protected. Deletes covering /reports or the root are protected because their subtree overlaps the rule. Notes may run in parallel with a protected report call: an actual mixed pause can have an empty saved files map and three pending tool calls while requiring just one protected report decision. Count action_requests in the interrupt, not every unresolved tool call. + +Every proposed action appears in its original order, including duplicate target paths. A write to an existing file is a replacement proposal: compare the actual saved text in the workspace with proposed new text in the approval panel. A new write is a new file proposal. An edit shows its actual old_string, new_string and replace_all setting; the UI does not predict the edited result. A delete shows the target path and file or subtree intent. + +The controls offer **Approve entire batch** and **Reject entire batch**, only where the choice is permitted by every review config. There are no Edit or Respond controls. A supported batch contains at most 20 recognized write_file, edit_file or delete actions with bounded canonical arguments. Unknown arguments and malformed proposals leave decisions unavailable with an explanation and New conversation available. A malformed whole workspace map or unconfirmed checkpoint ownership blocks confirmation and decisions. Unsupported individual file records remain visibly unavailable and never supply literal file text; they do not by themselves block a supported batch whose raw saved map is confirmed. The example sends one decisions object containing exactly one ordered approve or reject decision for each protected action request, with no partial decision or automatic approval. + + + + +## Confirm what was saved + +A tool result received is separate from saved workspace authority. Pause confirmation checks the native paused outcome, the exact current root checkpoint and history head, the owned human request and retained canonical message prefix, and the matching current interrupt batch. Terminal confirmation checks native success and a terminal root checkpoint with no pending tasks or interrupts. File contents come from that actual checkpoint, not from tool arguments, model prose or a predicted effect. + +After a decision, the example reconciles the actual saved state. Approval does not guarantee a file will be created; a tool can fail. Rejection can lead to another proposal, which needs a new confirmed approval. It does not guarantee a terminal response, absence of the file or prevention of future writes. + + + + +## Stop and recover + +**Stop** preserves the last confirmed workspace and requires **New conversation** before another Send. An uncertain confirmation also keeps the last confirmed state and requires New; it never reuses a stale approval. New, Stop, disposal and changes of connection or operation invalidate local decision ownership. Stopping protects the client view and is not a rollback of backend writes. There is no automatic retry, replay or resume. + +## Build from source + +From a Threadplane checkout with dependencies installed: + +~~~sh +npx nx build cockpit-deep-agents-filesystem-react +npx nx e2e cockpit-deep-agents-filesystem-react +~~~ + +Browser verification uses the real SDK, compiled Python graph and saved checkpoints with deterministic providers. Verification endpoints and fixtures are excluded from public Code output. +`; + +export function ReactDeepAgentsFilesystemPreview({ + 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 0bd217b34..3f5701cef 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -28,6 +28,21 @@ const findWorkspaceRoot = (): string => { const WORKSPACE_ROOT = findWorkspaceRoot(); const CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content'); +it('resolves every native Filesystem article include from its registered sources and shared backend', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); + if (!canonical) throw new Error('Missing canonical Filesystem'); + 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/ReactDeepAgentsFilesystemPreview.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(8); + expect(page.assetPaths.some(path => /fixture|wire\.py|\.spec\./.test(path))).toBe(false); + for (const path of page.assetPaths) expect(context.sources[path]?.length).toBeGreaterThan(0); +}); it('resolves every native 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')!; @@ -43,6 +58,17 @@ it('resolves every native Planning article include from its registered sources a for (const path of page.assetPaths) expect(context.sources[path]?.length).toBeGreaterThan(0); }); +it('uses the actual Filesystem workspace labels and distinguishes unavailable records from invalid maps', () => { + const article = readFileSync(join(WORKSPACE_ROOT, 'apps/website/src/components/docs/ReactDeepAgentsFilesystemPreview.tsx'), 'utf8'); + const panel = readFileSync(join(WORKSPACE_ROOT, 'cockpit/deep-agents/filesystem/react/src/workspace-panel.tsx'), 'utf8'); + for (const label of ['Live workspace', 'Paused · confirmed workspace', 'Saved workspace', 'Last confirmed workspace']) { + expect(panel).toContain(label); + expect(article).toContain(`**${label}**`); + } + expect(article).toContain('A malformed whole workspace map or unconfirmed checkpoint ownership blocks confirmation and decisions.'); + expect(article).toContain('Unsupported individual file records remain visibly unavailable and never supply literal file text'); +}); + 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 dd3cc7cf6..96ac8917f 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(29); - expect(new Set(prerequisites).size).toBe(29); + expect(prerequisites).toHaveLength(30); + expect(new Set(prerequisites).size).toBe(30); 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-nine owned React servers after exact fresh Nx prerequisites', () => { + it('starts thirty 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[012345678])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(29); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(30); 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(29); - expect(new Set(projects).size).toBe(29); + expect(projects).toHaveLength(30); + expect(new Set(projects).size).toBe(30); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -88,6 +88,8 @@ describe('Website Playwright configuration', () => { expect(projects).toContain('cockpit-chat-input-react'); expect(projects).toContain('cockpit-chat-threads-react'); expect(projects).toContain('cockpit-deep-agents-planning-react'); + expect(projects).toContain('cockpit-deep-agents-filesystem-react'); + expect(reactServers.find(server => server.url === 'http://127.0.0.1:4629')?.command).toBe('npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4628')?.command).toBe('npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4627')?.command).toBe('npx nx run cockpit-chat-generative-ui-python:smoke && node scripts/react-cockpit/serve.mjs chat-generative-ui --no-parent'); 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'); @@ -373,6 +375,7 @@ describe('Website Playwright configuration', () => { 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-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent',url:'http://127.0.0.1:4629'}), expect.objectContaining({command:'npx nx run cockpit-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/filesystem/python/project.json b/cockpit/deep-agents/filesystem/python/project.json index 0ff11ccc2..9992cef9d 100644 --- a/cockpit/deep-agents/filesystem/python/project.json +++ b/cockpit/deep-agents/filesystem/python/project.json @@ -19,6 +19,18 @@ "executor": "nx:run-commands", "options": { "command": "npx tsx -e \"import { deepAgentsFilesystemPythonModule } from './cockpit/deep-agents/filesystem/python/src/index.ts'; const mod = deepAgentsFilesystemPythonModule; if (mod.id !== 'deep-agents-filesystem-python') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Filesystem (Python)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" + }, + "cache": false, + "dependsOn": [ + "test" + ] + }, + "test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "cwd": "cockpit/deep-agents/filesystem/python", + "command": "uv run --frozen --python 3.12 python -m unittest discover -s tests" } } }, diff --git a/cockpit/deep-agents/filesystem/python/tests/test_streaming.py b/cockpit/deep-agents/filesystem/python/tests/test_streaming.py new file mode 100644 index 000000000..3291e95ea --- /dev/null +++ b/cockpit/deep-agents/filesystem/python/tests/test_streaming.py @@ -0,0 +1,256 @@ +"""Contracts of the actual Filesystem builder and pinned StateBackend/HITL.""" +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 call(name, args, identity="tool"): + return {"name": name, "args": args, "id": identity, "type": "tool_call"} + + +def write(path, content, identity="write"): + return call("write_file", {"file_path": path, "content": content}, identity) + + +class LocalModel(BaseChatModel): + responses: list[AIMessage] = [] + cursor: int = 0 + + @property + def _llm_type(self): + return "local-filesystem-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_filesystem_contract", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.build_filesystem_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 FilesystemStreamingTests(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": "filesystem-contract"}} + + async def send(self, batches, identity="turn"): + self.model.responses = responses(batches, identity) + self.model.cursor = 0 + return await self.stream_graph({"messages": [{"type": "human", "id": identity, "content": identity}]}) + + async def stream_graph(self, value): + events = [deepcopy(e) async for e in self.graph.astream(value, self.config, + stream_mode=["messages", "values", "updates", "checkpoints"])] + saved = await self.graph.aget_state(self.config) + exact = await self.graph.aget_state(saved.config) + self.assertEqual(exact.values, saved.values) + self.assertEqual({k:v for k,v in [v for k,v in events if k == "values"][-1].items() if k != "__interrupt__"}, saved.values) + return saved, events + + def batch(self, state): + interrupts = [i for t in state.tasks for i in t.interrupts] + self.assertEqual(len(interrupts), 1) + return interrupts[0].value + + async def resume(self, decisions): + from langgraph.types import Command + return await self.stream_graph(Command(resume={"decisions": [{"type": d} for d in decisions]})) + + async def test_real_note_report_pause_approve_and_exact_history(self): + saved, events = await self.send([[write("/notes/kase.txt", "KASE 7820 ft", "note")], + [write("/reports/kase.md", "Assessment ready", "report")]]) + self.assertEqual(saved.values["files"]["/notes/kase.txt"]["content"], "KASE 7820 ft") + self.assertNotIn("/reports/kase.md", saved.values["files"]) + batch = self.batch(saved) + self.assertEqual([a["name"] for a in batch["action_requests"]], ["write_file"]) + self.assertEqual(batch["action_requests"][0]["args"], {"file_path":"/reports/kase.md", "content":"Assessment ready"}) + self.assertIn("approve", batch["review_configs"][0]["allowed_decisions"]) + self.assertTrue(saved.next) + terminal,_ = await self.resume(["approve"]) + self.assertEqual(terminal.values["files"]["/reports/kase.md"]["content"], "Assessment ready") + self.assertFalse(terminal.next) + self.assertFalse(terminal.tasks) + history = [s async for s in self.graph.aget_state_history(self.config)] + self.assertEqual(history[0].config, terminal.config) + self.assertTrue(any(t.interrupts for s in history for t in s.tasks)) + + async def test_reject_retains_note_and_can_repropose(self): + await self.send([[write("/notes/a.txt","retain","note")], [write("/reports/a.txt","first","first")], [write("/reports/a.txt","second","second")]]) + paused,_ = await self.resume(["reject"]) + self.assertNotIn("/reports/a.txt", paused.values["files"]) + rejected = next(m for m in paused.values["messages"] if isinstance(m,ToolMessage) and m.tool_call_id == "first") + self.assertEqual(rejected.status,"error") + self.assertEqual(self.batch(paused)["action_requests"][0]["args"]["content"], "second") + terminal,_ = await self.resume(["approve"]) + self.assertEqual(terminal.values["files"]["/reports/a.txt"]["content"],"second") + + async def test_reject_terminal_has_no_optimistic_report(self): + await self.send([[write("/reports/a.txt","first")]]) + terminal,_ = await self.resume(["reject"]) + self.assertFalse(terminal.next) + self.assertNotIn("/reports/a.txt", terminal.values.get("files",{})) + self.assertEqual(next(m for m in terminal.values["messages"] if isinstance(m,ToolMessage)).status,"error") + + async def test_ordered_batch_including_duplicate_paths(self): + for paths in (("/reports/a.txt","/reports/b.txt"),("/reports/a.txt","/reports/a.txt")): + with self.subTest(paths=paths): + self.setUp() + saved,_ = await self.send([[write(paths[0],"first","a"),write(paths[1],"second","b")]]) + batch=self.batch(saved) + self.assertEqual([a["args"]["file_path"] for a in batch["action_requests"]],list(paths)) + self.assertEqual(len(batch["review_configs"]),2) + terminal,_=await self.resume(["reject","reject"]) + self.assertFalse(terminal.next) + self.assertFalse(terminal.values.get("files",{})) + + async def test_existing_write_overwrites_and_edit_replace_all(self): + await self.send([[write("/notes/a.txt","old old")]]) + saved,_=await self.send([[write("/notes/a.txt","overwrite","overwrite")]],"overwrite-turn") + self.assertEqual(saved.values["files"]["/notes/a.txt"]["content"],"overwrite") + result=next(m for m in saved.values["messages"] if isinstance(m,ToolMessage) and m.tool_call_id=="overwrite") + self.assertEqual(result.status,"success") + await self.send([[write("/notes/a.txt","old old","restore")]],"restore-turn") + saved,_=await self.send([[call("edit_file",{"file_path":"/notes/a.txt","old_string":"old","new_string":"new","replace_all":True},"edit")]],"edit-turn") + self.assertEqual(saved.values["files"]["/notes/a.txt"]["content"],"new new") + + async def test_recursive_delete_permissions_and_read_only_unchanged(self): + await self.send([[write("/reports/sub/a.txt","report")]]) + await self.resume(["approve"]) + saved,_=await self.send([[call("read_file",{"file_path":"/reports/sub/a.txt"},"read")]],"read-turn") + self.assertFalse(saved.next) + self.assertEqual(saved.values["files"]["/reports/sub/a.txt"]["content"],"report") + saved,_=await self.send([[call("delete",{"file_path":"/reports"},"delete")]],"delete-turn") + self.assertEqual(self.batch(saved)["action_requests"][0]["name"],"delete") + saved,_=await self.resume(["approve"]) + self.assertEqual(saved.values["files"],{}) + unchanged,_=await self.send([],"unchanged") + self.assertEqual(unchanged.values["files"],{}) + + async def test_no_files_and_empty_utf8_file(self): + saved,_=await self.send([]) + self.assertEqual(saved.values.get("files",{}),{}) + saved,_=await self.send([[write("/notes/empty.txt","")]],"empty-turn") + self.assertEqual(saved.values["files"]["/notes/empty.txt"]["content"],"") + + async def test_write_schema_error_retains_files(self): + await self.send([[write("/notes/a.txt","retain")]]) + saved,_=await self.send([[call("write_file",{"file_path":"/notes/b.txt","content":42},"invalid")]],"invalid-turn") + self.assertNotIn("/notes/b.txt",saved.values["files"]) + result=next(m for m in saved.values["messages"] if isinstance(m,ToolMessage) and m.tool_call_id=="invalid") + self.assertEqual(result.status,"error") + + async def protected_overwrite(self, choice): + old="Prior report"; new="Replacement report" + await self.send([[write("/reports/existing.md",old,"initial")], + [write("/reports/existing.md",new,"replacement")]]) + paused,_=await self.resume(["approve"]) + self.assertEqual(paused.values["files"]["/reports/existing.md"]["content"],old) + action=self.batch(paused)["action_requests"][0] + self.assertEqual(action["name"],"write_file") + self.assertEqual(action["args"],{"file_path":"/reports/existing.md","content":new}) + exact=await self.graph.aget_state(paused.config) + self.assertEqual(exact.values,paused.values) + terminal,_=await self.resume([choice]) + self.assertEqual(terminal.values["files"]["/reports/existing.md"]["content"],new if choice=="approve" else old) + result=next(m for m in terminal.values["messages"] if isinstance(m,ToolMessage) and m.tool_call_id=="replacement") + self.assertEqual(result.status,"success" if choice=="approve" else "error") + self.assertFalse(terminal.next); self.assertFalse(terminal.tasks) + + async def test_protected_overwrite_approve_replaces_old_saved_text(self): + await self.protected_overwrite("approve") + + async def test_protected_overwrite_reject_retains_old_saved_text(self): + await self.protected_overwrite("reject") + + async def test_report_edit_permission_and_exact_arguments(self): + await self.send([[write("/reports/a.txt","old old")]]) + await self.resume(["approve"]) + args={"file_path":"/reports/a.txt","old_string":"old","new_string":"new","replace_all":True} + saved,_=await self.send([[call("edit_file",args,"edit")]],"edit-turn") + self.assertEqual(self.batch(saved)["action_requests"][0]["args"],args) + self.assertEqual(saved.values["files"]["/reports/a.txt"]["content"],"old old") + saved,_=await self.resume(["approve"]) + self.assertEqual(saved.values["files"]["/reports/a.txt"]["content"],"new new") + + async def test_legacy_and_unsupported_records_are_explicit_seed_evidence(self): + # Synthetic input records, separately identified from backend-produced UTF8 records. + seeded={"/legacy.txt":{"content":["first","second"]},"/binary.bin":{"content":"aGVsbG8=","encoding":"base64"}} + self.model.responses=responses([],"synthetic-records"); self.model.cursor=0 + saved,_=await self.stream_graph({"messages":[{"type":"human","id":"synthetic-records","content":"Inspect records"}],"files":seeded}) + self.assertEqual(saved.values["files"],seeded) + from deepagents.backends.utils import file_data_to_string + self.assertEqual(file_data_to_string(seeded["/legacy.txt"]),"first\nsecond") + + async def test_mixed_pending_batch_pauses_before_unrestricted_note_and_report(self): + saved,_=await self.send([[write("/notes/a.txt","note","note"),write("/reports/a.txt","report","report"),call("read_file",{"file_path":"/notes/a.txt"},"read")]]) + self.assertFalse(saved.values.get("files",{})) + batch=self.batch(saved) + self.assertEqual([a["args"]["file_path"] for a in batch["action_requests"]],["/reports/a.txt"]) + from langgraph.types import Command + # Parallel tool event order may differ from persisted canonical order. + events=[deepcopy(e) async for e in self.graph.astream(Command(resume={"decisions":[{"type":"approve"}]}),self.config,stream_mode=["messages","values","updates","checkpoints"])] + terminal=await self.graph.aget_state(self.config) + exact=await self.graph.aget_state(terminal.config) + self.assertEqual(exact.values,terminal.values) + self.assertTrue(events) + results=[m for m in terminal.values["messages"] if isinstance(m,ToolMessage)] + self.assertEqual({m.tool_call_id for m in results},{"note","report","read"}) + self.assertEqual(len([m for m in terminal.values["messages"] if m.type=="human"]),1) + self.assertEqual(terminal.values["files"]["/notes/a.txt"]["content"],"note") + self.assertEqual(terminal.values["files"]["/reports/a.txt"]["content"],"report") + self.assertFalse(terminal.next) + + def test_worker_contract_exists(self): + self.assertTrue((Path(__file__).parents[5]/"scripts/react-cockpit/deep-agents-filesystem-wire.py").is_file(), "Filesystem worker missing") + +if __name__ == "__main__": + unittest.main() diff --git a/cockpit/deep-agents/filesystem/react/README.md b/cockpit/deep-agents/filesystem/react/README.md new file mode 100644 index 000000000..97250cf7d --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/README.md @@ -0,0 +1,7 @@ +# Native React Filesystem + +Run `npx nx e2e cockpit-deep-agents-filesystem-react` for the local compiled-graph fixture, or build with `npx nx build cockpit-deep-agents-filesystem-react`. The native session uses assistant `da-filesystem` through the existing runtime bridge. Initial mount, New conversation and the KASE suggestion are inert; only Send creates a conversation. + +The read-only workspace displays actual file text. Live observations are distinct from saved or paused-confirmed checkpoint files. Proposed writes, edits and deletes are shown separately without applying them locally. Approve or Reject sends the complete ordered batch after checking the current owned root checkpoint. Unsupported proposals cannot resume. + +Stop, New and disposal invalidate pending callbacks. Cancellation does not roll back backend execution. Unconfirmed requests retain the last confirmed workspace and require New. Local proof transport buffers the real unchanged compiled graph before delivery; it patches only model construction and forbids outbound sockets. Fixture and test sources are excluded from shipped assets. diff --git a/cockpit/deep-agents/filesystem/react/e2e/filesystem.spec.ts b/cockpit/deep-agents/filesystem/react/e2e/filesystem.spec.ts new file mode 100644 index 000000000..78da5eca4 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/e2e/filesystem.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +const base = '/deep-agents/filesystem/react/'; +test('installed native candidate keeps draft inert, shows actual pause, then approves complete batch', async ({ + page, + request, +}) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto(base); + await expect(page.getByRole('status')).toContainText('Ready.'); + expect(await (await request.get('/__requests')).json()).toEqual([]); + await page.getByRole('button', { name: 'Runway note for KASE' }).click(); + await expect(page.getByRole('textbox', { name: 'Message' })).toHaveValue( + /Work up a runway suitability note/ + ); + expect(await (await request.get('/__requests')).json()).toEqual([]); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Approve entire batch' }) + ).toBeVisible(); + await expect(page.getByRole('region', { name: 'Workspace' })).toContainText( + 'KASE field elevation is 7820 ft.' + ); + await expect( + page.getByRole('region', { name: 'Awaiting approval' }) + ).toContainText('New file proposal'); + await page.screenshot({ + path: '/tmp/threadplane-filesystem-task3-paused-desktop.png', + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ + path: '/tmp/threadplane-filesystem-task3-paused-mobile.png', + fullPage: true, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + await page.getByRole('button', { name: 'Approve entire batch' }).click(); + await expect(page.getByRole('status')).toContainText('Response saved.'); + await page + .getByRole('button', { name: '/reports/kase.md', exact: true }) + .click(); + await expect(page.getByRole('region', { name: 'Workspace' })).toContainText( + 'KASE assessment ready.' + ); + await page.screenshot({ + path: '/tmp/threadplane-filesystem-task3-terminal-mobile.png', + fullPage: true, + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.screenshot({ + path: '/tmp/threadplane-filesystem-task3-terminal-desktop.png', + fullPage: true, + }); + const requests = await (await request.get('/__requests')).json(); + expect(requests.filter((x) => x.body?.input?.messages)).toHaveLength(1); + expect( + requests.filter((x) => x.body?.command).map((x) => x.body.command) + ).toEqual([{ resume: { decisions: [{ type: 'approve' }] } }]); + const proofs = await (await request.get('/__graph-proof')).json(); + expect(proofs.length).toBeGreaterThan(0); + expect( + proofs.filter((x) => ['submit', 'resume'].includes(x.op)).map((x) => x.op) + ).toEqual(['submit', 'resume']); + expect(pageErrors).toEqual([]); + expect( + proofs.every((x) => x.actualCompiledGraph && x.networkConnectAttempts === 0) + ).toBe(true); +}); diff --git a/cockpit/deep-agents/filesystem/react/e2e/playwright.config.ts b/cockpit/deep-agents/filesystem/react/e2e/playwright.config.ts new file mode 100644 index 000000000..113cbecf3 --- /dev/null +++ b/cockpit/deep-agents/filesystem/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:4629', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent', + cwd: '../../../../..', + url: 'http://127.0.0.1:4629', + reuseExistingServer: false, + timeout: 15000, + }, +}); diff --git a/cockpit/deep-agents/filesystem/react/eslint.config.mjs b/cockpit/deep-agents/filesystem/react/eslint.config.mjs new file mode 100644 index 000000000..05c9a4597 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/eslint.config.mjs @@ -0,0 +1,11 @@ +import base from '../../../../eslint.config.mjs'; +export default [ + ...base, + { + files: ['**/workspace-state.ts'], + rules: { + // This validator intentionally rejects control characters in filesystem paths. + 'no-control-regex': 'off', + }, + }, +]; diff --git a/cockpit/deep-agents/filesystem/react/index.html b/cockpit/deep-agents/filesystem/react/index.html new file mode 100644 index 000000000..abb83838a --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/index.html @@ -0,0 +1,13 @@ + + + + + + + Deep Agents Filesystem · React + + +
+ + + diff --git a/cockpit/deep-agents/filesystem/react/project.json b/cockpit/deep-agents/filesystem/react/project.json new file mode 100644 index 000000000..33fbcec0e --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/project.json @@ -0,0 +1,75 @@ +{ + "name": "cockpit-deep-agents-filesystem-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/deep-agents/filesystem/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/filesystem/react" + ], + "options": { + "command": "node scripts/react-cockpit/build.mjs deep-agents-filesystem" + } + }, + "fixture-test": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "test", + "projects": [ + "cockpit-deep-agents-filesystem-python" + ] + } + ], + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-filesystem-fixture.spec.mjs scripts/react-cockpit/deep-agents-filesystem-configuration.spec.mjs" + } + }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": [ + "build", + "fixture-test" + ], + "options": { + "config": "cockpit/deep-agents/filesystem/react/e2e/playwright.config.ts", + "skipInstall": true + } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/deep-agents/filesystem/react/vite.config.mts", + "reporters": [ + "default" + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "eslintConfig": "cockpit/deep-agents/filesystem/react/eslint.config.mjs" + } + } + } +} diff --git a/cockpit/deep-agents/filesystem/react/src/app.spec.tsx b/cockpit/deep-agents/filesystem/react/src/app.spec.tsx new file mode 100644 index 000000000..580da642f --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/app.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createConnectedApplication } from './connection'; +import type { FilesystemApplicationSnapshot } from './application'; +vi.mock('./connection', () => ({ createConnectedApplication: vi.fn() })); +import { FilesystemDemo, connectionFingerprint } from './app'; +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); +function harness() { + let snapshot: FilesystemApplicationSnapshot = { + threadId: null, + rows: [], + messages: [], + toolCalls: [], + observedWorkspace: { kind: 'missing', files: [] }, + savedWorkspace: { kind: 'missing', files: [] }, + approval: null, + decisionToken: null, + 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; + }), + decide: vi.fn(async () => true), + stop: vi.fn(async () => undefined), + newConversation: vi.fn(async () => { + update({ + viewGeneration: snapshot.viewGeneration + 1, + busy: false, + approval: null, + decisionToken: null, + 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: 'Runway note for KASE' })); + const box = h.view.getByRole('textbox', { + name: 'Message', + }) as HTMLTextAreaElement; + expect(box.value).toMatch(/Work up a runway suitability note for 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); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/app.tsx b/cockpit/deep-agents/filesystem/react/src/app.tsx new file mode 100644 index 000000000..245ec570a --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/app.tsx @@ -0,0 +1,258 @@ +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 FilesystemConnection, +} from './connection'; +import type { FilesystemApplicationSnapshot } from './application'; +import { WorkspacePanel } from './workspace-panel'; +import { ApprovalPanel } from './approval-panel'; +const literal = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value, null, 2); +export function renderMessage( + row: MessageRow, + snapshot: FilesystemApplicationSnapshot +) { + 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: FilesystemConnection) { + return JSON.stringify([ + connection.apiUrl, + Object.entries(connection.headers).sort(([a], [b]) => a.localeCompare(b)), + ]); +} +type Props = { + readonly connection: FilesystemConnection; + readonly onReady: () => void; +}; +export function FilesystemDemo(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 FilesystemOwner({ 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 workspace…' + : 'Receiving response…' + : snapshot.phase === 'paused' + ? 'Awaiting approval.' + : snapshot.phase === 'saved' + ? 'Response saved.' + : snapshot.phase === 'stopped' + ? 'Response stopped.' + : snapshot.phase === 'unconfirmed' + ? 'Workspace update unconfirmed.' + : snapshot.phase === 'failed' + ? 'Response failed.' + : 'Ready.'; + return ( +
+
+
+

React · Deep Agents

+

A workspace for the flight ahead.

+
+ +
+

+ Build a runway note using fixed aviation lookup data. Read actual files + and review proposed report changes before they run. +

+
+
+
+ {[ + { + label: 'Runway note for KASE', + text: 'Work up a runway suitability note for KASE. Save your raw lookups to /notes/kase-data.md, then write the finished note to /reports/kase-runway.md.', + }, + ].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 runway note…" + 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} +

+
+
+ + { + const current = application.getSnapshot(); + if ( + currentView() && + !current.busy && + current.phase === 'paused' && + current.approval === snapshot.approval + ) + void application.decide(choice, snapshot.decisionToken); + }} + /> +
+
+
+ ); +} diff --git a/cockpit/deep-agents/filesystem/react/src/application.spec.ts b/cockpit/deep-agents/filesystem/react/src/application.spec.ts new file mode 100644 index 000000000..9493bf8f8 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/application.spec.ts @@ -0,0 +1,160 @@ +import { expect, it, vi } from 'vitest'; +import { staticDelivery, type Message } from '@threadplane/core'; +import { + createFilesystemApplication, + type FilesystemSession, +} from './application'; +import type { FilesystemSnapshot } from './authority'; +// Synthetic boundary tests; actual native graph proof lives in connection.integration.spec. +function harness(files: unknown = {}) { + let state: FilesystemSnapshot = { + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }; + let raw: unknown; + let count = 0; + const listeners = new Set<() => void>(); + const emit = (next: FilesystemSnapshot) => { + state = next; + for (const callback of listeners) callback(); + }; + const session = { + getSnapshot: () => state, + subscribe: (callback: () => void) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }, + submit: vi.fn(async (text: string) => { + const messages = [ + { id: 'human' + ++count, type: 'human', content: text }, + { + id: 'answer' + count, + type: 'ai', + content: 'Actual answer', + tool_calls: [], + }, + ]; + const checkpoint = { + thread_id: 'owned', + checkpoint_ns: '', + checkpoint_id: 'cp' + count, + }; + raw = { checkpoint, values: { messages, files }, next: [], tasks: [] }; + emit({ + status: 'idle', + messages: messages.map((m) => ({ + id: m.id, + role: m.type === 'human' ? 'user' : 'assistant', + content: m.content, + delivery: staticDelivery(m.id), + ...(m.type === 'ai' ? { toolCallIds: [] } : {}), + })) as Message[], + toolCalls: [], + interrupts: [], + subgraphs: [], + values: { files }, + history: [{ checkpoint, next: [] }], + }); + return 'success' as const; + }), + resume: vi.fn(async () => 'success' as const), + load: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + } as unknown as FilesystemSession; + const client = { + createThread: vi.fn(async () => 'owned'), + sessionFactory: vi.fn(() => session), + readCurrent: vi.fn(async () => raw), + readCheckpoint: vi.fn(async () => raw), + }; + return { app: createFilesystemApplication(client), client, session, emit }; +} +it('initial mount and New remain inert without creation, load or submission', async () => { + const h = harness(); + await h.app.newConversation(); + expect(h.client.createThread).not.toHaveBeenCalled(); + expect(h.session.load).not.toHaveBeenCalled(); + expect(h.session.submit).not.toHaveBeenCalled(); + expect(h.app.getSnapshot().phase).toBe('idle'); + await h.app.dispose(); +}); +it('valid empty is confirmed while malformed files stay unconfirmed and require New', async () => { + for (const files of [{}, null]) { + const h = harness(files); + expect(await h.app.submit('Files')).toBe(files !== null); + expect(h.app.getSnapshot().phase).toBe( + files === null ? 'unconfirmed' : 'saved' + ); + expect(h.app.getSnapshot().canSubmit).toBe(files !== null); + await h.app.dispose(); + } +}); +it('legacy text and unavailable encodings remain literal checkpoint-backed projections', async () => { + const h = harness({ + '/legacy': { content: ['

literal

', 'second'] }, + '/binary': { content: 'bytes', encoding: 'base64' }, + }); + expect(await h.app.submit('Files')).toBe(true); + expect(h.app.getSnapshot().savedWorkspace.files).toEqual([ + { path: '/binary', kind: 'unavailable', reason: 'Unsupported encoding' }, + { path: '/legacy', kind: 'text', content: '

literal

\nsecond' }, + ]); + await h.app.dispose(); +}); +it('new during lazy creation prevents installing a late returned owner', async () => { + const h = harness(); + let release!: (id: string) => void; + h.client.createThread.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const pending = h.app.submit('Files'); + await h.app.newConversation(); + const fresh = h.app.getSnapshot(); + release('owned'); + expect(await pending).toBe(false); + expect(h.app.getSnapshot()).toBe(fresh); + expect(h.client.sessionFactory).not.toHaveBeenCalled(); + await h.app.dispose(); +}); +it('live files do not claim Saved while exact confirmation is held', async () => { + const h = harness({ '/actual': { content: 'actual' } }); + let release!: (value: unknown) => void; + const actual = h.client.readCheckpoint.getMockImplementation(); + h.client.readCheckpoint.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const pending = h.app.submit('Files'); + await vi.waitFor(() => expect(h.app.getSnapshot().phase).toBe('confirming')); + expect(h.app.getSnapshot().observedWorkspace.files).toHaveLength(1); + expect(h.app.getSnapshot().savedWorkspace.files).toHaveLength(0); + release(await actual!()); + expect(await pending).toBe(true); + await h.app.dispose(); +}); +it('disposal prevents late native subscriber publications', async () => { + const h = harness(); + await h.app.submit('Files'); + await h.app.dispose(); + const saved = h.app.getSnapshot(); + h.emit({ + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + values: { files: { '/late': { content: 'late' } } }, + }); + expect(h.app.getSnapshot()).toBe(saved); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/application.ts b/cockpit/deep-agents/filesystem/react/src/application.ts new file mode 100644 index 000000000..7b3535bce --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/application.ts @@ -0,0 +1,572 @@ +import type { + AgentSession, + AgentSnapshot, + CompleteOutcome, + Message, + ToolCall, +} from '@threadplane/core'; +import { + createMessageContent, + type MessageRow, +} from '@threadplane/content/messages'; +import { + copyJson, + inspectJson, + ownValue, + plainRecord, + workspaceState, + type WorkspaceState, + type PlainValue, +} from './workspace-state'; +import { + approvalState, + createDecision, + type ApprovalState, + type Choice, +} from './approval-state'; +import { + captureAuthority, + captureBaseline, + captureTurn, + captureResume, + checkpointSource, + sameObservedAuthority, + type Authority, + type AuthorityInput, + type Checkpoint, + type FilesystemSnapshot, + type Turn, +} from './authority'; +export interface FilesystemSession extends Omit { + getSnapshot(): AgentSnapshot & FilesystemSnapshot; + load(options?: { readonly signal?: AbortSignal }): Promise; + resume( + value: PlainValue, + options?: { readonly signal?: AbortSignal } + ): Promise; +} +export interface FilesystemClient { + createThread(signal: AbortSignal): Promise; + sessionFactory(threadId: string): FilesystemSession; + readCurrent(threadId: string, signal: AbortSignal): Promise; + readCheckpoint(source: Checkpoint, signal: AbortSignal): Promise; +} +type Workspace = Exclude; +export interface FilesystemApplicationSnapshot { + readonly threadId: string | null; + readonly rows: readonly MessageRow[]; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly observedWorkspace: Workspace; + readonly savedWorkspace: Workspace; + readonly approval: ApprovalState | null; + readonly decisionToken: string | null; + readonly phase: + | 'idle' + | 'working' + | 'confirming' + | 'paused' + | '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 = (): FilesystemApplicationSnapshot => + Object.freeze({ + threadId: null, + rows: Object.freeze([]), + messages: Object.freeze([]), + toolCalls: Object.freeze([]), + observedWorkspace: Object.freeze({ + kind: 'missing', + files: Object.freeze([]), + }), + savedWorkspace: Object.freeze({ + kind: 'missing', + files: Object.freeze([]), + }), + approval: null, + decisionToken: null, + phase: 'idle', + busy: false, + canSubmit: true, + outcome: null, + notice: null, + error: null, + viewGeneration: 0, + }); +/** Native state remains transient. Copy canonical metadata only, never the raw file map. */ +function metadata(source: FilesystemSnapshot): FilesystemSnapshot { + inspectJson(source, true, [['values'], ['history', '0', 'values']]); + const omit = (input: object, keys: string[]) => + Object.fromEntries( + Object.keys(input) + .filter((k) => !keys.includes(k)) + .map((k) => [k, ownValue(input, k)]) + ); + const history = ownValue(source, 'history'); + const copied = copyJson( + { + ...omit(source, ['values', 'history']), + ...(Array.isArray(history) + ? { + history: history.map((x) => { + if (!plainRecord(x)) throw Error('Unavailable history'); + return omit(x, ['values']); + }), + } + : {}), + }, + true + ) as FilesystemSnapshot; + return { + ...copied, + values: ownValue(source, 'values'), + history: history as readonly unknown[] | undefined, + }; +} +export function createFilesystemApplication(client: FilesystemClient) { + type Owner = { + id: string; + identity: string; + session: FilesystemSession; + content: ReturnType; + release?: () => void; + closed: boolean; + authority: Authority; + }; + type Attempt = { + controller: AbortController; + owner?: Owner; + generation: string; + view: number; + text: string; + turn?: Turn; + baseline?: ReturnType; + submitting?: boolean; + saving?: boolean; + previousHumanIds?: Set; + previousGenerations?: Set; + }; + 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) => + !disposed && + attempt === run && + !run.controller.signal.aborted && + snapshot.viewGeneration === run.view && + (!run.owner || (owner === run.owner && !run.owner.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 task = Promise.race([ + Promise.resolve() + .then(() => selected.session.dispose()) + .catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, 2000); + }), + ]).finally(() => clearTimeout(timer)); + cleanups.add(task); + void task.then(() => cleanups.delete(task)); + return task; + } + function observe(run: Attempt) { + if (!run.owner || !current(run)) return; + const source = run.owner.session.getSnapshot(); + if (!current(run)) return; + const state = metadata(source); + 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.submitting && !run.saving && run.baseline) { + const humans = state.messages.filter( + (m) => m.role === 'user' && !run.previousHumanIds?.has(m.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: run.owner.identity, + generation: run.generation, + threadId: run.owner.id, + humanId: human.id, + humanContent: run.text, + }); + } + const workspace = workspaceState(state.values); + const rows = run.owner.content.project(state as AgentSnapshot); + if (!current(run)) return; + publish({ + rows, + messages: state.messages, + toolCalls: state.toolCalls, + ...(workspace.kind === 'invalid' + ? { + notice: + 'The current files update is unavailable. The last confirmed workspace is retained.', + } + : { observedWorkspace: workspace, notice: null }), + }); + return state; + } + function finish( + run: Attempt, + phase: FilesystemApplicationSnapshot['phase'], + outcome: CompleteOutcome | null, + error: string | null, + canSubmit = false + ) { + if (!current(run)) return; + attempt = undefined; + publish({ busy: false, phase, outcome, error, canSubmit }); + } + function input( + run: Attempt, + state: FilesystemSnapshot, + raw: unknown, + outcome: string, + checkpoint: unknown + ): AuthorityInput { + return { + turn: run.turn, + owner: run.owner!.identity, + generation: run.generation, + threadId: run.owner!.id, + outcome, + observedCheckpoint: checkpoint, + observedValues: plainRecord(raw) ? ownValue(raw, 'values') : undefined, + loadedCheckpoint: raw, + snapshot: state, + }; + } + async function confirm(run: Attempt, outcome: CompleteOutcome) { + if (!run.owner || !current(run)) return false; + const selected = run.owner; + 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 state = observe(run); + const checkpoint = + state && + checkpointSource( + plainRecord(state.history?.[0]) + ? ownValue(state.history![0] as object, 'checkpoint') + : undefined, + selected.id + ); + if (!state || !checkpoint || !run.turn) + throw Error('Unavailable exact checkpoint'); + const raw = await client.readCheckpoint(checkpoint, run.controller.signal); + if (!current(run)) return false; + const latest = observe(run); + if (!latest || !current(run)) return false; + const authority = captureAuthority( + input(run, latest, raw, outcome, checkpoint) + ); + if (!current(run)) return false; + if (authority.kind === 'unconfirmed') { + const proposal = + latest.interrupts.length === 1 + ? approvalState(latest.interrupts[0]) + : null; + publish({ approval: proposal?.kind === 'unavailable' ? proposal : null }); + throw Error('Unconfirmed authority'); + } + selected.authority = authority; + publish({ + savedWorkspace: authority.workspace, + observedWorkspace: authority.workspace, + approval: authority.kind === 'paused' ? authority.approval : null, + decisionToken: authority.kind === 'paused' ? authority.signature : null, + }); + finish( + run, + authority.kind === 'paused' ? 'paused' : 'saved', + outcome, + null, + authority.kind === 'terminal' + ); + return true; + } + function fail(run: Attempt, outcome: CompleteOutcome | null) { + if (!current(run)) return; + publish({ + approval: + snapshot.approval?.kind === 'unavailable' ? snapshot.approval : null, + decisionToken: null, + }); + finish( + run, + outcome === 'success' || outcome === 'paused' ? 'unconfirmed' : 'failed', + outcome ?? 'error', + 'The request or saved workspace could not be confirmed. The last confirmed workspace is retained. Start a new conversation to continue.' + ); + } + async function submit(text: string) { + if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim()) + return false; + const run: Attempt = { + controller: new AbortController(), + generation: crypto.randomUUID(), + view: snapshot.viewGeneration, + text, + }; + attempt = run; + publish({ + busy: true, + canSubmit: false, + phase: 'working', + outcome: null, + error: null, + approval: null, + }); + let outcome: CompleteOutcome | null = null; + try { + if (!current(run)) return false; + if (!owner) { + const id = await client.createThread(run.controller.signal); + if (!current(run)) return false; + if (typeof id !== 'string' || !id) throw Error('Unconfirmed creation'); + const selected: Owner = { + id, + identity: crypto.randomUUID(), + session: client.sessionFactory(id), + content: createMessageContent(), + closed: false, + authority: { kind: 'unconfirmed' }, + }; + 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 { + /* Reconciliation fails 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; + run.baseline = captureBaseline( + selected.authority.kind === 'terminal' + ? { messages: selected.authority.messages } + : undefined + ); + if (!run.baseline) throw Error('Unavailable prefix'); + if (selected.authority.kind === 'terminal') { + const raw = await client.readCurrent( + selected.id, + run.controller.signal + ); + if (!current(run)) return false; + if ( + !plainRecord(raw) || + !plainRecord(ownValue(raw, 'values')) || + !checkpointSource(ownValue(raw, 'checkpoint'), selected.id) + ) + throw Error('Foreign current state'); + const latest = ownValue(raw, 'checkpoint'); + if ( + JSON.stringify(latest) !== + JSON.stringify(selected.authority.checkpoint) + ) + throw Error('Conversation advanced'); + } + const before = selected.session.getSnapshot(); + if (!current(run)) return false; + run.previousHumanIds = new Set( + before.messages.filter((m) => m.role === 'user').map((m) => m.id) + ); + run.previousGenerations = new Set( + before.messages.map((m) => m.delivery.generation) + ); + run.submitting = true; + outcome = await selected.session.submit(text, { + signal: run.controller.signal, + }); + if (!current(run)) return false; + observe(run); + if (!current(run)) return false; + if (outcome !== 'success' && outcome !== 'paused') + throw Error('Unconfirmed outcome'); + return await confirm(run, outcome); + } catch { + fail(run, outcome); + return false; + } + } + async function decide(choice: Choice, decisionToken: string | null) { + const selected = owner, + authority = selected?.authority; + if ( + disposed || + snapshot.busy || + snapshot.phase !== 'paused' || + !selected || + authority?.kind !== 'paused' || + !decisionToken || + decisionToken !== authority.signature || + snapshot.decisionToken !== decisionToken || + snapshot.approval !== authority.approval || + !authority.approval.choices.includes(choice) + ) + return false; + const run: Attempt = { + controller: new AbortController(), + generation: crypto.randomUUID(), + view: snapshot.viewGeneration, + text: authority.turn.humanContent, + owner: selected, + }; + run.turn = captureResume(authority.turn, run.generation); + attempt = run; + publish({ busy: true, canSubmit: false, phase: 'confirming', error: null }); + let outcome: CompleteOutcome | null = null; + try { + if (!current(run)) return false; + const raw = await client.readCurrent(selected.id, run.controller.signal); + if (!current(run)) return false; + // This MUST be the current head. Reading the old checkpoint alone misses external advancement. + const state = observe(run); + if ( + !state || + !current(run) || + (snapshot as FilesystemApplicationSnapshot).phase !== 'confirming' + ) + return false; + const preflight: AuthorityInput = { + ...input( + run, + state, + raw, + 'paused', + plainRecord(raw) ? ownValue(raw, 'checkpoint') : undefined + ), + turn: authority.turn, + generation: authority.turn.generation, + }; + if (!sameObservedAuthority(authority, preflight)) + throw Error('Pause advanced'); + const decision = createDecision( + authority.approval, + authority.approval.signature, + choice + ); + if ( + !decision || + !current(run) || + (snapshot as FilesystemApplicationSnapshot).phase !== 'confirming' + ) + throw Error('Unavailable whole batch'); + publish({ phase: 'working', approval: null, decisionToken: null }); + if (!current(run)) return false; + outcome = await selected.session.resume(decision, { + signal: run.controller.signal, + }); + if (!current(run)) return false; + observe(run); + if (!current(run)) return false; + if (outcome !== 'success' && outcome !== 'paused') + throw Error('Unconfirmed resume'); + return await confirm(run, outcome); + } catch { + fail(run, outcome); + return false; + } + } + async function stop() { + const run = attempt; + if (disposed || (!run && snapshot.phase !== 'paused')) return; + attempt = undefined; + run?.controller.abort(); + publish({ + busy: false, + canSubmit: false, + phase: 'stopped', + approval: null, + decisionToken: null, + outcome: 'aborted', + error: + 'Response stopped. The last confirmed workspace is retained. Cancellation does not roll back backend files. Start a new conversation to continue.', + }); + try { + await (run?.owner ?? owner)?.session.stop(); + } catch { + /* Not rollback. */ + } + } + 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, + decide, + stop, + newConversation, + dispose, + }; +} diff --git a/cockpit/deep-agents/filesystem/react/src/approval-panel.tsx b/cockpit/deep-agents/filesystem/react/src/approval-panel.tsx new file mode 100644 index 000000000..b3d16c296 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/approval-panel.tsx @@ -0,0 +1,103 @@ +import type { ApprovalState, Choice } from './approval-state'; +import type { WorkspaceState } from './workspace-state'; +export function ApprovalPanel({ + approval, + workspace, + busy, + onDecision, +}: { + approval: ApprovalState | null; + workspace: WorkspaceState; + busy: boolean; + onDecision: (choice: Choice) => void; +}) { + if (!approval) return null; + if (approval.kind === 'unavailable') + return ( +
+

Proposal unavailable

+

{approval.reason}

+ {approval.rawProposal !== undefined && ( +
{JSON.stringify(approval.rawProposal, null, 2)}
+ )} +

Start a new conversation to continue.

+
+ ); + const paths = new Set( + workspace.kind === 'valid' ? workspace.files.map((x) => x.path) : [] + ); + return ( +
+

+ Awaiting approval · {approval.actions.length} proposed{' '} + {approval.actions.length === 1 ? 'action' : 'actions'} +

+

+ Review the entire ordered batch. These are proposed arguments, not saved + file changes. +

+
    + {approval.actions.map((action, index) => { + const path = String(action.args.file_path); + return ( +
  1. +

    + {action.name} · {path} +

    + {action.name === 'write_file' ? ( + <> +

    + {paths.has(path) + ? 'Replacement proposal' + : 'New file proposal · not saved'} +

    +
    {String(action.args.content)}
    + + ) : action.name === 'edit_file' ? ( + <> +

    + Edit intent · replace_all:{' '} + {String(action.args.replace_all ?? false)} +

    +

    Old text

    +
    {String(action.args.old_string)}
    +

    New text

    +
    {String(action.args.new_string)}
    + + ) : ( +

    + Delete intent · {path === '/' ? 'root and descendants' : path}{' '} + (file or directory subtree) +

    + )} +
    + Raw proposal description +
    {action.description}
    +
    +
  2. + ); + })} +
+
+ {approval.choices.map((choice) => ( + + ))} +
+ {!approval.choices.length && ( +

+ No whole-batch Approve or Reject decision is permitted. Start a new + conversation. +

+ )} +
+ ); +} diff --git a/cockpit/deep-agents/filesystem/react/src/approval-state.spec.ts b/cockpit/deep-agents/filesystem/react/src/approval-state.spec.ts new file mode 100644 index 000000000..ed68b9fa5 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/approval-state.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { approvalState, createDecision } from './approval-state'; + +export const action = ( + name = 'write_file', + args: any = { file_path: '/report', content: 'new' } +) => ({ name, args, description: 'Review this mutation' }); +export const interrupt = ( + actions = [action()], + allowed = ['approve', 'edit', 'reject', 'respond'] +) => ({ + id: 'pause-id', + value: { + action_requests: actions, + review_configs: actions.map((a) => ({ + action_name: a.name, + allowed_decisions: allowed, + })), + }, +}); +describe('whole current approval batch', () => { + it('keeps every action ordered including duplicate targets and exact decision count', () => { + const batch = approvalState( + interrupt([ + action(), + action('edit_file', { + file_path: '/report', + old_string: 'new', + new_string: 'edited', + replace_all: true, + }), + action('delete', { file_path: '/report' }), + ]) + ); + expect(batch.kind).toBe('valid'); + if (batch.kind !== 'valid') throw new Error('Expected valid batch'); + expect(batch.actions.map((a: any) => a.name)).toEqual([ + 'write_file', + 'edit_file', + 'delete', + ]); + expect(createDecision(batch, batch.signature, 'approve')).toEqual({ + decisions: [ + { type: 'approve' }, + { type: 'approve' }, + { type: 'approve' }, + ], + }); + expect(createDecision(batch, batch.signature, 'reject')).toEqual({ + decisions: [{ type: 'reject' }, { type: 'reject' }, { type: 'reject' }], + }); + expect(Object.isFrozen(batch.actions[0].args)).toBe(true); + }); + it('permits only intersection choices and rejects stale signatures and edit/respond', () => { + const raw = interrupt([action(), action()]); + raw.value.review_configs[1].allowed_decisions = ['reject']; + const batch = approvalState(raw); + if (batch.kind !== 'valid') throw new Error('Expected valid batch'); + expect(batch.choices).toEqual(['reject']); + for (const choice of ['approve', 'edit', 'respond', 'confirm', 'cancel']) + expect(createDecision(batch, batch.signature, choice)).toBeUndefined(); + expect(createDecision(batch, 'old', 'reject')).toBeUndefined(); + }); + it.each([ + action('unknown'), + action('write_file', { file_path: '/report', content: 'x', append: true }), + action('edit_file', { + file_path: '/report', + old_string: 'x', + new_string: 'y', + replace_all: 'true', + }), + action('delete', { file_path: '/report', content: 'x' }), + action('delete', { file_path: '../report' }), + action('write_file', { file_path: '/report', content: 'x'.repeat(65537) }), + ])('disables the entire batch for an unsupported action', (bad) => { + const batch = approvalState(interrupt([action(), bad])); + expect(batch.kind).toBe('unavailable'); + if (batch.kind !== 'unavailable') + throw new Error('Expected unavailable batch'); + expect(batch.reason).toEqual(expect.any(String)); + expect(createDecision(batch, 'unavailable', 'approve')).toBeUndefined(); + }); + it('fails closed on hidden keys, mismatched configs, oversized metadata and aggregate text', () => { + const extra: any = interrupt(); + extra.value.action_requests[0].hidden = 'mutation'; + const mismatch = interrupt(); + mismatch.value.review_configs[0].action_name = 'delete'; + const desc = interrupt(); + desc.value.action_requests[0].description = 'x'.repeat(1048577); + const aggregate = interrupt( + Array.from({ length: 9 }, () => + action('edit_file', { + file_path: '/r', + old_string: 'x'.repeat(65536), + new_string: 'y'.repeat(65536), + }) + ) + ); + for (const raw of [ + extra, + mismatch, + desc, + aggregate, + interrupt(Array.from({ length: 21 }, () => action())), + ]) + expect(approvalState(raw).kind).toBe('unavailable'); + }); + it('does not invoke accessors or silently omit symbol keys', () => { + let hits = 0; + const raw: any = interrupt(); + Object.defineProperty(raw.value.action_requests[0].args, 'secret', { + enumerable: true, + get() { + hits++; + return true; + }, + }); + expect(approvalState(raw).kind).toBe('unavailable'); + const symbol: any = interrupt(); + symbol.value.action_requests[0].args[Symbol('secret')] = true; + expect(approvalState(symbol).kind).toBe('unavailable'); + expect(hits).toBe(0); + }); + it('supports recursive root delete and real full-argument descriptions within explicit metadata bounds', () => { + const raw = interrupt([ + action('delete', { file_path: '/' }), + action('write_file', { + file_path: '/reports/max', + content: 'x'.repeat(65536), + }), + ]); + raw.value.action_requests[1].description = + 'Tool write_file Args: ' + 'x'.repeat(65536); + expect(approvalState(raw).kind).toBe('valid'); + }); + it('keeps bounded unsupported raw arguments visible without rewriting paths or unknown keys', () => { + const raw = interrupt([ + action('write_file', { + file_path: 'reports/./raw', + content: 'x', + hidden_mutation: true, + }), + ]); + const projected = approvalState(raw); + expect(projected.kind).toBe('unavailable'); + expect((projected as any).rawProposal).toEqual(raw); + }); + it('does not emit decisions for forged choices, changed captured actions or getters', () => { + const batch = approvalState(interrupt()); + if (batch.kind !== 'valid') throw new Error('Expected valid batch'); + expect( + createDecision( + { ...batch, choices: ['edit'] } as any, + batch.signature, + 'edit' + ) + ).toBeUndefined(); + expect( + createDecision({ ...batch, actions: [] }, batch.signature, 'approve') + ).toBeUndefined(); + let hits = 0; + const malicious = { + ...batch, + get signature() { + hits++; + return batch.signature; + }, + }; + expect( + createDecision(malicious, batch.signature, 'approve') + ).toBeUndefined(); + expect(hits).toBe(0); + }); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/approval-state.ts b/cockpit/deep-agents/filesystem/react/src/approval-state.ts new file mode 100644 index 000000000..ce93253be --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/approval-state.ts @@ -0,0 +1,191 @@ +import { + canonicalPath, + copyJson, + MAX_TEXT, + MAX_TOTAL_TEXT, + plainRecord, + sameJson, + type PlainValue, +} from './workspace-state'; +export type Choice = 'approve' | 'reject'; +// Native HITL embeds the Python repr of complete args in description. A pair +// of maximally escaped text arguments can exceed 8 KiB by a large margin. +export const MAX_DESCRIPTION = 1048576; +export const MAX_METADATA = 2097152; +export interface ApprovalAction { + readonly name: 'write_file' | 'edit_file' | 'delete'; + readonly args: Readonly>; + readonly description: string; +} +export type ApprovalState = + | { + readonly kind: 'unavailable'; + readonly reason: string; + readonly rawProposal?: PlainValue; + } + | { + readonly kind: 'valid'; + readonly interruptId: string; + readonly actions: readonly ApprovalAction[]; + readonly choices: readonly Choice[]; + readonly signature: string; + }; +const exactKeys = ( + r: Record, + required: string[], + optional: string[] = [] +) => + required.every((k) => Object.hasOwn(r, k)) && + Object.keys(r).every((k) => required.includes(k) || optional.includes(k)); +/** Pinned WriteFileSchema, EditFileSchema, DeleteSchema; never normalize args. */ +export function mutationArgs(name: unknown, args: unknown): boolean { + if ( + !plainRecord(args) || + !( + canonicalPath(args.file_path) || + (name === 'delete' && args.file_path === '/') + ) + ) + return false; + const text = (key: string) => + typeof args[key] === 'string' && (args[key] as string).length <= MAX_TEXT; + if (name === 'write_file') + return exactKeys(args, ['file_path', 'content']) && text('content'); + if (name === 'edit_file') + return ( + exactKeys( + args, + ['file_path', 'old_string', 'new_string'], + ['replace_all'] + ) && + text('old_string') && + text('new_string') && + (!Object.hasOwn(args, 'replace_all') || + typeof args.replace_all === 'boolean') + ); + return name === 'delete' && exactKeys(args, ['file_path']); +} +export function approvalState(input: unknown): ApprovalState { + let rawProposal: PlainValue | undefined; + try { + input = copyJson(input); + // Capture unsupported input only if the entire raw JSON is bounded. Never + // rewrite a path, omit an unknown mutation key, or evaluate an accessor. + let rawText = 0; + const count = (v: unknown): void => { + if (typeof v === 'string') rawText += v.length; + else if (Array.isArray(v)) v.forEach(count); + else if (plainRecord(v)) + Object.entries(v).forEach(([k, x]) => { + rawText += k.length; + count(x); + }); + if (rawText > MAX_METADATA + MAX_TOTAL_TEXT) + throw new Error('Raw proposal exceeds visible budget'); + }; + count(input); + rawProposal = input as PlainValue; + if ( + !plainRecord(input) || + !exactKeys(input, ['id', 'value']) || + typeof input.id !== 'string' || + !input.id || + input.id.length > 1024 || + !plainRecord(input.value) || + !exactKeys(input.value, ['action_requests', 'review_configs']) + ) + throw new Error(); + const actions = input.value.action_requests, + configs = input.value.review_configs; + if ( + !Array.isArray(actions) || + !actions.length || + actions.length > 20 || + !Array.isArray(configs) || + configs.length !== actions.length + ) + throw new Error(); + let total = 0, + metadata = 0; + for (const [i, action] of actions.entries()) { + const config = configs[i]; + if ( + !plainRecord(action) || + !exactKeys(action, ['name', 'args', 'description']) || + !mutationArgs(action.name, action.args) || + typeof action.description !== 'string' || + action.description.length > MAX_DESCRIPTION || + !plainRecord(config) || + !exactKeys(config, ['action_name', 'allowed_decisions']) || + config.action_name !== action.name || + !Array.isArray(config.allowed_decisions) || + !config.allowed_decisions.length || + config.allowed_decisions.length > 4 || + new Set(config.allowed_decisions).size !== + config.allowed_decisions.length || + config.allowed_decisions.some( + (v) => !['approve', 'edit', 'reject', 'respond'].includes(v) + ) + ) + throw new Error(); + metadata += action.description.length; + total += Object.values( + action.args as Record + ).reduce((n, v) => n + (typeof v === 'string' ? v.length : 0), 0); + if (total > MAX_TOTAL_TEXT || metadata > MAX_METADATA) throw new Error(); + } + const choices = (['approve', 'reject'] as Choice[]).filter((choice) => + configs.every((c) => c.allowed_decisions.includes(choice)) + ); + return Object.freeze({ + kind: 'valid', + interruptId: input.id, + actions: actions as readonly ApprovalAction[], + choices: Object.freeze(choices), + signature: JSON.stringify(input), + }); + } catch { + return Object.freeze({ + kind: 'unavailable', + reason: + rawProposal === undefined + ? 'Raw current proposal is unavailable: non-JSON data or visible bounds exceeded. No decisions can be sent.' + : 'Complete raw proposal shown: unsupported action, arguments, metadata, shape or bounds. No decisions can be sent.', + ...(rawProposal !== undefined ? { rawProposal } : {}), + }); + } +} +/** Match the captured pause against current authority before calling this. */ +export function createDecision( + batch: ApprovalState, + currentSignature: string, + choice: unknown +): PlainValue | undefined { + try { + // A signature contains JSON escapes in addition to the raw bounded text. + const captured = copyJson(batch, false, 33554432); + if ( + (choice !== 'approve' && choice !== 'reject') || + !plainRecord(captured) || + captured.kind !== 'valid' || + typeof currentSignature !== 'string' || + currentSignature.length > (MAX_METADATA + MAX_TOTAL_TEXT) * 6 || + captured.signature !== currentSignature + ) + return undefined; + const current = approvalState(JSON.parse(currentSignature)); + if ( + current.kind !== 'valid' || + !sameJson(current, captured) || + !current.choices.includes(choice) + ) + return undefined; + return Object.freeze({ + decisions: Object.freeze( + current.actions.map(() => Object.freeze({ type: choice })) + ), + }); + } catch { + return undefined; + } +} diff --git a/cockpit/deep-agents/filesystem/react/src/authority.spec.ts b/cockpit/deep-agents/filesystem/react/src/authority.spec.ts new file mode 100644 index 000000000..f7635e506 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/authority.spec.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from 'vitest'; +import { + captureAuthority, + captureBaseline, + captureResume, + captureTurn, + sameObservedAuthority, +} from './authority'; + +const identity = { + owner: 'owner', + generation: 'attempt', + threadId: 'thread', + humanId: 'human', + humanContent: 'Assess airport', +}; +const cp = { thread_id: 'thread', checkpoint_ns: '', checkpoint_id: 'cp' }; +const proposal = { + name: 'write_file', + args: { file_path: '/reports/report', content: 'future' }, + description: 'Review', +}; +const pause = { + id: 'interrupt', + value: { + action_requests: [proposal], + review_configs: [ + { + action_name: 'write_file', + allowed_decisions: ['approve', 'edit', 'reject', 'respond'], + }, + ], + }, +}; +// Synthetic unit messages grounded in the pinned native wire shapes. Integration +// of actual graph output and createSession belongs to Task3, not this fixture. +function example(paused = true, rejected = false): any { + const calls = [ + { id: 'call', name: proposal.name, args: proposal.args, type: 'tool_call' }, + ]; + const messages: any[] = [ + { id: 'human', type: 'human', name: null, content: identity.humanContent }, + { + id: 'ai', + type: 'ai', + name: null, + content: '', + tool_calls: calls, + invalid_tool_calls: [], + }, + ...(!paused + ? [ + { + id: 'result', + type: 'tool', + name: proposal.name, + content: rejected ? 'User rejected' : 'Updated file', + tool_call_id: 'call', + status: rejected ? 'error' : 'success', + }, + { + id: 'answer', + type: 'ai', + name: null, + content: 'Assessment ready', + tool_calls: [], + invalid_tool_calls: [], + }, + ] + : []), + ]; + const files = { '/old': { content: 'saved', encoding: 'utf-8' } }; + const values = { messages, files }; + const next = paused ? ['HumanInTheLoopMiddleware.after_model'] : []; + return { + ...identity, + turn: captureTurn(captureBaseline({ messages: [], files: {} }), identity), + outcome: paused ? 'paused' : 'success', + observedCheckpoint: cp, + observedValues: paused ? { ...values, __interrupt__: [pause] } : values, + loadedCheckpoint: { + checkpoint: cp, + values, + next, + tasks: paused ? [{ id: 'task', name: next[0], interrupts: [pause] }] : [], + }, + snapshot: { + status: 'idle', + interrupts: paused ? [pause] : [], + subgraphs: [], + values: { files }, + history: [{ checkpoint: cp, next }], + messages: messages.map((m) => ({ + id: m.id, + role: + m.type === 'human' ? 'user' : m.type === 'ai' ? 'assistant' : 'tool', + content: m.content, + delivery: { + phase: 'complete', + generation: m.id, + outcome: paused && m.id === 'ai' ? 'paused' : 'success', + }, + ...(m.name ? { name: m.name } : {}), + ...(m.tool_calls + ? { toolCallIds: m.tool_calls.map((c: any) => c.id) } + : {}), + ...(m.tool_call_id ? { toolCallId: m.tool_call_id } : {}), + })), + toolCalls: calls.map((c) => ({ + id: c.id, + name: c.name, + args: c.args, + status: paused ? 'pending' : 'complete', + ...(!paused + ? { result: rejected ? 'User rejected' : 'Updated file' } + : {}), + })), + }, + }; +} +describe('exact native checkpoint authority', () => { + it('confirms saved files on native pause without predicting the proposed report', () => { + const input = example(); + const result = captureAuthority(input); + expect(result.kind).toBe('paused'); + if (result.kind !== 'paused') throw new Error('Expected paused authority'); + expect(result.workspace.files.map((f: any) => f.path)).toEqual(['/old']); + expect(result.approval.actions[0].args.content).toBe('future'); + expect(sameObservedAuthority(result, input)).toBe(true); + expect(Object.isFrozen(result)).toBe(true); + }); + it('confirms native terminal and legitimate rejected tool results from actual saved files', () => { + for (const rejected of [false, true]) { + const input = example(false, rejected); + expect(captureAuthority(input).kind).toBe('terminal'); + input.loadedCheckpoint.values.files = {}; + input.observedValues = input.loadedCheckpoint.values; + input.snapshot.values = { files: {} }; + const empty = captureAuthority(input); + if (empty.kind !== 'terminal') + throw new Error('Expected terminal authority'); + expect(empty.workspace.files).toEqual([]); + } + }); + it.each(['failed', 'cancelled', 'success'])( + 'cannot obtain pause authority from %s outcome', + (outcome) => { + const input = example(); + input.outcome = outcome; + expect(captureAuthority(input)).toEqual({ kind: 'unconfirmed' }); + } + ); + it('requires observed values, exact latest history head, raw tasks and owned root checkpoint', () => { + const changes: ((i: any) => void)[] = [ + (i) => { + i.owner = 'other'; + }, + (i) => { + i.generation = 'old'; + }, + (i) => { + i.threadId = 'other'; + }, + (i) => { + i.snapshot.status = 'running'; + }, + (i) => { + i.snapshot.error = 'error'; + }, + (i) => { + i.snapshot.subgraphs = [{}]; + }, + (i) => { + i.observedCheckpoint = { ...cp, checkpoint_ns: 'foreign' }; + }, + (i) => { + i.observedCheckpoint = { ...cp, checkpoint_map: {} }; + }, + (i) => { + i.observedCheckpoint = { + ...cp, + checkpoint_map: { '': 'cp', child: 'foreign' }, + }; + }, + (i) => { + i.snapshot.history = [ + { + checkpoint: { ...cp, checkpoint_id: 'old' }, + next: i.loadedCheckpoint.next, + }, + ]; + }, + (i) => { + i.loadedCheckpoint.tasks = []; + }, + (i) => { + i.observedValues = { ...i.observedValues, files: {} }; + }, + (i) => { + i.snapshot.values = { files: {} }; + }, + (i) => { + i.loadedCheckpoint.values.files = null; + }, + (i) => { + i.snapshot.interrupts = []; + }, + (i) => { + i.loadedCheckpoint.tasks[0].interrupts.push(pause); + }, + ]; + for (const change of changes) { + const input = example(); + change(input); + expect(captureAuthority(input)).toEqual({ kind: 'unconfirmed' }); + } + }); + it('checks current human, canonical prefix, ids, arguments, result content and delivery', () => { + const changes: ((i: any) => void)[] = [ + (i) => { + i.turn = captureTurn( + captureBaseline({ + messages: [{ id: 'old', type: 'human', content: 'old' }], + files: {}, + }), + identity + ); + }, + (i) => { + i.loadedCheckpoint.values.messages[0].content = 'old'; + }, + (i) => { + i.loadedCheckpoint.values.messages[1].id = 'human'; + }, + (i) => { + i.snapshot.messages[1].delivery.outcome = 'success'; + }, + (i) => { + i.snapshot.messages[1].delivery.generation = 'other'; + }, + (i) => { + i.snapshot.toolCalls[0].args = { + file_path: '/different', + content: 'future', + }; + }, + (i) => { + i.snapshot.toolCalls.push(i.snapshot.toolCalls[0]); + }, + (i) => { + i.loadedCheckpoint.values.messages[1].invalid_tool_calls = [{}]; + }, + ]; + for (const change of changes) { + const input = example(); + change(input); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + const terminal = example(false); + terminal.snapshot.toolCalls[0].result = 'predicted'; + expect(captureAuthority(terminal).kind).toBe('unconfirmed'); + const unfinished = example(false); + unfinished.loadedCheckpoint.values.messages.pop(); + unfinished.snapshot.messages.pop(); + expect(captureAuthority(unfinished).kind).toBe('unconfirmed'); + }); + it('captures resume using the original human and prefix with a new local generation', () => { + const original = example().turn; + const resumed = captureResume(original, 'resume-attempt'); + expect(resumed).toEqual({ ...original, generation: 'resume-attempt' }); + const input = example(); + input.turn = resumed; + input.generation = 'resume-attempt'; + const authority = captureAuthority(input); + expect(authority.kind).toBe('paused'); + input.generation = 'attempt'; + expect(sameObservedAuthority(authority, input)).toBe(false); + expect( + captureTurn( + captureBaseline({ + messages: [{ id: 'human', type: 'human', content: 'old' }], + files: {}, + }), + identity + ) + ).toBeUndefined(); + }); + it('invalidates captured pause on changed checkpoint or current batch', () => { + const input = example(); + const saved = captureAuthority(input); + const changed = example(); + changed.snapshot.interrupts[0] = { ...pause, id: 'different' }; + expect(sameObservedAuthority(saved, changed)).toBe(false); + changed.observedCheckpoint = { ...cp, checkpoint_id: 'new' }; + expect(sameObservedAuthority(saved, changed)).toBe(false); + }); + it('correlates protected subset in mixed pending notes, report and legitimate read calls', () => { + const input = example(); + const extras = [ + { + id: 'note', + name: 'write_file', + args: { file_path: '/notes/note', content: 'note' }, + type: 'tool_call', + }, + { + id: 'read', + name: 'read_file', + args: { file_path: '/notes/old' }, + type: 'tool_call', + }, + { + id: 'airport', + name: 'lookup_field_elevation', + args: { airport: 'KASE' }, + type: 'tool_call', + }, + ]; + input.loadedCheckpoint.values.messages[1].tool_calls.unshift(...extras); + input.snapshot.messages[1].toolCallIds.unshift(...extras.map((c) => c.id)); + input.snapshot.toolCalls.unshift( + ...extras.map((c) => ({ + id: c.id, + name: c.name, + args: c.args, + status: 'pending', + })) + ); + expect(captureAuthority(input).kind).toBe('paused'); + // A second protected call is not allowed to disappear from the interrupt. + const extra = { + id: 'hidden', + name: 'delete', + args: { file_path: '/reports' }, + type: 'tool_call', + }; + input.loadedCheckpoint.values.messages[1].tool_calls.push(extra); + input.snapshot.messages[1].toolCallIds.push(extra.id); + input.snapshot.toolCalls.push({ ...extra, status: 'pending' }); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('accepts duplicate target actions in their complete canonical order', () => { + const input = example(); + const second = { + id: 'second', + name: 'edit_file', + args: { + file_path: '/reports/report', + old_string: 'future', + new_string: 'edited', + }, + type: 'tool_call', + }; + input.loadedCheckpoint.values.messages[1].tool_calls.push(second); + input.snapshot.messages[1].toolCallIds.push(second.id); + input.snapshot.toolCalls.push({ ...second, status: 'pending' }); + const batch = { + ...pause, + value: { + action_requests: [ + proposal, + { name: second.name, args: second.args, description: 'Edit' }, + ], + review_configs: [ + pause.value.review_configs[0], + { + action_name: second.name, + allowed_decisions: ['approve', 'reject'], + }, + ], + }, + }; + input.snapshot.interrupts = [batch]; + input.loadedCheckpoint.tasks[0].interrupts = [batch]; + input.observedValues.__interrupt__ = [batch]; + expect(captureAuthority(input).kind).toBe('paused'); + batch.value.action_requests.reverse(); + batch.value.review_configs.reverse(); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('requires successful native terminal outcome and raw resolved tool flow', () => { + for (const outcome of ['failed', 'cancelled', 'paused']) { + const input = example(false); + input.outcome = outcome; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + const input = example(false, true); + input.loadedCheckpoint.values.messages[2].tool_call_id = 'unknown'; + input.snapshot.messages[2].toolCallId = 'unknown'; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('recognizes root recursive delete as protected without treating an exact /reports write as protected', () => { + const input = example(); + const deletion = { + id: 'root-delete', + name: 'delete', + args: { file_path: '/' }, + type: 'tool_call', + }; + input.loadedCheckpoint.values.messages[1].tool_calls = [deletion]; + input.snapshot.messages[1].toolCallIds = [deletion.id]; + input.snapshot.toolCalls = [{ ...deletion, status: 'pending' }]; + const batch = { + id: 'root-pause', + value: { + action_requests: [ + { + name: 'delete', + args: deletion.args, + description: 'Delete root subtree', + }, + ], + review_configs: [ + { action_name: 'delete', allowed_decisions: ['approve', 'reject'] }, + ], + }, + }; + input.snapshot.interrupts = [batch]; + input.loadedCheckpoint.tasks[0].interrupts = [batch]; + input.observedValues.__interrupt__ = [batch]; + expect(captureAuthority(input).kind).toBe('paused'); + }); + it('accepts read-only unchanged saved files and supplies only immutable actual messages for the next baseline', () => { + const input = example(false); + input.loadedCheckpoint.values.messages.splice(1, 2); + input.snapshot.messages.splice(1, 2); + input.snapshot.toolCalls = []; + const result = captureAuthority(input); + expect(result.kind).toBe('terminal'); + if (result.kind !== 'terminal') throw new Error('Expected terminal'); + expect(result.workspace.files).toEqual([ + { path: '/old', kind: 'text', content: 'saved' }, + ]); + expect(Object.isFrozen(result.messages)).toBe(true); + expect(captureBaseline({ messages: result.messages })).toEqual({ + messages: input.loadedCheckpoint.values.messages, + }); + expect(result).not.toHaveProperty('values'); + }); + it('rejects foreign nested task state even when native subgraphs are empty', () => { + const input = example(); + input.loadedCheckpoint.tasks[0].state = { + checkpoint: { ...cp, checkpoint_ns: 'child', checkpoint_id: 'foreign' }, + }; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('confirms valid legacy checkpoints through raw, observed, native and optional history parity', () => { + const files = { + '/a': { content: Array(65536).fill('') }, + '/b': { content: Array(65536).fill('') }, + }; + for (const paused of [true, false]) { + const input = example(paused); + input.loadedCheckpoint.values.files = files; + input.observedValues.files = files; + input.snapshot.values = { files }; + input.snapshot.history[0].values = input.loadedCheckpoint.values; + const result = captureAuthority(input); + expect(result.kind).toBe(paused ? 'paused' : 'terminal'); + if (result.kind === 'unconfirmed') + throw new Error('Expected confirmed legacy checkpoint'); + expect(result.workspace.files).toHaveLength(2); + expect(captureBaseline(input.loadedCheckpoint.values)?.messages).toEqual( + result.messages + ); + } + }); + it('keeps oversized non-file canonical checkpoint evidence unconfirmed', () => { + const input = example(); + input.loadedCheckpoint.metadata = { arbitrary: Array(100001).fill('') }; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/authority.ts b/cockpit/deep-agents/filesystem/react/src/authority.ts new file mode 100644 index 000000000..5c0284e7f --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/authority.ts @@ -0,0 +1,561 @@ +import type { Message, ToolCall } from '@threadplane/core'; +import { + approvalState, + mutationArgs, + type ApprovalState, +} from './approval-state'; +import { + copyJson, + inspectJson, + ownValue, + plainRecord, + sameJson, + workspaceState, + type WorkspaceState, +} from './workspace-state'; + +export interface TurnIdentity { + readonly owner: string; + /** Local attempt identity, never a backend run id. */ + readonly generation: string; + readonly threadId: string; + readonly humanId: string; + readonly humanContent: string; + readonly backendRunId?: string; +} +export interface Baseline { + readonly messages: readonly unknown[]; +} +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 FilesystemSnapshot { + 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 AuthorityInput { + readonly turn: Turn | undefined; + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly backendRunId?: string; + /** Native submit/resume result outcome, not server run status. */ + readonly outcome: string; + readonly observedCheckpoint: unknown; + readonly observedValues: unknown; + /** State loaded from the exact observed checkpoint. */ + readonly loadedCheckpoint: unknown; + readonly snapshot: FilesystemSnapshot; +} +interface Confirmed { + readonly checkpoint: Checkpoint; + readonly workspace: Exclude; + /** Actual saved wire messages for the next pre-send canonical prefix. */ + readonly messages: readonly unknown[]; + readonly turn: Turn; + readonly signature: string; +} +export type Authority = + | { readonly kind: 'unconfirmed' } + | (Confirmed & { readonly kind: 'terminal' }) + | (Confirmed & { + readonly kind: 'paused'; + readonly approval: Extract; + }); + +export function captureBaseline(input?: unknown): Baseline | undefined { + try { + const values = input === undefined ? { messages: [] } : input; + inspectJson(values, false, [[]]); + if (!plainRecord(values)) return undefined; + const messages = copyJson(ownValue(values, 'messages')); + if (!Array.isArray(messages)) return undefined; + return Object.freeze({ messages }); + } catch { + return undefined; + } +} +export function captureTurn( + baseline: Baseline | undefined, + input: TurnIdentity +): Turn | undefined { + try { + const identity = copyJson(input, true), + prefix = copyJson(baseline); + if ( + !plainRecord(identity) || + !plainRecord(prefix) || + !Array.isArray(prefix.messages) || + ['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)) || + prefix.messages.some((m) => plainRecord(m) && m.id === identity.humanId) + ) + return undefined; + return Object.freeze({ ...identity, baseline: prefix }) as unknown as Turn; + } catch { + return undefined; + } +} +/** A resume is another attempt of the original human turn, not a new human. */ +export function captureResume( + turn: Turn | undefined, + generation: string +): Turn | undefined { + if (!turn || !generation || generation === turn.generation) return undefined; + return captureTurn(turn.baseline, { ...turn, generation }); +} +export function checkpointSource( + input: unknown, + threadId: string +): Checkpoint | undefined { + try { + const cp = copyJson(input); + if ( + !plainRecord(cp) || + cp.thread_id !== threadId || + cp.checkpoint_ns !== '' || + typeof cp.checkpoint_id !== 'string' || + !cp.checkpoint_id || + Object.keys(cp).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) + ) + return undefined; + if ( + Object.hasOwn(cp, 'checkpoint_map') && + (!plainRecord(cp.checkpoint_map) || + Object.keys(cp.checkpoint_map).length !== 1 || + cp.checkpoint_map[''] !== cp.checkpoint_id) + ) + return undefined; + return cp as unknown as Checkpoint; + } catch { + return undefined; + } +} +const empty = (v: unknown) => + v === undefined || (Array.isArray(v) && v.length === 0); +const clean = (r: Record) => + (r.error === undefined || r.error === null) && + ['subgraphs', '__interrupt__', 'interrupts'].every((k) => empty(r[k])); +const except = (v: unknown, omit: string[]): Record => { + if (!plainRecord(v)) throw new Error('Expected record'); + return Object.fromEntries( + Object.keys(v) + .filter((k) => !omit.includes(k)) + .map((k) => [k, ownValue(v, k)]) + ); +}; +const projectedValues = (v: Record) => + except(v, ['messages', '__interrupt__']); +const withoutTransient = (v: Record) => + except(v, ['__interrupt__']); +const pauseNext = ['HumanInTheLoopMiddleware.after_model']; + +/** Files authority is checkpoint data, never a ToolMessage repr or prediction. + * A failed confirmation returns no replacement; callers retain prior authority. */ +export function captureAuthority(input: AuthorityInput): Authority { + const unconfirmed: Authority = Object.freeze({ kind: 'unconfirmed' }); + try { + inspectJson(input.loadedCheckpoint, false, [['values']]); + inspectJson(input.observedValues, false, [[]]); + inspectJson(input.snapshot, true, [['values'], ['history', '0', 'values']]); + const sourceValues = ownValue(input.loadedCheckpoint as object, 'values'); + const workspace = workspaceState(sourceValues); + if (workspace.kind === 'invalid') return unconfirmed; + const historySource = ownValue(input.snapshot, 'history'); + if (historySource !== undefined && !Array.isArray(historySource)) + return unconfirmed; + const history = Array.isArray(historySource) + ? Array.from( + { length: ownValue(historySource, 'length') as number }, + (_, i) => except(ownValue(historySource, String(i)), ['values']) + ) + : undefined; + const latestSource = Array.isArray(historySource) + ? ownValue(historySource, '0') + : undefined; + const turn = copyJson(input.turn, true), + state = copyJson( + { + ...except(input.snapshot, ['values', 'history']), + ...(history === undefined ? {} : { history }), + }, + true + ), + raw = copyJson(except(input.loadedCheckpoint, ['values'])), + observed = input.observedValues; + const otherValues = copyJson(except(sourceValues, ['files'])); + if (!plainRecord(otherValues)) return unconfirmed; + const values: Record = { + ...otherValues, + ...(plainRecord(sourceValues) && Object.hasOwn(sourceValues, 'files') + ? { files: ownValue(sourceValues, 'files') } + : {}), + }; + const paused = input.outcome === 'paused'; + if ( + (!paused && input.outcome !== 'success') || + !plainRecord(turn) || + !plainRecord(turn.baseline) || + !Array.isArray(turn.baseline.messages) || + !plainRecord(state) || + !plainRecord(raw) || + !plainRecord(observed) || + turn.owner !== input.owner || + turn.generation !== input.generation || + turn.threadId !== input.threadId || + state.status !== 'idle' || + (state.error !== undefined && state.error !== null) || + !Array.isArray(state.subgraphs) || + state.subgraphs.length || + !Array.isArray(state.interrupts) || + (raw.error !== undefined && raw.error !== null) || + !empty(raw.subgraphs) + ) + return unconfirmed; + const runId = input.backendRunId ?? turn.backendRunId; + if ( + (turn.backendRunId !== undefined && + input.backendRunId !== undefined && + turn.backendRunId !== input.backendRunId) || + (runId !== undefined && + (typeof runId !== 'string' || + !runId || + (raw.run_id !== undefined && raw.run_id !== runId) || + (plainRecord(raw.metadata) && + raw.metadata.run_id !== undefined && + raw.metadata.run_id !== runId))) + ) + return unconfirmed; + const checkpoint = checkpointSource(raw.checkpoint, input.threadId), + requested = checkpointSource(input.observedCheckpoint, input.threadId); + const latest = Array.isArray(state.history) ? state.history[0] : undefined; + const next = paused ? pauseNext : []; + if ( + !checkpoint || + !requested || + !sameJson(checkpoint, requested) || + !plainRecord(latest) || + !clean(latest) || + !sameJson(latest.next, next) || + !sameJson( + checkpointSource(latest.checkpoint, input.threadId), + checkpoint + ) || + !sameJson(raw.next, next) || + !Array.isArray(raw.tasks) || + (latest.tasks !== undefined && !sameJson(latest.tasks, raw.tasks)) || + (plainRecord(latestSource) && + ownValue(latestSource, 'values') !== undefined && + !sameJson(ownValue(latestSource, 'values'), values)) + ) + return unconfirmed; + if ( + !plainRecord(values) || + !clean(values) || + !sameJson(values, withoutTransient(observed)) || + !sameJson(projectedValues(values), ownValue(input.snapshot, 'values')) || + !Array.isArray(values.messages) || + !Array.isArray(state.messages) || + !Array.isArray(state.toolCalls) + ) + return unconfirmed; + let approval: Extract | undefined; + if (paused) { + if (state.interrupts.length !== 1 || raw.tasks.length !== 1) + return unconfirmed; + const task = raw.tasks[0]; + if ( + !plainRecord(task) || + typeof task.id !== 'string' || + !task.id || + task.name !== pauseNext[0] || + !Array.isArray(task.interrupts) || + task.interrupts.length !== 1 || + (task.error !== undefined && task.error !== null) || + (task.state !== undefined && task.state !== null) || + !empty(task.subgraphs) || + !sameJson(task.interrupts, state.interrupts) || + (ownValue(observed, '__interrupt__') !== undefined && + !sameJson(ownValue(observed, '__interrupt__'), state.interrupts)) + ) + return unconfirmed; + const proposal = approvalState(state.interrupts[0]); + if (proposal.kind !== 'valid') return unconfirmed; + approval = proposal; + } else if ( + state.interrupts.length || + raw.tasks.length || + !clean(raw) || + !empty(ownValue(observed, '__interrupt__')) + ) + 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 || + values.messages + .slice(prefix.length + 1) + .some((m) => plainRecord(m) && m.type === 'human') + ) + return unconfirmed; + if ( + !canonicalMatches( + values.messages, + state.messages, + state.toolCalls, + approval + ) + ) + return unconfirmed; + if (!paused) { + 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; + } + const confirmed = { + checkpoint, + workspace, + messages: values.messages, + turn: turn as unknown as Turn, + signature: JSON.stringify({ + checkpoint, + turn, + workspace, + otherValues: except(values, ['files']), + messages: state.messages, + tools: state.toolCalls, + interrupts: state.interrupts, + }), + }; + return approval + ? Object.freeze({ ...confirmed, kind: 'paused', approval }) + : Object.freeze({ ...confirmed, kind: 'terminal' }); + } catch { + return unconfirmed; + } +} + +/** Canonical native projections must match every raw id, call, argument, result, + * text and delivery. Pending calls may exist only in the final paused AI step. */ +function canonicalMatches( + raw: unknown[], + messages: unknown[], + tools: unknown[], + approval?: Extract +): boolean { + if (raw.length !== messages.length) return false; + const catalog = new Map>(), + ids = new Set(), + seen = new Set(); + for (const t of tools) { + if ( + !plainRecord(t) || + typeof t.id !== 'string' || + !t.id || + catalog.has(t.id) || + !['complete', 'pending'].includes(t.status as string) + ) + return false; + catalog.set(t.id, t); + } + const pending = new Map>(); + 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 || + 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) || + !plainRecord(m.delivery) || + m.delivery.phase !== 'complete' || + m.delivery.generation !== m.id + ) + return false; + ids.add(wire.id); + const extra = wire.additional_kwargs ?? {}; + if ( + !plainRecord(extra) || + 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 calls = wire.tool_calls ?? [], + projected = m.toolCallIds ?? []; + if ( + !Array.isArray(calls) || + !Array.isArray(projected) || + calls.length !== projected.length + ) + return false; + for (const [j, c] of calls.entries()) { + if ( + !plainRecord(c) || + typeof c.id !== 'string' || + !c.id || + c.id !== projected[j] || + seen.has(c.id) || + typeof c.name !== 'string' || + !c.name || + !plainRecord(c.args) || + (c.type !== undefined && c.type !== 'tool_call') || + Object.keys(c).some( + (k) => !['id', 'name', 'args', 'type'].includes(k) + ) + ) + return false; + const t = catalog.get(c.id); + if (!t || t.name !== c.name || !sameJson(t.args, c.args)) return false; + seen.add(c.id); + pending.set(c.id, c); + } + const pausedMessage = + !!approval && i === raw.length - 1 && calls.length > 0; + if (m.delivery.outcome !== (pausedMessage ? 'paused' : 'success')) + return false; + } else if (wire.type === 'tool') { + const t = catalog.get(wire.tool_call_id as string); + if ( + !t || + !pending.delete(wire.tool_call_id as string) || + t.status !== 'complete' || + t.result !== wire.content || + !['success', 'error'].includes(wire.status as string) || + (wire.name !== t.name && + !( + wire.status === 'error' && + (wire.name === undefined || wire.name === null) + )) || + !empty(wire.tool_calls) || + !empty(m.toolCallIds) || + m.delivery.outcome !== 'success' + ) + return false; + } else if ( + pending.size || + !empty(wire.tool_calls) || + !empty(m.toolCallIds) || + wire.tool_call_id !== undefined || + m.delivery.outcome !== 'success' + ) + return false; + } + if (seen.size !== catalog.size) return false; + if (!approval) return pending.size === 0; + if ( + !pending.size || + [...pending.keys()].some( + (id) => + catalog.get(id)?.status !== 'pending' || + Object.hasOwn(catalog.get(id)!, 'result') + ) + ) + return false; + const protectedCalls: Record[] = []; + for (const call of pending.values()) { + if (['write_file', 'edit_file', 'delete'].includes(call.name as string)) { + if (!mutationArgs(call.name, call.args)) return false; + const path = (call.args as Record).file_path; + // Pinned graph permission: exact write/edit descendants; recursive delete + // checks subtree overlap with the /reports anchor. + if ( + path.startsWith('/reports/') || + (call.name === 'delete' && (path === '/reports' || path === '/')) + ) + protectedCalls.push(call); + } else if ( + ![ + 'ls', + 'read_file', + 'glob', + 'grep', + 'lookup_field_elevation', + 'lookup_runway_length', + ].includes(call.name as string) + ) + return false; + } + return ( + protectedCalls.length === approval.actions.length && + protectedCalls.every( + (c, i) => + c.name === approval.actions[i].name && + sameJson(c.args, approval.actions[i].args) + ) + ); +} +export function sameObservedAuthority( + captured: Authority, + input: AuthorityInput +): boolean { + if (captured.kind !== 'paused') return false; + const current = captureAuthority(input); + return ( + current.kind === 'paused' && + captured.signature === current.signature && + captured.approval.signature === current.approval.signature + ); +} diff --git a/cockpit/deep-agents/filesystem/react/src/connection.integration.spec.ts b/cockpit/deep-agents/filesystem/react/src/connection.integration.spec.ts new file mode 100644 index 000000000..255c327a7 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/connection.integration.spec.ts @@ -0,0 +1,396 @@ +import { + createServer, + type Server, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createFilesystemClient } from './connection'; +import { createFilesystemApplication } from './application'; + +/* 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 { createDeepAgentsFilesystemFixture as createFixture } from '../../../../../scripts/react-cockpit/deep-agents-filesystem-fixture.mjs'; +/* eslint-enable @nx/enforce-module-boundaries */ +interface LocalFixture { + ( + request: IncomingMessage, + response: ServerResponse, + pathname: string + ): Promise; + close(): Promise; +} +const createDeepAgentsFilesystemFixture = 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(); + }); +}); +const evidenceDirectory = mkdtempSync( + '/tmp/threadplane-filesystem-task3-native-' +); +writeFileSync( + '/tmp/threadplane-filesystem-task3-native-latest-directory.txt', + evidenceDirectory +); +let evidenceNumber = 0; +async function setup() { + const handle = createDeepAgentsFilesystemFixture(); + 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 = createFilesystemClient({ apiUrl: base + '/api', headers: {} }); + const rawCheckpoints: unknown[] = []; + const readExact = client.readCheckpoint; + const read = vi + .spyOn(client, 'readCheckpoint') + .mockImplementation(async (...args) => { + const raw = await readExact(...args); + rawCheckpoints.push(raw); + return raw; + }), + app = createFilesystemApplication(client); + application = app; + const lifecycle: unknown[] = []; + let scenario = 'normal'; + const record = async (label: string) => { + const snapshot = app.getSnapshot(); + lifecycle.push({ + label, + phase: snapshot.phase, + outcome: snapshot.outcome, + threadId: snapshot.threadId, + messages: snapshot.messages, + toolCalls: snapshot.toolCalls, + savedWorkspace: snapshot.savedWorkspace, + observedWorkspace: snapshot.observedWorkspace, + approval: snapshot.approval, + decisionToken: snapshot.decisionToken, + }); + writeFileSync( + evidenceDirectory + '/' + String(evidenceNumber) + '.json', + JSON.stringify( + { + scenario, + lifecycle, + rawCheckpoints, + requests: await (await fetch(base + '/__requests')).json(), + proof: await (await fetch(base + '/__graph-proof')).json(), + }, + null, + 2 + ) + ); + }; + evidenceNumber++; + const submit = app.submit, + decide = app.decide; + app.submit = async (...args) => { + const result = await submit(...args); + await record('submit'); + return result; + }; + app.decide = async (...args) => { + const result = await decide(...args); + await record('decision'); + return result; + }; + const configure = async ( + nextScenario: string, + extra: Record = {} + ) => { + const response = await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify({ scenario: nextScenario, ...extra }), + }); + expect(response.ok).toBe(true); + scenario = nextScenario; + }; + const release = () => fetch(base + '/__release', { method: 'POST' }); + return { app, client, read, base, configure, release }; +} + +it('mount is inert and native submit pauses with actual note, exact whole batch approve confirms report', async () => { + const f = await setup(); + expect(await (await fetch(f.base + '/__requests')).json()).toEqual([]); + expect(await f.app.submit('Runway note')).toBe(true); + expect(f.app.getSnapshot()).toMatchObject({ + phase: 'paused', + canSubmit: false, + }); + expect(f.app.getSnapshot().savedWorkspace.files.map((x) => x.path)).toEqual([ + '/notes/kase.txt', + ]); + expect(await f.app.decide('approve', f.app.getSnapshot().decisionToken)).toBe( + true + ); + expect(f.app.getSnapshot().phase).toBe('saved'); + expect(f.app.getSnapshot().savedWorkspace.files.map((x) => x.path)).toContain( + '/reports/kase.md' + ); + const requests: { + body?: { command?: { resume: { decisions: unknown[] } } }; + }[] = await (await fetch(f.base + '/__requests')).json(); + expect( + requests.filter((x) => x.body?.command).map((x) => x.body!.command!) + ).toEqual([{ resume: { decisions: [{ type: 'approve' }] } }]); + const proof: { + actualCompiledGraph: boolean; + networkConnectAttempts: number; + op: string; + }[] = await (await fetch(f.base + '/__graph-proof')).json(); + expect(proof.length).toBeGreaterThan(0); + expect( + proof.filter((x) => ['submit', 'resume'].includes(x.op)).map((x) => x.op) + ).toEqual(['submit', 'resume']); + expect( + proof.every((x) => x.actualCompiledGraph && x.networkConnectAttempts === 0) + ).toBe(true); +}, 30000); +for (const scenario of [ + 'batch', + 'duplicates', + 'report-overwrite', + 'delete', + 'reject-reproposal', +]) + it( + 'native ' + + scenario + + ' preserves ordered batch and original human across explicit decisions', + async () => { + const f = await setup(); + await f.configure(scenario); + expect(await f.app.submit('Work on files')).toBe(true); + expect(f.app.getSnapshot().phase).toBe('paused'); + const human = f.app + .getSnapshot() + .messages.filter((x) => x.role === 'user'); + const batch = f.app.getSnapshot().approval; + if (!batch || batch.kind !== 'valid') throw Error('Expected valid batch'); + expect(batch?.kind).toBe('valid'); + expect( + await f.app.decide( + scenario === 'reject-reproposal' ? 'reject' : 'approve', + f.app.getSnapshot().decisionToken + ) + ).toBe(true); + expect( + f.app.getSnapshot().messages.filter((x) => x.role === 'user') + ).toEqual(human); + expect(f.app.getSnapshot().phase).toBe( + ['reject-reproposal', 'report-overwrite', 'delete'].includes(scenario) + ? 'paused' + : 'saved' + ); + if (['report-overwrite', 'delete'].includes(scenario)) { + expect( + await f.app.decide('approve', f.app.getSnapshot().decisionToken) + ).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); + } + const requests: { + body?: { command?: { resume: { decisions: unknown[] } } }; + }[] = await (await fetch(f.base + '/__requests')).json(); + expect( + requests.filter((x) => x.body?.command)[0]?.body?.command?.resume + .decisions + ).toHaveLength(batch.actions.length); + }, + 30000 + ); +it('reject does not predict a saved report and double decision sends once', async () => { + const f = await setup(); + await f.app.submit('Files'); + const one = f.app.decide('reject', f.app.getSnapshot().decisionToken); + expect(await f.app.decide('approve', f.app.getSnapshot().decisionToken)).toBe( + false + ); + expect(await one).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); + expect( + f.app.getSnapshot().savedWorkspace.files.map((x) => x.path) + ).not.toContain('/reports/kase.md'); +}, 30000); +for (const scenario of [ + 'no-files', + 'read-only', + 'unchanged', + 'empty', + 'write-error', + 'edit', +]) + it( + 'actual ' + scenario + ' reconciles files without predictions', + async () => { + const f = await setup(); + await f.configure(scenario); + expect(await f.app.submit('Files')).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); + }, + 30000 + ); +it('first transport failure requires New and no retry authors a conversation', async () => { + const f = await setup(); + await f.configure('normal', { failStream: true }); + expect(await f.app.submit('Files')).toBe(false); + expect(f.app.getSnapshot().canSubmit).toBe(false); + expect(await f.app.submit('Retry')).toBe(false); + await f.app.newConversation(); + expect(await f.app.submit('Fresh')).toBe(true); +}, 30000); +it('failed checkpoint retains confirmed workspace and requires New', async () => { + const f = await setup(); + await f.app.submit('Files'); + const saved = f.app.getSnapshot().savedWorkspace; + f.read.mockRejectedValueOnce(Error('Exact read failed')); + expect(await f.app.decide('approve', f.app.getSnapshot().decisionToken)).toBe( + false + ); + expect(f.app.getSnapshot()).toMatchObject({ + savedWorkspace: saved, + phase: 'unconfirmed', + canSubmit: false, + }); +}, 30000); +for (const cancel of ['stop', 'newConversation', 'dispose'] as const) + it( + cancel + ' invalidates held native completion and all later callbacks', + async () => { + const f = await setup(); + await f.configure('normal', { holdCheckpoint: true }); + const pending = f.app.submit('Files'); + expect(await f.app.submit('Twice')).toBe(false); + await vi.waitFor( + () => expect(f.app.getSnapshot().phase).toBe('confirming'), + { timeout: 10000 } + ); + await f.app[cancel](); + const snapshot = f.app.getSnapshot(); + await f.release(); + await pending; + expect(f.app.getSnapshot()).toBe(snapshot); + }, + 30000 + ); +it('stale prior batch token cannot approve a replacement pause in the same view', async () => { + const f = await setup(); + await f.configure('reject-reproposal'); + await f.app.submit('Files'); + const old = f.app.getSnapshot().decisionToken; + expect(await f.app.decide('reject', old)).toBe(true); + expect(f.app.getSnapshot().phase).toBe('paused'); + const before: { body?: { command?: unknown } }[] = await ( + await fetch(f.base + '/__requests') + ).json(); + expect(await f.app.decide('approve', old)).toBe(false); + expect(await (await fetch(f.base + '/__requests')).json()).toEqual(before); + expect(f.app.getSnapshot().phase).toBe('paused'); +}, 30000); +it('preflight external current head advancement prevents resume despite exact old checkpoint remaining readable', async () => { + const f = await setup(); + await f.app.submit('Files'); + const token = f.app.getSnapshot().decisionToken; + const head = (await f.client.readCurrent( + f.app.getSnapshot().threadId!, + new AbortController().signal + )) as { checkpoint: Record }; + vi.spyOn(f.client, 'readCurrent').mockResolvedValue({ + ...head, + checkpoint: { ...head.checkpoint, checkpoint_id: 'external-new-head' }, + }); + const before: { body?: { command?: unknown } }[] = await ( + await fetch(f.base + '/__requests') + ).json(); + expect(await f.app.decide('approve', token)).toBe(false); + const after: { body?: { command?: unknown } }[] = await ( + await fetch(f.base + '/__requests') + ).json(); + expect(after.filter((x) => x.body?.command)).toHaveLength( + before.filter((x) => x.body?.command).length + ); + expect(f.app.getSnapshot().canSubmit).toBe(false); +}, 30000); +for (const scenario of ['protected-edit', 'mixed']) + it( + 'actual native ' + + scenario + + ' confirms the current protected subset and explicit second pause', + async () => { + const f = await setup(); + await f.configure(scenario); + expect(await f.app.submit('Files')).toBe(true); + expect(f.app.getSnapshot().phase).toBe('paused'); + if (scenario === 'mixed') { + expect(f.app.getSnapshot().savedWorkspace.files).toEqual([]); + expect( + f.app.getSnapshot().toolCalls.filter((x) => x.status === 'pending') + ).toHaveLength(3); + expect(f.app.getSnapshot().approval?.kind).toBe('valid'); + } + expect( + await f.app.decide('approve', f.app.getSnapshot().decisionToken) + ).toBe(true); + if (scenario === 'protected-edit') { + expect(f.app.getSnapshot().phase).toBe('paused'); + expect(f.app.getSnapshot().savedWorkspace.files[0]).toMatchObject({ + content: 'old old', + }); + expect( + await f.app.decide('approve', f.app.getSnapshot().decisionToken) + ).toBe(true); + expect(f.app.getSnapshot().savedWorkspace.files[0]).toMatchObject({ + content: 'new new', + }); + } + expect(f.app.getSnapshot().phase).toBe('saved'); + }, + 30000 + ); +it('later native read-only and unchanged turns retain exact saved files and canonical pre-human prefix', async () => { + const f = await setup(); + expect(await f.app.submit('Initial files')).toBe(true); + expect(await f.app.decide('approve', f.app.getSnapshot().decisionToken)).toBe( + true + ); + const saved = f.app.getSnapshot().savedWorkspace; + const humans = f.app.getSnapshot().messages.filter((x) => x.role === 'user'); + for (const scenario of ['read-only', 'unchanged']) { + await f.configure(scenario); + expect(await f.app.submit('Keep files ' + scenario)).toBe(true); + expect(f.app.getSnapshot().phase).toBe('saved'); + expect(f.app.getSnapshot().savedWorkspace).toEqual(saved); + } + expect( + f.app + .getSnapshot() + .messages.filter((x) => x.role === 'user') + .slice(0, 1) + ).toEqual(humans); + expect( + f.app.getSnapshot().messages.filter((x) => x.role === 'user') + ).toHaveLength(3); +}, 30000); diff --git a/cockpit/deep-agents/filesystem/react/src/connection.spec.ts b/cockpit/deep-agents/filesystem/react/src/connection.spec.ts new file mode 100644 index 000000000..7a825da71 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/connection.spec.ts @@ -0,0 +1,172 @@ +// @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 { filesystemConnection, createFilesystemClient } from './connection'; +afterEach(() => vi.resetAllMocks()); +it('resolves shared proxy and developer target, rejecting incompatible configuration', () => { + expect( + filesystemConnection({ + status: 'unconfigured', + } as unknown as RuntimeBridgeConfiguration).apiUrl + ).toMatch(/\/api$/); + expect( + filesystemConnection({ + 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(() => + filesystemConnection({ + status: 'error', + } as unknown as RuntimeBridgeConfiguration) + ).toThrow('Runtime'); + expect(() => + filesystemConnection({ + 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 = createFilesystemClient({ + 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', + resume: async () => 'success', + load: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + }); + client.sessionFactory(id); + expect(sdk.native.mock.calls[0][0]).toMatchObject({ + assistantId: 'da-filesystem', + 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 = createFilesystemClient({ + 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 = createFilesystemClient({ + 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(); +}); +it('foreign preloaded or incompatible native sessions are disposed before installation', async () => { + const client = createFilesystemClient({ + apiUrl: 'https://authored.invalid', + headers: {}, + }); + sdk.create.mockImplementation(async ({ threadId }: { threadId: string }) => ({ + thread_id: threadId, + })); + const id = await client.createThread(new AbortController().signal); + for (const foreign of [true, false]) { + const dispose = vi.fn(async () => undefined); + sdk.native.mockReturnValue({ + getSnapshot: () => ({ + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + history: foreign + ? [ + { + checkpoint: { + thread_id: 'foreign', + checkpoint_ns: '', + checkpoint_id: 'old', + }, + }, + ] + : [], + }), + subscribe: () => () => undefined, + submit: async () => 'success', + ...(foreign ? { resume: async () => 'success' } : {}), + load: async () => undefined, + stop: async () => undefined, + dispose, + }); + expect(() => client.sessionFactory(id)).toThrow('unavailable'); + await Promise.resolve(); + expect(dispose).toHaveBeenCalledOnce(); + } +}); diff --git a/cockpit/deep-agents/filesystem/react/src/connection.ts b/cockpit/deep-agents/filesystem/react/src/connection.ts new file mode 100644 index 000000000..9118b7c2c --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/connection.ts @@ -0,0 +1,143 @@ +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 { + createFilesystemApplication, + type FilesystemClient, + type FilesystemSession, +} from './application'; +import { copyJson } from './workspace-state'; +import { checkpointSource, type Checkpoint } from './authority'; +export interface FilesystemConnection { + readonly apiUrl: string; + readonly headers: Record; +} +export function filesystemConnection( + configuration: RuntimeBridgeConfiguration +): FilesystemConnection { + 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 createFilesystemClient( + input: FilesystemConnection +): FilesystemClient { + 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-filesystem', + 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) || + state.messages.length !== 0 || + state.toolCalls.length !== 0 || + state.interrupts.length !== 0 || + state.subgraphs.length !== 0 || + (state.history !== undefined && + (!Array.isArray(state.history) || state.history.length !== 0)) || + ![ + 'getSnapshot', + 'subscribe', + 'submit', + 'resume', + 'load', + 'stop', + 'dispose', + ].every( + (method) => + typeof session?.[method as keyof typeof session] === 'function' + ) + ) + throw Error('Rejected session'); + return session as FilesystemSession; + } 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: FilesystemConnection) { + return createFilesystemApplication(createFilesystemClient(connection)); +} diff --git a/cockpit/deep-agents/filesystem/react/src/main.tsx b/cockpit/deep-agents/filesystem/react/src/main.tsx new file mode 100644 index 000000000..87a4af400 --- /dev/null +++ b/cockpit/deep-agents/filesystem/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 { FilesystemDemo } from './app'; +import { filesystemConnection } 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 filesystem example could not start.

); + }); diff --git a/cockpit/deep-agents/filesystem/react/src/panels.spec.tsx b/cockpit/deep-agents/filesystem/react/src/panels.spec.tsx new file mode 100644 index 000000000..18fd9d99c --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/panels.spec.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { WorkspacePanel } from './workspace-panel'; +import { ApprovalPanel } from './approval-panel'; +import { approvalState } from './approval-state'; +afterEach(cleanup); +const missing = { kind: 'missing', files: [] } as const; +it('files are literal, selection survives existing paths and falls back after removal', () => { + const files = [ + { + kind: 'text', + path: '/notes/a', + content: ' **literal**', + }, + { kind: 'unavailable', path: '/reports/b', reason: 'Unsupported encoding' }, + ] as const; + const view = render( + + ); + expect(view.getByText('Live workspace')).toBeTruthy(); + expect(view.container.querySelector('script')).toBeNull(); + expect(view.getByText(files[0].content)).toBeTruthy(); + fireEvent.click(view.getByRole('button', { name: '/reports/b' })); + expect(view.getByText('Unsupported encoding')).toBeTruthy(); + view.rerender( + + ); + expect(view.getByText('Unsupported encoding')).toBeTruthy(); + view.rerender( + + ); + expect(view.getByText(files[0].content)).toBeTruthy(); + view.rerender( + + ); + expect(view.getByText(files[0].content)).toBeTruthy(); +}); +it('duplicate actions stay separate and existing replacements are never ghosted or applied', () => { + const batch = approvalState({ + id: 'pause', + value: { + action_requests: [0, 1].map(() => ({ + name: 'write_file', + args: { file_path: '/reports/a', content: 'new' }, + description: 'raw proposal', + })), + review_configs: [0, 1].map(() => ({ + action_name: 'write_file', + allowed_decisions: ['approve', 'reject'], + })), + }, + }); + const decide = vi.fn(); + const view = render( + + ); + expect(view.getAllByText('Replacement proposal')).toHaveLength(2); + expect(view.getAllByText('new')).toHaveLength(2); + expect(view.container.querySelector('b')).toBeNull(); + fireEvent.click(view.getByRole('button', { name: 'Approve entire batch' })); + expect(decide).toHaveBeenCalledWith('approve'); + expect(view.queryByRole('button', { name: 'Edit' })).toBeNull(); +}); +it('unsupported proposals show complete literal raw JSON and cannot resume', () => { + const batch = approvalState({ + id: 'pause', + value: { + action_requests: [ + { name: 'evil', args: { x: '' }, description: 'x' }, + ], + review_configs: [], + }, + }); + const view = render( + + ); + expect(view.getByText(/Complete raw proposal/)).toBeTruthy(); + expect(view.container.textContent).toContain(''); + expect(view.container.querySelector('img')).toBeNull(); + expect(view.queryByRole('button')).toBeNull(); +}); + +it('empty literal content stays empty, missing and malformed maps are explicitly distinct', () => { + const view = render( + + ); + expect(view.getByLabelText('Literal file content').textContent).toBe(''); + expect(view.getByText('Empty file')).toBeTruthy(); + view.rerender( + + ); + expect(view.getByText(/No confirmed files map/)).toBeTruthy(); + expect(view.queryByText('The workspace is empty.')).toBeNull(); + view.rerender( + + ); + expect(view.getByText('Files unavailable: Malformed map')).toBeTruthy(); + expect(view.queryByText('The workspace is empty.')).toBeNull(); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/styles.css b/cockpit/deep-agents/filesystem/react/src/styles.css new file mode 100644 index 000000000..95a1e2f3e --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/styles.css @@ -0,0 +1,263 @@ +: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; +} +.filesystem-demo { + max-width: 1240px; + margin: 0 auto; + padding: 52px 32px 28px; +} +.filesystem-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; +} +.filesystem-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; +} +.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) { + .filesystem-demo { + padding: 28px 16px; + } + .filesystem-demo > header { + align-items: flex-start; + gap: 12px; + } + .filesystem-demo > header button { + font-size: 0.75rem; + padding: 0.55rem 0.65rem; + flex-shrink: 0; + } + .filesystem-layout { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + .conversation-panel { + padding: 18px; + } + h1 { + font-size: 1.85rem; + } + .introduction { + font-size: 0.9rem; + } +} +.workspace-column, +.workspace-panel, +.approval-panel, +.file-layout, +nav, +article, +pre { + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} +.workspace-panel, +.approval-panel { + border: 1px solid #d5dce0; + border-radius: 16px; + padding: 20px; + margin-bottom: 18px; + background: #fff; +} +.file-layout { + display: grid; + grid-template-columns: minmax(100px, 1fr) minmax(0, 2fr); + gap: 12px; +} +.file-layout nav button { + display: block; + width: 100%; + text-align: left; + margin-bottom: 8px; + overflow-wrap: anywhere; +} +.file-layout nav button[aria-pressed='true'] { + background: #dae7df; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + font-family: ui-monospace, monospace; + font-size: 0.84rem; + padding: 12px; + background: #f5f7f6; + border-radius: 8px; +} +h3, +h4, +button { + overflow-wrap: anywhere; +} +.approval-panel li { + margin-bottom: 22px; +} +.decision-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +@media (max-width: 760px) { + .file-layout { + grid-template-columns: 1fr; + } + .workspace-panel, + .approval-panel { + padding: 14px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} + +.tp-chat-input__send, +.tp-chat-input__stop { + white-space: nowrap; + overflow-wrap: normal; + flex-shrink: 0; + min-width: 4.5rem; +} +.tp-chat-input__hint { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/cockpit/deep-agents/filesystem/react/src/styles.d.ts b/cockpit/deep-agents/filesystem/react/src/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/cockpit/deep-agents/filesystem/react/src/workspace-panel.tsx b/cockpit/deep-agents/filesystem/react/src/workspace-panel.tsx new file mode 100644 index 000000000..e57c3dc5e --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/workspace-panel.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { selectWorkspacePath, type WorkspaceState } from './workspace-state'; +import type { FilesystemApplicationSnapshot } from './application'; +type Props = { + observed: WorkspaceState; + saved: WorkspaceState; + phase: FilesystemApplicationSnapshot['phase']; + notice: string | null; +}; +export function WorkspacePanel({ observed, saved, phase, notice }: Props) { + const live = phase === 'working' || phase === 'confirming'; + const state = live ? observed : saved; + const [selected, setSelected] = useState(); + const path = selectWorkspacePath(state, selected); + useEffect(() => { + if (path !== selected) setSelected(path); + }, [path, selected]); + const files = + state.kind === 'valid' || state.kind === 'missing' ? state.files : []; + const file = files.find((x) => x.path === path); + const groups = [ + ...new Set( + files.map((x) => x.path.slice(0, x.path.lastIndexOf('/')) || '/') + ), + ]; + return ( +
+

+ {live + ? 'Live workspace' + : phase === 'paused' + ? 'Paused · confirmed workspace' + : phase === 'saved' + ? 'Saved workspace' + : 'Last confirmed workspace'} +

+

+ {live + ? 'Observed files while the response runs. Saved files are confirmed separately.' + : phase === 'paused' + ? 'These files come from the saved pause checkpoint. Proposed changes below have not run.' + : 'Read-only file text from the last confirmed checkpoint.'} +

+ {notice &&

{notice}

} + {state.kind === 'valid' && !files.length && ( +

The workspace is empty.

+ )} + {state.kind === 'missing' && ( +

No confirmed files map yet. Send a message to begin.

+ )} + {state.kind === 'invalid' && ( +

Files unavailable: {state.reason}

+ )} +
+ + {file && ( +
+

{file.path}

+ {file.kind === 'text' ? ( + <> +
+                  {file.content}
+                
+ {file.content === '' &&

Empty file

} + + ) : ( +

{file.reason}

+ )} +
+ )} +
+
+ ); +} diff --git a/cockpit/deep-agents/filesystem/react/src/workspace-state.spec.ts b/cockpit/deep-agents/filesystem/react/src/workspace-state.spec.ts new file mode 100644 index 000000000..3d05e9e38 --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/workspace-state.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { workspaceState, selectWorkspacePath } from './workspace-state'; +function validWorkspace(input: unknown) { + const state = workspaceState(input); + expect(state.kind).toBe('valid'); + if (state.kind !== 'valid') throw new Error('Expected valid workspace'); + return state; +} + +describe('saved workspace projection', () => { + it('distinguishes initial missing, valid empty and malformed whole maps', () => { + expect(workspaceState(undefined)).toEqual({ kind: 'missing', files: [] }); + expect(workspaceState({})).toEqual({ kind: 'missing', files: [] }); + expect(workspaceState({ files: {} })).toEqual({ + kind: 'valid', + files: [], + complete: true, + }); + for (const files of [null, [], 'text', 1]) + expect(workspaceState({ files }).kind).toBe('invalid'); + }); + it('projects UTF8 strings, legacy lines and empty content, ordered with selection', () => { + const state = validWorkspace({ + files: { + '/z': { + content: '', + encoding: 'utf-8', + created_at: 'now', + modified_at: 'later', + }, + '/a': { content: ['one', 'two'] }, + '/b': { content: 'plain' }, + }, + }); + expect(state).toEqual({ + kind: 'valid', + complete: true, + files: [ + { path: '/a', kind: 'text', content: 'one\ntwo' }, + { path: '/b', kind: 'text', content: 'plain' }, + { + path: '/z', + kind: 'text', + content: '', + created_at: 'now', + modified_at: 'later', + }, + ], + }); + expect(selectWorkspacePath(state, '/b')).toBe('/b'); + expect(selectWorkspacePath(state, '/missing')).toBe('/a'); + expect(Object.isFrozen(state.files[0])).toBe(true); + }); + it('keeps unsupported individual records visibly unavailable', () => { + for (const record of [ + { content: 'YWJj', encoding: 'base64' }, + { content: 1 }, + 'abc', + { content: 'x', hidden: true }, + ]) { + const state = validWorkspace({ files: { '/a': record } }); + expect(state.kind).toBe('valid'); + expect(state.complete).toBe(false); + expect(state.files[0]).toMatchObject({ path: '/a', kind: 'unavailable' }); + expect(state.files[0]).not.toHaveProperty('content'); + } + }); + it.each([ + 'relative', + '/a//b', + '/a/../b', + '/a/./b', + '/a\\b', + '/a\u0000b', + '/', + '/a/', + '/' + 'x'.repeat(1024), + ])('rejects unsupported map path %s', (path) => { + expect(workspaceState({ files: { [path]: { content: 'x' } } }).kind).toBe( + 'invalid' + ); + }); + it('enforces file count and content bounds before copying or joining', () => { + expect( + workspaceState({ + files: Object.fromEntries( + Array.from({ length: 101 }, (_, i) => ['/f' + i, { content: '' }]) + ), + }).kind + ).toBe('invalid'); + expect( + validWorkspace({ files: { '/a': { content: 'x'.repeat(65536) } } }) + .complete + ).toBe(true); + for (const content of [ + 'x'.repeat(65537), + ['x'.repeat(65536), ''], + Array(65538).fill(''), + ]) { + expect( + validWorkspace({ files: { '/a': { content } } }).files[0].kind + ).toBe('unavailable'); + } + const files = Object.fromEntries( + Array.from({ length: 17 }, (_, i) => [ + '/f' + i, + { content: 'x'.repeat(65536) }, + ]) + ); + const state = validWorkspace({ files }); + expect(state.complete).toBe(false); + expect(state.files.filter((f: any) => f.kind === 'text')).toHaveLength(16); + }); + it('rejects non-JSON containers without evaluating getters', () => { + let hits = 0; + const getter = { + get files() { + hits++; + return {}; + }, + }; + const cycle: any = {}; + cycle.self = cycle; + const sparse = Array(2); + sparse[1] = 'x'; + const extended: any = ['x']; + extended.extra = true; + for (const value of [ + getter, + { files: { [Symbol('hidden')]: 'x' } }, + { files: Object.create({}) }, + { files: cycle }, + { files: { '/a': { content: sparse } } }, + { files: { '/a': { content: extended } } }, + ]) { + expect(workspaceState(value).kind).toBe('invalid'); + } + expect(hits).toBe(0); + }); + it('does not evaluate accessors introduced during descriptor inspection', () => { + let reads = 0; + const values: Record = { + files: { '/old': { content: 'saved' } }, + }; + values.later = new Proxy( + {}, + { + getPrototypeOf() { + Object.defineProperty(values, 'files', { + enumerable: true, + configurable: true, + get() { + reads++; + return {}; + }, + }); + return Object.prototype; + }, + } + ); + expect(workspaceState(values).kind).toBe('invalid'); + expect(reads).toBe(0); + }); + it('accepts two content-bounded legacy arrays without consuming canonical evidence nodes', () => { + const state = validWorkspace({ + files: { + '/a': { content: Array(65536).fill('') }, + '/b': { content: Array(65536).fill('') }, + }, + }); + expect(state.complete).toBe(true); + expect(state.files).toHaveLength(2); + }); + it('accepts the exact legacy aggregate boundary and marks the next file unavailable', () => { + const files = Object.fromEntries( + Array.from({ length: 16 }, (_, i) => [ + '/f' + String(i).padStart(2, '0'), + { content: Array(65537).fill('') }, + ]) + ); + const boundary = validWorkspace({ files }); + expect(boundary.complete).toBe(true); + expect( + boundary.files.reduce( + (n, file) => n + (file.kind === 'text' ? file.content.length : 0), + 0 + ) + ).toBe(1048576); + const overflow = validWorkspace({ + files: { ...files, '/z': { content: ['x'] } }, + }); + expect(overflow.complete).toBe(false); + expect(overflow.files.at(-1)?.kind).toBe('unavailable'); + }); + it('retains the canonical non-file JSON node limit', () => { + expect( + workspaceState({ files: {}, arbitrary: Array(100001).fill('') }).kind + ).toBe('invalid'); + }); +}); diff --git a/cockpit/deep-agents/filesystem/react/src/workspace-state.ts b/cockpit/deep-agents/filesystem/react/src/workspace-state.ts new file mode 100644 index 000000000..9e81ca54f --- /dev/null +++ b/cockpit/deep-agents/filesystem/react/src/workspace-state.ts @@ -0,0 +1,313 @@ +export const MAX_TEXT = 65536; +export const MAX_TOTAL_TEXT = 1048576; +export type PlainValue = + | null + | string + | boolean + | number + | readonly PlainValue[] + | { readonly [key: string]: PlainValue }; +export function plainRecord(value: unknown): value is Record { + return ( + value !== null && + typeof value === 'object' && + Object.getPrototypeOf(value) === Object.prototype + ); +} +export function ownValue(object: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) return undefined; + if (!('value' in descriptor) || (key !== 'length' && !descriptor.enumerable)) + throw new Error('Non-data property'); + return descriptor.value; +} +/** Inspect descriptors before reading. Never evaluate getters or drop keys. */ +export function inspectJson( + input: unknown, + optionalUndefined = false, + filesystemValues: readonly (readonly string[])[] = [] +): void { + const parents = new Set(); + let nodes = 0, + fileLines = 0; + // Only explicitly authorized values.files[path].content arrays receive this + // separate descriptor traversal budget. Generic JSON evidence stays at 100k + // nodes. A supported file has at most MAX_TEXT + 1 legacy lines; one extra + // line is inspectable as a visibly unavailable oversized record. + const maxRawLines = MAX_TEXT + 2; + const maxFileLines = filesystemValues.length * 100 * maxRawLines; + function visit(value: unknown, depth: number, path: readonly string[]): void { + if (++nodes > 100000 || depth > 100) + throw new Error('JSON bounds exceeded'); + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) + return; + const array = Array.isArray(value); + if (!array && !plainRecord(value)) throw new Error('Unsupported JSON'); + const object = value as object; + if ( + parents.has(object) || + (array && Object.getPrototypeOf(value) !== Array.prototype) + ) + throw new Error('Unsupported JSON'); + parents.add(object); + const keys = Reflect.ownKeys(object).filter( + (k) => !(array && k === 'length') + ); + const length = array ? (ownValue(object, 'length') as number) : 0; + if (array && keys.length !== length) throw new Error('Sparse array'); + const fileContent = + array && + filesystemValues.some( + (root) => + path.length === root.length + 3 && + root.every((part, i) => path[i] === part) && + path[root.length] === 'files' && + canonicalPath(path[root.length + 1]) && + path[root.length + 2] === 'content' + ); + if (fileContent && length > maxRawLines) + throw new Error('Raw legacy file line bounds exceeded'); + for (const key of keys) { + const d = Object.getOwnPropertyDescriptor(object, key); + if ( + typeof key !== 'string' || + !d || + !('value' in d) || + !d.enumerable || + (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length)) + ) + throw new Error('Non-JSON property'); + if (!array && optionalUndefined && d.value === undefined) continue; + if (fileContent && typeof d.value === 'string') { + if (++fileLines > maxFileLines) + throw new Error('Raw legacy workspace line bounds exceeded'); + } else visit(d.value, depth + 1, [...path, key]); + } + parents.delete(object); + } + visit(input, 0, []); +} +export function copyJson( + input: unknown, + optionalUndefined = false, + maxText = 8388608 +): unknown { + const parents = new Set(); + let nodes = 0, + text = 0; + function copy(value: unknown, depth: number): unknown { + if (++nodes > 100000 || depth > 100) + throw new Error('JSON bounds exceeded'); + if (typeof value === 'string') { + text += value.length; + if (text > maxText) throw new Error('Evidence text bounds exceeded'); + return value; + } + if ( + value === null || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) + return value; + const array = Array.isArray(value); + if ( + (!array && !plainRecord(value)) || + (array && Object.getPrototypeOf(value) !== Array.prototype) || + parents.has(value as object) + ) + throw new Error('Unsupported JSON'); + const object = value as object; + parents.add(object); + const descriptors = Object.getOwnPropertyDescriptors(object); + const keys = Reflect.ownKeys(descriptors).filter( + (k) => !(array && k === 'length') + ); + const length = array ? descriptors.length?.value : 0; + if ( + array && + (!Number.isInteger(length) || length < 0 || keys.length !== length) + ) + throw new Error('Sparse array'); + const result: Record = {}; + for (const key of keys) { + if (typeof key !== 'string') throw new Error('Symbol key'); + const d = descriptors[key]; + if ( + !d || + !('value' in d) || + !d.enumerable || + (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length)) + ) + throw new Error('Non-JSON property'); + if (!array && optionalUndefined && d.value === undefined) continue; + text += key.length; + if (text > maxText) throw new Error('Evidence text bounds exceeded'); + Object.defineProperty(result, key, { + value: copy(d.value, depth + 1), + enumerable: true, + }); + } + parents.delete(object); + return Object.freeze( + array ? Array.from({ length }, (_, i) => result[String(i)]) : result + ); + } + return copy(input, 0); +} +export function sameJson(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) { + const length = ownValue(left, 'length') as number; + if (length !== ownValue(right, 'length')) return false; + for (let i = 0; i < length; i++) + if (!sameJson(ownValue(left, String(i)), ownValue(right, String(i)))) + return false; + return true; + } + 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(ownValue(left, k), ownValue(right, k)) + ) + ); +} +export function canonicalPath(path: unknown): path is string { + return ( + typeof path === 'string' && + path.length <= 1024 && + path.startsWith('/') && + !/[\u0000-\u001f\u007f-\u009f\\]/.test(path) && + path + .slice(1) + .split('/') + .every((s) => s !== '' && s !== '.' && s !== '..') + ); +} +export type WorkspaceFile = + | { + readonly path: string; + readonly kind: 'text'; + readonly content: string; + readonly created_at?: string; + readonly modified_at?: string; + } + | { + readonly path: string; + readonly kind: 'unavailable'; + readonly reason: string; + }; +export type WorkspaceState = + | { readonly kind: 'missing'; readonly files: readonly WorkspaceFile[] } + | { readonly kind: 'invalid'; readonly reason: string } + | { + readonly kind: 'valid'; + readonly files: readonly WorkspaceFile[]; + readonly complete: boolean; + }; +export function workspaceState(input: unknown): WorkspaceState { + const missing = (): WorkspaceState => + Object.freeze({ kind: 'missing', files: Object.freeze([]) }); + if (input === undefined) return missing(); + try { + // Inspect without copying legacy lines, then enforce budgets before joining. + inspectJson(input, false, [[]]); + if (!plainRecord(input)) throw new Error(); + if (!Object.hasOwn(input, 'files')) return missing(); + const map = ownValue(input, 'files'); + if ( + !plainRecord(map) || + Object.keys(map).length > 100 || + !Object.keys(map).every(canonicalPath) + ) + throw new Error(); + let total = 0; + const files = Object.keys(map) + .sort() + .map((path): WorkspaceFile => { + const record = ownValue(map, path); + const unavailable = (reason: string): WorkspaceFile => + Object.freeze({ path, kind: 'unavailable', reason }); + if ( + !plainRecord(record) || + Object.keys(record).some( + (k) => + !['content', 'encoding', 'created_at', 'modified_at'].includes(k) + ) || + !Object.hasOwn(record, 'content') + ) + return unavailable('Unsupported file record'); + const encoding = ownValue(record, 'encoding'); + if (encoding !== undefined && encoding !== 'utf-8') + return unavailable('Unsupported encoding'); + for (const key of ['created_at', 'modified_at']) + if ( + ownValue(record, key) !== undefined && + (typeof ownValue(record, key) !== 'string' || + (ownValue(record, key) as string).length > 1024) + ) + return unavailable('Unsupported timestamp'); + const raw = ownValue(record, 'content'); + let length: number; + if (typeof raw === 'string') length = raw.length; + else if (Array.isArray(raw)) { + const count = ownValue(raw, 'length') as number; + if (count > MAX_TEXT + 1) + return unavailable('File content exceeds display limit'); + length = Math.max(0, count - 1); + for (let i = 0; i < count; i++) { + const line = ownValue(raw, String(i)); + if (typeof line !== 'string') + return unavailable('Unsupported file content'); + length += line.length; + if (length > MAX_TEXT) break; + } + } else return unavailable('Unsupported file content'); + if (length > MAX_TEXT) + return unavailable('File content exceeds display limit'); + if (total + length > MAX_TOTAL_TEXT) + return unavailable('Workspace content exceeds display budget'); + total += length; + return Object.freeze({ + path, + kind: 'text', + content: + typeof raw === 'string' + ? raw + : (copyJson(raw) as string[]).join('\n'), + ...(ownValue(record, 'created_at') !== undefined + ? { created_at: ownValue(record, 'created_at') as string } + : {}), + ...(ownValue(record, 'modified_at') !== undefined + ? { modified_at: ownValue(record, 'modified_at') as string } + : {}), + }); + }); + return Object.freeze({ + kind: 'valid', + files: Object.freeze(files), + complete: files.every((f) => f.kind === 'text'), + }); + } catch { + return Object.freeze({ + kind: 'invalid', + reason: 'Malformed or unbounded files data', + }); + } +} +export function selectWorkspacePath( + state: WorkspaceState, + selected?: string +): string | undefined { + return state.kind === 'valid' + ? state.files.find((f) => f.path === selected)?.path ?? state.files[0]?.path + : undefined; +} diff --git a/cockpit/deep-agents/filesystem/react/tsconfig.json b/cockpit/deep-agents/filesystem/react/tsconfig.json new file mode 100644 index 000000000..c99122e69 --- /dev/null +++ b/cockpit/deep-agents/filesystem/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/filesystem/react/vite.config.mts b/cockpit/deep-agents/filesystem/react/vite.config.mts new file mode 100644 index 000000000..b0b2621d2 --- /dev/null +++ b/cockpit/deep-agents/filesystem/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 2278d60ef..9c95cae5e 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -57,7 +57,7 @@ export const capabilities: readonly Capability[] = [ { 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, 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-filesystem', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, react: { project: 'cockpit-deep-agents-filesystem-react', port: 4629 }, pythonPort: 5311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'da-filesystem' }, { id: 'da-subagents', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, { id: 'da-memory', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonPort: 5313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index 1bb751b13..c74b2627e 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -44,6 +44,26 @@ import { describe, expect, it } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { capabilityModules, getFrontendCapabilityDescriptor } from './content-descriptors'; import { cockpitManifest } from './manifest'; +it('registers one frozen native Filesystem variant with the canonical backend and exact product sources', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); + if (!canonical) throw new Error('Missing canonical Filesystem'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react'); + expect(react).toBeDefined(); + expect(react?.manifestIdentity).toEqual(canonical.manifestIdentity); + expect(react?.backendAssetPaths).toEqual(deepAgentsFilesystemPythonModule.backendAssetPaths); + expect(react?.promptAssetPaths).toEqual(deepAgentsFilesystemPythonModule.promptAssetPaths); + expect(react?.runtimeUrl).toBe('deep-agents/filesystem/react'); + expect(react?.devPort).toBe(4629); + expect(react?.codeAssetPaths).toEqual(['app.tsx', 'application.ts', 'connection.ts', 'workspace-state.ts', 'approval-state.ts', 'authority.ts', 'workspace-panel.tsx', 'approval-panel.tsx', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/filesystem/react/src/${file}`)); + expect(Object.isFrozen(react)).toBe(true); + expect(Object.isFrozen(react?.codeAssetPaths)).toBe(true); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'angular')).toBe(canonical); + expect(capabilityModules.filter(entry => entry.id === canonical.id)).toHaveLength(1); + for (const path of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) { + expect(path).not.toMatch(/fixture|wire\.py|\.spec\.|proof/); + expect(existsSync(new URL('../../../../' + path, import.meta.url))).toBe(true); + } +}); it('registers one frozen native 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'); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 3832a3413..7bee91f4e 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1269,10 +1269,26 @@ const deepAgentsPlanningReact = (() => { }); })(); +const deepAgentsFilesystemReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'deep-agents-filesystem-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-filesystem-react'); + if (!canonical || !frontend) throw new Error('Filesystem frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'deep-agents-filesystem-python-react', + frontend: 'react', + title: 'Filesystem (React preview)', + codeAssetPaths: ['app.tsx', 'application.ts', 'connection.ts', 'workspace-state.ts', 'approval-state.ts', 'authority.ts', 'workspace-panel.tsx', 'approval-panel.tsx', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/filesystem/react/src/${file}`), + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, deepAgentsPlanningReact, + deepAgentsFilesystemReact, 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 114c56682..f5c32927b 100644 --- a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts @@ -14,7 +14,7 @@ it('keeps Planning as one canonical backend with Angular default and two exact f 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.frontend === 'react')).toHaveLength(30); expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'planning')).toEqual([ { frontend: 'angular', project: 'cockpit-deep-agents-planning-angular', port: 4310, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning', buildOutput: 'dist/cockpit/deep-agents/planning/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-planning-react', port: 4628, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning/react', buildOutput: 'dist/cockpit/deep-agents/planning/react' }, diff --git a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts index 8e3d35d23..724c4af3b 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -14,6 +14,14 @@ const streaming: CockpitManifestIdentity = { page: 'overview', language: 'python', }; +it('selects the Filesystem React frontend without changing the Angular or Python deployment', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-filesystem-python'); + if (!canonical) throw new Error('Missing canonical Filesystem'); + expect(getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-filesystem-react')).toMatchObject({port: 4629, runtimePath: 'deep-agents/filesystem/react'}); + expect(getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-filesystem-angular')).toMatchObject({port: 4311}); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'react')).toMatchObject({runtimeUrl: 'deep-agents/filesystem/react', devPort: 4629}); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'angular')).toBe(canonical); +}); it('resolves native Chat Generative UI with its canonical docs and existing backend', () => { const canonical=capabilityModules.find(entry=>entry.id==='chat-generative-ui-python'); if(!canonical) throw Error('Missing canonical Chat Generative UI'); @@ -251,8 +259,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(29); - expect(getCockpitFrontends()).toHaveLength(70); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(30); + expect(getCockpitFrontends()).toHaveLength(71); 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 +289,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(29); - expect(frontends).toHaveLength(70); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(70); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(30); + expect(frontends).toHaveLength(71); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(71); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -298,8 +306,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(29); - expect(getCockpitFrontends()).toHaveLength(70); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(30); + expect(getCockpitFrontends()).toHaveLength(71); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -327,8 +335,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(29); - expect(getCockpitFrontends()).toHaveLength(70); + ).toHaveLength(30); + expect(getCockpitFrontends()).toHaveLength(71); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -588,11 +596,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - 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).toHaveLength(71); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(71); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(71); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(71); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(71); expect( frontends.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 d256ca1c0..703f72cbb 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -21,13 +21,28 @@ test('Planning owns an exact closed native React identity', async () => { for (const key of ['planning', 'deep-agents/planning', '../deep-agents-planning', 'deep-agents-planning/../memory']) assert.throws(() => reactCockpitConfiguration(key), /Unsupported React cockpit topic/); }); +test('Filesystem backend changes select exactly both canonical frontends', () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-deep-agents-filesystem-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-filesystem-angular', 'cockpit-deep-agents-filesystem-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/filesystem/python')); +}); +test('Filesystem owns an exact closed native React identity', async () => { + const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); + assert.deepEqual(reactCockpitConfiguration('deep-agents-filesystem'), { + topic: 'filesystem', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/filesystem/react', + base: '/deep-agents/filesystem/react/', port: 4629, project: 'cockpit-deep-agents-filesystem-react', + }); + for (const key of ['filesystem', 'deep-agents/filesystem', '../deep-agents-filesystem', 'deep-agents-filesystem/../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, 70); + assert.equal(frontends.length, 71); 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', () => { @@ -36,7 +51,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, 70); + assert.equal(frontends.length, 71); 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 () => { @@ -55,7 +70,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, 70); + assert.equal(frontends.length, 71); 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 () => { @@ -374,11 +389,11 @@ test('CI discovers native React previews across protocols with their own Python const root = mkdtempSync(join(tmpdir(), 'cockpit-react-topics-')); t.after(() => rmSync(root, { recursive: true, force: true })); // All writes in this test target this isolated temporary root. - for (const frontend of ['react', 'python']) { - const directory = join(root, 'cockpit/deep-agents/planning', frontend); + for (const topic of ['planning', 'filesystem']) for (const frontend of ['react', 'python']) { + const directory = join(root, 'cockpit/deep-agents', topic, frontend); mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, 'project.json'), JSON.stringify({ - name: `cockpit-deep-agents-planning-${frontend}`, + name: `cockpit-deep-agents-${topic}-${frontend}`, targets: frontend === 'python' ? {} : { e2e: {} }, })); } @@ -417,7 +432,7 @@ test('CI discovers native React previews across protocols with their own Python ); } const caps = deriveCockpitCaps(root); - assert.equal(caps.length, 29); + assert.equal(caps.length, 30); 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 6a1c598ee..141a6c06d 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -13,6 +13,19 @@ it('wires native Planning build, fixture and website coverage to the shared Pyth expect(config).toContain('cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent'); expect(config).toContain("url: 'http://127.0.0.1:4628'"); }); +it('wires native Filesystem build, fixture and website coverage to the shared Python graph', () => { + const read = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), 'utf8')); + const react = read('cockpit/deep-agents/filesystem/react/project.json'); + 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-filesystem'); + expect(react.targets.e2e.dependsOn).toEqual(['build', 'fixture-test']); + expect(react.targets['fixture-test'].dependsOn).toContainEqual({ target: 'test', projects: ['cockpit-deep-agents-filesystem-python'] }); + const website = read('apps/website/project.json'); + expect(website.targets.e2e.dependsOn[0].projects).toContain('cockpit-deep-agents-filesystem-react'); + const config = readFileSync(join(repoRoot, 'apps/website/playwright.config.ts'), 'utf8'); + expect(config).toContain('cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent'); + expect(config).toContain("url: 'http://127.0.0.1:4629'"); +}); 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 748492e82..e3b8eb853 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,7 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'deep-agents-filesystem') + return Object.freeze({topic:'filesystem',library:'deep-agents',adapter:'langgraph',appPath:'cockpit/deep-agents/filesystem/react',base:'/deep-agents/filesystem/react/',port:4629,project:'cockpit-deep-agents-filesystem-react'}); if (topic === 'deep-agents-planning') return Object.freeze({ topic: 'planning', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/planning/react', diff --git a/scripts/react-cockpit/deep-agents-filesystem-configuration.spec.mjs b/scripts/react-cockpit/deep-agents-filesystem-configuration.spec.mjs new file mode 100644 index 000000000..8499fd9da --- /dev/null +++ b/scripts/react-cockpit/deep-agents-filesystem-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('Filesystem has a closed native React selection and fixture dispatch precedes generic fallback', () => { + assert.deepEqual(reactCockpitConfiguration('deep-agents-filesystem'), { + topic: 'filesystem', + library: 'deep-agents', + adapter: 'langgraph', + appPath: 'cockpit/deep-agents/filesystem/react', + base: '/deep-agents/filesystem/react/', + port: 4629, + project: 'cockpit-deep-agents-filesystem-react', + }); + assert.throws(() => reactCockpitConfiguration('deep-agents/../../foreign')); + const serve = readFileSync(new URL('./serve.mjs', import.meta.url), 'utf8'); + assert.ok( + serve.indexOf('await deepAgentsFilesystemFixture(request') < + serve.indexOf("if (pathname === '/__reset')") + ); +}); diff --git a/scripts/react-cockpit/deep-agents-filesystem-fixture.mjs b/scripts/react-cockpit/deep-agents-filesystem-fixture.mjs new file mode 100644 index 000000000..e9f93b221 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-filesystem-fixture.mjs @@ -0,0 +1,557 @@ +/** Loopback-only SDK transport backed by the actual compiled Filesystem 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('Filesystem 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('Filesystem graph operation failed.')); + else item.resolve(result); + } catch { + terminate(); + } + }); + return { + pid: child.pid, + send(value) { + if (dead) return Promise.reject(Error('Filesystem 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 createDeepAgentsFilesystemFixture({ + workerTimeoutMs = 30000, + spawnWorker = () => + spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/filesystem/python', + 'python', + 'scripts/react-cockpit/deep-agents-filesystem-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-filesystem-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ), +} = {}) { + const sourceSha256 = Object.fromEntries( + ['src/graph.py', 'prompts/filesystem.md', 'uv.lock'].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync( + new URL( + '../../cockpit/deep-agents/filesystem/python/' + path, + import.meta.url + ) + ) + ) + .digest('hex'), + ]) + ); + let worker, + closed = false, + closing, + queue = Promise.resolve(); + // Real execution is buffered before delivery; transport cancellation does not roll back files. + 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; + } + const chunks = []; + let byteLength = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.length; + if (byteLength > 1024 * 1024) { + json(response, {}, 413); + return true; + } + chunks.push(bytes); + } + const input = Buffer.concat(chunks, byteLength).toString('utf8'); + const body = input ? JSON.parse(input) : {}; + if (!only(body, Object.keys(body ?? {}))) { + json(response, {}, 400); + return true; + } + if (pathname === '/__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 (request.method !== 'POST') { + json(response, {}, 405); + return true; + } + if ( + !only(body, [ + 'scenario', + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ]) || + ('scenario' in body && + ![ + 'normal', + 'no-files', + 'unchanged', + 'read-only', + 'empty', + 'overwrite', + 'report-overwrite', + 'edit', + 'delete', + 'batch', + 'duplicates', + 'reject-reproposal', + 'write-error', + 'protected-edit', + 'mixed', + ].includes(body.scenario)) || + [ + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ].some((k) => k in body && typeof body[k] !== 'boolean') + ) { + json(response, {}, 400); + return true; + } + if ('scenario' in body) + await operate({ op: 'configure', scenario: body.scenario }); + Object.assign(options, body); + json(response, {}); + return true; + } + if (pathname === '/__release') { + if (request.method !== 'POST' || !only(body, [])) { + json(response, {}, 400); + return true; + } + for (const item of held) { + held.delete(item); + item.finish(); + } + json(response, {}); + return true; + } + if (pathname.startsWith('/__')) { + json(response, {}, 404); + return true; + } + if (requests.length >= 2000 || threads.size >= 100 || runs.size >= 1000) { + json(response, {}, 429); + return true; + } + requests.push( + structuredClone({ path: pathname, method: request.method, body }) + ); + if (pathname === '/api/threads' && request.method === 'POST') { + if (options.failCreation) { + options.failCreation = false; + json(response, { error: 'Controlled creation failure.' }, 500); + return true; + } + if ( + !uuid.test(body.thread_id ?? '') || + threads.has(body.thread_id) || + !only(body, ['thread_id', 'if_exists', 'metadata']) || + (body.if_exists !== undefined && body.if_exists !== 'raise') || + (body.metadata !== undefined && !only(body.metadata, [])) + ) { + json(response, {}, 400); + return true; + } + await operate({ op: 'create', threadId: body.thread_id }); + threads.add(body.thread_id); + json(response, { thread_id: body.thread_id }); + return true; + } + const route = /^\/api\/threads\/([^/]+)(.*)$/.exec(pathname); + if (!route || !threads.has(route[1])) { + json(response, {}, 404); + return true; + } + const [, threadId, suffix] = route; + if ( + (suffix === '/state' && request.method === 'GET') || + (suffix === '/state/checkpoint' && request.method === 'POST') + ) { + if (suffix === '/state' && input.length !== 0) { + json(response, {}, 400); + return true; + } + if (suffix === '/state/checkpoint') { + if (options.failCheckpoint) { + options.failCheckpoint = false; + json(response, { error: 'Controlled exact read failure.' }, 500); + return true; + } + if ( + !only(body, ['checkpoint']) || + !only(body.checkpoint, [ + 'thread_id', + 'checkpoint_id', + 'checkpoint_ns', + 'checkpoint_map', + ]) || + body.checkpoint.thread_id !== threadId || + typeof body.checkpoint.checkpoint_id !== 'string' || + typeof body.checkpoint.checkpoint_ns !== 'string' + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + if ( + !history.some( + (s) => + s.checkpoint.checkpoint_id === body.checkpoint.checkpoint_id && + s.checkpoint.checkpoint_ns === body.checkpoint.checkpoint_ns + ) || + (body.checkpoint.checkpoint_map !== undefined && + (!only(body.checkpoint.checkpoint_map, [ + body.checkpoint.checkpoint_ns, + ]) || + Object.values(body.checkpoint.checkpoint_map).some( + (id) => id !== body.checkpoint.checkpoint_id + ))) + ) { + json(response, {}, 400); + return true; + } + } + const { state } = await operate({ + op: 'state', + threadId, + ...(suffix === '/state/checkpoint' + ? { checkpoint: body.checkpoint } + : {}), + }); + const finish = () => json(response, state); + if ( + options.holdState || + (options.holdCheckpoint && suffix === '/state/checkpoint') + ) + hold(response, finish); + else finish(); + return true; + } + if (suffix === '/history' && request.method === 'POST') { + if ( + !only(body, ['limit']) || + (body.limit !== undefined && + (!Number.isInteger(body.limit) || + body.limit < 1 || + body.limit > 100)) + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + json(response, history.slice(0, body.limit ?? 10)); + return true; + } + const runRoute = /^\/runs\/([^/]+)(\/cancel)?$/.exec(suffix); + if ( + runRoute && + (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', + 'command', + 'stream_mode', + 'stream_subgraphs', + 'stream_resumable', + 'on_disconnect', + ]) || + !( + (body.command !== undefined && + (body.input === undefined || body.input === null) && + only(body.command, ['resume']) && + only(body.command.resume, ['decisions']) && + Array.isArray(body.command.resume.decisions) && + body.command.resume.decisions.length > 0 && + body.command.resume.decisions.every( + (d) => + only(d, ['type']) && ['approve', 'reject'].includes(d.type) + )) || + (body.command === undefined && + only(body.input, ['messages']) && + body.input.messages?.length === 1 && + only(message, ['type', 'id', 'content']) && + message.type === 'human' && + typeof message.id === 'string' && + message.id.trim() && + typeof message.content === 'string') + ) || + body.assistant_id !== 'da-filesystem' || + !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: body.command ? 'resume' : 'submit', + threadId, + ...(body.command + ? { command: body.command } + : { 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: 'Filesystem 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-filesystem-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-filesystem-fixture.spec.mjs new file mode 100644 index 000000000..c57d5aa1a --- /dev/null +++ b/scripts/react-cockpit/deep-agents-filesystem-fixture.spec.mjs @@ -0,0 +1,771 @@ +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 { createDeepAgentsFilesystemFixture } = await import( + './deep-agents-filesystem-fixture.mjs' +).catch(() => ({})); + +async function fixture(t, options) { + assert.equal( + typeof createDeepAgentsFilesystemFixture, + 'function', + 'Filesystem compiled-graph fixture exists' + ); + const handle = createDeepAgentsFilesystemFixture(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-filesystem', { + 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 preserves actual pause, exact checkpoint, resume body and terminal files', async (t) => { + const f = await fixture(t); + const pausedRun = await f.submit(); + const paused = await f.client.threads.getState(f.thread); + assert.equal( + paused.values.files['/notes/kase.txt'].content, + 'KASE field elevation is 7820 ft.' + ); + assert.equal(Object.hasOwn(paused.values.files, '/reports/kase.md'), false); + assert.equal(paused.tasks.flatMap((t) => t.interrupts).length, 1); + const batch = paused.tasks.flatMap((t) => t.interrupts)[0].value; + assert.equal(batch.action_requests.length, 1); + assert.equal(batch.review_configs.length, 1); + assert.equal( + (await f.client.runs.get(f.thread, pausedRun.run)).status, + 'success' + ); + assert.deepEqual( + await f.client.threads.getState(f.thread, paused.checkpoint), + paused + ); + assert.equal( + (await f.client.threads.getHistory(f.thread, { limit: 100 }))[0].checkpoint + .checkpoint_id, + paused.checkpoint.checkpoint_id + ); + const events = []; + for await (const e of f.client.runs.stream(f.thread, 'da-filesystem', { + command: { resume: { decisions: [{ type: 'approve' }] } }, + streamMode: ['values', 'messages-tuple', 'updates', 'checkpoints'], + })) + events.push(e); + const terminal = await f.client.threads.getState(f.thread); + assert.equal( + terminal.values.files['/reports/kase.md'].content, + 'KASE assessment ready.' + ); + assert.deepEqual(terminal.next, []); + assert.deepEqual(terminal.tasks, []); + const requests = await (await fetch(f.base + '/__requests')).json(); + assert.deepEqual(requests.find((r) => r.body.command)?.body.command, { + resume: { decisions: [{ type: 'approve' }] }, + }); + assert.deepEqual( + requests.find((r) => r.path.endsWith('/state/checkpoint')).body, + { checkpoint: paused.checkpoint } + ); + const proofs = await (await fetch(f.base + '/__graph-proof')).json(); + assert( + proofs.every((p) => p.actualCompiledGraph && p.networkConnectAttempts === 0) + ); +}); + +test('reject, replay and mismatched decisions cannot optimistically write', async (t) => { + const f = await fixture(t); + await f.submit(); + const resume = async (decisions) => { + for await (const e of f.client.runs.stream(f.thread, 'da-filesystem', { + command: { resume: { decisions } }, + streamMode: ['values'], + })) { + } + }; + await assert.rejects(resume([])); + await resume([{ type: 'reject' }]); + assert.equal( + Object.hasOwn( + (await f.client.threads.getState(f.thread)).values.files, + '/reports/kase.md' + ), + false + ); + await assert.rejects(resume([{ type: 'approve' }])); + await assert.rejects(f.client.threads.getState(randomUUID())); +}); + +async function decide(f, types) { + const events = []; + for await (const e of f.client.runs.stream(f.thread, 'da-filesystem', { + command: { resume: { decisions: types.map((type) => ({ type })) } }, + streamMode: ['values', 'messages-tuple', 'updates', 'checkpoints'], + })) + events.push(e); + return events; +} +for (const scenario of [ + 'batch', + 'duplicates', + 'reject-reproposal', + 'delete', + 'overwrite', + 'edit', + 'write-error', + 'empty', + 'no-files', + 'read-only', + 'unchanged', +]) { + test(`actual SDK scenario ${scenario}`, async (t) => { + const f = await fixture(t); + await f.control({ scenario }); + await f.submit(); + let saved = await f.client.threads.getState(f.thread); + if (['batch', 'duplicates'].includes(scenario)) { + const b = saved.tasks.flatMap((t) => t.interrupts)[0].value; + assert.equal(b.action_requests.length, 2); + assert.equal(b.review_configs.length, 2); + if (scenario === 'duplicates') + assert.equal( + b.action_requests[0].args.file_path, + b.action_requests[1].args.file_path + ); + await assert.rejects(decide(f, ['approve'])); + await decide(f, ['approve', 'approve']); + saved = await f.client.threads.getState(f.thread); + assert(Object.hasOwn(saved.values.files, '/reports/kase.md')); + } else if (scenario === 'reject-reproposal') { + await decide(f, ['reject']); + saved = await f.client.threads.getState(f.thread); + assert.equal( + Object.hasOwn(saved.values.files, '/reports/kase.md'), + false + ); + assert.equal( + saved.tasks.flatMap((t) => t.interrupts)[0].value.action_requests[0] + .args.content, + 'Revised assessment' + ); + await decide(f, ['approve']); + saved = await f.client.threads.getState(f.thread); + assert.equal( + saved.values.files['/reports/kase.md'].content, + 'Revised assessment' + ); + } else if (scenario === 'delete') { + await decide(f, ['approve']); + saved = await f.client.threads.getState(f.thread); + assert.equal( + saved.tasks.flatMap((t) => t.interrupts)[0].value.action_requests[0] + .name, + 'delete' + ); + await decide(f, ['approve']); + saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.files, {}); + } else if (scenario === 'overwrite') + assert.equal( + saved.values.files['/notes/kase.txt'].content, + 'Replacement' + ); + else if (scenario === 'edit') + assert.equal( + saved.values.files['/notes/kase.txt'].content, + 'Aspen field elevation is 7820 ft.' + ); + else if (scenario === 'empty') + assert.equal(saved.values.files['/notes/empty.txt'].content, ''); + else if (scenario === 'write-error') { + assert.deepEqual(saved.values.files, {}); + assert( + saved.values.messages.some( + (m) => m.type === 'tool' && m.status === 'error' + ) + ); + } else assert.deepEqual(saved.values.files ?? {}, {}); + assert.deepEqual(saved.next, []); + assert.deepEqual(saved.tasks, []); + }); +} + +test('cancellation stops held delivery without rollback; cleanup closes owned child', 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; + await f.client.runs.cancel(f.thread, run, true, 'interrupt'); + assert.equal((await stream.next()).done, true); + assert.equal((await f.client.runs.get(f.thread, run)).status, 'interrupted'); + assert.equal( + (await f.client.threads.getState(f.thread)).values.files['/notes/kase.txt'] + .content, + 'KASE field elevation is 7820 ft.' + ); + const life = await (await fetch(f.base + '/__lifetime')).json(); + assert.equal(life.held, 0); + await f.handle.close(); + assert.throws(() => process.kill(life.workerPid, 0), { code: 'ESRCH' }); +}); + +test('owned exact checkpoint and stream bodies reject foreign and unsupported inputs', 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 e of f.client.runs.stream(f.thread, 'da-filesystem', { + input: { + messages: [{ type: 'human', id: randomUUID(), content: 'Override' }], + files: {}, + }, + streamMode: ['values'], + })) { + } + }); + 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-filesystem', { + 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('two paused owners retain independent deterministic responses across resumes', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'reject-reproposal' }); + const a = await f.submit('A', 'human-a'); + await f.control({ scenario: 'normal' }); + const b = randomUUID(); + await f.client.threads.create({ threadId: b, ifExists: 'raise' }); + for await (const e of f.client.runs.stream(b, 'da-filesystem', { + input: { messages: [{ type: 'human', id: 'human-b', content: 'B' }] }, + streamMode: ['values'], + })) { + } + await decide(f, ['reject']); + let state = await f.client.threads.getState(f.thread); + assert.equal( + state.tasks.flatMap((t) => t.interrupts)[0].value.action_requests[0].args + .content, + 'Revised assessment' + ); + assert.equal(state.values.messages.at(-1).id, 'human-a-model-2'); + await decide(f, ['approve']); + state = await f.client.threads.getState(f.thread); + assert.equal(state.values.messages.at(-1).id, 'human-a-answer'); + assert.equal( + (await f.client.threads.getState(b)).values.messages.at(-1).id, + 'human-b-model-1' + ); + const proofs = await (await fetch(f.base + '/__graph-proof')).json(); + assert( + proofs + .filter((p) => p.op === 'resume' && p.threadId === f.thread) + .every((p) => p.scenario === 'reject-reproposal') + ); +}); + +test('private controls validate bodies, release held state and fail creation/stream/checkpoint once', async (t) => { + const f = await fixture(t); + assert.equal( + ( + await fetch(f.base + '/__configure', { + method: 'POST', + body: JSON.stringify({ scenario: 'synthetic-malformed' }), + }) + ).status, + 400 + ); + await f.control({ failCreation: true }); + await assert.rejects( + f.client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await f.control({ failStream: true }); + await assert.rejects(f.submit()); + await f.submit(); + const state = await f.client.threads.getState(f.thread); + await f.control({ failCheckpoint: true }); + await assert.rejects(f.client.threads.getState(f.thread, state.checkpoint)); + assert.deepEqual( + await f.client.threads.getState(f.thread, state.checkpoint), + state + ); + await f.control({ holdState: true }); + const held = f.client.threads.getState(f.thread); + // Poll private owned lifetime, bounded by wall time, without relying on arbitrary sleep. + const deadline = Date.now() + 1000; + let life; + do { + life = await (await fetch(f.base + '/__lifetime')).json(); + } while (life.held === 0 && Date.now() < deadline); + assert.equal(life.held, 1); + await fetch(f.base + '/__release', { method: 'POST' }); + assert.deepEqual(await held, state); +}); +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 handle = createDeepAgentsFilesystemFixture({ + workerTimeoutMs: 1000, + spawnWorker: () => + (child = spawn(process.execPath, ['-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + const server = createServer((req, res) => + handle(req, res, new URL(req.url, 'http://localhost').pathname) + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const client = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + await assert.rejects( + client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + }); +} + +test( + 'stdio worker independently rejects unknown owners, replay, foreign checkpoint and invalid resume', + { timeout: 30000 }, + async (t) => { + const child = spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/filesystem/python', + 'python', + 'scripts/react-cockpit/deep-agents-filesystem-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(); + }); + let sequence = 0; + const send = async (value) => { + const line = once(lines, 'line'); + child.stdin.write( + JSON.stringify({ ...value, requestId: ++sequence }) + '\n' + ); + return JSON.parse((await line)[0]); + }; + assert.equal( + (await send({ op: 'state', threadId: 'unknown' })).error, + 'Graph operation failed' + ); + await send({ op: 'create', threadId: 'a' }); + await send({ op: 'create', threadId: 'b' }); + const input = { + op: 'submit', + threadId: 'a', + messages: [{ type: 'human', id: 'human-a', content: 'A' }], + }; + const paused = await send(input); + assert.equal(paused.state.tasks.flatMap((t) => t.interrupts).length, 1); + assert.equal( + ( + await send({ + ...input, + messages: [ + { type: 'human', id: 'override', content: 'Override pause' }, + ], + }) + ).error, + 'Graph operation failed' + ); + assert.equal( + ( + await send({ + op: 'state', + threadId: 'b', + checkpoint: paused.state.checkpoint, + }) + ).error, + 'Graph operation failed' + ); + assert.equal( + ( + await send({ + op: 'state', + threadId: 'a', + checkpoint: { thread_id: 'a' }, + }) + ).error, + 'Graph operation failed' + ); + assert.equal( + ( + await send({ + op: 'resume', + threadId: 'a', + checkpoint: paused.state.checkpoint, + command: { resume: { decisions: [{ type: 'approve' }] } }, + }) + ).error, + 'Graph operation failed' + ); + for (const checkpoint of [ + { ...paused.state.checkpoint, checkpoint_map: { foreign: 'unknown' } }, + { ...paused.state.checkpoint, checkpoint_map: [] }, + { thread_id: 'a', checkpoint_id: paused.state.checkpoint.checkpoint_id }, + ]) { + assert.equal( + (await send({ op: 'state', threadId: 'a', checkpoint })).error, + 'Graph operation failed' + ); + } + const invalidIdLine = once(lines, 'line'); + child.stdin.write( + JSON.stringify({ op: 'state', threadId: 'a', requestId: true }) + '\n' + ); + assert.equal( + JSON.parse((await invalidIdLine)[0]).error, + 'Graph operation failed' + ); + for (const decisions of [ + [], + [{ type: 'edit' }], + [{ type: 'approve' }, { type: 'approve' }], + ]) + assert.equal( + ( + await send({ + op: 'resume', + threadId: 'a', + command: { resume: { decisions } }, + }) + ).error, + 'Graph operation failed' + ); + const terminal = await send({ + op: 'resume', + threadId: 'a', + command: { resume: { decisions: [{ type: 'reject' }] } }, + }); + assert.deepEqual(terminal.state.next, []); + assert.equal((await send(input)).error, 'Graph operation failed'); + assert.equal( + ( + await send({ + op: 'resume', + threadId: 'a', + command: { resume: { decisions: [{ type: 'approve' }] } }, + }) + ).error, + 'Graph operation failed' + ); + assert.equal( + ( + await send({ + op: 'submit', + threadId: 'b', + messages: [ + { type: 'human', id: 'wrong-type', content: 'B', extra: 'no' }, + ], + }) + ).error, + 'Graph operation failed' + ); + } +); + +for (const choice of ['approve', 'reject']) { + test(`protected overwrite ${choice} binds current proposal and old saved text`, async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'report-overwrite' }); + await f.submit(); + await decide(f, ['approve']); + const paused = await f.client.threads.getState(f.thread); + assert.equal( + paused.values.files['/reports/existing.md'].content, + 'Prior report' + ); + assert.deepEqual( + await f.client.threads.getState(f.thread, paused.checkpoint), + paused + ); + const batch = paused.tasks.flatMap((t) => t.interrupts)[0].value; + assert.equal(batch.action_requests.length, 1); + assert.equal(batch.action_requests[0].name, 'write_file'); + assert.deepEqual(batch.action_requests[0].args, { + file_path: '/reports/existing.md', + content: 'Replacement report', + }); + await decide(f, [choice]); + const terminal = await f.client.threads.getState(f.thread); + assert.equal( + terminal.values.files['/reports/existing.md'].content, + choice === 'approve' ? 'Replacement report' : 'Prior report' + ); + const result = terminal.values.messages.find( + (m) => m.type === 'tool' && m.tool_call_id.endsWith('-replacement') + ); + assert.equal(result.status, choice === 'approve' ? 'success' : 'error'); + assert.deepEqual(terminal.next, []); + assert.deepEqual(terminal.tasks, []); + }); +} + +async function fragmentedHandle(f, pathname, bytes, chunkSize = 1) { + const req = { + socket: { remoteAddress: '127.0.0.1' }, + method: 'POST', + url: pathname, + async *[Symbol.asyncIterator]() { + let i = 0; + for (; i < Math.min(32, bytes.length); i++) + yield bytes.subarray(i, i + 1); + for (; i < bytes.length; i += chunkSize) + yield bytes.subarray(i, i + chunkSize); + }, + }; + let status, + output = ''; + const res = { + destroyed: false, + writableEnded: false, + writeHead(value) { + status = value; + }, + write(value) { + output += value; + }, + end(value = '') { + output += value; + this.writableEnded = true; + }, + }; + assert.equal(await f.handle(req, res, pathname), true); + return { status, output }; +} + +test('actual handler preserves UTF8 JSON and human text across arbitrary buffer boundaries', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'no-files' }); + const human = { type: 'human', id: 'split-utf8-human', content: 'Café ✈️' }; + await f.submit(human.content, 'ordinary-utf8-human'); + assert.equal( + (await f.client.threads.getState(f.thread)).values.messages[0].content, + human.content + ); + const body = { + assistant_id: 'da-filesystem', + input: { messages: [human] }, + stream_mode: ['values'], + }; + const result = await fragmentedHandle( + f, + `/api/threads/${f.thread}/runs/stream`, + Buffer.from(JSON.stringify(body)) + ); + assert.equal(result.status, 200); + const requests = await (await fetch(f.base + '/__requests')).json(); + assert.deepEqual(requests.at(-1).body, body); + const saved = await f.client.threads.getState(f.thread); + assert.equal( + saved.values.messages.find((m) => m.id === human.id).content, + human.content + ); +}); + +test('raw request byte limit accepts exactly1MiB and rejects one additional byte before decoding', async (t) => { + const f = await fixture(t); + // JSON whitespace remains valid while testing exact byte counting with a split multibyte key. + const prefix = Buffer.from('{"é":true}'); + const exact = Buffer.concat([ + prefix, + Buffer.alloc(1024 * 1024 - prefix.length, 0x20), + ]); + const pathname = '/api/threads'; + const accepted = await fragmentedHandle(f, pathname, exact, 65536); + assert.equal( + accepted.status, + 400, + 'valid exact-limit body reaches route validation' + ); + const requests = await (await fetch(f.base + '/__requests')).json(); + assert.deepEqual(requests.at(-1).body, { é: true }); + const rejected = await fragmentedHandle( + f, + pathname, + Buffer.concat([exact, Buffer.from(' ')]), + 65536 + ); + assert.equal(rejected.status, 413); + assert.deepEqual( + await (await fetch(f.base + '/__requests')).json(), + requests, + 'oversized body never reaches route dispatch' + ); +}); + +test('native resume accepts the installed SDK null input with a command and no new human', async (t) => { + const f = await fixture(t); + await f.submit('Native null resume'); + const before = await f.client.threads.getState(f.thread); + const events = []; + for await (const item of f.client.runs.stream(f.thread, 'da-filesystem', { + input: null, + command: { resume: { decisions: [{ type: 'approve' }] } }, + streamMode: ['values', 'updates'], + })) events.push(item); + const after = await f.client.threads.getState(f.thread); + assert.equal(after.next.length, 0); + assert.equal(after.values.files['/reports/kase.md'].content, 'KASE assessment ready.'); + assert.deepEqual(after.values.messages.filter(m => m.type === 'human'), before.values.messages.filter(m => m.type === 'human')); + assert.ok(events.length); +}); diff --git a/scripts/react-cockpit/deep-agents-filesystem-wire.py b/scripts/react-cockpit/deep-agents-filesystem-wire.py new file mode 100644 index 000000000..7c9e108ab --- /dev/null +++ b/scripts/react-cockpit/deep-agents-filesystem-wire.py @@ -0,0 +1,211 @@ +"""Bounded newline-JSON worker running the unchanged compiled Filesystem builder.""" +import asyncio +from dataclasses import asdict, is_dataclass +from contextlib import redirect_stdout +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import socket +import sys +import traceback +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +PROJECT = ROOT / "cockpit/deep-agents/filesystem/python" +sys.path.insert(0, str(PROJECT / "tests")) +from test_streaming import LocalModel, build, call, responses, write + +SOURCES = ("src/graph.py", "prompts/filesystem.md", "uv.lock") +MAX_REQUEST = 1024 * 1024 +MAX_RESPONSE = 16 * 1024 * 1024 + + +def encode(value): + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + if is_dataclass(value): + return asdict(value) + raise TypeError("Unsupported graph wire value") + + +def checkpoint(state): + return { + "values": state.values, "next": list(state.next), + "checkpoint": {k: v for k, v in state.config["configurable"].items() + if k in ("thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map")}, + "parent_checkpoint": dict(state.parent_config["configurable"]) if state.parent_config else None, + "created_at": state.created_at, "metadata": state.metadata, + "tasks": [{"id": t.id, "name": t.name, + **({"error": t.error} if t.error is not None else {}), + **({"state": t.state} if t.state is not None else {}), + "interrupts": [{"id": item.id, "value": item.value} for item in t.interrupts]} + for t in state.tasks], + } + + +SCENARIOS = ("normal", "no-files", "unchanged", "read-only", "empty", "overwrite", "report-overwrite", "edit", "delete", "batch", "duplicates", "reject-reproposal", "write-error", "protected-edit", "mixed") + +def batches(scenario, identity): + w = lambda path, text, suffix: write(path, text, identity + "-" + suffix) + note = w("/notes/kase.txt", "KASE field elevation is 7820 ft.", "note") + report = w("/reports/kase.md", "KASE assessment ready.", "report") + if scenario in ("no-files", "unchanged"): + return [] + if scenario == "read-only": + return [[call("ls", {"path":"/"}, identity+"-ls")]] + if scenario == "empty": + return [[w("/notes/empty.txt", "", "empty")]] + if scenario == "write-error": + return [[call("write_file", {"file_path":"/notes/invalid.txt", "content":42}, identity+"-invalid")]] + if scenario == "mixed": + return [[note, report, call("read_file", {"file_path":"/notes/kase.txt"}, identity+"-read")]] + if scenario == "protected-edit": + return [[w("/reports/existing.md", "old old", "initial")], [call("edit_file", {"file_path":"/reports/existing.md", "old_string":"old", "new_string":"new", "replace_all":True}, identity+"-edit")]] + if scenario == "report-overwrite": + return [[w("/reports/existing.md", "Prior report", "initial")], + [w("/reports/existing.md", "Replacement report", "replacement")]] + if scenario == "overwrite": + return [[note], [w("/notes/kase.txt", "Replacement", "overwrite")]] + if scenario == "edit": + return [[note], [call("edit_file", {"file_path":"/notes/kase.txt", "old_string":"KASE", "new_string":"Aspen", "replace_all":True}, identity+"-edit")]] + if scenario == "delete": + return [[report], [call("delete", {"file_path":"/reports"}, identity+"-delete")]] + if scenario in ("batch", "duplicates"): + path = "/reports/kase.md" if scenario == "duplicates" else "/reports/second.md" + return [[note], [report, w(path, "Second report", "second")]] + if scenario == "reject-reproposal": + return [[note], [report], [w("/reports/kase.md", "Revised assessment", "reproposal")]] + return [[note], [report]] + + +async def main(): + model = LocalModel() + scenario = "normal" + owners = set() + seen = set() + scripts = {} + request_ids = set() + provenance = {} + with patch.object(socket.socket, "connect", side_effect=AssertionError("Remote socket forbidden")) as network: + with redirect_stdout(sys.stderr): + graph = build(model) + while line := sys.stdin.buffer.readline(MAX_REQUEST + 1): + request = {} + try: + if len(line) > MAX_REQUEST or not line.endswith(b"\n"): + raise ValueError("Oversized request") + request = json.loads(line) + if not isinstance(request, dict) or set(request) - {"requestId", "op", "threadId", "scenario", "checkpoint", "messages", "command"}: + raise ValueError("Malformed request") + if type(request.get("requestId")) is not int or request["requestId"] <= 0 or request["requestId"] in request_ids or len(request_ids) >= 2000: + raise ValueError("Invalid/replayed request id or lifetime limit") + request_ids.add(request["requestId"]) + with redirect_stdout(sys.stderr): + config = {"configurable": {"thread_id": request.get("threadId", "")}} + op = request["op"] + fields = {"create": {"threadId"}, "configure": {"scenario"}, "state": {"threadId", "checkpoint"}, + "history": {"threadId"}, "submit": {"threadId", "messages"}, "resume": {"threadId", "command"}} + if op not in fields or set(request) - ({"requestId", "op"} | fields[op]): + raise ValueError("Unsupported operation fields") + if op == "create": + if not isinstance(request.get("threadId"), str) or not request["threadId"] or request["threadId"] in owners or len(owners) >= 100: + raise ValueError("Invalid or duplicate owner") + owners.add(request["threadId"]) + elif op != "configure" and request.get("threadId") not in owners: + raise ValueError("Unknown owner") + if "checkpoint" in request: + requested_checkpoint = request["checkpoint"] + if (not isinstance(requested_checkpoint, dict) + or requested_checkpoint.get("thread_id") != config["configurable"]["thread_id"] + or set(requested_checkpoint) - {"thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map"} + or not isinstance(requested_checkpoint.get("checkpoint_id"), str) + or not requested_checkpoint["checkpoint_id"] + or not isinstance(requested_checkpoint.get("checkpoint_ns"), str)): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + if "checkpoint_map" in requested_checkpoint and ( + not isinstance(requested_checkpoint["checkpoint_map"], dict) + or set(requested_checkpoint["checkpoint_map"]) != {requested_checkpoint["checkpoint_ns"]} + or requested_checkpoint["checkpoint_map"][requested_checkpoint["checkpoint_ns"]] != requested_checkpoint["checkpoint_id"]): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + known = [s async for s in graph.aget_state_history(config)] + if not any(s.config["configurable"].get("checkpoint_id") == requested_checkpoint.get("checkpoint_id") and s.config["configurable"].get("checkpoint_ns", "") == requested_checkpoint.get("checkpoint_ns") for s in known): + raise ValueError("Unknown checkpoint") + config["configurable"].update({k: v for k, v in requested_checkpoint.items() if k != "thread_id"}) + op = request["op"] + if op == "configure": + scenario = request["scenario"] + if scenario not in SCENARIOS: + raise ValueError("Unsupported scenario") + result = {} + elif op == "create": + result = {} + elif op == "state": + result = {"state": checkpoint(await graph.aget_state(config))} + elif op == "history": + result = {"history": [checkpoint(s) async for s in graph.aget_state_history(config)]} + elif op == "submit": + current = await graph.aget_state(config) + if any(t.interrupts for t in current.tasks): + raise ValueError("Cannot submit over paused batch") + messages = request.get("messages") + if not isinstance(messages, list) or len(messages) != 1: + raise ValueError("Expected one human message") + message = messages[0] + if not isinstance(message, dict) or set(message) != {"type", "id", "content"} or message["type"] != "human" or not isinstance(message["id"], str) or not message["id"].strip() or not isinstance(message["content"], str): + raise ValueError("Invalid human message") + identity = message["id"] + if (request["threadId"], identity) in seen: + raise ValueError("Replayed human message") + seen.add((request["threadId"], identity)) + model.responses = responses(batches(scenario, identity), identity) + model.cursor = 0 + provenance[request["threadId"]] = scenario + events = [] + async for kind, event in graph.astream( + {"messages": request["messages"]}, config, + stream_mode=["messages", "values", "updates", "checkpoints"]): + events.append([kind, deepcopy(event)]) + scripts[request["threadId"]] = (model.responses, model.cursor) + result = {"events": events, "state": checkpoint(await graph.aget_state(config))} + elif op == "resume": + from langgraph.types import Command + current = await graph.aget_state(config) + interrupts = [i for t in current.tasks for i in t.interrupts] + command = request.get("command") + if not isinstance(command, dict) or set(command) != {"resume"} or not isinstance(command["resume"], dict) or set(command["resume"]) != {"decisions"} or len(interrupts) != 1: + raise ValueError("No current batch or invalid command") + decisions = command["resume"]["decisions"] + batch = interrupts[0].value + if not isinstance(decisions, list) or len(decisions) != len(batch["action_requests"]): + raise ValueError("Decision count mismatch") + for decision, review in zip(decisions, batch["review_configs"]): + if not isinstance(decision, dict) or set(decision) != {"type"} or decision["type"] not in ("approve", "reject") or decision["type"] not in review["allowed_decisions"]: + raise ValueError("Unsupported decision") + model.responses, model.cursor = scripts[request["threadId"]] + events = [] + async for kind, event in graph.astream(Command(resume=command["resume"]), config, + stream_mode=["messages", "values", "updates", "checkpoints"]): + events.append([kind, deepcopy(event)]) + scripts[request["threadId"]] = (model.responses, model.cursor) + result = {"events": events, "state": checkpoint(await graph.aget_state(config))} + else: + raise ValueError("Unsupported operation") + network.assert_not_called() + result["proof"] = { + "actualCompiledGraph": True, "op": op, + "threadId": request.get("threadId"), "scenario": provenance.get(request.get("threadId"), scenario), + "sourceSha256": {p: hashlib.sha256((PROJECT / p).read_bytes()).hexdigest() for p in SOURCES}, + "networkConnectAttempts": network.call_count, + } + result["requestId"] = request["requestId"] + wire = json.dumps(result, default=encode) + if len(wire.encode()) > MAX_RESPONSE: + raise ValueError("Oversized response") + print(wire, flush=True) + except Exception: + traceback.print_exc(file=sys.stderr) + print(json.dumps({"requestId": (request.get("requestId") if isinstance(request, dict) else None), "error": "Graph operation failed"}), flush=True) + + +asyncio.run(main()) diff --git a/scripts/react-cockpit/serve.mjs b/scripts/react-cockpit/serve.mjs index 66d309608..fd3840e02 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -25,9 +25,11 @@ 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 { createDeepAgentsFilesystemFixture } from './deep-agents-filesystem-fixture.mjs'; import { createDeepAgentsPlanningFixture } from './deep-agents-planning-fixture.mjs'; const configuration = reactCockpitConfiguration(process.argv[2]); +const deepAgentsFilesystemFixture = configuration.library === 'deep-agents' && configuration.topic === 'filesystem' ? createDeepAgentsFilesystemFixture() : null; const deepAgentsPlanningFixture = configuration.library === 'deep-agents' && configuration.topic === 'planning' ? createDeepAgentsPlanningFixture() : null; let chatGenerativeUiFixture = configuration.library === 'chat' && configuration.topic === 'generative-ui' ? createChatGenerativeUiFixture() : null; const chatTimelineFixture = configuration.library === 'chat' && configuration.topic === 'timeline' ? createChatTimelineFixture() : null; @@ -106,6 +108,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (deepAgentsFilesystemFixture && (await deepAgentsFilesystemFixture(request, response, pathname))) return; if (deepAgentsPlanningFixture && (await deepAgentsPlanningFixture(request, response, pathname))) return; if (chatGenerativeUiFixture && pathname === '/__reset') { if (request.method !== 'POST') return json(response, {}, 405); @@ -337,11 +340,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 (deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); await Promise.all([server, parentServer].filter(Boolean).map((owned) => new Promise((resolve) => { owned.close(resolve); owned.closeAllConnections(); diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 43e4092d1..8f5cabf71 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,9 +1,11 @@ { "schemaVersion": 1, - "baselineHead": "dd545f8c04c2803aae25135600a9876bc861a5dc", + "baselineHead": "a3e5452c45369e4caba69cb51b0c1c8b186216c1", "sourceState": { "modified": [ - "apps/website/content/docs/deep-agents/capabilities/planning.mdx" + "apps/website/content/docs/deep-agents/capabilities/filesystem.mdx", + "libs/cockpit-registry/src/lib/capability-registry.ts", + "libs/cockpit-registry/src/lib/content-descriptors.ts" ], "untracked": [] }, @@ -2678,7 +2680,7 @@ "id": "doc:apps/website/content/docs/deep-agents/capabilities/filesystem.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/capabilities/filesystem.mdx", - "sha256": "3a02cf18319cdbebf786b4a0d72d6340ead8d49d2b099119cf257a3649d8229e" + "sha256": "020539311d0b6d3450e36b8eaeb94587bf6f7604368cf652b3b65fd6bde054fa" }, { "id": "doc:apps/website/content/docs/deep-agents/capabilities/memory.mdx", @@ -13463,13 +13465,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "4d4c6981d7c743949415e37996705246726f6742f8d64d70163d162e53b73efd" + "sha256": "b0be0c0dca65b7ba110f6b0412c6dea4048e3fd2cc33ea3e4b5cccf941bc93ac" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "917e033c3eed3e90323c735efa1558a2de0c3cc89846b1f87c02d82bd588f0d6" + "sha256": "b84d95ce712bab88fad1625ebe3ef58e96e9df47f06256dcee1cc4f6c6760930" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",