Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/website/content/docs/deep-agents/capabilities/filesystem.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The Run tab shows the prebuilt `<chat>` 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

Expand Down Expand Up @@ -54,15 +54,15 @@ 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`.

<ExampleCode file="filesystem.component.ts" region="pending-path" title="filesystem.component.ts — the pending path" />

Reading it lets the tree show the file before it lands, so the reviewer sees where the write is headed while deciding.

### 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.

<ExampleCode file="filesystem.component.ts" region="files" title="filesystem.component.ts — the file projection" />

Expand All @@ -82,22 +82,28 @@ Keeping the tree and the approval in one sidebar is the point of the layout: the

### Resuming with a decision

`<chat-interrupt-panel>` 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" }`.
`<chat-interrupt-panel>` 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" }`.

<ExampleCode file="filesystem.component.ts" region="resume" title="filesystem.component.ts — resuming the run" />

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.

<Callout type="warning" title="The resume payload is an object, not a list">
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.
</Callout>

## 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 `..`.

<Callout type="warning" title="Anchor the permission pattern">
Expand Down
241 changes: 241 additions & 0 deletions apps/website/e2e/react-deep-agents-filesystem-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
7 changes: 7 additions & 0 deletions apps/website/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '../..',
Expand Down
3 changes: 2 additions & 1 deletion apps/website/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
],
Expand Down
12 changes: 12 additions & 0 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>);
const article = workspace?.props.reactDocsSlot as React.ReactElement<ElementProps> | 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<never>);
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<never>);
Expand Down
Loading
Loading