diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index 24cea56e7..fee728e2d 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -126,3 +126,5 @@ Element Rendering has an experimental local React preview at https://threadplane The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks. Computed Functions has an experimental local React preview at https://threadplane.ai/docs/render/api/provide-render?frontend=react. Three authored samples preserve native $computed expressions and use RenderSpec.functions for uppercase/reverse text, products and browser-local dates. Only complete expression nodes calculate; numeric/date prefixes never publish provisional results. Heading/Card/Value display resolved props and native children. Pure synchronous callbacks receive owned frozen arguments and may run repeatedly. Play/Pause, seek, Finish and Reset share one bounded private parser owner; sample changes and page exit revoke old callbacks. It makes no backend or model requests. Build with `npx nx build cockpit-render-computed-functions-react` and verify with `npx nx e2e cockpit-render-computed-functions-react`. + +The native Skills React preview at https://threadplane.ai/docs/deep-agents/capabilities/skills?frontend=react shares the existing Python Skills graph and enforced read-only /skills/ mount with Angular. Its prompt index contains skill names, descriptions, and instruction paths; bodies and references load on demand. PrivateStateAttr excludes Skills keys from declared input/output schemas, while the pinned graph exposes metadata in root values and checkpoints. Optional load errors can appear when produced and normal loads can omit them. Its named custom visibility event always includes both keys, using an empty-list fallback for load errors. The React preview distinguishes requested paths from successfully inspected tool results; Angular shows requested paths without result validation. Saved index authority and read evidence are separate, and archived evidence cannot authorize Send. Stop or page exit revokes active work. This Skills contract makes no inference about Memory visibility. Build with `npx nx build cockpit-deep-agents-skills-react` and verify with `npx nx e2e cockpit-deep-agents-skills-react`; React remains a private source-build package. diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index 24cea56e7..fee728e2d 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -126,3 +126,5 @@ Element Rendering has an experimental local React preview at https://threadplane The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks. Computed Functions has an experimental local React preview at https://threadplane.ai/docs/render/api/provide-render?frontend=react. Three authored samples preserve native $computed expressions and use RenderSpec.functions for uppercase/reverse text, products and browser-local dates. Only complete expression nodes calculate; numeric/date prefixes never publish provisional results. Heading/Card/Value display resolved props and native children. Pure synchronous callbacks receive owned frozen arguments and may run repeatedly. Play/Pause, seek, Finish and Reset share one bounded private parser owner; sample changes and page exit revoke old callbacks. It makes no backend or model requests. Build with `npx nx build cockpit-render-computed-functions-react` and verify with `npx nx e2e cockpit-render-computed-functions-react`. + +The native Skills React preview at https://threadplane.ai/docs/deep-agents/capabilities/skills?frontend=react shares the existing Python Skills graph and enforced read-only /skills/ mount with Angular. Its prompt index contains skill names, descriptions, and instruction paths; bodies and references load on demand. PrivateStateAttr excludes Skills keys from declared input/output schemas, while the pinned graph exposes metadata in root values and checkpoints. Optional load errors can appear when produced and normal loads can omit them. Its named custom visibility event always includes both keys, using an empty-list fallback for load errors. The React preview distinguishes requested paths from successfully inspected tool results; Angular shows requested paths without result validation. Saved index authority and read evidence are separate, and archived evidence cannot authorize Send. Stop or page exit revokes active work. This Skills contract makes no inference about Memory visibility. Build with `npx nx build cockpit-deep-agents-skills-react` and verify with `npx nx e2e cockpit-deep-agents-skills-react`; React remains a private source-build package. diff --git a/apps/website/content/docs/deep-agents/capabilities/skills.mdx b/apps/website/content/docs/deep-agents/capabilities/skills.mdx index 3c800a575..d0eb42af1 100644 --- a/apps/website/content/docs/deep-agents/capabilities/skills.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/skills.mdx @@ -1,21 +1,21 @@ --- title: Skills -description: SkillsMiddleware puts only a skill's frontmatter in the system prompt and leaves the body on the backend until a request matches. +description: SkillsMiddleware puts a skill's name, description, and instruction path in the prompt, leaving its body on the backend until requested. --- # Skills -A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a `name` and a `description`, following the [agentskills.io](https://agentskills.io) specification. `SkillsMiddleware` scans a mount for those folders, and puts only the frontmatter into the system prompt: a short entry per skill giving the name, the description, and the path to read for the full instructions. The body stays on the backend until a request matches. That two-stage load is what progressive disclosure means, and the running example is built to make both stages visible. +A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a `name` and a `description`, following the [agentskills.io](https://agentskills.io) specification. `SkillsMiddleware` scans a mount for those folders, and puts a short metadata index into the system prompt: a short entry per skill giving the name, the description, and the path to read for the full instructions. The body stays on the backend until a request matches. That two-stage load is what progressive disclosure means, and the running example is built to make both stages visible. Supported metadata can also contain optional license, compatibility, allowed-tools, and arbitrary metadata annotations; the React index displays these when present. ## What the demo does -The Run tab shows the prebuilt `` composition beside a panel titled Skill Index, which is empty until a run begins. The welcome suggestion asks whether a mid-size jet can operate out of KASE, and the panel fills in before the answer does: both skills are listed, `runway-analysis` and `weather-brief`, each with its description and a line reading "body not read", because both frontmatter blocks are in the prompt and neither body has been opened. +The Run tab shows the prebuilt `` composition beside a panel titled Skill Index, which is empty until a run begins. The welcome suggestion asks whether a mid-size jet can operate out of KASE, and the panel fills in before the answer does: both skills are listed, `runway-analysis` and `weather-brief`, each with its description and a line reading "body not read", because both metadata entries are in the prompt and no read has yet been requested. -Then exactly one skill opens. `runway-analysis` picks up an outline, the path of its `SKILL.md` appears under it, and the path of the margin table that the `SKILL.md` sends the agent to appears a step later. The weather skill stays closed for the whole run. It is in the index so that a different question, the conditions at KSFO for example, has somewhere else to route. +For the welcome scenario, the agent requests one skill. `runway-analysis` picks up an outline, the path of its `SKILL.md` appears under it, and the path of the margin table that the `SKILL.md` sends the agent to appears a step later. The Angular weather card has no matching requested path in the welcome scenario; this does not establish that its body was absent from all model context. It is in the index so that a different question, the conditions at KSFO for example, has somewhere else to route. ## How it is built -Three files carry the capability: a Python graph that seeds the skill mount, builds the agent, and republishes the middleware's private state, `skills_metadata` and `skills_load_errors`, an application config, and the Angular component that renders the panel. Open the Code tab to read them in place. +Both the Angular and React examples share the Python graph and `skills_readonly.py` enforcement module. The graph seeds the skill mount, builds the agent, and announces `skills_metadata` and `skills_load_errors`. Angular renders an index and requested paths; the React preview additionally distinguishes successfully inspected reads from requests, failures, and unavailable evidence. Open the Code tab to read the selected frontend alongside the complete shared backend. ### The names the graph and the panel agree on @@ -25,7 +25,7 @@ The mount point, the store namespace, and the name of the custom stream event ar ### A skill on disk -The frontmatter is the part the model sees first, so the `description` is written as a matching rule rather than as a summary, and its `name` must match the directory the file sits in, which is what makes the folder the unit rather than the file. This is the whole of `skills/runway-analysis/SKILL.md`: +The name, description, and instruction path form the index the model sees first, so the `description` is written as a matching rule rather than as a summary, and its `name` must match the directory the file sits in, which is what makes the folder the unit rather than the file. This is the whole of `skills/runway-analysis/SKILL.md`: ```markdown --- @@ -57,7 +57,7 @@ Always name the margin you applied. A verdict without the margin is not reviewable. ``` -Step 2 is the second stage of the disclosure: the margin table costs nothing until the `SKILL.md` sends the agent to it. +Step 2 is the second stage of disclosure: the margin table body is loaded only when requested. Scanning the metadata index itself still requires backend reads. ### Seeding the skill mount @@ -65,7 +65,9 @@ Step 2 is the second stage of the disclosure: the margin table costs nothing unt -Nothing in the demo writes to the store after import, so `/skills/` stays stable for the whole run, even though the machinery serving it is the same `StoreBackend` a writable mount would use. +The seed uses a writable `StoreBackend` only at import. Runtime access goes through `ReadOnlySkillsBackend`, which denies writes, edits, and uploads synchronously and asynchronously. This keeps the shared `/skills/` mount stable across threads; the default `StateBackend` remains writable for thread-local notes. Merely asking the model not to write would not enforce this boundary. + + Seed the store at `/runway-analysis/SKILL.md`, not `/skills/runway-analysis/SKILL.md`. The composite removes the matched prefix before delegating and re-adds it to the paths it returns, so a store seeded with the prefix surfaces to the agent as `/skills/skills/runway-analysis/...` and the scan finds nothing. @@ -85,20 +87,20 @@ The middleware appends its own guidance to the system message, including an inst You are a dispatcher. Your procedures are not in this prompt — they are skills on your filesystem under `/skills/`, and each one is a folder with a `SKILL.md`. -You have been given the name and description of every skill up front. That index +You have been given the name, description, and instruction path of every skill up front. That index is deliberately short. When a request matches a skill, read its `SKILL.md` with `read_file` before you start, and follow the procedure it gives you. If the `SKILL.md` points at another file, read that too — the numbers in a reference file are the authority, and your recollection is not. ``` -### Announcing the private state keys +### Announcing Skills metadata -`SkillsMiddleware` declares `skills_metadata` and `skills_load_errors` annotated with `PrivateStateAttr`, so the loaded index is deliberately absent from the agent's declared input and output and never reaches the `values` stream. A panel bound to the settled state alone would therefore stay empty until the run finished. A small middleware announces both keys on a channel the client does receive while the run is going, guarded because `get_stream_writer` raises outside a streaming context. +`SkillsMiddleware` annotates `skills_metadata` and `skills_load_errors` with `PrivateStateAttr`, excluding them from the declared input and output schemas. That does not hide the keys from every stream: the pinned Skills graph exposes `skills_metadata` in root `values` snapshots and saved checkpoints. The optional `skills_load_errors` key can appear when load errors are produced, and normal successful loads can omit it. `SkillsVisibilityMiddleware` always includes both keys in its named `custom` event, using an empty-list fallback for load errors and guarded because `get_stream_writer` raises outside a streaming context. This application event provides an explicit live signal; it is not proof that root values omit Skills metadata. -This is the same application-side shim the [memory capability](/docs/deep-agents/capabilities/memory) uses, and it is worth naming as such rather than mistaking it for a framework feature. +This is an application-side visibility event rather than a framework feature. The Skills contracts establish this behavior for the pinned Skills graph; they make no inference about the separate Memory capability. ### Providing the agent @@ -108,43 +110,43 @@ This is the same application-side shim the [memory capability](/docs/deep-agents ### Two sources for one index -A custom event is a live signal and is not replayed when a thread is reopened. The thread state is durable but arrives only when the client hydrates it. A panel that wants the index during the run and after a reload reads both. +A custom event is a live signal and is not replayed when a thread is reopened. Root values can also carry the index during a run, and the saved checkpoint carries it for hydration. The Angular panel prefers the named custom event and otherwise reads `agent.value()`. Its `checkpoint` label identifies that fallback, without proving the value came only from a reopened thread. -`agent.customEvents()` holds the custom events of the current run, cleared when a new run starts, so the live reader walks it newest first and takes the most recent payload under the event name the graph emits, while `agent.value()` is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not. +`agent.customEvents()` holds the custom events of the current run, cleared when a new run starts, so the live reader walks it newest first and takes the most recent payload under the event name the graph emits, while `agent.value()` is the agent state the adapter projects from the latest checkpoint, which may include the key from root values or a checkpoint. -### What the agent actually opened +### Requested paths and successfully inspected reads -The label the panel shows distinguishes the two sources rather than blending them: `live` means the visibility middleware announced the index during this run, and `checkpoint` is what a reopened thread looks like. The other half of the panel needs no shim at all. Reading a skill is a `read_file` call, an ordinary tool call on the runtime-neutral `agent.toolCalls()` Signal, so the paths are already on the client. +The Angular `live` label means the visibility middleware announced the index; `checkpoint` means the value fallback supplied it. Its path projection reads `read_file` arguments from the runtime-neutral `agent.toolCalls()` Signal. These are requested paths: a failed read can still appear, and the panel does not inspect the result or establish successful body disclosure. The React preview separates requests from successfully inspected matching tool results and exposes failed or unavailable evidence explicitly. -Each entry of `skills_metadata` carries the `path` of its `SKILL.md`, and everything before the last slash of that path is the skill's directory, so matching the opened paths against that prefix is what attributes a read to a skill and is why the margin table counts as opening `runway-analysis` rather than as an unrelated file read. +Each metadata entry carries its `SKILL.md` path. Angular derives the directory prefix and attributes requested paths under it to that skill. This prefix match associates the margin-table request with `runway-analysis`; it does not validate returned content. React validates canonical paths and matching successful tool results before marking content inspected. ### The panel -The sidebar renders the source label, then one card per skill: the name, the description the model was given, and either the paths read or the words "body not read". An opened card carries `data-opened="true"`, which is what draws the outline. +The sidebar renders the source label, then one card per skill: the name, the description the model was given, and either the requested paths or the words "body not read". An opened card carries `data-opened="true"`, which is what draws the outline. -Rendering both halves in one column is the point, because the index is what the prompt paid for and the read paths are what the request actually cost. +In Angular, "body not read" means no matching read request was observed. It is not evidence that the body was absent from all model context. In React, read evidence is independent of index authority: archived evidence does not authorize another Send, and body content is shown as inspected only after a successful matching result. ## The skill that stays closed -The strongest evidence that a skills setup is working is the skill that does not get opened. If every skill's files are read on every request, the index is not routing anything and the descriptions are doing no work. That makes the descriptions, not the procedures, the part to iterate on: they are the only text the model sees before it chooses. +A useful routing check is that an unrelated skill has no matching read request or successful result in the observed root run. If every skill's files are read on every request, the index is not routing anything and the descriptions are doing no work. That makes the descriptions, not the procedures, the part to iterate on: they guide selection alongside the name and instruction path in the index. -A test that only checks that `runway-analysis` was read passes just as happily when the agent reads everything. The example's end-to-end test asserts the negative too: `weather-brief` still carries `data-opened="false"` when the run is over. +The Angular end-to-end test checks both markers: `runway-analysis` has a matching requested path and `weather-brief` carries `data-opened="false"` when the run is over. The negative means no matching weather path request was observed; it does not prove body absence from model context or successful inspection of the selected skill. ## What's Next - The same private-state visibility problem, for `memory_contents`. + Explore the separate Memory capability and its own visibility contract. The backends the skill mount is assembled from, and approval on writes. diff --git a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx index 6a810c2c0..f851ce776 100644 --- a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx +++ b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx @@ -60,7 +60,7 @@ export class App { } ``` -What changes from capability to capability is not the provider and not the component. It is which part of the agent handle each panel reads: `value()` for state keys, `subagents()` for dispatches, `customEvents()` for keys the framework keeps private, `toolCalls()` for what the agent actually opened. +What changes from capability to capability is not the provider and not the component. It is which part of the agent handle each panel reads: `value()` for state keys, `subagents()` for dispatches, `customEvents()` for application visibility signals, `toolCalls()` for read requests and their results. ## Three ways a capability reaches the browser @@ -70,9 +70,7 @@ Deep Agents does not publish all five capabilities the same way, and the differe **Tool calls are already structured.** The subagent `task` tool and the filesystem `read_file` tool arrive as normal tool calls. The subagent tracker recognizes `task` by default, so child agents render as cards with no client configuration at all. -**Private state keys do not stream.** `memory_contents` and `skills_metadata` are annotated `PrivateStateAttr` by their middleware. That annotation keeps them out of the `values` stream by design — they are context for the model, not transcript — and it means a panel bound to `agent.value()` shows nothing while the agent is working. Those keys are written to the checkpoint, so they arrive at settle, but a live panel needs the graph to announce them on a channel the client does receive. The memory and skills pages show the small middleware that does it, and say plainly that it is an application-side shim rather than a framework feature. - -That last row is a real constraint of the framework as it stands, not an oversight in the demos. It is stated on both pages it affects. +**Private schema annotations need a capability-specific contract.** `PrivateStateAttr` excludes annotated keys from declared input and output schemas; it does not establish that every root stream omits them. The pinned Skills graph exposes `skills_metadata` in root values and saved checkpoints. The optional `skills_load_errors` key can appear there when load errors are produced; normal successful loads can omit it. A named application-side custom visibility event always includes both keys, using an empty-list fallback for load errors. Its page describes that verified behavior. Memory is a separate capability: the Skills tests do not establish its `memory_contents` visibility. ## The demos diff --git a/apps/website/e2e/react-deep-agents-skills-preview.spec.ts b/apps/website/e2e/react-deep-agents-skills-preview.spec.ts new file mode 100644 index 000000000..b74cc0003 --- /dev/null +++ b/apps/website/e2e/react-deep-agents-skills-preview.spec.ts @@ -0,0 +1,317 @@ +import { test, expect, type Page } from '@playwright/test'; +import { fromPageOrReactPreview } from './fixtures/react-preview-requests'; +const route = '/docs/deep-agents/capabilities/skills'; +const reactFrame = /(?:localhost:4631|deep-agents\/skills\/react)/; +const observations = new WeakMap< + Page, + { requests: string[]; errors: string[] } +>(); +test.describe('React Deep Agents Skills', () => { + 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('React Deep Agents Skills 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 Skills preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/deep-agents/skills/react/src/application.ts"]' + ) + ).toBeVisible(); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-skills-docs-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-skills-docs-mobile.png'), + }); + await page.setViewportSize({ width: 1280, height: 720 }); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: 'Code', exact: true }) + .click(); + await hydrated(page, 'code'); + for (const name of [ + 'app.tsx', + 'application.ts', + 'connection.ts', + 'authority.ts', + 'index-projection.ts', + 'read-projection.ts', + 'main.tsx', + 'styles.css', + 'project.json', + 'skills_readonly.py', + ]) + await expect( + page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name, exact: true }) + ).toBeVisible(); + await expect(page.getByRole('tabpanel')).toContainText( + 'createConnectedApplication' + ); + const tree = page.getByRole('complementary', { name: 'File tree' }); + await expect( + tree.getByRole('button', { name: 'SKILL.md', exact: true }) + ).toHaveCount(2); + await tree.getByRole('button', { name: 'margins.md', exact: true }).click(); + await expect(page.getByRole('tabpanel')).toContainText('4,800'); + await expect(page.getByRole('tabpanel')).toContainText('14%'); + await expect( + page.getByRole('complementary', { name: 'File tree' }) + ).not.toContainText(/fixture|wire\.py|\.spec\.|proof/); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-skills-code-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-skills-code-mobile.png'), + }); + await page.setViewportSize({ width: 1280, height: 720 }); + const graph = page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name: 'graph.py', exact: true }); + await graph.click(); + await expect(page.getByRole('tabpanel')).toContainText('create_deep_agent'); + await expect(page).toHaveURL(/(?:\?|&)mode=code(?:&|$)/); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: /^Run(?:,|$)/ }) + .click(); + await emptyRun(page); + await noOverflow(page); + expect( + await page + .frameLocator('iframe') + .locator('html') + .evaluate((element) => element.scrollWidth <= element.clientWidth) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-skills-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-skills-run-mobile.png'), + }); + }); + test('the canonical Skills 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 Skills preview', + exact: true, + }) + ).toHaveCount(0); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Skills 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: 'Mid-size jet at KASE', exact: true }) + .click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue( + 'Can a mid-size jet operate out of KASE?' + ); + await frame + .getByRole('button', { name: 'New conversation', exact: true }) + .click(); + await emptyRun(page); + }); + + test('frontend changes release the old React frame and return to an empty inert owner', async ({ + page, + }) => { + await page.goto(route + '?frontend=react&mode=run', { + waitUntil: 'domcontentloaded', + }); + await emptyRun(page); + const oldFrame = await page.locator('iframe').elementHandle(); + await page + .frameLocator('iframe') + .getByLabel('Message', { exact: true }) + .fill('Old frontend draft'); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: 'Docs', exact: true }) + .click(); + await hydrated(page, 'docs'); + await page.getByLabel('Example UI').selectOption('angular'); + await expect(page.getByLabel('Example UI')).toHaveValue('angular'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Skills preview', + exact: true, + }) + ).toHaveCount(0); + expect(await oldFrame?.evaluate((frame) => frame.isConnected)).toBe(false); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Skills preview', + exact: true, + }) + ).toBeVisible(); + await page + .locator('[data-workspace-desktop-navigation]') + .getByRole('button', { name: /^Run(?:,|$)/ }) + .click(); + await emptyRun(page); + expect( + await page + .locator('iframe') + .evaluate((frame, original) => frame === original, oldFrame) + ).toBe(false); + }); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 5e67a75c7..799dd1405 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -423,6 +423,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'npx nx run cockpit-deep-agents-skills-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-skills --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4631', + 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 499e10928..ca3127931 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -105,7 +105,8 @@ "cockpit-chat-generative-ui-react", "cockpit-deep-agents-planning-react", "cockpit-deep-agents-filesystem-react", - "cockpit-deep-agents-subagents-react" + "cockpit-deep-agents-subagents-react", + "cockpit-deep-agents-skills-react" ] } ], diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 2b4a88cb3..62ce6f017 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -126,3 +126,5 @@ Element Rendering has an experimental local React preview at https://threadplane The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks. Computed Functions has an experimental local React preview at https://threadplane.ai/docs/render/api/provide-render?frontend=react. Three authored samples preserve native $computed expressions and use RenderSpec.functions for uppercase/reverse text, products and browser-local dates. Only complete expression nodes calculate; numeric/date prefixes never publish provisional results. Heading/Card/Value display resolved props and native children. Pure synchronous callbacks receive owned frozen arguments and may run repeatedly. Play/Pause, seek, Finish and Reset share one bounded private parser owner; sample changes and page exit revoke old callbacks. It makes no backend or model requests. Build with `npx nx build cockpit-render-computed-functions-react` and verify with `npx nx e2e cockpit-render-computed-functions-react`. + +The native Skills React preview at https://threadplane.ai/docs/deep-agents/capabilities/skills?frontend=react shares the existing Python Skills graph and enforced read-only /skills/ mount with Angular. Its prompt index contains skill names, descriptions, and instruction paths; bodies and references load on demand. PrivateStateAttr excludes Skills keys from declared input/output schemas, while the pinned graph exposes metadata in root values and checkpoints. Optional load errors can appear when produced and normal loads can omit them. Its named custom visibility event always includes both keys, using an empty-list fallback for load errors. The React preview distinguishes requested paths from successfully inspected tool results; Angular shows requested paths without result validation. Saved index authority and read evidence are separate, and archived evidence cannot authorize Send. Stop or page exit revokes active work. This Skills contract makes no inference about Memory visibility. Build with `npx nx build cockpit-deep-agents-skills-react` and verify with `npx nx e2e cockpit-deep-agents-skills-react`; React remains a private source-build package. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 2b4a88cb3..62ce6f017 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -126,3 +126,5 @@ Element Rendering has an experimental local React preview at https://threadplane The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks. Computed Functions has an experimental local React preview at https://threadplane.ai/docs/render/api/provide-render?frontend=react. Three authored samples preserve native $computed expressions and use RenderSpec.functions for uppercase/reverse text, products and browser-local dates. Only complete expression nodes calculate; numeric/date prefixes never publish provisional results. Heading/Card/Value display resolved props and native children. Pure synchronous callbacks receive owned frozen arguments and may run repeatedly. Play/Pause, seek, Finish and Reset share one bounded private parser owner; sample changes and page exit revoke old callbacks. It makes no backend or model requests. Build with `npx nx build cockpit-render-computed-functions-react` and verify with `npx nx e2e cockpit-render-computed-functions-react`. + +The native Skills React preview at https://threadplane.ai/docs/deep-agents/capabilities/skills?frontend=react shares the existing Python Skills graph and enforced read-only /skills/ mount with Angular. Its prompt index contains skill names, descriptions, and instruction paths; bodies and references load on demand. PrivateStateAttr excludes Skills keys from declared input/output schemas, while the pinned graph exposes metadata in root values and checkpoints. Optional load errors can appear when produced and normal loads can omit them. Its named custom visibility event always includes both keys, using an empty-list fallback for load errors. The React preview distinguishes requested paths from successfully inspected tool results; Angular shows requested paths without result validation. Saved index authority and read evidence are separate, and archived evidence cannot authorize Send. Stop or page exit revokes active work. This Skills contract makes no inference about Memory visibility. Build with `npx nx build cockpit-deep-agents-skills-react` and verify with `npx nx e2e cockpit-deep-agents-skills-react`; React remains a private source-build package. 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 13f7d54a8..aec6799ef 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 Skills-specific native React Docs and exact sources while retaining Angular Docs', async () => { + const tree = await route('deep-agents', 'capabilities', 'skills'); + 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('ReactDeepAgentsSkillsPreview'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/skills/react/src/read-projection.ts'); + expect(article?.props.exampleCode?.sources?.['cockpit/deep-agents/skills/react/src/authority.ts']).toContain('checkpoint'); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths.some(path => path.includes('/skills/angular/'))).toBe(true); + expect(article?.props.exampleCode?.assetPaths.some(path => /fixture|wire\.py|\.spec\.|proof/.test(path))).toBe(false); +}); it('selects Subagents-specific native React Docs and exact sources while retaining Angular Docs', async () => { const tree = await route('deep-agents', 'capabilities', 'subagents'); const workspace = findElement(tree, WebsiteWorkspace as ComponentType); 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 c91023c93..e09f1de71 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 { ReactDeepAgentsSkillsPreview } from '../../../../../components/docs/ReactDeepAgentsSkillsPreview'; import { ReactDeepAgentsSubagentsPreview } from '../../../../../components/docs/ReactDeepAgentsSubagentsPreview'; import { ReactDeepAgentsFilesystemPreview } from '../../../../../components/docs/ReactDeepAgentsFilesystemPreview'; import { ReactDeepAgentsPlanningPreview } from '../../../../../components/docs/ReactDeepAgentsPlanningPreview'; @@ -285,11 +286,13 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents'].includes(slug)) || + (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents', 'skills'].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 === 'subagents' ? ( + library === 'deep-agents' && section === 'capabilities' && slug === 'skills' ? ( + + ) : library === 'deep-agents' && section === 'capabilities' && slug === 'subagents' ? ( ) : library === 'deep-agents' && section === 'capabilities' && slug === 'filesystem' ? ( diff --git a/apps/website/src/components/docs/ReactDeepAgentsSkillsPreview.tsx b/apps/website/src/components/docs/ReactDeepAgentsSkillsPreview.tsx new file mode 100644 index 000000000..e49dc06b0 --- /dev/null +++ b/apps/website/src/components/docs/ReactDeepAgentsSkillsPreview.tsx @@ -0,0 +1,65 @@ +import { MdxRenderer } from './MdxRenderer'; +import type { ExampleCodeContext } from '../../lib/example-code'; +const source = `# React Deep Agents Skills preview + +This experimental example composes native React chat components with a Skills index and literal skill read observations. Angular remains the default on this canonical guide. Both frontends share the same Python graph, Skills prompt and FREE runtime. + +## Start an assessment + +Choose **Run**, type a message and choose **Send** or press **Enter**. **Mid-size jet at KASE** fills this exact draft without sending: + +> Can a mid-size jet operate out of KASE? + +Aviation examples use fixed sample data. Only explicit Send creates the owned thread, lazily, using the native selector **da-skills**. Opening Docs or Run, changing frontend or mode, navigating browser history, filling suggestions and choosing **New conversation** do not author a run. Mode navigation and browser history preserve the mounted frame and unsent draft. Reload and New clear the draft and local view. + + + + +## Inspect the index and actual reads + +The index exposes names, descriptions, virtual instruction paths and optional support annotations from root **skills_metadata**. Declared input/output schema exclusions do not promise privacy: actual root stream values and checkpoints contain metadata. Metadata alone does not establish that instructions were read. Both runway-analysis and weather-brief remain index evidence even when only one has associated reads. + +**Observed root values** identifies live index evidence. **Confirmed checkpoint** requires matching current root state, fresh history head, exact checkpoint and the native transcript/load. Other actual root fields remain part of this authority. + + + + +**Inspected** requires an associated raw read_file call with supported arguments and an actual successful tool result. Instruction and reference results remain literal text under **Literal read result**. Inspection does not claim the whole file was read. **Partially inspected**, **Read failed**, **Read pending**, **Read status unknown** and **Read evidence unavailable** distinguish incomplete evidence. A requested path or model prose alone cannot establish successful inspection. The Angular guide retains its requested-path marker behavior. + + + +## Save, restore and stop + +**Response saved.** requires native success and exact fresh root authority. A resolved tool error can still belong to a saved response while the read remains failed. Unsupported data or uncertain confirmation shows a notice and disables Send until New. Display and raw capture limits bound the aggregate conversation before composing prior and current observations; oversized evidence remains unavailable. + +**Saved conversations** restores only an exact retained local checkpoint and transcript. Choosing a saved conversation performs reads without authoring a run. **New conversation** clears the active draft and view; it retains locally confirmed entries until reload. **Stop** cancels local delivery; backend work may continue. There is no automatic retry, replay or Resume. Stale owner or generation results cannot restore an older view. + + + +## Shared backend and source build + +Skills instructions and references are mounted read-only through the shared adapter; default thread-local notes remain writable. The complete shared Python source includes the graph, adapter, and bundled instruction and reference assets in Code. The graph and adapter are shown here: + + + + +~~~sh +npx nx build cockpit-deep-agents-skills-react +npx nx e2e cockpit-deep-agents-skills-react +~~~ + +Browser verification uses the actual compiled Python graph, installed native SDK and deterministic model. Fixtures, proof endpoints and verification controls are excluded from public Code and the production frontend. +`; +export function ReactDeepAgentsSkillsPreview({ + 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 ce171996d..ab644f665 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -1,8 +1,10 @@ +import { getExampleCodeContext, getWebsiteWorkspacePage } from './workspace-page'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { capabilityModules, getFrontendCapabilityDescriptor } from '@threadplane/cockpit-registry'; import { describe, expect, it } from 'vitest'; import { + fenceFor, resolveExampleFile, sliceRegion, type ExampleCodeContext, @@ -197,3 +199,44 @@ describe('docs pages teach through their example', () => { expect(offenders, 'docs-only pages must not use ').toEqual([]); }); }); + +it('resolves every native Skills article include and the complete read-only backend', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-skills-python')!; + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react')!; + expect(react).toBeDefined(); + 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/ReactDeepAgentsSkillsPreview.tsx'), 'utf8'); + expect(includesIn(article)).toHaveLength(8); + for (const include of includesIn(article)) { + expect(resolveExampleFile(include.file, context)).toBe(include.file); + expect(context.sources[include.file]?.length).toBeGreaterThan(0); + } + expect(page.assetPaths).toContain('cockpit/deep-agents/skills/python/src/skills_readonly.py'); + 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('loads every bundled Skills Markdown asset as literal code for both frontends', async () => { + const model = await getWebsiteWorkspacePage({ docsPath: '/docs/deep-agents/capabilities/skills', title: 'Skills' }); + const react = model.frontendVariants?.react; + expect(react).toBeDefined(); + if (!react) throw new Error('Missing native Skills workspace'); + const markdown = ['skills/runway-analysis/SKILL.md', 'skills/runway-analysis/reference/margins.md', 'skills/weather-brief/SKILL.md'].map(file => `cockpit/deep-agents/skills/python/${file}`); + const backend = ['src/graph.py', 'src/skills_readonly.py', ...markdown.map(path => path.replace('cockpit/deep-agents/skills/python/', ''))].map(file => `cockpit/deep-agents/skills/python/${file}`); + for (const variant of [model, react]) { + const context = getExampleCodeContext(variant); + expect(context).not.toBeNull(); + if (!context) throw new Error('Missing Skills source context'); + expect(variant.presentation.kind).toBe('capability'); + if (variant.presentation.kind !== 'capability') throw new Error('Missing Skills capability'); + expect(variant.presentation.backendAssetPaths).toEqual(backend); + for (const path of markdown) { + const literal = readFileSync(join(WORKSPACE_ROOT, path), 'utf8'); + expect(resolveExampleFile(path, context)).toBe(path); + expect(context.sources[path]).toBe(literal); + expect(variant.contentBundle.codeFiles[path]).not.toContain('File not found:'); + expect(fenceFor(context.sources[path], path)).toMatch(/^`{3,}md\n/); + } + } +}); diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 55f0938c6..6ae0b33a1 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(31); - expect(new Set(prerequisites).size).toBe(31); + expect(prerequisites).toHaveLength(32); + expect(new Set(prerequisites).size).toBe(32); expect([...prerequisites].sort()).toEqual( getCockpitFrontends() .filter(frontend => frontend.frontend === 'react') @@ -59,19 +59,19 @@ describe('Website Playwright configuration', () => { const project = JSON.parse(readFileSync(resolve(import.meta.dirname,'../project.json'),'utf8')); expect(project.implicitDependencies).toContain('cockpit-ag-ui-tool-views-angular'); }); - it('starts thirty-one owned React servers after exact fresh Nx prerequisites', () => { + it('starts thirty-two owned React servers after exact fresh Nx prerequisites', () => { const config = createWebsitePlaywrightConfig({ CI: 'true' }); const servers = Array.isArray(config.webServer) ? config.webServer : []; - const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|30)$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(31); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|3[01])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(32); 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(31); - expect(new Set(projects).size).toBe(31); + expect(projects).toHaveLength(32); + expect(new Set(projects).size).toBe(32); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -90,6 +90,8 @@ describe('Website Playwright configuration', () => { expect(projects).toContain('cockpit-deep-agents-planning-react'); expect(projects).toContain('cockpit-deep-agents-filesystem-react'); expect(projects).toContain('cockpit-deep-agents-subagents-react'); + expect(projects).toContain('cockpit-deep-agents-skills-react'); + expect(reactServers.find(server => server.url === 'http://127.0.0.1:4631')?.command).toBe('npx nx run cockpit-deep-agents-skills-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-skills --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4630')?.command).toBe('npx nx run cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4629')?.command).toBe('npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4628')?.command).toBe('npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent'); @@ -379,6 +381,7 @@ describe('Website Playwright configuration', () => { expect.objectContaining({command:'npx nx run cockpit-deep-agents-planning-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-planning --no-parent',url:'http://127.0.0.1:4628'}), expect.objectContaining({command:'npx nx run cockpit-deep-agents-filesystem-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-filesystem --no-parent',url:'http://127.0.0.1:4629'}), expect.objectContaining({command:'npx nx run cockpit-deep-agents-subagents-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-subagents --no-parent',url:'http://127.0.0.1:4630'}), + expect.objectContaining({command:'npx nx run cockpit-deep-agents-skills-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-skills --no-parent',url:'http://127.0.0.1:4631'}), 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/skills/angular/e2e/da-skills.spec.ts b/cockpit/deep-agents/skills/angular/e2e/da-skills.spec.ts index 11d7960cc..a9e687ba3 100644 --- a/cockpit/deep-agents/skills/angular/e2e/da-skills.spec.ts +++ b/cockpit/deep-agents/skills/angular/e2e/da-skills.spec.ts @@ -40,8 +40,8 @@ test('da-skills: the index loads up front and only the matching skill is opened' await expect(opened.nth(1)).toHaveText('/skills/runway-analysis/reference/margins.md'); // The answer used the margin table rather than a guess: 4,800 x (1 + 0.14 x - // 7.82) rounds to 10,057 ft, which is longer than KASE's 8,006 ft runway. + // 7.82) rounds to 10,055 ft, which is longer than KASE's 8,006 ft runway. const finalText = await bubble.innerText(); - expect(finalText).toMatch(/10,?057/); + expect(finalText).toMatch(/10,?055/); expect(finalText.toLowerCase()).toContain('runway-analysis'); }); diff --git a/cockpit/deep-agents/skills/angular/e2e/fixtures/da-skills.json b/cockpit/deep-agents/skills/angular/e2e/fixtures/da-skills.json index 7cb19d5ca..c4ad31b1a 100644 --- a/cockpit/deep-agents/skills/angular/e2e/fixtures/da-skills.json +++ b/cockpit/deep-agents/skills/angular/e2e/fixtures/da-skills.json @@ -52,7 +52,7 @@ "turnIndex": 4 }, "response": { - "content": "A mid-size jet requires 10,057 ft of runway at KASE (Aspen), but the longest available is only 8,006 ft. \n\nMargin applied: mid-size jet, 4,800 ft sea-level balanced field, 14% penalty per 1,000 ft elevation. Verdict: The runway is not long enough for a mid-size jet at this elevation. (Used: runway-analysis skill)" + "content": "A mid-size jet requires 10,055 ft of runway at KASE (Aspen), but the longest available is only 8,006 ft. \n\nMargin applied: mid-size jet, 4,800 ft sea-level balanced field, 14% penalty per 1,000 ft elevation. Verdict: The runway is not long enough for a mid-size jet at this elevation. (Used: runway-analysis skill)" } }, { diff --git a/cockpit/deep-agents/skills/angular/prompts/skills.md b/cockpit/deep-agents/skills/angular/prompts/skills.md index 94f25cbf1..6846199a3 100644 --- a/cockpit/deep-agents/skills/angular/prompts/skills.md +++ b/cockpit/deep-agents/skills/angular/prompts/skills.md @@ -1,5 +1,7 @@ # Deep Agents Skills (Angular) -This capability shows progressive disclosure happening. A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a name and a description; `SkillsMiddleware` puts only that frontmatter in the system prompt and leaves the body on the filesystem until a request matches. A reference file inside the skill stays unread until the `SKILL.md` sends the agent to it. +SkillsMiddleware puts a metadata index — name, description, and instruction path — in the system prompt. Skill bodies and references are loaded through read_file when requested. The shared /skills/ mount is enforced read-only; thread-local notes remain writable. -The sidebar therefore has two halves. The index comes from `skills_metadata`, which is annotated `PrivateStateAttr` and so never reaches the `values` stream — the graph republishes it as a `custom` stream event, and the checkpoint covers a reopened thread. The files the agent actually opened come from the ordinary tool-call stream, because `read_file` is not private at all. +PrivateStateAttr excludes Skills metadata and load errors from declared input/output schemas. In the pinned graph these keys can still appear in root values and checkpoints. A named custom event is an additional live source. The Angular panel prefers that event and falls back to agent.value(); its checkpoint label names the fallback rather than proving a reopened thread. + +The Angular path projection uses read_file arguments only: it shows requested paths, including failed requests, without validating tool results. Its body not read label means no matching request was observed. The React preview separately marks successfully inspected results. This Skills contract makes no inference about Memory. diff --git a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts index 76095dfa1..e0ce9804a 100644 --- a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts +++ b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts @@ -11,9 +11,9 @@ interface SkillEntry { name: string; description: string; path: string; - /** Directory the skill lives in, used to match files the agent opened. */ + /** Directory prefix used to attribute requested file paths. */ root: string; - /** Files under this skill the agent has read during the run. */ + /** Requested paths under this skill; results are not inspected here. */ opened: string[]; } @@ -29,19 +29,20 @@ const SUGGESTIONS = [ /** * SkillsComponent shows progressive disclosure happening. * - * `SkillsMiddleware` loads only each SKILL.md's frontmatter — a name and a - * description — into the system prompt. The body stays on the filesystem until - * a request matches, and a reference file inside the skill stays unread until - * the SKILL.md sends the agent to it. The panel therefore has two halves: the - * index the model was given up front, and the files it actually opened. + * SkillsMiddleware puts name, description, and instruction path in the prompt. + * The panel displays that index and paths requested through read_file. * - * The index is not on the `values` stream: `skills_metadata` is annotated - * `PrivateStateAttr`. The graph republishes it as a `custom` stream event, and - * the checkpoint carries it for a reopened thread — the same two-source pattern - * as the memory capability. + * PrivateStateAttr excludes Skills keys from declared input/output schemas; + * the pinned graph exposes metadata in root values and checkpoints. Optional + * load errors can appear when produced and normal loads can omit them. The named + * custom event always includes both keys, using an empty-list error fallback. + * The panel prefers that event and falls back to agent.value(). Its checkpoint + * label identifies that fallback, without proving reopened-thread provenance. * - * The opened files come from the ordinary tool-call stream, because `read_file` - * is not private at all. + * Path attribution uses arguments only and does not inspect successful results. + * A failed read can appear; body not read means no matching request was observed. + * The React preview separately validates inspected reads. No Memory visibility + * behavior is inferred from this Skills contract. */ @Component({ selector: 'app-skills', @@ -212,7 +213,7 @@ export class SkillsComponent { // #endregion // #region opened-paths - /** Absolute paths the agent has read with `read_file` during this run. */ + /** Requested read_file paths, without validating completion or returned content. */ private readonly openedPaths = computed(() => { const paths: string[] = []; for (const call of this.agent.toolCalls()) { diff --git a/cockpit/deep-agents/skills/python/project.json b/cockpit/deep-agents/skills/python/project.json index a15274dd2..704fbb283 100644 --- a/cockpit/deep-agents/skills/python/project.json +++ b/cockpit/deep-agents/skills/python/project.json @@ -19,6 +19,17 @@ "executor": "nx:run-commands", "options": { "command": "npx tsx -e \"import { deepAgentsSkillsPythonModule } from './cockpit/deep-agents/skills/python/src/index.ts'; const mod = deepAgentsSkillsPythonModule; if (mod.id !== 'deep-agents-skills-python') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Skills (Python)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" + }, + "dependsOn": [ + "test" + ] + }, + "test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "cwd": "cockpit/deep-agents/skills/python", + "command": "uv run --frozen --python 3.12 python -m unittest discover -s tests" } } }, diff --git a/cockpit/deep-agents/skills/python/prompts/skills.md b/cockpit/deep-agents/skills/python/prompts/skills.md index 2bc405fdc..65a1a6f7d 100644 --- a/cockpit/deep-agents/skills/python/prompts/skills.md +++ b/cockpit/deep-agents/skills/python/prompts/skills.md @@ -3,7 +3,7 @@ You are a dispatcher. Your procedures are not in this prompt — they are skills on your filesystem under `/skills/`, and each one is a folder with a `SKILL.md`. -You have been given the name and description of every skill up front. That index +You have been given the name, description, and instruction path of every skill up front. That index is deliberately short. When a request matches a skill, read its `SKILL.md` with `read_file` before you start, and follow the procedure it gives you. If the `SKILL.md` points at another file, read that too — the numbers in a reference diff --git a/cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md b/cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md index 8aaa074d5..eeeaaffd3 100644 --- a/cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md +++ b/cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md @@ -11,6 +11,6 @@ per 1,000 ft of field elevation. | Regional turboprop | 4,200 ft | 10% | Apply the penalty linearly: a mid-size jet at 7,820 ft needs -4,800 x (1 + 0.14 x 7.82) = 10,057 ft. +4,800 x (1 + 0.14 x 7.82) = 10,055.04 ft (10,055 ft rounded). Add a further 15% for a wet runway. diff --git a/cockpit/deep-agents/skills/python/src/graph.py b/cockpit/deep-agents/skills/python/src/graph.py index 1542f307c..4f7e2a526 100644 --- a/cockpit/deep-agents/skills/python/src/graph.py +++ b/cockpit/deep-agents/skills/python/src/graph.py @@ -1,12 +1,12 @@ """Deep Agents skills capability: SKILL.md progressive disclosure. A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a `name` -and a `description`. `SkillsMiddleware` loads only that frontmatter into the -system prompt — a short index the model can scan — and leaves the body on the +and a `description`. `SkillsMiddleware` loads a name, description, and instruction path into the +system prompt — a short metadata index the model can scan — and leaves the body on the filesystem for the agent to `read_file` when a request actually matches. That -two-stage load is what "progressive disclosure" means: the instructions cost -nothing until they are needed, and a reference file inside the skill costs -nothing until the SKILL.md sends the agent to it. +two-stage load is what "progressive disclosure" means: instruction bodies +contribute tokens when needed, and a reference file body is loaded only +when the SKILL.md sends the agent to it. Index scanning still reads the backend. Where the skills live matters. They must be readable through a backend, and this demo is deployed on a shared public LangGraph deployment, so a host-filesystem @@ -16,9 +16,12 @@ read-only at `/skills/` through a `CompositeBackend`; everything else the agent writes goes to the thread's own `StateBackend`. -Visibility: `skills_metadata` is annotated `PrivateStateAttr`, so it never -reaches the `values` stream. `SkillsVisibilityMiddleware` republishes it as a -`custom` stream event. +Visibility: `PrivateStateAttr` excludes Skills keys from declared input/output +schemas. The pinned graph exposes metadata in root values and checkpoints; +optional load errors can appear when produced, and normal loads can omit them. +`SkillsVisibilityMiddleware` always includes both keys in its custom event, +using an empty-list fallback for load errors; +this verified Skills contract makes no inference about Memory. """ from pathlib import Path @@ -32,6 +35,8 @@ from langgraph.config import get_stream_writer from langgraph.store.memory import InMemoryStore +from src.skills_readonly import ReadOnlySkillsBackend + # region skills-constants PROMPTS_DIR = Path(__file__).parent.parent / "prompts" SKILLS_DIR = Path(__file__).parent.parent / "skills" @@ -104,9 +109,8 @@ def lookup_weather(airport: str) -> str: def _seed_skills_store() -> InMemoryStore: """Load the bundled skill folders into a process-local store. - Read once at import. The store is never written to at runtime, so the agent - sees `/skills/` as a stable read-only mount even though it is served by the - same `StoreBackend` machinery as a writable one. + Read once at import through a writable backend. Runtime access is mounted + through `ReadOnlySkillsBackend`, which denies every mutation entry point. """ store = InMemoryStore() backend = StoreBackend(namespace=lambda _runtime: SKILLS_NAMESPACE, store=store) @@ -127,9 +131,11 @@ def _seed_skills_store() -> InMemoryStore: class SkillsVisibilityMiddleware(AgentMiddleware): """Republish `skills_metadata` as a `custom` stream event. - Same shim as the memory capability's: the key stays private on the state and - is announced alongside it, so a panel can render the loaded skill index - while the agent works rather than only once the run settles. + Skills keys are excluded from declared input/output schemas. Root values + and checkpoints expose metadata; optional load errors can appear when + produced and can be omitted on normal loads. This live event always includes + both keys, with an empty-list fallback for load errors. It makes no claim + about Memory visibility. """ @property @@ -181,7 +187,7 @@ def build_skills_agent(): backend=CompositeBackend( default=StateBackend(), routes={ - SKILLS_ROOT: StoreBackend( + SKILLS_ROOT: ReadOnlySkillsBackend( namespace=lambda _runtime: SKILLS_NAMESPACE, store=SKILLS_STORE, ), diff --git a/cockpit/deep-agents/skills/python/src/index.ts b/cockpit/deep-agents/skills/python/src/index.ts index 901af1125..03a7bacc6 100644 --- a/cockpit/deep-agents/skills/python/src/index.ts +++ b/cockpit/deep-agents/skills/python/src/index.ts @@ -34,6 +34,10 @@ export const deepAgentsSkillsPythonModule: CockpitCapabilityModule = { ], backendAssetPaths: [ 'cockpit/deep-agents/skills/python/src/graph.py', + 'cockpit/deep-agents/skills/python/src/skills_readonly.py', + 'cockpit/deep-agents/skills/python/skills/runway-analysis/SKILL.md', + 'cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md', + 'cockpit/deep-agents/skills/python/skills/weather-brief/SKILL.md', ], runtimeUrl: 'deep-agents/skills', devPort: 4314, diff --git a/cockpit/deep-agents/skills/python/src/skills_readonly.py b/cockpit/deep-agents/skills/python/src/skills_readonly.py new file mode 100644 index 000000000..db5b672e1 --- /dev/null +++ b/cockpit/deep-agents/skills/python/src/skills_readonly.py @@ -0,0 +1,47 @@ +"""Read-only StoreBackend mount for bundled Skills on the shared deployment.""" + +from deepagents.backends import StoreBackend +from deepagents.backends.protocol import ( + PERMISSION_DENIED, + DeleteResult, + EditResult, + FileUploadResponse, + WriteResult, +) + + +class ReadOnlySkillsBackend(StoreBackend): + """Preserve StoreBackend reads while denying every mutation entry point. + + Seed the store through a regular StoreBackend before mounting this class. + The async methods are overridden too: StoreBackend's async implementations + access the store directly rather than delegating to these sync methods. + """ + + def write(self, file_path: str, content: str) -> WriteResult: + return WriteResult(error=PERMISSION_DENIED) + + async def awrite(self, file_path: str, content: str) -> WriteResult: + return self.write(file_path, content) + + def edit( + self, file_path: str, old_string: str, new_string: str, replace_all: bool = False + ) -> EditResult: + return EditResult(error=PERMISSION_DENIED) + + async def aedit( + self, file_path: str, old_string: str, new_string: str, replace_all: bool = False + ) -> EditResult: + return self.edit(file_path, old_string, new_string, replace_all) + + def delete(self, file_path: str) -> DeleteResult: + return DeleteResult(error=PERMISSION_DENIED) + + async def adelete(self, file_path: str) -> DeleteResult: + return self.delete(file_path) + + def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + return [FileUploadResponse(path=path, error=PERMISSION_DENIED) for path, _ in files] + + async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + return self.upload_files(files) diff --git a/cockpit/deep-agents/skills/python/tests/test_readonly.py b/cockpit/deep-agents/skills/python/tests/test_readonly.py new file mode 100644 index 000000000..e21d3e588 --- /dev/null +++ b/cockpit/deep-agents/skills/python/tests/test_readonly.py @@ -0,0 +1,137 @@ +"""Typed backend outcomes, real composite routing, and namespace deployment imports.""" +from pathlib import Path +import shutil +import socket +from unittest.mock import patch +import subprocess +import sys +import tempfile +import unittest + +from deepagents.backends import CompositeBackend, StateBackend, StoreBackend +from deepagents.backends.protocol import DeleteResult, EditResult, FileUploadResponse, WriteResult, SandboxBackendProtocol +from langgraph.store.memory import InMemoryStore +from test_streaming import LocalModel, ROOT, load + +from src.skills_readonly import ReadOnlySkillsBackend + +class ReadonlyBackendTests(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.store = InMemoryStore() + self.namespace = ('offline', 'skills') + self.writable = StoreBackend(namespace=lambda _: self.namespace, store=self.store) + self.writable.write('/runway/SKILL.md', 'Bundled instructions') + self.writable.write('/runway/reference.md', 'Original reference') + self.readonly = ReadOnlySkillsBackend(namespace=lambda _: self.namespace, store=self.store) + self.composite = CompositeBackend(default=StateBackend(), routes={'/skills/': self.readonly}) + def unchanged(self): + self.assertEqual(self.writable.read('/runway/reference.md').file_data['content'], 'Original reference') + self.assertEqual(sorted(e['path'] for e in self.writable.glob('**', '/').matches), ['/runway/SKILL.md', '/runway/reference.md']) + self.assertEqual(self.writable.read('/runway/SKILL.md').file_data['content'], 'Bundled instructions') + def assert_denied(self, result, expected): + self.assertIsInstance(result, expected) + self.assertEqual(result.error, 'permission_denied') + self.assertIsNone(result.path) + self.unchanged() + async def test_all_sync_and_async_mutation_entrypoints_are_typed_denials(self): + for backend, prefix in ((self.readonly, ''), (self.composite, '/skills')): + with self.subTest(prefix=prefix): + self.assert_denied(backend.write(prefix+'/new.md', 'forbidden'), WriteResult) + self.assert_denied(await backend.awrite(prefix+'/new.md', 'forbidden'), WriteResult) + self.assert_denied(backend.edit(prefix+'/runway/reference.md', 'Original', 'Changed'), EditResult) + self.assert_denied(await backend.aedit(prefix+'/runway/reference.md', 'Original', 'Changed', True), EditResult) + self.assert_denied(backend.delete(prefix+'/runway'), DeleteResult) + self.assert_denied(await backend.adelete(prefix+'/runway'), DeleteResult) + files = [(prefix+'/runway/reference.md', b'changed'), (prefix+'/new.md', b'new')] + for results in (backend.upload_files(files), await backend.aupload_files(files)): + self.assertEqual([r.path for r in results], [p for p,_ in files]) + self.assertTrue(all(isinstance(r, FileUploadResponse) and r.error == 'permission_denied' for r in results)) + self.unchanged() + self.assertEqual(backend.upload_files([]), []) + self.assertEqual(await backend.aupload_files([]), []) + async def test_reads_listing_grep_glob_and_download_delegate(self): + path = '/skills/runway/reference.md' + self.assertFalse(isinstance(self.readonly, SandboxBackendProtocol)) + self.assertEqual(self.composite.read(path).file_data['content'], 'Original reference') + self.assertEqual((await self.composite.aread(path)).file_data['content'], 'Original reference') + self.assertEqual(self.composite.ls('/skills/runway/').entries, (await self.composite.als('/skills/runway/')).entries) + self.assertEqual(self.composite.grep('Original', '/skills/').matches, (await self.composite.agrep('Original', '/skills/')).matches) + self.assertTrue(self.composite.grep('Original', '/skills/').matches) + self.assertEqual(self.composite.glob('**/*.md','/skills/').matches, (await self.composite.aglob('**/*.md','/skills/')).matches) + for results in (self.composite.download_files([path, '/skills/missing']), await self.composite.adownload_files([path, '/skills/missing'])): + self.assertEqual(results[0].path, path) + self.assertEqual(results[0].content, b'Original reference') + self.assertEqual(results[1].error, 'file_not_found') + async def test_mixed_batch_preserves_skill_and_writes_thread_state(self): + # StateBackend deliberately resolves the real graph's current runtime. + model = LocalModel() + module, graph = load(model) + before = module.SKILLS_STORE.search(module.SKILLS_NAMESPACE) + model.calls = [('write_file', {'file_path':'/notes.md','content':'Private note'})] + config = {'configurable': {'thread_id': 'note'}} + state = await graph.ainvoke({'messages':[{'type':'human','id':'note','content':'Write notes.'}]}, config) + self.assertIn('/notes.md', state['files']) + self.assertEqual(module.SKILLS_STORE.search(module.SKILLS_NAMESPACE), before) + # Invoke the actual mixed batch inside a LangGraph node so the default + # StateBackend uses real channel reads/writes, without mocking runtime. + from langgraph.graph import StateGraph, START, END + from typing import TypedDict + class FileState(TypedDict): + files: dict + files = [('/skills/runway/reference.md', b'forbidden'), ('/notes.md', b'allowed'), ('/skills/new.md', b'forbidden')] + def check(results): + self.assertEqual([r.path for r in results], [p for p,_ in files]) + self.assertEqual([r.error for r in results], ['permission_denied', None, 'permission_denied']) + self.unchanged() + def sync_batch(state): + check(self.composite.upload_files(files)) + return {} + async def async_batch(state): + check(await self.composite.aupload_files(files)) + return {} + for node in (sync_batch, async_batch): + builder = StateGraph(FileState) + builder.add_node('batch', node) + builder.add_edge(START, 'batch') + builder.add_edge('batch', END) + state = await builder.compile().ainvoke({'files': {}}) + self.assertEqual(state['files']['/notes.md']['content'], 'allowed') + + +class DeploymentImportTests(unittest.TestCase): + def test_unique_module_import_from_staged_namespace_portions(self): + self.assertFalse((ROOT/'src/__init__.py').exists()) + repo = ROOT.parents[3] + generator = (repo/'scripts/generate-shared-deployment-config.ts').read_text() + self.assertIn('cpSync(sourceDir, stagedDir, { recursive: true })', generator) + manifest = __import__('json').loads((ROOT/'langgraph.json').read_text()) + self.assertEqual(manifest['graphs']['da-skills'], './src/graph.py:graph') + with tempfile.TemporaryDirectory() as temp: + staged = Path(temp)/'deps/da-skills' + # Same root-copy convention; omit runtime caches, which are not + # source or dependencies and are unnecessary for import routing. + shutil.copytree(ROOT, staged, ignore=shutil.ignore_patterns('.venv','__pycache__')) + other = Path(temp)/'deps/other/src' + other.mkdir(parents=True) + (other/'other_capability.py').write_text('VALUE = 42\n') + script = """ +import importlib.util, pathlib, socket, sys +from unittest.mock import patch +sys.path[:0] = [sys.argv[1], sys.argv[2], sys.argv[3]] +from test_streaming import LocalModel, load +from src.other_capability import VALUE +from src.skills_readonly import ReadOnlySkillsBackend +assert VALUE == 42 +assert pathlib.Path(__import__('src.skills_readonly', fromlist=['x']).__file__).resolve() == pathlib.Path(sys.argv[1], 'src/skills_readonly.py').resolve() +with patch.object(socket.socket, 'connect', side_effect=AssertionError('Network forbidden')) as network: + module, graph = load(LocalModel(), pathlib.Path(sys.argv[1])) + assert module.ReadOnlySkillsBackend is ReadOnlySkillsBackend + network.assert_not_called() +""" + result = subprocess.run([sys.executable, '-c', script, str(staged), str(other.parent), str(ROOT/'tests')], cwd=temp, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + +if __name__ == '__main__': + unittest.main() diff --git a/cockpit/deep-agents/skills/python/tests/test_streaming.py b/cockpit/deep-agents/skills/python/tests/test_streaming.py new file mode 100644 index 000000000..78f27d321 --- /dev/null +++ b/cockpit/deep-agents/skills/python/tests/test_streaming.py @@ -0,0 +1,112 @@ +"""Offline contracts against the actual compiled Skills graph.""" +from copy import deepcopy +import importlib.util +import os +from pathlib import Path +import socket +import unittest +from unittest.mock import patch + +os.environ['LANGSMITH_TRACING'] = 'false' +os.environ['LANGCHAIN_TRACING_V2'] = 'false' +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langgraph.checkpoint.memory import InMemorySaver +import deepagents # Resolve provider class checks before injecting the local model. + +ROOT = Path(__file__).parents[1] +REFERENCE = '/skills/runway-analysis/reference/margins.md' +INSTRUCTIONS = '/skills/runway-analysis/SKILL.md' + +class LocalModel(BaseChatModel): + calls: list = [] + @property + def _llm_type(self): + return 'offline-skills-contract' + def bind_tools(self, tools, **kwargs): + return self + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + human = next(m for m in reversed(messages) if m.type == 'human') + results = [m for m in messages[messages.index(human):] if m.type == 'tool'] + if len(results) < len(self.calls): + name, args = self.calls[len(results)] + answer = AIMessage(content='', tool_calls=[{'name': name, 'args': args, 'id': human.id+'-'+str(len(results)), 'type': 'tool_call'}]) + else: + answer = AIMessage(content='Offline assessment complete.') + return ChatResult(generations=[ChatGeneration(message=answer)]) + +def load(model, root=ROOT): + with patch('langchain_openai.ChatOpenAI', return_value=model): + spec = importlib.util.spec_from_file_location('offline_skills_graph', root/'src/graph.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module, module.build_skills_agent().builder.compile(checkpointer=InMemorySaver()) + +class SkillsStreamingTests(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.module, self.graph = load(self.model) + async def send(self, thread, calls): + self.model.calls = calls + config = {'configurable': {'thread_id': thread}} + events = [deepcopy(e) async for e in self.graph.astream({'messages': [{'type': 'human', 'id':thread, 'content':'Offline dispatch request.'}]}, config, subgraphs=True, stream_mode=['values','custom','checkpoints'])] + saved = await self.graph.aget_state(config) + return saved, events + async def test_edit_denied_and_distinct_thread_reads_unchanged_reference(self): + original = (ROOT/'skills/runway-analysis/reference/margins.md').read_text() + first, _ = await self.send('edit', [('edit_file', {'file_path':REFERENCE,'old_string':'4,800','new_string':'MUTATED','replace_all':True})]) + result = next(m for m in first.values['messages'] if m.type == 'tool') + second, _ = await self.send('reader', [('read_file', {'file_path':REFERENCE})]) + read = next(m for m in second.values['messages'] if m.type == 'tool') + self.assertIn('4,800', read.content) + self.assertNotIn('MUTATED', read.content) + self.assertEqual(result.status, 'error') + self.assertIn('permission_denied', result.content) + self.assertEqual((ROOT/'skills/runway-analysis/reference/margins.md').read_text(), original) + async def test_write_denied_and_distinct_thread_cannot_read_added_skill(self): + path = '/skills/new-skill/SKILL.md' + first, _ = await self.send('writer', [('write_file', {'file_path':path,'content':'UNAUTHORIZED SKILL'})]) + second, _ = await self.send('reader', [('read_file', {'file_path':path})]) + result = next(m for m in first.values['messages'] if m.type == 'tool') + read = next(m for m in second.values['messages'] if m.type == 'tool') + self.assertEqual(read.status, 'error') + self.assertEqual(result.status, 'error') + self.assertIn('permission_denied', result.content) + async def test_native_values_custom_checkpoints_and_progressive_reads(self): + saved, events = await self.send('reads', [('read_file', {'file_path':INSTRUCTIONS}), ('read_file', {'file_path':REFERENCE}), ('write_file', {'file_path':'/notes.md','content':'Thread-local dispatch note'})]) + # PrivateStateAttr describes I/O schema exclusion, not root stream secrecy. + for schema in (self.graph.get_input_jsonschema(), self.graph.get_output_jsonschema()): + properties = schema.get('properties', {}) + self.assertNotIn('skills_metadata', properties) + self.assertNotIn('skills_load_errors', properties) + # Normal successful loads omit the optional raw key; custom visibility + # supplies [] without rewriting the raw root/checkpoint state. + self.assertNotIn('skills_load_errors', saved.values) + metadata = saved.values['skills_metadata'] + self.assertIn('runway-analysis', [s['name'] for s in metadata]) + root = [d for ns,k,d in events if not ns and k == 'values'] + self.assertEqual(root[-1]['skills_metadata'], metadata) + self.assertNotIn('skills_load_errors', root[-1]) + custom = [d for ns,k,d in events if not ns and k == 'custom' and d.get('name') == self.module.SKILLS_EVENT] + self.assertEqual(custom[-1]['data']['skills_metadata'], metadata) + self.assertEqual(custom[-1]['data']['skills_load_errors'], []) + checkpoints = [d for ns,k,d in events if not ns and k == 'checkpoints'] + self.assertEqual(checkpoints[-1]['values']['skills_metadata'], metadata) + self.assertNotIn('skills_load_errors', checkpoints[-1]['values']) + exact = await self.graph.aget_state(saved.config) + history = [s async for s in self.graph.aget_state_history(saved.config)] + self.assertEqual(exact.values, saved.values) + self.assertEqual(history[0].values, saved.values) + results = [m for m in saved.values['messages'] if m.type == 'tool'] + self.assertTrue(all(m.status == 'success' for m in results)) + self.assertIn(REFERENCE, results[0].content) + self.assertIn('4,800', results[1].content) + self.assertIn('/notes.md', saved.values['files']) + other, _ = await self.send('other-note-reader', [('read_file', {'file_path':'/notes.md'})]) + self.assertEqual(next(m for m in other.values['messages'] if m.type == 'tool').status, 'error') + +if __name__ == '__main__': + unittest.main() diff --git a/cockpit/deep-agents/skills/react/README.md b/cockpit/deep-agents/skills/react/README.md new file mode 100644 index 000000000..3e3a8f9e2 --- /dev/null +++ b/cockpit/deep-agents/skills/react/README.md @@ -0,0 +1,11 @@ +# Native React Skills + +The Skills cockpit uses the native React presentation and neutral LangGraph session runtime. Build the installed consumer with `npx nx build cockpit-deep-agents-skills-react`. Run unit tests with `npx nx test cockpit-deep-agents-skills-react`, lint with `npx nx lint cockpit-deep-agents-skills-react`, and check the closed frontend selection with `npx nx run cockpit-deep-agents-skills-react:configuration-test`. + +The local selection is `deep-agents-skills`, with base path `/deep-agents/skills/react/` and reserved port 4631. It addresses the `da-skills` graph. Try drafting “Can a mid-size jet operate out of KASE?” and explicitly send it to create a conversation. + +The index shows observed root metadata: each skill's name, description, virtual instruction path, and optional support annotations. Instruction bodies and references appear as on-demand tool read observations. An inspected read is evidence of the returned lines, not a claim that the whole file was read. Tool errors remain visible even when the parent response is confirmed. + +New clears the draft and view while retaining a bounded catalog of locally confirmed conversations. Selecting one reads fresh current state, history, the exact checkpoint, and the native session; it never sends a prompt. Reload clears the catalog. Stop or uncertain parent state requires New. No retry, replay, or resume controls are provided. + +Run `npx nx fixture-test cockpit-deep-agents-skills-react` for the source-bound compiled Python graph and installed SDK transport contracts, and `npx nx e2e cockpit-deep-agents-skills-react` for the installed native browser app. The loopback fixture uses deterministic model construction with remote sockets blocked, and preserves actual root values, checkpoints and tool results. Its controls and proof endpoints are excluded from the production frontend. These local checks do not establish live-service behavior. diff --git a/cockpit/deep-agents/skills/react/e2e/playwright.config.ts b/cockpit/deep-agents/skills/react/e2e/playwright.config.ts new file mode 100644 index 000000000..18035987e --- /dev/null +++ b/cockpit/deep-agents/skills/react/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + outputDir: 'test-results', + reporter: [['list']], + workers: 1, + fullyParallel: false, + timeout: 60000, + use: { baseURL: 'http://127.0.0.1:4631', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'node scripts/react-cockpit/serve.mjs deep-agents-skills', + cwd: '../../../../..', + url: 'http://127.0.0.1:4631', + reuseExistingServer: false, + timeout: 15000, + }, +}); diff --git a/cockpit/deep-agents/skills/react/e2e/skills.spec.ts b/cockpit/deep-agents/skills/react/e2e/skills.spec.ts new file mode 100644 index 000000000..7381d8b65 --- /dev/null +++ b/cockpit/deep-agents/skills/react/e2e/skills.spec.ts @@ -0,0 +1,218 @@ +import { + test, + expect, + type Page, + type APIRequestContext, + type TestInfo, +} from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +const base = '/deep-agents/skills/react/'; +async function configure( + request: APIRequestContext, + scenario = 'reads', + extra: Record = {} +) { + expect( + ( + await request.post('/__configure', { + data: { + scenario, + holdStream: false, + holdState: false, + holdCheckpoint: false, + failCreation: false, + failStream: false, + failCheckpoint: false, + ...extra, + }, + }) + ).ok() + ).toBe(true); +} +async function send(page: Page, text = 'What is the field data for KSFO?') { + await page.getByRole('textbox', { name: 'Message' }).fill(text); + await page.getByRole('button', { name: 'Send', exact: true }).click(); +} +async function saved(page: Page) { + await expect(page.getByRole('status')).toHaveText('Response saved.', { + timeout: 45000, + }); +} +async function proof( + request: APIRequestContext, + name: string, + testInfo: TestInfo +) { + mkdirSync(testInfo.outputDir, { recursive: true }); + for (const endpoint of ['requests', 'graph-proof', 'raw-events']) + writeFileSync( + testInfo.outputPath(`${name}-${endpoint}.json`), + JSON.stringify( + await (await request.get(`/__${endpoint}`)).json(), + null, + 2 + ) + ); +} +test('actual Skills root index, successful literal reads, followup and exact retained restoration', async ({ + page, + request, +}, testInfo) => { + await configure(request); + const before = await (await request.get('/__requests')).json(); + await page.goto(base); + await page.getByRole('button', { name: 'Mid-size jet at KASE' }).click(); + await expect(page.getByRole('textbox')).toHaveValue( + 'Can a mid-size jet operate out of KASE?' + ); + expect(await (await request.get('/__requests')).json()).toEqual(before); + await page.reload(); + await expect(page.getByRole('textbox')).toHaveValue(''); + await send(page, 'Can a mid-size jet operate out of KASE?'); + await saved(page); + await expect(page.getByTestId('skills-source')).toHaveText( + 'Confirmed checkpoint' + ); + await expect( + page.getByRole('region', { name: 'Skills index' }) + ).toContainText('weather-brief'); + await expect(page.locator('[data-read-kind="inspected"]')).toHaveCount(2); + const reads = page.getByRole('region', { name: 'Skill read observations' }); + await reads.getByText('Literal read result', { exact: true }).first().click(); + await expect(reads.locator('pre').first()).toContainText( + '/skills/runway-analysis/reference/margins.md' + ); + const prior = await page.getByLabel('Saved conversations').inputValue(); + await send(page, 'Followup'); + await saved(page); + await expect(page.locator('[data-read-kind="inspected"]')).toHaveCount(4); + await page.getByRole('button', { name: 'New conversation' }).click(); + await expect(page.getByRole('textbox')).toHaveValue(''); + await expect(page.locator('[data-read-kind]')).toHaveCount(0); + const requestsBefore = await (await request.get('/__requests')).json(); + await page.getByLabel('Saved conversations').selectOption(prior); + await expect(page.getByRole('status')).toContainText('saved'); + await expect(page.locator('[data-read-kind="inspected"]')).toHaveCount(4); + const requestsAfter = await (await request.get('/__requests')).json(); + expect( + requestsAfter.filter((r: { path: string }) => + r.path.endsWith('/runs/stream') + ).length + ).toEqual( + requestsBefore.filter((r: { path: string }) => + r.path.endsWith('/runs/stream') + ).length + ); + await page.screenshot({ + path: testInfo.outputPath('skills-desktop.png'), + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('skills-mobile.png'), + fullPage: true, + }); + await proof(request, 'reads-restoration', testInfo); +}); +for (const [scenario, kind, count] of [ + ['no-read', 'inspected', 0], + ['partial', 'partial', 1], + ['error', 'error', 1], + ['alternate', 'inspected', 1], +] as const) + test(`actual Skills ${scenario} remains evidence based`, async ({ + page, + request, + }, info) => { + await configure(request, scenario); + await page.goto(base); + await send(page); + await saved(page); + await expect(page.locator(`[data-read-kind="${kind}"]`)).toHaveCount(count); + await proof(request, scenario, info); + }); +test('New rejects stale held checkpoint and clears draft without replay', async ({ + page, + request, +}, info) => { + await configure(request, 'reads', { holdCheckpoint: true }); + await page.goto(base); + await send(page); + await expect(page.getByRole('status')).toHaveText( + 'Confirming saved conversation…' + ); + await page.getByRole('button', { name: 'New conversation' }).click(); + await request.post('/__release', { data: {} }); + await expect(page.getByRole('status')).toHaveText('Ready.'); + await expect(page.locator('[data-read-kind]')).toHaveCount(0); + await expect(page.getByRole('textbox')).toHaveValue(''); + await proof(request, 'stale-new', info); +}); +test('Stop and failed transport require New and never replay', async ({ + page, + request, +}, info) => { + await configure(request, 'reads', { holdStream: true }); + await page.goto(base); + await send(page); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + await expect(page.getByRole('textbox')).toBeDisabled(); + await request.post('/__release', { data: {} }); + await page.getByRole('button', { name: 'New conversation' }).click(); + await configure(request, 'reads', { failStream: true }); + await send(page); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); + await proof(request, 'stop-error', info); +}); + +test('duplicate Send and exact confirmation failure cannot silently replay', async ({ + page, + request, +}, info) => { + await configure(request, 'reads', { failCheckpoint: true }); + await page.goto(base); + const before = (await (await request.get('/__requests')).json()).length; + await page + .getByRole('textbox') + .fill('Can a mid-size jet operate out of KASE?'); + await page + .getByRole('button', { name: 'Send', exact: true }) + .evaluate((element) => { + const button = element as HTMLButtonElement; + button.click(); + button.click(); + }); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); + const requests = (await (await request.get('/__requests')).json()).slice( + before + ); + expect( + requests.filter((r: { path: string }) => r.path.endsWith('/runs/stream')) + ).toHaveLength(1); + await proof(request, 'duplicate-exact-failure', info); +}); +test('creation failure remains unavailable without authoring a run', async ({ + page, + request, +}, info) => { + await configure(request, 'reads', { failCreation: true }); + await page.goto(base); + const before = (await (await request.get('/__requests')).json()).length; + await send(page); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); + const requests = (await (await request.get('/__requests')).json()).slice( + before + ); + expect( + requests.filter((r: { path: string }) => r.path.endsWith('/runs/stream')) + ).toHaveLength(0); + await proof(request, 'creation-failure', info); +}); diff --git a/cockpit/deep-agents/skills/react/eslint.config.mjs b/cockpit/deep-agents/skills/react/eslint.config.mjs new file mode 100644 index 000000000..da9422011 --- /dev/null +++ b/cockpit/deep-agents/skills/react/eslint.config.mjs @@ -0,0 +1,14 @@ +import base from '../../../../eslint.config.mjs'; +export default [ + ...base, + { + files: ['cockpit/deep-agents/skills/react/src/index-projection.ts'], + // The virtual-path contract intentionally rejects NUL and control characters. + rules: { 'no-control-regex': 'off' }, + }, + { + files: ['cockpit/deep-agents/skills/react/src/read-projection.ts'], + // The pinned Deep Agents read formatter uses a literal two-space gutter. + rules: { 'no-regex-spaces': 'off' }, + }, +]; diff --git a/cockpit/deep-agents/skills/react/index.html b/cockpit/deep-agents/skills/react/index.html new file mode 100644 index 000000000..8b3d43a74 --- /dev/null +++ b/cockpit/deep-agents/skills/react/index.html @@ -0,0 +1,12 @@ + + + + + + Deep Agents skills · React + + +
+ + + diff --git a/cockpit/deep-agents/skills/react/project.json b/cockpit/deep-agents/skills/react/project.json new file mode 100644 index 000000000..21eefac75 --- /dev/null +++ b/cockpit/deep-agents/skills/react/project.json @@ -0,0 +1,88 @@ +{ + "name": "cockpit-deep-agents-skills-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/deep-agents/skills/react/src", + "projectType": "application", + "tags": [ + "scope:cockpit-examples", + "framework:react", + "scope:cockpit-e2e" + ], + "targets": { + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "build", + "projects": [ + "core", + "content", + "react" + ] + } + ], + "outputs": [ + "{workspaceRoot}/dist/cockpit/deep-agents/skills/react" + ], + "options": { + "command": "node scripts/react-cockpit/build.mjs deep-agents-skills" + } + }, + "configuration-test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-skills-configuration.spec.mjs" + } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/deep-agents/skills/react/vite.config.mts", + "reporters": [ + "default" + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "eslintConfig": "cockpit/deep-agents/skills/react/eslint.config.mjs" + } + }, + "fixture-test": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "test", + "projects": [ + "cockpit-deep-agents-skills-python" + ] + }, + { + "target": "build", + "projects": [ + "core" + ] + } + ], + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-skills-fixture.spec.mjs scripts/react-cockpit/deep-agents-skills-configuration.spec.mjs" + } + }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": [ + "build", + "fixture-test" + ], + "options": { + "config": "cockpit/deep-agents/skills/react/e2e/playwright.config.ts", + "skipInstall": true + } + } + } +} diff --git a/cockpit/deep-agents/skills/react/src/app.spec.tsx b/cockpit/deep-agents/skills/react/src/app.spec.tsx new file mode 100644 index 000000000..fbe63b069 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/app.spec.tsx @@ -0,0 +1,280 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { createMessageContent } from '@threadplane/content/messages'; +import { createConnectedApplication } from './connection'; +vi.mock('./connection', () => ({ createConnectedApplication: vi.fn() })); +import { SkillsDemo, SkillPanels, renderMessage } from './app'; +afterEach(cleanup); + +it('places the conversation and native composer beside a Skills sidebar', () => { + const state = { + rows: [], + index: { kind: 'absent' }, + reads: [], + authority: { kind: 'unconfirmed' }, + busy: false, + canSubmit: true, + viewGeneration: 0, + history: [], + error: null, + outcome: null, + }; + vi.mocked(createConnectedApplication).mockReturnValue({ + getSnapshot: () => state, + subscribe: () => () => undefined, + dispose: vi.fn(), + } as never); + const v = render( + undefined} + /> + ); + const sidebar = v.getByRole('complementary', { name: 'Skills sidebar' }); + const workspace = sidebar.parentElement; + if (!workspace) throw Error('Missing Skills workspace'); + const chat = v + .getByRole('region', { name: 'Conversation' }) + .closest('.skills-chat'); + expect(workspace.classList.contains('skills-workspace')).toBe(true); + expect(chat?.parentElement).toBe(workspace); + expect(chat?.contains(v.getByRole('textbox'))).toBe(true); + expect( + sidebar.contains(v.getByRole('region', { name: 'Skills index' })) + ).toBe(true); + expect( + sidebar.contains(v.getByRole('region', { name: 'Skill read observations' })) + ).toBe(true); +}); + +it('readiness and suggestions are inert; New and history selection clear the draft', () => { + const state = { + rows: [], + toolCalls: [], + index: { kind: 'absent' }, + reads: [], + authority: { kind: 'unconfirmed' }, + busy: false, + canSubmit: true, + viewGeneration: 0, + activity: 'idle', + history: [{ id: 'old', label: 'Previous conversation' }], + error: null, + outcome: null, + }; + const app = { + getSnapshot: () => state, + subscribe: () => () => undefined, + submit: vi.fn(), + stop: vi.fn(), + newConversation: vi.fn(), + selectConversation: vi.fn(), + dispose: vi.fn(), + }; + vi.mocked(createConnectedApplication).mockReturnValue(app as never); + const ready = vi.fn(); + const v = render( + + ); + expect(ready).toHaveBeenCalledTimes(1); + fireEvent.click(v.getByRole('button', { name: 'Mid-size jet at KASE' })); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toContain( + 'KASE' + ); + expect(app.submit).not.toHaveBeenCalled(); + fireEvent.change(v.getByRole('combobox', { name: 'Saved conversations' }), { + target: { value: 'old' }, + }); + expect(app.selectConversation).toHaveBeenCalledWith('old'); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe(''); + fireEvent.click(v.getByRole('button', { name: 'Mid-size jet at KASE' })); + fireEvent.click(v.getByRole('button', { name: 'New conversation' })); + expect(app.newConversation).toHaveBeenCalledTimes(1); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe(''); +}); + +it('labels observed and unknown read evidence honestly and renders virtual paths literally', () => { + const v = render( + ', + path: '/skills/runway-analysis/SKILL.md', + }, + ], + }} + reads={[ + { + id: 'read', + skillName: 'runway-analysis', + path: '/skills/runway-analysis/SKILL.md', + category: 'instruction', + kind: 'unknown', + result: '', + }, + ]} + /> + ); + expect(v.getByText('Observed root values')).toBeTruthy(); + expect(v.getByText('Read status unknown')).toBeTruthy(); + expect(v.container.querySelector('script, img, a')).toBeNull(); + expect(v.getByText('')).toBeTruthy(); +}); + +it('keeps empty, absent and unavailable indexes distinct and partial reads explicit', () => { + const v = render( + + ); + expect(v.getByText('Confirmed checkpoint')).toBeTruthy(); + expect(v.getByText('The skills index is empty.')).toBeTruthy(); + expect(v.getByText('Partially inspected')).toBeTruthy(); + v.rerender(); + expect(v.getByText('No skills index observed.')).toBeTruthy(); + v.rerender( + + ); + expect(v.getByText('Skills index unavailable.')).toBeTruthy(); + expect(v.getByText('Read observations unavailable.')).toBeTruthy(); +}); + +it('uses literal native tool observations without treating completed delivery as inspection', () => { + const content = createMessageContent(); + const rows = content.project({ + status: 'idle', + messages: [ + { + id: 'a', + role: 'assistant', + content: '', + toolCallIds: ['t'], + delivery: { generation: 'g', phase: 'complete', outcome: 'success' }, + }, + ], + toolCalls: [ + { + id: 't', + name: 'read_file', + args: { file_path: '/skills/demo/SKILL.md' }, + status: 'complete', + result: '', + }, + ], + }); + const v = render(renderMessage(rows[0])); + expect( + v.getByRole('region', { name: 'Observed tool delivery' }) + ).toBeTruthy(); + expect(v.getByText('Observed delivery: complete')).toBeTruthy(); + expect(v.container.querySelector('img')).toBeNull(); + content.dispose(); +}); + +it('blocks unsafe replay after Stop and New only requests a fresh local conversation', () => { + const state = { + rows: [], + toolCalls: [], + index: { kind: 'absent' }, + reads: [], + authority: { kind: 'unconfirmed' }, + busy: false, + canSubmit: false, + viewGeneration: 2, + activity: 'idle', + history: [], + error: 'Response stopped.', + outcome: 'aborted', + }; + const app = { + getSnapshot: () => state, + subscribe: () => () => undefined, + submit: vi.fn(), + stop: vi.fn(), + newConversation: vi.fn(), + selectConversation: vi.fn(), + dispose: vi.fn(), + }; + vi.mocked(createConnectedApplication).mockReturnValue(app as never); + const v = render( + undefined} + /> + ); + expect((v.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true); + expect( + ( + v.getByRole('button', { + name: 'Mid-size jet at KASE', + }) as HTMLButtonElement + ).disabled + ).toBe(true); + const form = v.getByRole('textbox').closest('form'); + if (!form) throw Error('Missing composer form'); + fireEvent.submit(form); + expect(app.submit).not.toHaveBeenCalled(); + fireEvent.click(v.getByRole('button', { name: 'New conversation' })); + expect(app.newConversation).toHaveBeenCalledTimes(1); + expect(app.submit).not.toHaveBeenCalled(); +}); + +it('shows raw confirmed tool error even when native delivery is complete', () => { + const content = createMessageContent(); + const rows = content.project({ + status: 'idle', + messages: [ + { + id: 'a', + role: 'assistant', + content: '', + toolCallIds: ['t'], + delivery: { generation: 'g', phase: 'complete', outcome: 'success' }, + }, + ], + toolCalls: [ + { + id: 't', + name: 'read_file', + args: { file_path: '/skills/demo/SKILL.md' }, + status: 'complete', + result: 'Tool failed', + }, + ], + }); + const v = render( + renderMessage(rows[0], { + kind: 'terminal', + rawToolStatuses: { t: 'error' }, + } as never) + ); + expect(v.getByText('Tool result: error')).toBeTruthy(); + expect(v.getByRole('region', { name: 'Observed tool error' })).toBeTruthy(); + content.dispose(); +}); diff --git a/cockpit/deep-agents/skills/react/src/app.tsx b/cockpit/deep-agents/skills/react/src/app.tsx new file mode 100644 index 000000000..b2e427db1 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/app.tsx @@ -0,0 +1,340 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAgent } from '@threadplane/react'; +import { + ChatInput, + MessageList, + ToolObservation, + Reasoning, + Citations, +} from '@threadplane/react/chat'; +import { Markdown } from '@threadplane/react/markdown'; +import type { MessageRow } from '@threadplane/content/messages'; +import type { SkillIndex } from './index-projection'; +import type { ReadProjection, SkillRead } from './read-projection'; +import { copyData, LIMITS, type Authority } from './authority'; +import { + createConnectedApplication, + type skillsConnection, +} from './connection'; + +function literal(value: unknown): string { + try { + const copied = copyData(value); + const text = + typeof copied === 'string' + ? copied + : JSON.stringify(copied, null, 2) ?? ''; + return text.length <= LIMITS.fieldText + ? text + : 'Observation text unavailable: display limit exceeded.'; + } catch { + return 'Observation text unavailable.'; + } +} + +/** Application owns row Markdown snapshots and tool/result association. */ +export function renderMessage( + row: MessageRow, + authority: Authority = { kind: 'unconfirmed' } +) { + if (row.role === 'tool') return null; + const label = + row.role === 'user' + ? 'You' + : row.role === 'assistant' + ? 'Assistant' + : 'System'; + return ( +
+

{label}

+ {row.role === 'assistant' && row.reasoning && ( + + )} + + {row.toolCalls.map((call) => { + const error = + call.status === 'error' || + (authority.kind === 'terminal' && + authority.rawToolStatuses[call.id] === 'error'); + return ( +
+

+ {error + ? 'Tool result: error' + : `Observed delivery: ${call.status}`} +

+ +
+ ); + })} + {row.role === 'assistant' && row.message.citations && ( + + )} +
+ ); +} + +const readLabels: Record = { + pending: 'Read pending', + unknown: 'Read status unknown', + error: 'Read failed', + unavailable: 'Read evidence unavailable', + partial: 'Partially inspected', + inspected: 'Inspected', +}; +const hasReads = (value: ReadProjection): value is readonly SkillRead[] => + Array.isArray(value); +export function SkillPanels({ + index, + reads, +}: { + index: SkillIndex; + reads: ReadProjection; +}) { + return ( +
+
+

Skills index

+

+ The index shows names, descriptions, virtual instruction paths, and + optional support annotations. Metadata does not establish that + instructions were read. +

+ {index.kind === 'ready' ? ( + <> +

+ {index.source === 'checkpoint' + ? 'Confirmed checkpoint' + : 'Observed root values'} +

+ {!index.skills.length &&

The skills index is empty.

} + {index.skills.map((skill) => ( +
+

{skill.name}

+

{skill.description}

+ {skill.path} + {(skill.license !== undefined || + skill.compatibility !== undefined || + skill.allowed_tools !== undefined || + skill.metadata !== undefined) && ( +
+ Skill metadata + {skill.license !== undefined && ( +

License: {skill.license ?? 'unspecified'}

+ )} + {skill.compatibility !== undefined && ( +

+ Compatibility: {skill.compatibility ?? 'unspecified'} +

+ )} + {skill.allowed_tools !== undefined && ( +

+ Allowed tools:{' '} + {skill.allowed_tools.join(', ') || 'none listed'} +

+ )} + {skill.metadata &&
{literal(skill.metadata)}
} +
+ )} +
+ ))} + + ) : ( +

+ {index.kind === 'absent' + ? 'No skills index observed.' + : index.kind === 'stale' + ? 'Skills index belongs to an earlier conversation.' + : 'Skills index unavailable.'} +

+ )} +
+
+

Skill read observations

+

+ Instruction bodies and references are read on demand during a + response. Inspected describes observed read evidence; it does not + imply the whole file was read. +

+ {hasReads(reads) ? ( + !reads.length ? ( +

No associated skill reads observed.

+ ) : ( + reads.map((read) => ( +
+

{read.skillName}

+

+ Read call: {read.id} +

+

+ {read.category === 'instruction' + ? 'Skill instruction' + : 'Skill reference'} +

+ {read.path} +

{readLabels[read.kind]}

+ {read.empty &&

An empty-file reminder was observed.

} + {read.result !== undefined && ( +
+ Literal read result +
{read.result}
+
+ )} +
+ )) + ) + ) : ( +

Read observations unavailable.

+ )} +
+
+ ); +} + +export function SkillsDemo({ + connection, + onReady, +}: { + connection: ReturnType; + onReady: () => void; +}) { + const [application] = useState(() => createConnectedApplication(connection)); + const [draft, setDraft] = useState(''); + const snapshot = useAgent(application); + const renderRow = useCallback( + (row: MessageRow) => renderMessage(row, snapshot.authority), + [snapshot.authority] + ); + useEffect(() => { + onReady(); + }, [onReady]); + useEffect( + () => () => { + void application.dispose(); + }, + [application] + ); + useEffect(() => { + setDraft(''); + }, [snapshot.viewGeneration]); + const status = snapshot.busy + ? snapshot.activity === 'confirming' + ? 'Confirming saved conversation…' + : snapshot.activity === 'restoring' + ? 'Restoring conversation…' + : snapshot.activity === 'creating' + ? 'Creating conversation…' + : 'Receiving response…' + : !snapshot.canSubmit + ? 'Start a new conversation to continue.' + : snapshot.outcome === 'success' + ? 'Response saved.' + : 'Ready.'; + return ( +
+
+
+

React · Experimental

+

Deep Agents skills

+
+ +
+

+ Inspect progressive disclosure of skill metadata, instructions, and + reference files. Aviation examples use fixed sample data. Stop cancels + local delivery; backend work may continue. +

+
+ +
+ +
+
+ + {snapshot.error &&

{snapshot.error}

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

{status}

+
+ +
+
+ ); +} diff --git a/cockpit/deep-agents/skills/react/src/application.spec.ts b/cockpit/deep-agents/skills/react/src/application.spec.ts new file mode 100644 index 000000000..8e7b46a63 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/application.spec.ts @@ -0,0 +1,522 @@ +import { it, expect, vi } from 'vitest'; +import { createSkillsApplication, type SkillsSession } from './application'; +import { authorityInput } from './test-fixtures'; +import type { NativeSnapshot } from './authority'; +function setup(status = 'success') { + const input = authorityInput(status); + let state: NativeSnapshot = { + status: 'idle', + messages: [], + toolCalls: [], + values: {}, + interrupts: [], + subgraphs: [], + }; + const listeners = new Set<() => void>(); + const session = { + getSnapshot: () => state, + subscribe: (f: () => void) => { + listeners.add(f); + return () => listeners.delete(f); + }, + submit: vi.fn(async () => { + state = input.snapshot as NativeSnapshot; + listeners.forEach((f) => f()); + return 'success' as const; + }), + load: vi.fn(async () => { + state = input.loadedSnapshot as NativeSnapshot; + }), + stop: vi.fn(), + dispose: vi.fn(), + }; + const client = { + createThread: vi.fn(async () => 'thread'), + sessionFactory: vi.fn(() => session as unknown as SkillsSession), + readCurrent: vi.fn(async () => input.observedState), + readHistory: vi.fn(async () => input.history), + readCheckpoint: vi.fn(async () => input.loadedCheckpoint), + }; + return { + app: createSkillsApplication(client), + session, + client, + input, + set: (s: NativeSnapshot) => { + state = s; + listeners.forEach((f) => f()); + }, + }; +} +it('creates lazily only on explicit nonempty Send and prevents duplicate clicks', async () => { + const x = setup(); + await x.app.newConversation(); + await x.app.submit(' '); + await x.app.selectConversation('unknown'); + expect(x.client.createThread).not.toHaveBeenCalled(); + const first = x.app.submit('Inspect runway.'); + await x.app.submit('Inspect runway.'); + await first; + expect(x.session.submit).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().canSubmit).toBe(true); +}); +it('confirms tool errors honestly without disabling an otherwise confirmed parent', async () => { + const x = setup('error'); + await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().reads).toMatchObject([{ kind: 'error' }]); +}); +it('New preserves known local history and selection freshly reads without model execution', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.app.getSnapshot().history).toHaveLength(1); + await x.app.selectConversation('thread'); + expect(x.session.submit).toHaveBeenCalledTimes(1); + expect(x.client.readCurrent).toHaveBeenCalledTimes(2); + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().index).toMatchObject({ source: 'checkpoint' }); +}); +it('fresh selection refuses externally changed transcripts and cannot revive unsafe owners', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + x.input.observedState.values.messages[0].content = 'external'; + await x.app.selectConversation('thread'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + const count = x.client.readCurrent.mock.calls.length; + await x.app.selectConversation('thread'); + expect(x.client.readCurrent).toHaveBeenCalledTimes(count); +}); +it.each(['paused', 'error', 'aborted'])( + 'requires New after %s outcome', + async (outcome) => { + const x = setup(); + x.session.submit.mockResolvedValue(outcome as never); + await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + await x.app.submit('Again'); + expect(x.session.submit).toHaveBeenCalledTimes(1); + } +); +it('Stop during creation invalidates stale return and requires New', async () => { + const x = setup(); + let resolve!: (s: string) => void; + x.client.createThread.mockImplementation( + () => new Promise((r) => (resolve = r)) + ); + const run = x.app.submit('Inspect runway.'); + await x.app.stop(); + resolve('thread'); + await run; + expect(x.session.submit).not.toHaveBeenCalled(); + expect(x.app.getSnapshot().canSubmit).toBe(false); + await x.app.newConversation(); + expect(x.app.getSnapshot().canSubmit).toBe(true); +}); +it('New cancels pending confirmation and disposal releases only owned work', async () => { + const x = setup(); + let resolve!: (s: typeof x.input.observedState) => void; + x.client.readCurrent.mockImplementation( + () => new Promise((r) => (resolve = r)) + ); + const run = x.app.submit('Inspect runway.'); + await Promise.resolve(); + await Promise.resolve(); + await x.app.newConversation(); + resolve(x.input.observedState); + await run; + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.session.load).not.toHaveBeenCalled(); + await x.app.dispose(); + expect(x.session.dispose).toHaveBeenCalledTimes(1); +}); +it('new application clears the page-local catalog', () => { + expect(setup().app.getSnapshot().history).toEqual([]); +}); +it('routes new followup reads as pending/unknown while preserving prior confirmed read errors, then confirms fresh raw evidence', async () => { + const x = setup('error'); + await x.app.submit('Inspect runway.'); + let finish!: (value: 'success') => void; + x.session.submit.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const running = x.app.submit('Read the reference.'); + const delivery = { + generation: 'followup-native', + phase: 'complete', + outcome: 'success', + }; + const args = { file_path: '/skills/runway-analysis/reference/margins.md' }; + const pending = { + ...x.input.loadedSnapshot, + messages: [ + ...x.input.loadedSnapshot.messages, + { + id: 'followup-human', + role: 'user', + content: 'Read the reference.', + delivery, + }, + { + id: 'followup-call', + role: 'assistant', + content: '', + toolCallIds: ['followup-read'], + delivery, + }, + ], + toolCalls: [ + ...x.input.loadedSnapshot.toolCalls, + { id: 'followup-read', name: 'read_file', args, status: 'pending' }, + ], + }; + x.set(pending as NativeSnapshot); + expect(x.app.getSnapshot().reads).toMatchObject([ + { id: 'read', kind: 'error' }, + { id: 'followup-read', kind: 'pending' }, + ]); + const currentCall = pending.toolCalls[1]; + x.set({ + ...pending, + toolCalls: [...pending.toolCalls, currentCall], + } as NativeSnapshot); + expect(x.app.getSnapshot().reads).toMatchObject([ + { id: 'read', kind: 'error' }, + { id: 'followup-read', kind: 'unavailable' }, + ]); + x.set({ + ...pending, + toolCalls: [pending.toolCalls[0], ...Array(100).fill(currentCall)], + } as NativeSnapshot); + expect(x.app.getSnapshot().reads).toEqual({ kind: 'unavailable' }); + x.set(pending as NativeSnapshot); + expect(x.app.getSnapshot().reads).toMatchObject([ + { id: 'read', kind: 'error' }, + { id: 'followup-read', kind: 'pending' }, + ]); + const result = '1 reference'; + const complete = { + ...pending, + messages: [ + ...pending.messages, + { + id: 'followup-result', + role: 'tool', + name: 'read_file', + toolCallId: 'followup-read', + content: result, + delivery, + }, + { + id: 'followup-final', + role: 'assistant', + content: 'Reference answer.', + toolCallIds: [], + delivery, + }, + ], + toolCalls: [ + ...x.input.loadedSnapshot.toolCalls, + { + id: 'followup-read', + name: 'read_file', + args, + status: 'complete', + result, + }, + ], + }; + x.set(complete as NativeSnapshot); + expect(x.app.getSnapshot().reads).toMatchObject([ + { id: 'read', kind: 'error' }, + { id: 'followup-read', kind: 'unknown' }, + ]); + const raw = { + ...x.input.loadedCheckpoint, + checkpoint: { + ...x.input.loadedCheckpoint.checkpoint, + checkpoint_id: 'followup-checkpoint', + }, + values: { + ...x.input.loadedCheckpoint.values, + messages: [ + ...x.input.loadedCheckpoint.values.messages, + { id: 'followup-human', type: 'human', content: 'Read the reference.' }, + { + id: 'followup-call', + type: 'ai', + content: '', + tool_calls: [ + { id: 'followup-read', name: 'read_file', args, type: 'tool_call' }, + ], + }, + { + id: 'followup-result', + type: 'tool', + name: 'read_file', + tool_call_id: 'followup-read', + content: result, + status: 'success', + }, + { + id: 'followup-final', + type: 'ai', + content: 'Reference answer.', + tool_calls: [], + }, + ], + }, + }; + x.client.readCurrent.mockResolvedValue(raw as never); + x.client.readHistory.mockResolvedValue([raw] as never); + x.client.readCheckpoint.mockResolvedValue(raw as never); + x.session.load.mockImplementation(async () => + x.set(complete as NativeSnapshot) + ); + finish('success'); + await running; + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().reads).toMatchObject([ + { id: 'read', kind: 'error' }, + { id: 'followup-read', kind: 'inspected', category: 'reference' }, + ]); +}); +it.each(['final', 'restore'])( + 'refuses unsupported child state acquired during %s native load', + async (mode) => { + const x = setup(); + if (mode === 'restore') { + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + } + x.input.loadedSnapshot.subgraphs.push({ id: 'unsupported' } as never); + if (mode === 'restore') await x.app.selectConversation('thread'); + else await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + } +); +it('cleanup releases the owned session even when subscription release throws', async () => { + const x = setup(); + x.session.subscribe = () => () => { + throw Error('release failed'); + }; + await x.app.submit('Inspect runway.'); + await expect(x.app.newConversation()).resolves.toBeUndefined(); + expect(x.session.dispose).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().threadId).toBeNull(); +}); +it('New from a restoration publication invalidates the operation before session acquisition', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + let once = false; + x.app.subscribe(() => { + if (x.app.getSnapshot().activity === 'restoring' && !once) { + once = true; + void x.app.newConversation(); + } + }); + await x.app.selectConversation('thread'); + expect(x.app.getSnapshot().threadId).toBeNull(); + expect(x.client.sessionFactory).toHaveBeenCalledTimes(1); +}); +it('Stop before a restoration session is acquired cannot reselect that unsafe saved thread', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + const restore = x.app.selectConversation('thread'); + await x.app.stop(); + await restore; + await x.app.selectConversation('thread'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().history).toEqual([]); +}); +it('bounds the page-local catalog and does not restore evicted IDs', async () => { + const x = setup(); + let number = 0; + x.client.createThread.mockImplementation(async () => { + const id = `thread-${++number}`; + for (const s of [ + x.input.observedState, + x.input.loadedCheckpoint, + ...x.input.history, + ]) + s.checkpoint.thread_id = id; + return id; + }); + const factory = x.client.sessionFactory.getMockImplementation(); + if (!factory) throw Error('Missing session factory'); + x.client.sessionFactory.mockImplementation(() => { + x.set({ + status: 'idle', + messages: [], + toolCalls: [], + values: {}, + interrupts: [], + subgraphs: [], + }); + return factory(); + }); + for (let i = 0; i < 101; i++) { + await x.app.newConversation(); + await x.app.submit('Inspect runway.'); + } + expect(x.app.getSnapshot().history.length).toBeLessThanOrEqual(100); + expect(x.app.getSnapshot().history.length).toBeGreaterThan(0); + await x.app.newConversation(); + await x.app.selectConversation('thread-1'); + expect(x.app.getSnapshot().threadId).toBeNull(); +}); +it('bounds aggregate catalog text even before reaching the entry cap', async () => { + const x = setup(); + const result = '1 ' + 'x'.repeat(39997); + let number = 0; + for (const state of [ + x.input.observedState, + x.input.loadedCheckpoint, + ...x.input.history, + ]) + state.values.messages[2].content = result; + for (const state of [x.input.snapshot, x.input.loadedSnapshot]) { + state.messages[2].content = result; + state.toolCalls[0].result = result; + } + x.client.createThread.mockImplementation(async () => { + const id = `large-${++number}`; + for (const state of [ + x.input.observedState, + x.input.loadedCheckpoint, + ...x.input.history, + ]) + state.checkpoint.thread_id = id; + return id; + }); + const factory = x.client.sessionFactory.getMockImplementation(); + if (!factory) throw Error('Missing session factory'); + x.client.sessionFactory.mockImplementation(() => { + x.set({ + status: 'idle', + messages: [], + toolCalls: [], + values: {}, + interrupts: [], + subgraphs: [], + }); + return factory(); + }); + for (let i = 0; i < 40; i++) { + await x.app.newConversation(); + await x.app.submit('Inspect runway.'); + } + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().history.length).toBeLessThan(40); + expect(x.app.getSnapshot().history.length).toBeGreaterThan(0); +}); +it('changing native generation during a turn permanently invalidates that owner', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.set(x.input.snapshot as NativeSnapshot); + const changed = structuredClone(x.input.snapshot); + changed.messages[0].delivery.generation = 'foreign'; + x.set(changed as NativeSnapshot); + x.set(x.input.snapshot as NativeSnapshot); + return 'success'; + }); + await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it('malformed live state never reaches the display and requires New', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.set({ messages: null } as never); + return 'success'; + }); + await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().rows).toEqual([]); +}); +it('unsupported child state cannot masquerade as confirmed root skill evidence', async () => { + const x = setup(); + x.input.snapshot.subgraphs.push({ id: 'child' } as never); + x.input.loadedSnapshot.subgraphs.push({ id: 'child' } as never); + await x.app.submit('Inspect runway.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it('keeps prior confirmed tool errors visible during a fresh followup', async () => { + const x = setup('error'); + await x.app.submit('Inspect runway.'); + let finish!: (s: 'success') => void; + x.session.submit.mockImplementation(() => new Promise((r) => (finish = r))); + const run = x.app.submit('Follow up.'); + expect(x.app.getSnapshot().reads).toMatchObject([{ kind: 'error' }]); + await x.app.stop(); + finish('success'); + await run; +}); +it('New during an unfinished followup cannot revive its previous catalog authority', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + let finish!: (s: 'success') => void; + x.session.submit.mockImplementation(() => new Promise((r) => (finish = r))); + const run = x.app.submit('Follow up.'); + await Promise.resolve(); + await x.app.newConversation(); + await x.app.selectConversation('thread'); + finish('success'); + await run; + expect(x.app.getSnapshot().threadId).toBeNull(); + expect(x.app.getSnapshot().history).toEqual([]); +}); +it('a restored owner follows up with its full saved raw baseline without creating another thread', async () => { + const x = setup(); + await x.app.submit('Inspect runway.'); + await x.app.newConversation(); + await x.app.selectConversation('thread'); + const raw = [ + ...x.input.loadedCheckpoint.values.messages, + { id: 'h2', type: 'human', content: 'Follow up.' }, + { id: 'a2', type: 'ai', content: 'Answer.', tool_calls: [] }, + ]; + const delivery = { generation: 'g2', phase: 'complete', outcome: 'success' }; + const native = { + ...x.input.loadedSnapshot, + messages: [ + ...x.input.loadedSnapshot.messages, + { id: 'h2', role: 'user', content: 'Follow up.', delivery }, + { + id: 'a2', + role: 'assistant', + content: 'Answer.', + toolCallIds: [], + delivery, + }, + ], + }; + const saved = { + ...x.input.loadedCheckpoint, + checkpoint: { + ...x.input.loadedCheckpoint.checkpoint, + checkpoint_id: 'second', + }, + values: { ...x.input.loadedCheckpoint.values, messages: raw }, + }; + x.client.readCurrent.mockResolvedValue(saved as never); + x.client.readHistory.mockResolvedValue([saved] as never); + x.client.readCheckpoint.mockResolvedValue(saved as never); + x.session.load.mockImplementation(async () => + x.set(native as NativeSnapshot) + ); + x.session.submit.mockImplementation(async () => { + x.set(native as NativeSnapshot); + expect(x.app.getSnapshot().rows.at(-1)?.message.content).toBe('Answer.'); + return 'success'; + }); + await x.app.submit('Follow up.'); + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.client.createThread).toHaveBeenCalledTimes(1); +}); diff --git a/cockpit/deep-agents/skills/react/src/application.ts b/cockpit/deep-agents/skills/react/src/application.ts new file mode 100644 index 000000000..b1929b985 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/application.ts @@ -0,0 +1,647 @@ +import type { + AgentSession, + AgentStatus, + CompleteOutcome, + ToolCall, +} from '@threadplane/core'; +import { + createMessageContent, + type MessageRow, +} from '@threadplane/content/messages'; +import { + captureAuthority, + captureBaseline, + captureTurn, + copyData, + record, + sameData, + LIMITS, + type Authority, + type Baseline, + type Turn, + type Checkpoint, + type NativeSnapshot, +} from './authority'; +import { projectIndex, type SkillIndex } from './index-projection'; +import { + projectReads, + type ReadProjection, + type SkillRead, +} from './read-projection'; +export interface SkillsSession extends Omit { + getSnapshot(): NativeSnapshot; + load(options?: { readonly signal?: AbortSignal }): Promise; +} +export interface SkillsClient { + createThread(signal: AbortSignal): Promise; + sessionFactory(id: string): SkillsSession; + readCurrent(id: string, signal: AbortSignal): Promise; + readHistory(id: string, signal: AbortSignal): Promise; + readCheckpoint(checkpoint: Checkpoint, signal: AbortSignal): Promise; +} +export interface SkillsSnapshot { + readonly threadId: string | null; + readonly rows: readonly MessageRow[]; + readonly toolCalls: readonly ToolCall[]; + readonly index: SkillIndex; + readonly reads: ReadProjection; + readonly authority: Authority; + readonly activity: + | 'idle' + | 'creating' + | 'running' + | 'confirming' + | 'restoring'; + readonly busy: boolean; + readonly canSubmit: boolean; + readonly outcome: CompleteOutcome | null; + readonly error: string | null; + readonly viewGeneration: number; + readonly history: readonly { id: string; label: string }[]; +} +const initial = ( + viewGeneration = 0, + history: SkillsSnapshot['history'] = [] +): SkillsSnapshot => ({ + threadId: null, + rows: [], + toolCalls: [], + index: { kind: 'absent' }, + reads: [], + authority: { kind: 'unconfirmed' }, + activity: 'idle', + busy: false, + canSubmit: true, + outcome: null, + error: null, + viewGeneration, + history, +}); +export function createSkillsApplication(client: SkillsClient) { + type Terminal = Extract; + type Saved = { authority: Terminal; native: NativeSnapshot; label: string }; + type Owner = { + id: string; + key: string; + generation: string; + session: SkillsSession; + content: ReturnType; + baseline: Baseline; + turn?: Turn; + nativeGeneration?: string; + expectedText?: string; + confirmed?: NativeSnapshot; + authority?: Terminal; + release?: () => void; + closed: boolean; + unsafe: boolean; + loading: boolean; + resolved: Map; + completed: Map; + }; + let snapshot = initial(), + owner: Owner | undefined, + operation: AbortController | undefined, + disposed = false, + serial = 0, + conversationSerial = 0, + disposal: Promise | undefined; + const listeners = new Set<() => void>(), + catalog = new Map(), + cleanups = new Set>(); + const publish = (update: Partial) => { + if (disposed) return; + snapshot = Object.freeze({ ...snapshot, ...update }); + listeners.forEach((f) => f()); + }; + const history = () => [...catalog].map(([id, s]) => ({ id, label: s.label })); + function bounded(work: () => void | Promise) { + let timer: ReturnType; + return Promise.race([ + Promise.resolve() + .then(work) + .catch(() => undefined), + new Promise((r) => { + timer = setTimeout(r, 2000); + }), + ]).finally(() => clearTimeout(timer)); + } + function close(o: Owner) { + if (o.closed) return Promise.resolve(); + o.closed = true; + // Each owned resource gets its release attempt even if another cleanup fails. + try { + o.release?.(); + } catch { + /* Continue releasing the session. */ + } + try { + o.content.dispose(); + } catch { + /* Continue releasing the session. */ + } + const p = bounded(() => o.session.dispose()); + cleanups.add(p); + void p.finally(() => cleanups.delete(p)); + return p; + } + function detach() { + const o = owner; + owner = undefined; + return o ? close(o) : Promise.resolve(); + } + function block(o?: Owner) { + if (o) { + o.unsafe = true; + catalog.delete(o.id); + } + publish({ + canSubmit: false, + outcome: 'error', + error: + 'This conversation could not be confirmed. Start a new conversation to continue.', + history: history(), + }); + } + function install( + o: Owner, + s: NativeSnapshot, + authority: Authority, + source: 'observed-root' | 'checkpoint' + ) { + const index = projectIndex({ + owner: o.key, + generation: o.generation, + currentOwner: o.key, + currentGeneration: o.generation, + source, + values: s.values, + }); + const rows = o.content.project({ + status: s.status as AgentStatus, + messages: s.messages, + toolCalls: s.toolCalls, + }); + const isReadRows = (value: ReadProjection): value is readonly SkillRead[] => + Array.isArray(value); + // Validate the full current catalog first so splitting evidence cannot hide + // duplicate IDs, aggregate limits, or malformed current calls. + let reads = projectReads(index, s.toolCalls, { kind: 'unconfirmed' }); + if (source === 'checkpoint') { + reads = projectReads(index, s.toolCalls, authority); + } else if (authority.kind === 'terminal' && isReadRows(reads)) { + // A rebound prior authority applies only to its validated raw call IDs. + // New calls retain pending/unknown until this turn receives fresh authority. + const priorCalls = s.toolCalls.filter((call) => + Object.hasOwn(authority.rawToolStatuses, call.id) + ); + const priorReads = projectReads(index, priorCalls, authority); + if (!isReadRows(priorReads)) reads = { kind: 'unavailable' }; + else { + const confirmed = new Map(priorReads.map((read) => [read.id, read])); + reads = reads.map((read) => + read.kind === 'unavailable' ? read : confirmed.get(read.id) ?? read + ); + } + } + publish({ + rows, + toolCalls: s.toolCalls, + index, + reads, + authority, + }); + } + function read(o: Owner, valid: () => boolean): NativeSnapshot | undefined { + if (!valid() || o.loading || o.unsafe) return; + try { + const source = o.session.getSnapshot(), + s = copyData(source) as NativeSnapshot; + if ( + !record(s) || + ![s.messages, s.toolCalls, s.interrupts, s.subgraphs].every( + Array.isArray + ) + ) + throw Error('Malformed snapshot'); + if (s.subgraphs.length) throw Error('Unsupported child state'); + if ( + o.confirmed && + (s.messages.length < o.confirmed.messages.length || + o.confirmed.messages.some( + (m, i) => + !sameData( + { ...m, delivery: undefined }, + { ...s.messages[i], delivery: undefined } + ) + ) || + o.confirmed.toolCalls.some( + (t) => + !sameData( + t, + s.toolCalls.find((x) => x.id === t.id) + ) + )) + ) + throw Error('Changed prior baseline'); + if ( + o.expectedText !== undefined && + s.messages.length > o.baseline.messages.length + ) { + const h = s.messages[o.baseline.messages.length]; + if ( + h.role !== 'user' || + h.content !== o.expectedText || + !h.delivery?.generation || + s.messages + .slice(o.baseline.messages.length + 1) + .some((m) => m.role === 'user') + ) + throw Error('Unowned human'); + if (!o.turn) { + o.turn = captureTurn(o.baseline, { + owner: o.key, + generation: o.generation, + threadId: o.id, + humanId: h.id, + humanContent: o.expectedText, + }); + o.nativeGeneration = h.delivery.generation; + if (!o.turn) throw Error('Unavailable turn'); + } + if ( + s.messages + .slice(o.baseline.messages.length) + .some((m) => m.delivery?.generation !== o.nativeGeneration) + ) + throw Error('Foreign generation'); + } + if (o.turn) { + const h = s.messages[o.baseline.messages.length]; + if ( + !h || + h.id !== o.turn.humanId || + h.delivery.generation !== o.nativeGeneration + ) + throw Error('Foreign generation'); + } + for (const m of s.messages) { + const semantic = { ...m, delivery: undefined }; + if (o.completed.has(m.id) && !sameData(o.completed.get(m.id), semantic)) + throw Error('Changed complete message'); + if (m.delivery?.phase === 'complete') o.completed.set(m.id, semantic); + } + for (const t of s.toolCalls) { + if (o.resolved.has(t.id) && !sameData(o.resolved.get(t.id), t)) + throw Error('Changed resolved tool'); + if (t.status === 'complete' || t.status === 'error') + o.resolved.set(t.id, t); + } + if ( + [...o.completed.keys()].some( + (id) => !s.messages.some((m) => m.id === id) + ) || + [...o.resolved.keys()].some( + (id) => !s.toolCalls.some((t) => t.id === id) + ) + ) + throw Error('Missing completed work'); + if (!valid()) return; + if (o.session.getSnapshot() !== source) throw Error('Changed source'); + // A validated full prior native prefix permits rebinding prior read + // evidence to this owner. It grants no authority to the current turn. + const priorTurn = + o.authority && + captureTurn(o.authority.turn.baseline, { + owner: o.key, + generation: o.generation, + threadId: o.id, + humanId: o.authority.turn.humanId, + humanContent: o.authority.turn.humanContent, + }); + const prior: Authority = + o.authority && priorTurn + ? { ...o.authority, turn: priorTurn } + : { kind: 'unconfirmed' }; + install(o, s, prior, 'observed-root'); + return s; + } catch { + if (valid()) block(o); + return; + } + } + function make(id: string): Owner { + const o: Owner = { + id, + key: String(++serial), + generation: String(++serial), + session: client.sessionFactory(id), + content: createMessageContent(), + baseline: { messages: [] }, + closed: false, + unsafe: false, + loading: false, + resolved: new Map(), + completed: new Map(), + }; + o.release = o.session.subscribe(() => { + const active = operation; + if (active && !o.loading) + read( + o, + () => + !disposed && + operation === active && + !active.signal.aborted && + owner === o && + !o.closed + ); + }); + return o; + } + async function confirm( + o: Owner, + controller: AbortController, + live: NativeSnapshot, + valid: () => boolean, + retained?: Saved + ) { + const observedState = copyData( + await client.readCurrent(o.id, controller.signal), + false + ); + if (!valid()) return; + const h = copyData( + await client.readHistory(o.id, controller.signal), + false + ); + if (!valid()) return; + if (!Array.isArray(h) || !record(h[0])) throw Error('Missing history'); + const requested = copyData(h[0].checkpoint, false) as Checkpoint; + const loadedCheckpoint = copyData( + await client.readCheckpoint(requested, controller.signal), + false + ); + if (!valid()) return; + o.loading = true; + await o.session.load({ signal: controller.signal }); + if (!valid()) return; + const source = o.session.getSnapshot(), + loaded = copyData(source) as NativeSnapshot; + if (!Array.isArray(loaded.subgraphs) || loaded.subgraphs.length) + throw Error('Unsupported child state'); + const authority = captureAuthority({ + turn: o.turn, + owner: o.key, + generation: o.generation, + threadId: o.id, + outcome: 'success', + observedState, + history: h, + loadedCheckpoint, + snapshot: retained ? loaded : live, + loadedSnapshot: loaded, + }); + if ( + authority.kind !== 'terminal' || + (retained && + (!sameData(authority.checkpoint, retained.authority.checkpoint) || + !sameData(authority.values, retained.authority.values))) + ) + throw Error('Unconfirmed root'); + if (!valid()) return; + if (o.session.getSnapshot() !== source) throw Error('Changed publication'); + const baseline = captureBaseline({ messages: authority.messages }); + if (!baseline) throw Error('Missing baseline'); + o.confirmed = loaded; + o.authority = authority; + o.baseline = baseline; + o.loading = false; + o.turn = undefined; + o.expectedText = undefined; + const saved: Saved = { + authority, + native: loaded, + label: catalog.get(o.id)?.label ?? `Conversation ${++conversationSerial}`, + }; + // Bound the entire page-local catalog, including repeated raw/native text. + // Oldest entries leave the selectable catalog rather than remaining usable. + copyData(saved); + catalog.delete(o.id); + catalog.set(o.id, saved); + while (catalog.size > LIMITS.entries) { + const oldest = catalog.keys().next().value; + if (oldest !== undefined) catalog.delete(oldest); + } + while (true) { + try { + copyData([...catalog.values()]); + break; + } catch { + const oldest = catalog.keys().next().value; + if (oldest === undefined || oldest === o.id) { + catalog.delete(o.id); + throw Error('Unavailable catalog budget'); + } + catalog.delete(oldest); + } + } + install(o, loaded, authority, 'checkpoint'); + if (!valid()) return; + publish({ canSubmit: true, outcome: 'success', history: history() }); + } + async function submit(text: string) { + if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim()) + return; + if (text.length > LIMITS.fieldText) { + block(owner); + return; + } + const controller = new AbortController(); + operation = controller; + const current = () => + !disposed && operation === controller && !controller.signal.aborted; + publish({ + busy: true, + canSubmit: false, + activity: owner ? 'running' : 'creating', + outcome: null, + error: null, + }); + try { + if (!current()) return; + if (!owner) { + const id = await client.createThread(controller.signal); + if (!current()) return; + if (typeof id !== 'string' || !id || id.length > LIMITS.fieldText) + throw Error('Unknown thread'); + const o = make(id); + if (!current()) { + void close(o); + return; + } + owner = o; + publish({ threadId: id, activity: 'running' }); + } + const o = owner, + valid = () => current() && owner === o && !o.closed; + o.generation = String(++serial); + o.turn = undefined; + o.nativeGeneration = undefined; + o.expectedText = text; + o.completed = new Map(); + o.resolved = new Map(); + const before = read(o, valid); + if (!valid()) return; + if ( + !before || + o.unsafe || + before.messages.length !== o.baseline.messages.length + ) + throw Error('Unowned baseline'); + const outcome = await o.session.submit(text, { + signal: controller.signal, + }); + if (!valid()) return; + const live = read(o, valid); + if (!valid()) return; + const human = live?.messages[o.baseline.messages.length]; + if ( + !live || + o.unsafe || + outcome !== 'success' || + !human || + human.role !== 'user' || + human.content !== text || + !human.delivery?.generation || + live.messages + .slice(o.baseline.messages.length + 1) + .some((m) => m.role === 'user') + ) + throw Error('Unconfirmed delivery'); + o.turn = captureTurn(o.baseline, { + owner: o.key, + generation: o.generation, + threadId: o.id, + humanId: human.id, + humanContent: text, + }); + o.nativeGeneration = human.delivery.generation; + if ( + !o.turn || + live.messages + .slice(o.baseline.messages.length) + .some((m) => m.delivery.generation !== o.nativeGeneration) + ) + throw Error('Foreign generation'); + publish({ activity: 'confirming' }); + await confirm(o, controller, live, valid); + } catch { + if (current()) block(owner); + } finally { + if (current()) { + operation = undefined; + publish({ busy: false, activity: 'idle' }); + } + } + } + async function selectConversation(id: string) { + if (disposed || !catalog.has(id)) return; + if (operation && snapshot.threadId === id) { + await stop(); + return; + } + const saved = catalog.get(id); + if (!saved) return; + if (operation && snapshot.threadId) catalog.delete(snapshot.threadId); + operation?.abort(); + operation = undefined; + const cleanup = detach(); + const controller = new AbortController(); + operation = controller; + const current = () => + !disposed && operation === controller && !controller.signal.aborted; + snapshot = initial(snapshot.viewGeneration + 1, history()); + publish({ + busy: true, + canSubmit: false, + activity: 'restoring', + threadId: id, + }); + try { + await cleanup; + if (!current()) return; + const o = make(id); + owner = o; + o.loading = true; + o.turn = captureTurn(saved.authority.turn.baseline, { + ...saved.authority.turn, + owner: o.key, + generation: o.generation, + }); + const valid = () => current() && owner === o && !o.closed; + await confirm(o, controller, saved.native, valid, saved); + } catch { + if (current()) { + catalog.delete(id); + block(owner); + } + } finally { + if (current()) { + operation = undefined; + publish({ busy: false, activity: 'idle' }); + } + } + } + async function stop() { + if (disposed || !operation) return; + operation.abort(); + operation = undefined; + const o = owner; + const invalidated = o?.id ?? snapshot.threadId; + if (invalidated) catalog.delete(invalidated); + const p = detach(); + publish({ + busy: false, + canSubmit: false, + activity: 'idle', + outcome: 'aborted', + error: null, + history: history(), + }); + await Promise.all([p, bounded(() => o?.session.stop())]); + } + async function newConversation() { + if (disposed) return; + if (operation && snapshot.threadId) catalog.delete(snapshot.threadId); + operation?.abort(); + operation = undefined; + const p = detach(); + snapshot = initial(snapshot.viewGeneration + 1, history()); + publish({}); + await p; + } + function dispose() { + if (disposal) return disposal; + disposed = true; + operation?.abort(); + operation = undefined; + void detach(); + listeners.clear(); + return (disposal = Promise.all([...cleanups]).then(() => undefined)); + } + return { + getSnapshot: () => snapshot, + subscribe: (f: () => void) => { + if (disposed) return () => undefined; + listeners.add(f); + return () => listeners.delete(f); + }, + submit, + stop, + newConversation, + selectConversation, + dispose, + }; +} +export type SkillsApplication = ReturnType; diff --git a/cockpit/deep-agents/skills/react/src/authority.spec.ts b/cockpit/deep-agents/skills/react/src/authority.spec.ts new file mode 100644 index 000000000..9445495ac --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/authority.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { + captureAuthority, + captureBaseline, + captureTurn, + copyData, +} from './authority'; +import { authorityInput } from './test-fixtures'; +describe('terminal owned Skills authority', () => { + it('confirms current/fresh-history/exact/native-load equality and retains raw success/error', () => { + expect(captureAuthority(authorityInput())).toMatchObject({ + kind: 'terminal', + rawToolStatuses: { read: 'success' }, + }); + expect(captureAuthority(authorityInput('error'))).toMatchObject({ + kind: 'terminal', + rawToolStatuses: { read: 'error' }, + }); + }); + it.each(['generation', 'threadId', 'outcome'])( + 'rejects mismatched %s', + (key) => { + const input = authorityInput(); + (input as any)[key] = 'wrong'; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + ); + it.each([ + 'observedState', + 'history', + 'loadedCheckpoint', + 'snapshot', + 'loadedSnapshot', + ])('rejects contradictory %s', (key) => { + const input = authorityInput(); + const target = key === 'history' ? input.history[0] : (input as any)[key]; + target.values.skills_metadata = []; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); + it('requires actual raw status and exact tool arguments/results/ids', () => { + expect(captureAuthority(authorityInput('unknown')).kind).toBe( + 'unconfirmed' + ); + for (const modify of [ + (i: any) => + (i.snapshot.toolCalls[0].args.file_path = '/skills/other/SKILL.md'), + (i: any) => (i.snapshot.toolCalls[0].result = 'false'), + (i: any) => i.snapshot.toolCalls.push(i.snapshot.toolCalls[0]), + (i: any) => i.loadedCheckpoint.tasks.push({ id: 'pending' }), + (i: any) => i.loadedCheckpoint.next.push('tools'), + ]) { + const i = authorityInput(); + modify(i); + expect(captureAuthority(i).kind).toBe('unconfirmed'); + } + }); + it('owns bounded plain data and rejects symbols, cycles, sparse arrays and hidden accessors', () => { + const cycle: any = {}; + cycle.self = cycle; + for (const value of [ + cycle, + Array(2), + new Date(), + { [Symbol('x')]: 1 }, + Object.defineProperty({}, 'x', { get: () => 1 }), + ]) + expect(() => copyData(value)).toThrow(); + }); + it('confirms a followup only against its full prior human/tool transcript', () => { + const previous = authorityInput(); + const prior = previous.loadedCheckpoint.values.messages; + const input = authorityInput(); + const continuation = [ + { id: 'second-human', type: 'human', content: 'Follow up.' }, + { + id: 'second-final', + type: 'ai', + content: 'Followup answer.', + tool_calls: [], + }, + ]; + const state = { + ...input.loadedCheckpoint, + values: { + ...input.loadedCheckpoint.values, + messages: [...prior, ...continuation], + }, + }; + const delivery = { + generation: 'next-native', + phase: 'complete', + outcome: 'success', + }; + const snapshot = { + ...input.snapshot, + messages: [ + ...input.snapshot.messages, + { id: 'second-human', role: 'user', content: 'Follow up.', delivery }, + { + id: 'second-final', + role: 'assistant', + content: 'Followup answer.', + toolCallIds: [], + delivery, + }, + ], + }; + const turn = captureTurn(captureBaseline({ messages: prior }), { + owner: 'owner', + generation: 'generation', + threadId: 'thread', + humanId: 'second-human', + humanContent: 'Follow up.', + }); + const candidate = { + ...input, + turn, + observedState: state, + loadedCheckpoint: structuredClone(state), + history: [structuredClone(state)], + snapshot, + loadedSnapshot: structuredClone(snapshot), + }; + expect(captureAuthority(candidate).kind).toBe('terminal'); + const incomplete = captureTurn( + captureBaseline({ messages: prior.slice(1) }), + { + owner: 'owner', + generation: 'generation', + threadId: 'thread', + humanId: 'second-human', + humanContent: 'Follow up.', + } + ); + expect(captureAuthority({ ...candidate, turn: incomplete }).kind).toBe( + 'unconfirmed' + ); + candidate.loadedCheckpoint.values.messages[2].content = + 'Prior result changed'; + expect(captureAuthority(candidate).kind).toBe('unconfirmed'); + }); + it('restoration requires an explicit current owner binding with a fresh exact checkpoint', () => { + const input = authorityInput(); + expect( + captureAuthority({ + ...input, + owner: 'new-owner', + generation: 'new-generation', + }).kind + ).toBe('unconfirmed'); + const turn = captureTurn(captureBaseline(), { + owner: 'new-owner', + generation: 'new-generation', + threadId: 'thread', + humanId: 'human', + humanContent: 'Inspect runway.', + }); + expect( + captureAuthority({ + ...input, + turn, + owner: 'new-owner', + generation: 'new-generation', + }).kind + ).toBe('terminal'); + }); +}); diff --git a/cockpit/deep-agents/skills/react/src/authority.ts b/cockpit/deep-agents/skills/react/src/authority.ts new file mode 100644 index 000000000..a43e775f5 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/authority.ts @@ -0,0 +1,636 @@ +import type { Message, ToolCall } from '@threadplane/core'; + +/** All limits count UTF16 code units, including property keys. */ +export const LIMITS = Object.freeze({ + depth: 32, + nodes: 10000, + copyText: 4 * 1024 * 1024, + displayText: 1024 * 1024, + fieldText: 65536, + entries: 100, +}); +export const record = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); +export const text = (v: unknown): v is string => + typeof v === 'string' && v.length > 0; + +/** Bounded capture before recursion or serialization. Never evaluates an accessor. + * Enumerable keys are counted before the reflective own-key scan; descriptors + * are inspected individually rather than allocating an unbounded descriptor map. */ +export function copyData(value: unknown, allowUndefined = true): unknown { + let nodes = 0, + units = 0; + const parents = new Set(); + const charge = (s: string) => { + units += s.length; + if (units > LIMITS.copyText) throw new Error('Data text limit'); + }; + function visit(v: unknown, depth: number): unknown { + if (++nodes > LIMITS.nodes || depth > LIMITS.depth) + throw new Error('Data structure limit'); + if (v === null || typeof v === 'boolean') return v; + if (typeof v === 'string') { + charge(v); + return v; + } + if (v === undefined && allowUndefined) return undefined; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v !== 'object' || v === null || parents.has(v)) + throw new Error('Nonplain data'); + const array = Array.isArray(v), + proto = Object.getPrototypeOf(v); + if ( + array + ? proto !== Array.prototype + : proto !== Object.prototype && proto !== null + ) + throw new Error('Nonplain prototype'); + const length = array + ? Object.getOwnPropertyDescriptor(v, 'length')?.value + : undefined; + if ( + array && + (!Number.isInteger(length) || length > LIMITS.nodes - nodes || length < 0) + ) + throw new Error('Array limit'); + let enumerable = 0; + for (const k in v) + if (Object.hasOwn(v, k) && ++enumerable > LIMITS.nodes - nodes) + throw new Error('Key limit'); + const keys = Reflect.ownKeys(v); + if (keys.length > LIMITS.nodes - nodes + (array ? 1 : 0)) + throw new Error('Key limit'); + if (array && keys.length !== length + 1) + throw new Error('Sparse or decorated array'); + const out: Record | unknown[] = array + ? [] + : Object.create(null); + parents.add(v); + for (const k of keys) { + if (typeof k !== 'string') throw new Error('Symbol data'); + if (array && k === 'length') continue; + if (array && (!/^(0|[1-9][0-9]*)$/.test(k) || Number(k) >= length)) + throw new Error('Decorated array'); + charge(k); + const d = Object.getOwnPropertyDescriptor(v, k); + if (!d || !('value' in d) || !d.enumerable) + throw new Error('Accessor or hidden data'); + Object.defineProperty(out, k, { + value: visit(d.value, depth + 1), + enumerable: true, + }); + } + parents.delete(v); + return Object.freeze(out); + } + return visit(value, 0); +} + +/** Stable semantic equality for already-owned bounded plain data. */ +export function sameData(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((v, i) => sameData(v, b[i])); + if (!record(a) || !record(b)) return false; + const ak = Object.keys(a), + bk = Object.keys(b); + return ( + ak.length === bk.length && + ak.every((k) => Object.hasOwn(b, k) && sameData(a[k], b[k])) + ); +} + +export function displayUnits(strings: readonly string[]): number { + let total = 0; + for (const s of strings) { + if (typeof s !== 'string') throw new Error('Unavailable display text'); + if (s.length > LIMITS.fieldText) throw new Error('Display field limit'); + total += s.length; + if (total > LIMITS.displayText) throw new Error('Display aggregate limit'); + } + return total; +} +/** Already-owned optional native metadata stays literal and contract-shaped. */ +export function validMessageMetadata(m: Record): boolean { + if ( + ['name', 'reasoning', 'toolCallId'].some( + (k) => m[k] !== undefined && typeof m[k] !== 'string' + ) + ) + return false; + if ( + m.toolCallIds !== undefined && + (!Array.isArray(m.toolCallIds) || + m.toolCallIds.some((v) => !text(v)) || + new Set(m.toolCallIds).size !== m.toolCallIds.length) + ) + return false; + if ( + m.citations !== undefined && + (!Array.isArray(m.citations) || + m.citations.some( + (c) => + !record(c) || + !text(c.id) || + typeof c.index !== 'number' || + !Number.isFinite(c.index) || + ['title', 'url', 'snippet', 'sourceType', 'iconUrl'].some( + (k) => c[k] !== undefined && typeof c[k] !== 'string' + ) || + (c.publishedAt !== undefined && + typeof c.publishedAt !== 'string' && + typeof c.publishedAt !== 'number') + )) + ) + return false; + return true; +} +export interface Baseline { + readonly messages: readonly unknown[]; +} +export interface TurnIdentity { + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly humanId: string; + readonly humanContent: string; +} +export interface Turn extends TurnIdentity { + readonly baseline: Baseline; +} +export interface Checkpoint { + readonly thread_id: string; + readonly checkpoint_ns: ''; + readonly checkpoint_id: string; + readonly checkpoint_map?: Readonly>; +} +export interface NativeSnapshot { + readonly status: string; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly values?: unknown; + readonly interrupts: readonly unknown[]; + readonly subgraphs: readonly unknown[]; + readonly error?: unknown; +} +export interface AuthorityInput { + readonly turn: Turn | undefined; + readonly owner: string; + readonly generation: string; + readonly threadId: string; + readonly outcome: string; + /** Current root from the owned raw client, never child values or native synthesis. */ + readonly observedState: unknown; + /** Fresh raw history from that same client and thread, newest first. */ + readonly history: readonly unknown[]; + /** Exact getState(thread, history[0].checkpoint), not a second current lookup. */ + readonly loadedCheckpoint: unknown; + readonly snapshot: NativeSnapshot; + readonly loadedSnapshot: NativeSnapshot; +} +export type Authority = + | { readonly kind: 'unconfirmed' } + | { + readonly kind: 'terminal'; + readonly checkpoint: Checkpoint; + readonly messages: readonly unknown[]; + readonly values: Readonly>; + readonly rawToolStatuses: Readonly>; + readonly turn: Turn; + }; +export function captureBaseline( + input: unknown = { messages: [] } +): Baseline | undefined { + try { + const v = copyData(input, false); + if (!record(v) || !Array.isArray(v.messages)) return undefined; + return Object.freeze({ messages: v.messages }); + } catch { + return undefined; + } +} +export function captureTurn( + baseline: Baseline | undefined, + input: TurnIdentity +): Turn | undefined { + try { + const b = copyData(baseline, false), + identity = copyData(input, false); + if ( + !record(b) || + !Array.isArray(b.messages) || + !record(identity) || + ['owner', 'generation', 'threadId', 'humanId'].some( + (k) => + !text(identity[k]) || + (identity[k] as string).length > LIMITS.fieldText + ) || + typeof identity.humanContent !== 'string' || + identity.humanContent.length > LIMITS.fieldText || + b.messages.some((m) => record(m) && m.id === identity.humanId) + ) + return undefined; + return Object.freeze({ ...identity, baseline: b }) as unknown as Turn; + } catch { + return undefined; + } +} +function checkpointSource( + value: unknown, + threadId: string +): Checkpoint | undefined { + if ( + !record(value) || + value.thread_id !== threadId || + value.checkpoint_ns !== '' || + !text(value.checkpoint_id) || + value.checkpoint_id.length > LIMITS.fieldText || + Object.keys(value).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) + ) + return undefined; + if ( + value.checkpoint_map !== undefined && + (!record(value.checkpoint_map) || + Object.keys(value.checkpoint_map).length !== 1 || + value.checkpoint_map[''] !== value.checkpoint_id) + ) + return undefined; + return value as unknown as Checkpoint; +} +const empty = (v: unknown) => + v === undefined || (Array.isArray(v) && v.length === 0); +const clean = (v: Record) => + (v.error === undefined || v.error === null) && + empty(v.interrupts) && + empty(v.subgraphs) && + empty(v.__interrupt__); + +/** Canonical root semantics independent of native delivery bookkeeping. */ +function normalizedRaw(raw: readonly unknown[]): + | { + messages: Record[]; + tools: Record[]; + statuses: Record; + } + | undefined { + const messages: Record[] = [], + tools = new Map>(), + ids = new Set(), + pending = new Set(), + statuses: Record = Object.create(null); + for (const w of raw) { + if ( + !record(w) || + !text(w.id) || + ids.has(w.id) || + typeof w.content !== 'string' || + !clean(w) || + !empty(w.invalid_tool_calls) || + !['human', 'ai', 'tool'].includes(w.type as string) + ) + return undefined; + ids.add(w.id); + const extra = w.additional_kwargs ?? {}; + if ( + !record(extra) || + !empty(extra.tool_calls) || + extra.function_call !== undefined + ) + return undefined; + const reasoning = + typeof w.reasoning === 'string' + ? w.reasoning + : typeof extra.reasoning_content === 'string' + ? extra.reasoning_content + : undefined; + // Supported citation shape is normalized explicitly; no private runtime imports. + const source = extra.citations ?? extra.sources; + let citations: unknown; + if (Array.isArray(source)) + citations = source.map((s, i) => { + if (typeof s === 'string') + return { id: `c${i + 1}`, index: i + 1, url: s }; + const r = record(s) ? s : {}, + out: Record = { + id: + [r.id, r.refId].find((x) => typeof x === 'string') ?? `c${i + 1}`, + index: + typeof r.index === 'number' && Number.isFinite(r.index) + ? r.index + : i + 1, + }; + for (const [k, aliases] of Object.entries({ + title: ['title', 'name'], + url: ['url', 'href', 'source'], + snippet: ['snippet', 'content', 'excerpt'], + sourceType: ['sourceType'], + iconUrl: ['iconUrl'], + })) { + const v = aliases.map((a) => r[a]).find((x) => typeof x === 'string'); + if (v !== undefined) out[k] = v; + } + if ( + typeof r.publishedAt === 'string' || + typeof r.publishedAt === 'number' + ) + out.publishedAt = r.publishedAt; + if (record(r.extra)) out.extra = r.extra; + return out; + }); + const m: Record = { + id: w.id, + role: + w.type === 'human' ? 'user' : w.type === 'ai' ? 'assistant' : 'tool', + content: w.content, + ...(typeof w.name === 'string' ? { name: w.name } : {}), + }; + if (reasoning !== undefined) m.reasoning = reasoning; + if (citations !== undefined) m.citations = citations; + if (w.type === 'ai') { + if (pending.size || w.tool_call_id !== undefined) return undefined; + const calls = w.tool_calls ?? []; + if (!Array.isArray(calls)) return undefined; + m.toolCallIds = []; + for (const c of calls) { + if ( + !record(c) || + !text(c.id) || + !text(c.name) || + tools.has(c.id) || + !Object.hasOwn(c, 'args') || + (c.type !== undefined && c.type !== 'tool_call') || + Object.keys(c).some( + (k) => !['id', 'name', 'args', 'type'].includes(k) + ) + ) + return undefined; + (m.toolCallIds as string[]).push(c.id); + tools.set(c.id, { + id: c.id, + name: c.name, + args: c.args, + status: 'pending', + }); + pending.add(c.id); + } + } else if (w.type === 'tool') { + const t = + typeof w.tool_call_id === 'string' + ? tools.get(w.tool_call_id) + : undefined; + if ( + !t || + !pending.delete(w.tool_call_id as string) || + !['success', 'error'].includes(w.status as string) || + (w.name !== t.name && + !( + w.status === 'error' && + (w.name === null || w.name === undefined) + )) || + !empty(w.tool_calls) + ) + return undefined; + m.toolCallId = w.tool_call_id; + t.status = 'complete'; + t.result = w.content; + statuses[w.tool_call_id as string] = w.status as 'success' | 'error'; + } else if ( + pending.size || + !empty(w.tool_calls) || + w.tool_call_id !== undefined + ) + return undefined; + messages.push(m); + } + if ( + pending.size || + messages[0]?.role !== 'user' || + messages.at(-1)?.role !== 'assistant' || + !text(messages.at(-1)?.content) || + (messages.at(-1)?.toolCallIds as unknown[])?.length + ) + return undefined; + return { messages, tools: [...tools.values()], statuses }; +} +function nativeSemantic( + input: NativeSnapshot +): + | { messages: Record[]; tools: readonly ToolCall[] } + | undefined { + if ( + input.status !== 'idle' || + (input.error !== undefined && input.error !== null) || + !Array.isArray(input.interrupts) || + input.interrupts.length || + !Array.isArray(input.subgraphs) || + !Array.isArray(input.messages) || + !Array.isArray(input.toolCalls) + ) + return undefined; + const messages: Record[] = [], + ids = new Set(); + for (const m of input.messages) { + if ( + !record(m) || + !text(m.id) || + ids.has(m.id) || + typeof m.content !== 'string' || + !validMessageMetadata(m) || + !record(m.delivery) || + !text(m.delivery.generation) || + m.delivery.phase !== 'complete' || + m.delivery.outcome !== 'success' || + Object.keys(m).some( + (k) => + ![ + 'id', + 'role', + 'content', + 'delivery', + 'reasoning', + 'citations', + 'name', + 'toolCallId', + 'toolCallIds', + ].includes(k) + ) + ) + return undefined; + ids.add(m.id); + const out: Record = { + id: m.id, + role: m.role, + content: m.content, + }; + for (const k of ['name', 'reasoning', 'citations', 'toolCallId']) + if (m[k as keyof Message] !== undefined) out[k] = m[k as keyof Message]; + if (m.role === 'assistant') { + if (m.toolCallIds !== undefined && !Array.isArray(m.toolCallIds)) + return undefined; + out.toolCallIds = m.toolCallIds ?? []; + } else if (!empty(m.toolCallIds)) return undefined; + messages.push(out); + } + return { messages, tools: input.toolCalls }; +} +/** Only contiguous, complete result batches declared by a current assistant may + * permute. Prior canonical history and all human/assistant boundaries stay strict. */ +function matchesRoot( + raw: ReturnType, + native: ReturnType, + prefix: number +): boolean { + if ( + !raw || + !native || + raw.messages.length !== native.messages.length || + raw.tools.length !== native.tools.length + ) + return false; + const catalog = new Map(); + for (const t of native.tools) { + if ( + !record(t) || + !text(t.id) || + catalog.has(t.id) || + t.status !== 'complete' + ) + return false; + catalog.set(t.id, t); + } + for (const t of raw.tools) + if (!sameData(t, catalog.get(t.id as string))) return false; + for (let i = 0; i < raw.messages.length; i++) { + const r = raw.messages[i], + n = native.messages[i]; + if (!sameData(r, n)) return false; + const callIds = r.role === 'assistant' ? (r.toolCallIds as string[]) : []; + if (i >= prefix && callIds.length) { + const batch = raw.messages.slice(i + 1, i + 1 + callIds.length), + observed = native.messages.slice(i + 1, i + 1 + callIds.length); + if ( + batch.length !== callIds.length || + observed.length !== callIds.length || + batch.some((m) => m.role !== 'tool') || + observed.some((m) => m.role !== 'tool') + ) + return false; + const byId = new Map(observed.map((m) => [m.toolCallId, m])); + if ( + byId.size !== callIds.length || + batch.some( + (m) => + !callIds.includes(m.toolCallId as string) || + !sameData(m, byId.get(m.toolCallId)) + ) + ) + return false; + i += callIds.length; + } + } + return true; +} + +/** A failure yields no replacement: callers retain previously confirmed authority. + * The caller captures its owned client and turn once before asynchronous reads. */ +export function captureAuthority(input: unknown): Authority { + const unavailable: Authority = Object.freeze({ kind: 'unconfirmed' }); + try { + const captured = copyData(input) as AuthorityInput; + const turn = captured.turn; + if ( + !turn || + ['owner', 'generation', 'threadId', 'humanId'].some( + (k) => + !text((turn as unknown as Record)[k]) || + ((turn as unknown as Record)[k] as string).length > + LIMITS.fieldText + ) || + typeof turn.humanContent !== 'string' || + turn.humanContent.length > LIMITS.fieldText || + captured.outcome !== 'success' || + turn.owner !== captured.owner || + turn.generation !== captured.generation || + turn.threadId !== captured.threadId || + !Array.isArray(turn.baseline?.messages) + ) + return unavailable; + const observed = copyData(captured.observedState, false), + loaded = copyData(captured.loadedCheckpoint, false), + history = copyData(captured.history, false); + if ( + !record(observed) || + !record(loaded) || + !Array.isArray(history) || + !history.length || + !sameData(observed, loaded) || + !sameData(history[0], loaded) || + !clean(loaded) || + !Array.isArray(loaded.next) || + loaded.next.length || + !Array.isArray(loaded.tasks) || + loaded.tasks.length || + !record(loaded.values) || + !clean(loaded.values) || + !Array.isArray(loaded.values.messages) + ) + return unavailable; + const cp = checkpointSource(loaded.checkpoint, captured.threadId); + if (!cp) return unavailable; + const checkpointIds = new Set(); + for (const h of history) { + if (!record(h)) return unavailable; + const c = checkpointSource(h.checkpoint, captured.threadId); + if (!c || checkpointIds.has(c.checkpoint_id)) return unavailable; + checkpointIds.add(c.checkpoint_id); + } + const messages = loaded.values.messages, + prefix = turn.baseline.messages; + if (!sameData(prefix, messages.slice(0, prefix.length))) return unavailable; + const human = messages[prefix.length]; + if ( + !record(human) || + human.type !== 'human' || + human.id !== turn.humanId || + human.content !== turn.humanContent || + messages.filter((m) => record(m) && m.id === turn.humanId).length !== 1 || + messages + .slice(prefix.length + 1) + .some((m) => record(m) && m.type === 'human') + ) + return unavailable; + const raw = normalizedRaw(messages), + before = nativeSemantic(captured.snapshot), + after = nativeSemantic(captured.loadedSnapshot); + const otherValues: Record = {}; + for (const k of Object.keys(loaded.values)) + if (k !== 'messages' && k !== '__interrupt__') + otherValues[k] = loaded.values[k]; + if ( + !raw || + !matchesRoot(raw, before, prefix.length) || + !matchesRoot(raw, after, prefix.length) || + !sameData(captured.snapshot.values, otherValues) || + !sameData(captured.loadedSnapshot.values, otherValues) + ) + return unavailable; + return Object.freeze({ + kind: 'terminal', + checkpoint: cp, + messages, + values: loaded.values, + rawToolStatuses: Object.freeze(raw.statuses), + turn, + }); + } catch { + return unavailable; + } +} diff --git a/cockpit/deep-agents/skills/react/src/connection.spec.ts b/cockpit/deep-agents/skills/react/src/connection.spec.ts new file mode 100644 index 000000000..8696a1335 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/connection.spec.ts @@ -0,0 +1,105 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +const construct = vi.fn(); +const create = vi.fn(), + getState = vi.fn(), + getHistory = vi.fn(); +vi.mock('@langchain/langgraph-sdk', () => ({ + Client: class { + threads = { create, getState, getHistory }; + constructor(options: unknown) { + construct(options); + } + }, +})); +vi.mock('@threadplane/langgraph', () => ({ createSession: vi.fn() })); +import { createSession } from '@threadplane/langgraph'; +import { createSkillsClient } from './connection'; +beforeEach(() => { + vi.clearAllMocks(); +}); +it('reads only threads it explicitly created and owns immutable credentials', async () => { + const headers = { 'x-api-key': 'original' }; + const c = createSkillsClient({ apiUrl: 'https://example.test', headers }); + headers['x-api-key'] = 'changed'; + expect(construct.mock.calls[0][0]).toMatchObject({ + apiUrl: 'https://example.test', + defaultHeaders: { 'x-api-key': 'original' }, + callerOptions: { maxRetries: 0 }, + }); + const signal = new AbortController().signal; + await expect(c.readCurrent('foreign', signal)).rejects.toThrow(); + expect(getState).not.toHaveBeenCalled(); + create.mockImplementation(async ({ threadId }) => ({ thread_id: threadId })); + const id = await c.createThread(signal); + vi.mocked(createSession).mockReturnValue({ + getSnapshot: () => ({ + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }), + subscribe: vi.fn(), + submit: vi.fn(), + load: vi.fn(), + stop: vi.fn(), + dispose: vi.fn(), + } as never); + c.sessionFactory(id); + expect(createSession).toHaveBeenCalledWith({ + assistantId: 'da-skills', + threadId: id, + apiUrl: 'https://example.test', + clientOptions: { + maxRetries: 0, + defaultHeaders: { 'x-api-key': 'original' }, + }, + }); + getHistory.mockResolvedValue([]); + await c.readHistory(id, signal); + expect(getHistory).toHaveBeenCalledWith(id, { limit: 1, signal }); + await expect( + c.readCheckpoint( + { thread_id: id, checkpoint_ns: 'child' as '', checkpoint_id: 'bad' }, + signal + ) + ).rejects.toThrow(); + expect(getState).not.toHaveBeenCalled(); +}); +it('captures checkpoint getters without evaluating them', async () => { + let reads = 0; + const c = createSkillsClient({ + apiUrl: 'https://example.test', + headers: {}, + }); + const signal = new AbortController().signal; + const bad = Object.defineProperty({}, 'thread_id', { + get() { + reads++; + return 'bad'; + }, + enumerable: true, + }); + await expect(c.readCheckpoint(bad as never, signal)).rejects.toThrow(); + expect(reads).toBe(0); +}); + +it('aborted pending owned read cannot return authority', async () => { + const c = createSkillsClient({ + apiUrl: 'https://example.test', + headers: {}, + }); + const controller = new AbortController(); + create.mockImplementation(async ({ threadId }) => ({ thread_id: threadId })); + const id = await c.createThread(controller.signal); + let finish: ((value: unknown) => void) | undefined; + getState.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const pending = c.readCurrent(id, controller.signal); + controller.abort(); + finish?.({ checkpoint: { thread_id: id } }); + await expect(pending).rejects.toThrow('Conversation unavailable'); +}); diff --git a/cockpit/deep-agents/skills/react/src/connection.ts b/cockpit/deep-agents/skills/react/src/connection.ts new file mode 100644 index 000000000..1555a4aea --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/connection.ts @@ -0,0 +1,148 @@ +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 { + createSkillsApplication, + type SkillsClient, + type SkillsSession, +} from './application'; +import { copyData, record, text, LIMITS, type Checkpoint } from './authority'; +export interface SkillsConnection { + readonly apiUrl: string; + readonly headers: Record; +} +export function skillsConnection( + configuration: RuntimeBridgeConfiguration +): SkillsConnection { + 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 createSkillsClient(input: SkillsConnection): SkillsClient { + const connection = Object.freeze({ + apiUrl: input.apiUrl, + headers: Object.freeze({ ...input.headers }), + }), + known = new Set(); + const client = new Client({ + apiUrl: connection.apiUrl, + apiKey: null, + defaultHeaders: connection.headers, + callerOptions: { maxRetries: 0 }, + }); + function owned(id: string, signal: AbortSignal) { + if (!known.has(id) || signal.aborted) + throw Error('Conversation unavailable.'); + } + return { + async createThread(signal) { + if (signal.aborted) throw Error('Aborted'); + const id = crypto.randomUUID(), + created = await client.threads.create({ + threadId: id, + ifExists: 'raise', + signal, + }); + if (signal.aborted || created.thread_id !== id) + throw Error('Conversation creation was not confirmed.'); + known.add(id); + return id; + }, + sessionFactory(id) { + if (!known.has(id)) throw Error('Unknown conversation.'); + const session = createSession({ + assistantId: 'da-skills', + threadId: id, + apiUrl: connection.apiUrl, + clientOptions: { maxRetries: 0, defaultHeaders: connection.headers }, + }); + try { + const state = session.getSnapshot(); + if ( + ![ + 'getSnapshot', + 'subscribe', + 'submit', + 'load', + 'stop', + 'dispose', + ].every( + (k) => typeof session[k as keyof typeof session] === 'function' + ) || + ![ + state.messages, + state.toolCalls, + state.interrupts, + state.subgraphs, + ].every((x) => Array.isArray(x) && x.length === 0) + ) + throw Error('Rejected session'); + return session as SkillsSession; + } catch { + void Promise.resolve(session.dispose()).catch(() => undefined); + throw Error('Runtime session is unavailable.'); + } + }, + async readCurrent(id, signal) { + owned(id, signal); + const value = await client.threads.getState(id, undefined, { signal }); + owned(id, signal); + return value; + }, + async readHistory(id, signal) { + owned(id, signal); + const value = await client.threads.getHistory(id, { limit: 1, signal }); + owned(id, signal); + return value; + }, + async readCheckpoint(requested, signal) { + const v = copyData(requested, false); + if ( + !record(v) || + !text(v.thread_id) || + v.checkpoint_ns !== '' || + !text(v.checkpoint_id) || + v.checkpoint_id.length > LIMITS.fieldText || + Object.keys(v).some( + (k) => + ![ + 'thread_id', + 'checkpoint_ns', + 'checkpoint_id', + 'checkpoint_map', + ].includes(k) + ) || + (v.checkpoint_map !== undefined && + (!record(v.checkpoint_map) || + Object.keys(v.checkpoint_map).length !== 1 || + v.checkpoint_map[''] !== v.checkpoint_id)) + ) + throw Error('Checkpoint unavailable.'); + const c = v as unknown as Checkpoint; + owned(c.thread_id, signal); + const value = await client.threads.getState( + c.thread_id, + c as Parameters[1], + { signal } + ); + owned(c.thread_id, signal); + return value; + }, + }; +} +export function createConnectedApplication(connection: SkillsConnection) { + return createSkillsApplication(createSkillsClient(connection)); +} diff --git a/cockpit/deep-agents/skills/react/src/fixtures/actual-skills-root.json b/cockpit/deep-agents/skills/react/src/fixtures/actual-skills-root.json new file mode 100644 index 000000000..9c409200e --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/fixtures/actual-skills-root.json @@ -0,0 +1,685 @@ +{ + "current": { + "values": { + "messages": [ + { + "content": "Inspect bundled skills.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "human", + "name": null, + "id": "offline-human" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f58-7640-9cc9-99efc7807820-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/SKILL.md", + "limit": 100 + }, + "id": "offline-human-0", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": " 1 ---\n 2 name: runway-analysis\n 3 description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.\n 4 license: MIT\n 5 ---\n 6 \n 7 # Runway Analysis\n 8 \n 9 ## When to use\n10 \n11 The user is asking whether an aircraft can safely operate from a specific\n12 runway, or is comparing two airports for a trip.\n13 \n14 ## Procedure\n15 \n16 1. Get the field elevation and the longest runway length with\n17 `lookup_field_elevation` and `lookup_runway_length`.\n18 2. Read `/skills/runway-analysis/reference/margins.md` for the required margin\n19 table. Do not work from memory \u2014 the table is the authority.\n20 3. Compare the runway length against the required distance for the aircraft\n21 class at that elevation.\n22 4. State the verdict in one sentence, then give the two numbers you compared.\n23 \n24 ## Reporting\n25 \n26 Always name the margin you applied. A verdict without the margin is not\n27 reviewable.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "7b6a1016-ccf6-415b-bb71-24bcadbed082", + "tool_call_id": "offline-human-0", + "artifact": null, + "status": "success" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f61-73e3-801e-46b66c545867-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/reference/margins.md", + "limit": 1 + }, + "id": "offline-human-1", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": "1 # Required Runway Margins\n\n[Read 1 line (lines 1-1 of 16 total). 15 lines remaining from offset 1.]", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "a20b3f67-90ea-4a9b-b463-ac2225735b2e", + "tool_call_id": "offline-human-1", + "artifact": null, + "status": "success" + }, + { + "content": "Offline assessment complete.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f6f-7bd1-a3dc-104e438edb92-0", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": null + } + ], + "skills_metadata": [ + { + "name": "runway-analysis", + "description": "Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.", + "path": "/skills/runway-analysis/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + }, + { + "name": "weather-brief", + "description": "Turn a raw field observation into a short operational weather brief. Use when the user asks about conditions, ceilings, visibility, or whether the weather is a problem for a departure or arrival.", + "path": "/skills/weather-brief/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + } + ], + "files": {} + }, + "next": [], + "tasks": [], + "checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-231a-6376-800b-d00d16e869a4" + }, + "metadata": { + "source": "loop", + "step": 11, + "parents": {}, + "counters_since_delta_snapshot": { + "messages": [ + 6, + 13 + ], + "files": [ + 0, + 13 + ] + } + }, + "created_at": "2026-10-11T22:43:09.810357+00:00", + "parent_checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-2317-6e28-800a-c4310de0c69c" + } + }, + "exact": { + "values": { + "messages": [ + { + "content": "Inspect bundled skills.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "human", + "name": null, + "id": "offline-human" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f58-7640-9cc9-99efc7807820-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/SKILL.md", + "limit": 100 + }, + "id": "offline-human-0", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": " 1 ---\n 2 name: runway-analysis\n 3 description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.\n 4 license: MIT\n 5 ---\n 6 \n 7 # Runway Analysis\n 8 \n 9 ## When to use\n10 \n11 The user is asking whether an aircraft can safely operate from a specific\n12 runway, or is comparing two airports for a trip.\n13 \n14 ## Procedure\n15 \n16 1. Get the field elevation and the longest runway length with\n17 `lookup_field_elevation` and `lookup_runway_length`.\n18 2. Read `/skills/runway-analysis/reference/margins.md` for the required margin\n19 table. Do not work from memory \u2014 the table is the authority.\n20 3. Compare the runway length against the required distance for the aircraft\n21 class at that elevation.\n22 4. State the verdict in one sentence, then give the two numbers you compared.\n23 \n24 ## Reporting\n25 \n26 Always name the margin you applied. A verdict without the margin is not\n27 reviewable.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "7b6a1016-ccf6-415b-bb71-24bcadbed082", + "tool_call_id": "offline-human-0", + "artifact": null, + "status": "success" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f61-73e3-801e-46b66c545867-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/reference/margins.md", + "limit": 1 + }, + "id": "offline-human-1", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": "1 # Required Runway Margins\n\n[Read 1 line (lines 1-1 of 16 total). 15 lines remaining from offset 1.]", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "a20b3f67-90ea-4a9b-b463-ac2225735b2e", + "tool_call_id": "offline-human-1", + "artifact": null, + "status": "success" + }, + { + "content": "Offline assessment complete.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f6f-7bd1-a3dc-104e438edb92-0", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": null + } + ], + "skills_metadata": [ + { + "name": "runway-analysis", + "description": "Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.", + "path": "/skills/runway-analysis/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + }, + { + "name": "weather-brief", + "description": "Turn a raw field observation into a short operational weather brief. Use when the user asks about conditions, ceilings, visibility, or whether the weather is a problem for a departure or arrival.", + "path": "/skills/weather-brief/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + } + ], + "files": {} + }, + "next": [], + "tasks": [], + "checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-231a-6376-800b-d00d16e869a4" + }, + "metadata": { + "source": "loop", + "step": 11, + "parents": {}, + "counters_since_delta_snapshot": { + "messages": [ + 6, + 13 + ], + "files": [ + 0, + 13 + ] + } + }, + "created_at": "2026-10-11T22:43:09.810357+00:00", + "parent_checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-2317-6e28-800a-c4310de0c69c" + } + }, + "history": [ + { + "values": { + "messages": [ + { + "content": "Inspect bundled skills.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "human", + "name": null, + "id": "offline-human" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f58-7640-9cc9-99efc7807820-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/SKILL.md", + "limit": 100 + }, + "id": "offline-human-0", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": " 1 ---\n 2 name: runway-analysis\n 3 description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.\n 4 license: MIT\n 5 ---\n 6 \n 7 # Runway Analysis\n 8 \n 9 ## When to use\n10 \n11 The user is asking whether an aircraft can safely operate from a specific\n12 runway, or is comparing two airports for a trip.\n13 \n14 ## Procedure\n15 \n16 1. Get the field elevation and the longest runway length with\n17 `lookup_field_elevation` and `lookup_runway_length`.\n18 2. Read `/skills/runway-analysis/reference/margins.md` for the required margin\n19 table. Do not work from memory \u2014 the table is the authority.\n20 3. Compare the runway length against the required distance for the aircraft\n21 class at that elevation.\n22 4. State the verdict in one sentence, then give the two numbers you compared.\n23 \n24 ## Reporting\n25 \n26 Always name the margin you applied. A verdict without the margin is not\n27 reviewable.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "7b6a1016-ccf6-415b-bb71-24bcadbed082", + "tool_call_id": "offline-human-0", + "artifact": null, + "status": "success" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f61-73e3-801e-46b66c545867-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/reference/margins.md", + "limit": 1 + }, + "id": "offline-human-1", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": "1 # Required Runway Margins\n\n[Read 1 line (lines 1-1 of 16 total). 15 lines remaining from offset 1.]", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "a20b3f67-90ea-4a9b-b463-ac2225735b2e", + "tool_call_id": "offline-human-1", + "artifact": null, + "status": "success" + }, + { + "content": "Offline assessment complete.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f6f-7bd1-a3dc-104e438edb92-0", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": null + } + ], + "skills_metadata": [ + { + "name": "runway-analysis", + "description": "Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.", + "path": "/skills/runway-analysis/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + }, + { + "name": "weather-brief", + "description": "Turn a raw field observation into a short operational weather brief. Use when the user asks about conditions, ceilings, visibility, or whether the weather is a problem for a departure or arrival.", + "path": "/skills/weather-brief/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + } + ], + "files": {} + }, + "next": [], + "tasks": [], + "checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-231a-6376-800b-d00d16e869a4" + }, + "metadata": { + "source": "loop", + "step": 11, + "parents": {}, + "counters_since_delta_snapshot": { + "messages": [ + 6, + 13 + ], + "files": [ + 0, + 13 + ] + } + }, + "created_at": "2026-10-11T22:43:09.810357+00:00", + "parent_checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-2317-6e28-800a-c4310de0c69c" + } + }, + { + "values": { + "messages": [ + { + "content": "Inspect bundled skills.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "human", + "name": null, + "id": "offline-human" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f58-7640-9cc9-99efc7807820-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/SKILL.md", + "limit": 100 + }, + "id": "offline-human-0", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": " 1 ---\n 2 name: runway-analysis\n 3 description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.\n 4 license: MIT\n 5 ---\n 6 \n 7 # Runway Analysis\n 8 \n 9 ## When to use\n10 \n11 The user is asking whether an aircraft can safely operate from a specific\n12 runway, or is comparing two airports for a trip.\n13 \n14 ## Procedure\n15 \n16 1. Get the field elevation and the longest runway length with\n17 `lookup_field_elevation` and `lookup_runway_length`.\n18 2. Read `/skills/runway-analysis/reference/margins.md` for the required margin\n19 table. Do not work from memory \u2014 the table is the authority.\n20 3. Compare the runway length against the required distance for the aircraft\n21 class at that elevation.\n22 4. State the verdict in one sentence, then give the two numbers you compared.\n23 \n24 ## Reporting\n25 \n26 Always name the margin you applied. A verdict without the margin is not\n27 reviewable.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "7b6a1016-ccf6-415b-bb71-24bcadbed082", + "tool_call_id": "offline-human-0", + "artifact": null, + "status": "success" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f61-73e3-801e-46b66c545867-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/reference/margins.md", + "limit": 1 + }, + "id": "offline-human-1", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": "1 # Required Runway Margins\n\n[Read 1 line (lines 1-1 of 16 total). 15 lines remaining from offset 1.]", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "a20b3f67-90ea-4a9b-b463-ac2225735b2e", + "tool_call_id": "offline-human-1", + "artifact": null, + "status": "success" + }, + { + "content": "Offline assessment complete.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f6f-7bd1-a3dc-104e438edb92-0", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": null + } + ], + "skills_metadata": [ + { + "name": "runway-analysis", + "description": "Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.", + "path": "/skills/runway-analysis/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + }, + { + "name": "weather-brief", + "description": "Turn a raw field observation into a short operational weather brief. Use when the user asks about conditions, ceilings, visibility, or whether the weather is a problem for a departure or arrival.", + "path": "/skills/weather-brief/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + } + ], + "files": {} + }, + "next": [ + "SkillsVisibilityMiddleware.after_agent" + ], + "tasks": [ + [ + "2b6a87fe-d00f-a8a8-b398-ba3aea606196", + "SkillsVisibilityMiddleware.after_agent", + [ + "__pregel_pull", + "SkillsVisibilityMiddleware.after_agent" + ], + null, + [], + null, + {} + ] + ], + "checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-2317-6e28-800a-c4310de0c69c" + }, + "metadata": { + "source": "loop", + "step": 10, + "parents": {}, + "counters_since_delta_snapshot": { + "messages": [ + 6, + 12 + ], + "files": [ + 0, + 12 + ] + } + }, + "created_at": "2026-10-11T22:43:09.809390+00:00", + "parent_checkpoint": { + "thread_id": "offline-skills-task2", + "checkpoint_ns": "", + "checkpoint_id": "1f1c5c52-2300-619c-8009-6f294c3864c2" + } + } + ], + "rootValues": { + "messages": [ + { + "content": "Inspect bundled skills.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "human", + "name": null, + "id": "offline-human" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f58-7640-9cc9-99efc7807820-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/SKILL.md", + "limit": 100 + }, + "id": "offline-human-0", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": " 1 ---\n 2 name: runway-analysis\n 3 description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.\n 4 license: MIT\n 5 ---\n 6 \n 7 # Runway Analysis\n 8 \n 9 ## When to use\n10 \n11 The user is asking whether an aircraft can safely operate from a specific\n12 runway, or is comparing two airports for a trip.\n13 \n14 ## Procedure\n15 \n16 1. Get the field elevation and the longest runway length with\n17 `lookup_field_elevation` and `lookup_runway_length`.\n18 2. Read `/skills/runway-analysis/reference/margins.md` for the required margin\n19 table. Do not work from memory \u2014 the table is the authority.\n20 3. Compare the runway length against the required distance for the aircraft\n21 class at that elevation.\n22 4. State the verdict in one sentence, then give the two numbers you compared.\n23 \n24 ## Reporting\n25 \n26 Always name the margin you applied. A verdict without the margin is not\n27 reviewable.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "7b6a1016-ccf6-415b-bb71-24bcadbed082", + "tool_call_id": "offline-human-0", + "artifact": null, + "status": "success" + }, + { + "content": "", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f61-73e3-801e-46b66c545867-0", + "tool_calls": [ + { + "name": "read_file", + "args": { + "file_path": "/skills/runway-analysis/reference/margins.md", + "limit": 1 + }, + "id": "offline-human-1", + "type": "tool_call" + } + ], + "invalid_tool_calls": [], + "usage_metadata": null + }, + { + "content": "1 # Required Runway Margins\n\n[Read 1 line (lines 1-1 of 16 total). 15 lines remaining from offset 1.]", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "tool", + "name": "read_file", + "id": "a20b3f67-90ea-4a9b-b463-ac2225735b2e", + "tool_call_id": "offline-human-1", + "artifact": null, + "status": "success" + }, + { + "content": "Offline assessment complete.", + "additional_kwargs": {}, + "response_metadata": {}, + "type": "ai", + "name": null, + "id": "lc_run--01a12d22-5f6f-7bd1-a3dc-104e438edb92-0", + "tool_calls": [], + "invalid_tool_calls": [], + "usage_metadata": null + } + ], + "skills_metadata": [ + { + "name": "runway-analysis", + "description": "Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.", + "path": "/skills/runway-analysis/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + }, + { + "name": "weather-brief", + "description": "Turn a raw field observation into a short operational weather brief. Use when the user asks about conditions, ceilings, visibility, or whether the weather is a problem for a departure or arrival.", + "path": "/skills/weather-brief/SKILL.md", + "metadata": {}, + "license": "MIT", + "compatibility": null, + "allowed_tools": [] + } + ], + "files": {} + }, + "provenance": { + "compiledGraph": true, + "providerCalls": 0, + "networkConnectAttempts": 0, + "deepagents": "0.7.11" + } +} diff --git a/cockpit/deep-agents/skills/react/src/index-projection.spec.ts b/cockpit/deep-agents/skills/react/src/index-projection.spec.ts new file mode 100644 index 000000000..1b1698be8 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/index-projection.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { projectIndex } from './index-projection'; +import { skill, indexInput } from './test-fixtures'; +describe('owned Skills index', () => { + it('captures root metadata without retaining caller objects', () => { + const input = indexInput(); + const actual = projectIndex(input); + expect(actual.kind).toBe('ready'); + if (actual.kind !== 'ready') throw Error('Expected index'); + expect(actual.skills[0]).toEqual(skill); + (input.values as any).skills_metadata[0].description = 'mutated'; + expect(actual.skills[0].description).toBe('Bundled runway example.'); + }); + it('accepts fresh checkpoint values and ignores conflicting custom metadata', () => { + const input = { + ...indexInput({ + skills_metadata: [{ ...skill, description: 'Fresh' }], + custom: { skills_metadata: [{ ...skill, description: 'False' }] }, + }), + source: 'checkpoint' as const, + }; + expect(projectIndex(input)).toMatchObject({ + kind: 'ready', + source: 'checkpoint', + skills: [{ description: 'Fresh' }], + }); + }); + it('authoritatively represents empty and absent metadata', () => { + expect(projectIndex(indexInput({ skills_metadata: [] }))).toMatchObject({ + kind: 'ready', + skills: [], + }); + expect( + projectIndex(indexInput({ custom: { skills_metadata: [skill] } })) + ).toMatchObject({ kind: 'absent' }); + }); + it.each( + [ + [{ ...skill, name: '../runway' }], + [{ ...skill, name: 'runway--analysis' }], + [{ ...skill, path: '/skills/runway-analysis/../other/SKILL.md' }], + [{ ...skill, path: '/skills/runway-analysis-other/SKILL.md' }], + [{ ...skill, path: '/skills/runway-analysis//SKILL.md' }], + [{ ...skill, path: '/skills/runway-analysis/%2e%2e/SKILL.md' }], + [{ ...skill, metadata: { author: 1 } }], + [{ ...skill, license: {} }], + [{ ...skill, allowed_tools: ['read_file', 'read_file'] }], + [{ ...skill, description: 'x'.repeat(65537) }], + [skill, skill], + Array.from({ length: 101 }, (_, i) => ({ + ...skill, + name: 's' + i, + path: '/skills/s' + i + '/SKILL.md', + })), + ].map((skills_metadata) => ({ skills_metadata })) + )('rejects malformed/bounded/duplicate index %#', ({ skills_metadata }) => { + expect(projectIndex(indexInput({ skills_metadata }))).toMatchObject({ + kind: 'unavailable', + }); + }); + it('rejects stale generations and unsupported prototypes/accessors without evaluating them', () => { + expect( + projectIndex({ ...indexInput(), currentGeneration: 'new' }) + ).toMatchObject({ kind: 'stale' }); + const malicious = Object.defineProperty({}, 'skills_metadata', { + enumerable: true, + get: () => { + throw Error('getter called'); + }, + }); + expect(projectIndex(indexInput(malicious))).toMatchObject({ + kind: 'unavailable', + }); + expect(projectIndex(indexInput(new Date()))).toMatchObject({ + kind: 'unavailable', + }); + }); + it('bounds aggregate display including supported annotation keys', () => { + const skills_metadata = Array.from({ length: 30 }, (_, i) => ({ + name: 's' + i, + path: '/skills/s' + i + '/SKILL.md', + description: 'x'.repeat(40000), + })); + expect(projectIndex(indexInput({ skills_metadata })).kind).toBe( + 'unavailable' + ); + const value = { ...skill, metadata: { ['x'.repeat(65537)]: 'value' } }; + expect(projectIndex(indexInput({ skills_metadata: [value] })).kind).toBe( + 'unavailable' + ); + }); + it('drops unsupported extra annotations rather than exposing arbitrary metadata', () => { + const actual = projectIndex( + indexInput({ + skills_metadata: [ + { ...skill, instructionBody: 'Never part of the index' }, + ], + }) + ); + expect(actual).toMatchObject({ kind: 'ready' }); + if (actual.kind === 'ready') + expect(actual.skills[0]).not.toHaveProperty('instructionBody'); + }); +}); diff --git a/cockpit/deep-agents/skills/react/src/index-projection.ts b/cockpit/deep-agents/skills/react/src/index-projection.ts new file mode 100644 index 000000000..5c3579791 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/index-projection.ts @@ -0,0 +1,144 @@ +import { copyData, displayUnits, LIMITS, record, text } from './authority'; + +export interface Skill { + readonly name: string; + readonly description: string; + readonly path: string; + readonly metadata?: Readonly>; + readonly license?: string | null; + readonly compatibility?: string | null; + readonly allowed_tools?: readonly string[]; +} +export interface IndexInput { + readonly owner: string; + readonly generation: string; + readonly currentOwner: string; + readonly currentGeneration: string; + readonly source: 'observed-root' | 'checkpoint'; + readonly values: unknown; +} +export type SkillIndex = + | { + readonly kind: 'ready'; + readonly source: IndexInput['source']; + readonly owner: string; + readonly generation: string; + readonly skills: readonly Skill[]; + } + | { readonly kind: 'absent' | 'unavailable' | 'stale' }; + +/** Literal canonical virtual paths only; never decode or normalize a model path. */ +export function skillPath(value: unknown): value is string { + return ( + text(value) && + value.length <= LIMITS.fieldText && + value.startsWith('/skills/') && + !/[\\\\%?#\u0000-\u0020\u007f]/.test(value) && + value + .slice(1) + .split('/') + .every((part) => part.length > 0 && part !== '.' && part !== '..') + ); +} + +/** Every call replaces the projection. Empty, absent and malformed values cannot + * revive a previously observed index. Custom event metadata is never a source. */ +export function projectIndex(input: IndexInput): SkillIndex { + try { + const owned = copyData(input) as IndexInput; + if ( + !['owner', 'generation', 'currentOwner', 'currentGeneration'].every( + (k) => { + const v = owned[k as keyof IndexInput]; + return text(v) && v.length <= LIMITS.fieldText; + } + ) + ) + return { kind: 'unavailable' }; + if ( + owned.owner !== owned.currentOwner || + owned.generation !== owned.currentGeneration + ) + return { kind: 'stale' }; + if ( + !['observed-root', 'checkpoint'].includes(owned.source) || + !record(owned.values) + ) + return { kind: 'unavailable' }; + if (!Object.hasOwn(owned.values, 'skills_metadata')) + return { kind: 'absent' }; + const entries = owned.values.skills_metadata; + if (!Array.isArray(entries) || entries.length > LIMITS.entries) + return { kind: 'unavailable' }; + const names = new Set(), + paths = new Set(), + strings: string[] = [], + skills: Skill[] = []; + for (const v of entries) { + if ( + !record(v) || + !text(v.name) || + v.name.length > 64 || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.name) || + !text(v.description) || + !skillPath(v.path) || + v.path !== '/skills/' + v.name + '/SKILL.md' || + names.has(v.name) || + paths.has(v.path) + ) + return { kind: 'unavailable' }; + names.add(v.name); + paths.add(v.path); + const skill: Record = { + name: v.name, + description: v.description, + path: v.path, + }; + strings.push(v.name, v.description, v.path); + for (const key of ['license', 'compatibility']) { + const value = v[key]; + if (value !== undefined) { + if (value !== null && typeof value !== 'string') + return { kind: 'unavailable' }; + skill[key] = value; + if (typeof value === 'string') strings.push(value); + } + } + if (v.metadata !== undefined) { + if ( + !record(v.metadata) || + Object.keys(v.metadata).length > LIMITS.entries + ) + return { kind: 'unavailable' }; + for (const [key, value] of Object.entries(v.metadata)) { + if (!text(key) || typeof value !== 'string') + return { kind: 'unavailable' }; + strings.push(key, value); + } + skill.metadata = v.metadata; + } + if (v.allowed_tools !== undefined) { + if ( + !Array.isArray(v.allowed_tools) || + v.allowed_tools.length > LIMITS.entries || + v.allowed_tools.some((v) => !text(v)) || + new Set(v.allowed_tools).size !== v.allowed_tools.length + ) + return { kind: 'unavailable' }; + strings.push(...v.allowed_tools); + skill.allowed_tools = v.allowed_tools; + } + skills.push(Object.freeze(skill) as unknown as Skill); + } + displayUnits(strings); + return Object.freeze({ + kind: 'ready', + source: owned.source, + owner: owned.owner, + generation: owned.generation, + skills: Object.freeze(skills), + }); + } catch { + return { kind: 'unavailable' }; + } +} diff --git a/cockpit/deep-agents/skills/react/src/main.tsx b/cockpit/deep-agents/skills/react/src/main.tsx new file mode 100644 index 000000000..90568d493 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/main.tsx @@ -0,0 +1,45 @@ +import { createRoot } from 'react-dom/client'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Neutral internal bridge copied by the isolated build. +import { + GENERATED_RUNTIME_PARENT_ORIGINS, + installRuntimeBridge, +} from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { SkillsDemo } from './app'; +import { skillsConnection } from './connection'; +import '@threadplane/react/chat/styles.css'; +import './styles.css'; + +const element = document.getElementById('root'); +if (!element) throw new Error('Application root is missing'); +const bridge = installRuntimeBridge(undefined, { + allowedParentOrigins: GENERATED_RUNTIME_PARENT_ORIGINS, +}); +const root = createRoot(element); +let active = true; +window.addEventListener( + 'pagehide', + () => { + active = false; + root.unmount(); + bridge.dispose(); + }, + { once: true } +); +void bridge + .awaitConfiguration() + .then((configuration) => { + if (!active) return; + root.render( + bridge.markReady()} + /> + ); + }) + .catch(() => { + if (!active) return; + bridge.markError('bootstrap_failed'); + root.render( +

The Deep Agents skills example could not start.

+ ); + }); diff --git a/cockpit/deep-agents/skills/react/src/native-contract.spec.ts b/cockpit/deep-agents/skills/react/src/native-contract.spec.ts new file mode 100644 index 000000000..758c19a9f --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/native-contract.spec.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'node:fs'; +import { Client } from '@langchain/langgraph-sdk'; +import { createSession } from '@threadplane/langgraph'; +import { it, expect, vi } from 'vitest'; +import { captureAuthority, captureBaseline, captureTurn } from './authority'; +import { projectIndex } from './index-projection'; +import { projectReads } from './read-projection'; +// Generated offline from the actual compiled Skills graph, deterministic injected +// LocalModel, InMemorySaver, deepagents 0.7.11; socket.connect was blocked. +// Full source capture script and history are private /tmp Task2 evidence. +const actual = JSON.parse( + readFileSync( + new URL('./fixtures/actual-skills-root.json', import.meta.url), + 'utf8' + ) +); +it('preserves actual root skills_metadata across native run, installed SDK history, and native load', async () => { + const requests: string[] = []; + const fetch = vi.fn( + async (url: string | URL | Request, init?: RequestInit) => { + const path = new URL( + typeof url === 'string' ? url : url instanceof URL ? url.href : url.url + ).pathname; + requests.push(path); + if (!path.startsWith('/threads/offline-skills-task2/')) + throw Error('Unexpected offline request'); + if (path.endsWith('/state/checkpoint')) { + expect(JSON.parse(init?.body as string).checkpoint).toEqual( + actual.current.checkpoint + ); + } else if (!path.endsWith('/state') && !path.endsWith('/history')) + throw Error('Unsupported offline route'); + return new Response( + JSON.stringify( + path.endsWith('/history') + ? actual.history + : path.endsWith('/state/checkpoint') + ? actual.exact + : actual.current + ), + { headers: { 'content-type': 'application/json' } } + ); + } + ); + const sdk = new Client({ + apiUrl: 'http://offline.invalid', + apiKey: null, + callerOptions: { fetch, maxRetries: 0 }, + }); + const crypto = globalThis.crypto; + vi.stubGlobal('crypto', { randomUUID: () => 'offline-human' }); + const session = createSession({ + assistantId: 'da-skills', + threadId: 'offline-skills-task2', + transport: { + async *stream(_a, _t, payload) { + expect((payload as any).messages[0]).toEqual({ + type: 'human', + id: 'offline-human', + content: 'Inspect bundled skills.', + }); + yield { type: 'values', data: actual.rootValues }; + }, + getHistory: (thread) => sdk.threads.getHistory(thread), + }, + }); + try { + const outcome = await session.submit('Inspect bundled skills.'); + expect(outcome).toBe('success'); + const snapshot = session.getSnapshot(); + const observedState = await sdk.threads.getState('offline-skills-task2'); + const history = await sdk.threads.getHistory('offline-skills-task2'); + const loadedCheckpoint = await sdk.threads.getState( + 'offline-skills-task2', + actual.current.checkpoint + ); + await session.load?.(); + const loadedSnapshot = session.getSnapshot(); + expect(snapshot.values?.skills_metadata).toEqual( + actual.current.values.skills_metadata + ); + expect(loadedSnapshot.values?.skills_metadata).toEqual( + actual.current.values.skills_metadata + ); + expect(snapshot.toolCalls.map((c) => c.status)).toEqual([ + 'complete', + 'complete', + ]); + expect( + snapshot.toolCalls.every((c) => !Object.hasOwn(c, 'rawStatus')) + ).toBe(true); + const turn = captureTurn(captureBaseline(), { + owner: 'owner', + generation: 'generation', + threadId: 'offline-skills-task2', + humanId: 'offline-human', + humanContent: 'Inspect bundled skills.', + }); + const authority = captureAuthority({ + turn, + owner: 'owner', + generation: 'generation', + threadId: 'offline-skills-task2', + outcome, + observedState, + history, + loadedCheckpoint, + snapshot, + loadedSnapshot, + }); + expect(authority.kind).toBe('terminal'); + if (authority.kind !== 'terminal') + throw Error('Unconfirmed actual fixture'); + const index = projectIndex({ + owner: 'owner', + generation: 'generation', + currentOwner: 'owner', + currentGeneration: 'generation', + source: 'checkpoint', + values: authority.values, + }); + const reads = projectReads(index, loadedSnapshot.toolCalls, authority); + expect(Array.isArray(reads)).toBe(true); + if (!Array.isArray(reads)) throw Error('Unavailable read evidence'); + expect(reads.map((r) => r.kind)).toEqual(['inspected', 'partial']); + expect(requests).toHaveLength(4); + expect( + requests.filter((p) => p.endsWith('/state/checkpoint')) + ).toHaveLength(1); + } finally { + await session.dispose(); + vi.stubGlobal('crypto', crypto); + vi.unstubAllGlobals(); + } +}); diff --git a/cockpit/deep-agents/skills/react/src/read-projection.spec.ts b/cockpit/deep-agents/skills/react/src/read-projection.spec.ts new file mode 100644 index 000000000..1ed7ed547 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/read-projection.spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from 'vitest'; +import { captureAuthority } from './authority'; +import { projectIndex } from './index-projection'; +import { projectReads } from './read-projection'; +import { authorityInput, indexInput } from './test-fixtures'; +function read( + status = 'success', + args: unknown = { file_path: '/skills/runway-analysis/SKILL.md' }, + result = '1 instruction' +) { + const input = authorityInput(status, args, result); + return projectReads( + projectIndex(indexInput()), + input.snapshot.toolCalls, + captureAuthority(input) + ); +} +describe('literal successful inspected read evidence', () => { + it('marks an exact owned instruction read only with confirmed raw status', () => { + expect(read()).toMatchObject([ + { + path: '/skills/runway-analysis/SKILL.md', + category: 'instruction', + kind: 'inspected', + result: '1 instruction', + }, + ]); + }); + it('marks exact descendant reference paths separately and excludes sibling prefixes', () => { + expect( + read('success', { + file_path: '/skills/runway-analysis/reference/margins.md', + }) + ).toMatchObject([{ category: 'reference', kind: 'inspected' }]); + for (const file_path of [ + '/skills/runway-analysis-other/SKILL.md', + '/skills/runway-analysis/../other/file', + '/skills/runway-analysis/%2e%2e/file', + '/skills/runway-analysis\\reference/file', + ]) + expect(read('success', { file_path })).toEqual([]); + }); + it('does not interpret native complete as success without raw evidence', () => { + const i = authorityInput(); + expect( + projectReads(projectIndex(indexInput()), i.snapshot.toolCalls, { + kind: 'unconfirmed', + }) + ).toMatchObject([{ kind: 'unknown' }]); + i.snapshot.toolCalls[0].status = 'pending'; + expect( + projectReads(projectIndex(indexInput()), i.snapshot.toolCalls, { + kind: 'unconfirmed', + }) + ).toMatchObject([{ kind: 'pending' }]); + }); + it('keeps error and unknown status unavailable, preserving literal result', () => { + expect( + read( + 'error', + undefined, + 'Error: Line offset 900 exceeds file length (4 lines)' + ) + ).toMatchObject([ + { + kind: 'error', + result: 'Error: Line offset 900 exceeds file length (4 lines)', + }, + ]); + expect(read('unknown')).toMatchObject([{ kind: 'unknown' }]); + }); + it.each([0, -1, -100])( + 'excludes nonpositive limit %i even for an empty file', + (limit) => { + expect( + read( + 'success', + { file_path: '/skills/runway-analysis/SKILL.md', limit }, + 'System reminder: File exists but has empty contents' + ) + ).toMatchObject([{ kind: 'unavailable' }]); + } + ); + it('excludes the exact no-lines warning and unsupported result types', () => { + const warning = + 'System reminder: no lines were read because `limit` was 0. The file was not inspected and may have contents; retry with `limit` >= 1 to read it.'; + expect(read('success', undefined, warning)).toMatchObject([ + { kind: 'unavailable', result: warning }, + ]); + const i = authorityInput(); + (i.snapshot.toolCalls[0] as any).result = { content: 'text' }; + expect( + projectReads( + projectIndex(indexInput()), + i.snapshot.toolCalls, + captureAuthority(i) + ) + ).toMatchObject([{ kind: 'unavailable' }]); + }); + it.each([ + '1 first\n\n[Read 1 line (lines 1-1 of 3 total). 2 lines remaining from offset 1.]', + '1 first\n\n[Read 1 line (lines 1-1). More lines remain from offset 1.]', + "1 first\n\n[Output was truncated due to size limits. The file content is very large. Consider reformatting the file to make it easier to navigate. For example, if this is JSON, use execute(command='jq . /skills/runway-analysis/SKILL.md') to pretty-print it with line breaks. For other formats, you can use appropriate formatting tools to split long lines.]", + ])('preserves partial/paginated/truncated text', (result) => { + expect(read('success', undefined, result)).toMatchObject([ + { kind: 'partial', result }, + ]); + }); + it('treats positive offset as a partial window and supports inspected empty files', () => { + expect( + read( + 'success', + { file_path: '/skills/runway-analysis/SKILL.md', offset: 1, limit: 2 }, + '2 last' + ) + ).toMatchObject([{ kind: 'partial' }]); + expect( + read( + 'success', + undefined, + 'System reminder: File exists but has empty contents' + ) + ).toMatchObject([{ kind: 'inspected', empty: true }]); + }); + it('invalidates duplicate ids and contradictory native results/arguments', () => { + for (const modify of [ + (i: any) => i.snapshot.toolCalls.push(i.snapshot.toolCalls[0]), + (i: any) => + (i.snapshot.toolCalls[0].args = { + file_path: '/skills/runway-analysis/reference/other.md', + }), + (i: any) => (i.snapshot.toolCalls[0].result = 'contradiction'), + ]) { + const i = authorityInput(); + const authority = captureAuthority(i); + modify(i); + expect( + projectReads( + projectIndex(indexInput()), + i.snapshot.toolCalls, + authority + ) + ).toMatchObject([{ kind: 'unavailable' }]); + } + }); + it('never invents a selection or negative read claim from absent evidence', () => { + expect( + projectReads(projectIndex(indexInput()), [], { kind: 'unconfirmed' }) + ).toEqual([]); + expect( + projectReads( + projectIndex(indexInput({})), + authorityInput().snapshot.toolCalls, + { kind: 'unconfirmed' } + ) + ).toEqual([]); + }); + it('rejects stale authority and unknown argument shapes without reviving reads', () => { + const i = authorityInput(); + const authority = captureAuthority(i); + const index = projectIndex({ + ...indexInput(), + generation: 'new', + currentGeneration: 'new', + }); + expect(projectReads(index, i.snapshot.toolCalls, authority)).toMatchObject([ + { kind: 'unavailable' }, + ]); + expect( + read('success', { + file_path: '/skills/runway-analysis/SKILL.md', + offset: '0', + }) + ).toMatchObject([{ kind: 'unavailable' }]); + expect( + read( + 'success', + { file_path: '/skills/runway-analysis/SKILL.md', offset: -1 }, + '1 first' + ) + ).toMatchObject([{ kind: 'inspected' }]); + }); + it('does not represent malformed aggregate evidence as an absent read catalog', () => { + expect( + projectReads(projectIndex(indexInput()), new Date(), { + kind: 'unconfirmed', + }) + ).toMatchObject({ kind: 'unavailable' }); + }); + it('keeps actual pinned pagination partial when followed by the negative offset clamp notice', () => { + const result = + '1 ---\n\n[Read 1 line (lines 1-1 of 27 total). 26 lines remaining from offset 1.]\n\n[Requested offset -1 is before the start of the file; read from line 1 instead.]'; + expect( + read( + 'success', + { file_path: '/skills/runway-analysis/SKILL.md', offset: -1, limit: 1 }, + result + ) + ).toMatchObject([{ kind: 'partial', result }]); + }); + it.each([ + 'unexpected nonnumbered output', + '0 invalid marker', + '1 single space gutter', + '1.1 orphan continuation', + '1 valid\narbitrary unnumbered row', + ])('keeps unsupported raw success literal but unavailable: %s', (result) => { + expect(read('success', undefined, result)).toMatchObject([ + { kind: 'unavailable', result }, + ]); + }); + it('preserves positive gutters, blank source lines and continuation rows without interpreting source notice lookalikes', () => { + const result = + ' 1 first\n1.1 continued\n 2 \n 3 [Read 1 line (lines 1-1 of 2 total). 1 line remaining from offset 1.]\n 4 System reminder: File exists but has empty contents'; + expect(read('success', undefined, result)).toMatchObject([ + { kind: 'inspected', empty: false, result }, + ]); + }); + it('represents malformed read arguments explicitly while ignoring valid unrelated paths', () => { + expect(read('success', null)).toEqual({ kind: 'unavailable' }); + expect(read('success', { file_path: '/notes.md' })).toEqual([]); + }); + it.each([ + { limit: null }, + { offset: null }, + { limit: undefined }, + { offset: undefined }, + ])('does not default explicit unsupported bounds %#', (bounds) => { + expect( + read('success', { + file_path: '/skills/runway-analysis/SKILL.md', + ...bounds, + }) + ).toMatchObject([{ kind: 'unavailable' }]); + }); +}); diff --git a/cockpit/deep-agents/skills/react/src/read-projection.ts b/cockpit/deep-agents/skills/react/src/read-projection.ts new file mode 100644 index 000000000..5798fd645 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/read-projection.ts @@ -0,0 +1,224 @@ +import { + copyData, + displayUnits, + LIMITS, + record, + sameData, + type Authority, +} from './authority'; +import { skillPath, type SkillIndex } from './index-projection'; +export interface SkillRead { + readonly id: string; + readonly skillName: string; + readonly path: string; + readonly category: 'instruction' | 'reference'; + readonly kind: + | 'pending' + | 'unknown' + | 'error' + | 'unavailable' + | 'partial' + | 'inspected'; + readonly result?: string; + /** The literal pinned empty-file reminder was observed; no body is invented. */ + readonly empty?: boolean; +} +const EMPTY = 'System reminder: File exists but has empty contents'; +const NO_LINES = + /^System reminder: no lines were read because `limit` was -?\d+\. The file was not inspected and may have contents; retry with `limit` >= 1 to read it\.$/; +const PARTIAL = + /\n\n\[Read \d+ lines? \(lines \d+-\d+(?: of \d+ total)?\)\. (?:\d+ lines? remaining|More lines remain) from offset \d+\.\]$/; + +/** Recognize only the pinned text formatter, without interpreting numbered source + * text as notices. "Inspected" describes evidence, never an entire-file claim. */ +function inspectedResult( + result: string, + path: string, + offset: number +): 'inspected' | 'partial' | undefined { + if (result === EMPTY) return offset > 0 ? 'partial' : 'inspected'; + let body = result, + partial = offset > 0; + const clamp = + '\n\n[Requested offset ' + + offset + + ' is before the start of the file; read from line 1 instead.]'; + if (offset < 0 && body.endsWith(clamp)) body = body.slice(0, -clamp.length); + const pagination = body.match(PARTIAL); + if (pagination) { + partial = true; + body = body.slice(0, -pagination[0].length); + } + const truncation = + "\n\n[Output was truncated due to size limits. The file content is very large. Consider reformatting the file to make it easier to navigate. For example, if this is JSON, use execute(command='jq . " + + path + + "') to pretty-print it with line breaks. For other formats, you can use appropriate formatting tools to split long lines.]"; + if (body.endsWith(truncation)) { + partial = true; + body = body.slice(0, -truncation.length); + // The pinned size cap can leave only its documented warning. + if (!body) return 'partial'; + } + let previous = 0, + continuation = 0; + for (const row of body.split('\n')) { + const match = /^ *([1-9][0-9]*)(?:\.([1-9][0-9]*))? [\s\S]*$/.exec(row); + if (!match) return undefined; + const line = Number(match[1]), + chunk = Number(match[2] ?? 0); + if (!Number.isSafeInteger(line) || !Number.isSafeInteger(chunk)) + return undefined; + if (chunk) { + if (line !== previous || chunk !== continuation + 1) return undefined; + } else if (previous && line !== previous + 1) return undefined; + previous = line; + continuation = chunk; + } + return partial ? 'partial' : 'inspected'; +} + +/** Native completion carries no ToolMessage status. Confirmed root raw evidence + * must match the unique native id, exact arguments and literal result again. */ +export type ReadProjection = + | readonly SkillRead[] + | { readonly kind: 'unavailable' }; +export function projectReads( + index: SkillIndex, + tools: unknown, + authority: Authority +): ReadProjection { + if (index.kind !== 'ready') return []; + try { + const calls = copyData(tools); + if (!Array.isArray(calls) || calls.length > LIMITS.entries) + return { kind: 'unavailable' }; + if ( + calls.some( + (call) => + !record(call) || + typeof call.id !== 'string' || + typeof call.name !== 'string' + ) + ) + return { kind: 'unavailable' }; + if ( + calls.some( + (call) => + record(call) && + call.name === 'read_file' && + (!record(call.args) || typeof call.args.file_path !== 'string') + ) + ) + return { kind: 'unavailable' }; + const ownedAuthority = copyData(authority) as Authority; + const ids = new Map(); + for (const call of calls) + if (record(call) && typeof call.id === 'string') + ids.set(call.id, (ids.get(call.id) ?? 0) + 1); + const output: SkillRead[] = [], + strings: string[] = []; + for (const call of calls) { + if ( + !record(call) || + typeof call.id !== 'string' || + call.name !== 'read_file' || + !record(call.args) || + !skillPath(call.args.file_path) + ) + continue; + const path = call.args.file_path; + const skill = index.skills.find((s) => + path.startsWith(s.path.slice(0, -'SKILL.md'.length)) + ); + if (!skill) continue; + // A duplicate call is one unavailable row, never two successful reads. + if (output.some((r) => r.id === call.id)) continue; + const result = + typeof call.result === 'string' && + call.result.length <= LIMITS.fieldText + ? call.result + : undefined; + const row = { + id: call.id, + skillName: skill.name, + path, + category: + path === skill.path + ? ('instruction' as const) + : ('reference' as const), + ...(result !== undefined ? { result } : {}), + }; + let kind: SkillRead['kind'] = + call.status === 'pending' ? 'pending' : 'unknown'; + let empty: boolean | undefined; + const limit = Object.hasOwn(call.args, 'limit') ? call.args.limit : 100, + offset = Object.hasOwn(call.args, 'offset') ? call.args.offset : 0; + if ( + ids.get(call.id) !== 1 || + (call.result !== undefined && result === undefined) || + !Number.isSafeInteger(limit) || + !Number.isSafeInteger(offset) + ) + kind = 'unavailable'; + else if (ownedAuthority.kind === 'terminal') { + const aiCalls = ownedAuthority.messages + .flatMap((m) => + record(m) && m.type === 'ai' && Array.isArray(m.tool_calls) + ? m.tool_calls + : [] + ) + .filter((c) => record(c) && c.id === call.id); + const results = ownedAuthority.messages.filter( + (m) => record(m) && m.type === 'tool' && m.tool_call_id === call.id + ); + const rawCall = aiCalls[0], + rawResult = results[0]; + if ( + ownedAuthority.turn.owner !== index.owner || + ownedAuthority.turn.generation !== index.generation || + aiCalls.length !== 1 || + results.length !== 1 || + !record(rawCall) || + !record(rawResult) || + rawCall.name !== call.name || + !sameData(rawCall.args, call.args) || + rawResult.content !== result || + call.status !== 'complete' + ) + kind = 'unavailable'; + else if (rawResult.status !== ownedAuthority.rawToolStatuses[call.id]) + kind = 'unavailable'; + else if (ownedAuthority.rawToolStatuses[call.id] === 'error') + kind = 'error'; + else if (ownedAuthority.rawToolStatuses[call.id] === 'success') { + if ( + !Number.isSafeInteger(limit) || + (limit as number) <= 0 || + !Number.isSafeInteger(offset) || + result === undefined || + !result || + NO_LINES.test(result) + ) + kind = 'unavailable'; + else { + kind = + inspectedResult(result, path, offset as number) ?? 'unavailable'; + if (kind !== 'unavailable') empty = result === EMPTY; + } + } + } + strings.push(row.id, row.skillName, path, result ?? ''); + output.push( + Object.freeze({ + ...row, + kind, + ...(empty !== undefined ? { empty } : {}), + }) + ); + } + displayUnits(strings); + return Object.freeze(output); + } catch { + return { kind: 'unavailable' }; + } +} diff --git a/cockpit/deep-agents/skills/react/src/styles.css b/cockpit/deep-agents/skills/react/src/styles.css new file mode 100644 index 000000000..8db985fe7 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/styles.css @@ -0,0 +1,128 @@ +:root { + font-family: system-ui, sans-serif; + color: #202832; + background: #fafafa; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.skills-demo { + max-width: 64rem; + margin: auto; + padding: 1.5rem; + min-height: 100dvh; + display: flex; + flex-direction: column; + gap: 1rem; +} +.skills-demo, +.skills-demo * { + min-width: 0; + overflow-wrap: anywhere; +} +.skills-demo header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; +} +.skills-demo h1 { + font-size: 1.5rem; + margin: 0; +} +.skills-demo h2 { + font-size: 1.1rem; +} +.skills-demo h3 { + font-size: 1rem; +} +.skills-demo p { + margin-block: 0.5rem; +} +.eyebrow { + color: #606b76; + font-size: 0.8rem; +} +.skills-demo button, +.skills-demo select { + font: inherit; +} +.skills-demo button { + cursor: pointer; +} +.skills-demo button:disabled { + cursor: default; +} +.skills-demo .tp-chat-list { + min-height: 12rem; + max-height: 32rem; + overflow: auto; + flex: 1; +} +.skills-workspace { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + align-items: start; + gap: 1.5rem; +} +.skills-chat { + display: flex; + flex-direction: column; + gap: 1rem; +} +.seed-prompts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.skill-panels { + display: flex; + flex-direction: column; + gap: 1rem; +} +.skill-panels > section { + border: 1px solid #cbd5e1; + border-radius: 0.75rem; + padding: 1rem; + max-height: 22rem; + overflow: auto; +} +.skill-panels article { + border-top: 1px solid #cbd5e1; + padding-block: 0.5rem; +} +.skill-panels pre, +.tool-observation pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 16rem; + overflow: auto; +} +.tool-observation { + padding: 0.75rem; + margin-block: 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 0.75rem; +} +.skills-demo summary { + cursor: pointer; +} +.skills-demo [role='alert'] { + color: #a52323; +} +.skills-demo [role='status'] { + color: #606b76; + font-size: 0.9rem; +} +@media (max-width: 42rem) { + .skills-workspace { + grid-template-columns: minmax(0, 1fr); + } + .skills-demo { + padding: 1rem; + } +} diff --git a/cockpit/deep-agents/skills/react/src/styles.d.ts b/cockpit/deep-agents/skills/react/src/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/cockpit/deep-agents/skills/react/src/test-fixtures.ts b/cockpit/deep-agents/skills/react/src/test-fixtures.ts new file mode 100644 index 000000000..ac841c74b --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/test-fixtures.ts @@ -0,0 +1,123 @@ +import { captureBaseline, captureTurn } from './authority'; +export function authorityInput( + status = 'success', + args: unknown = { file_path: '/skills/runway-analysis/SKILL.md' }, + result = '1 instruction' +) { + const messages = [ + { id: 'human', type: 'human', content: 'Inspect runway.' }, + { + id: 'call-message', + type: 'ai', + content: '', + tool_calls: [{ id: 'read', name: 'read_file', args, type: 'tool_call' }], + }, + { + id: 'result', + type: 'tool', + content: result, + name: 'read_file', + tool_call_id: 'read', + status, + }, + { id: 'final', type: 'ai', content: 'Assessment.', tool_calls: [] }, + ]; + const values = { + messages, + skills_metadata: [ + { + name: 'runway-analysis', + description: 'Runway example.', + path: '/skills/runway-analysis/SKILL.md', + }, + ], + files: {}, + }; + const state = { + values, + next: [], + tasks: [], + checkpoint: { + thread_id: 'thread', + checkpoint_ns: '', + checkpoint_id: 'checkpoint', + }, + }; + const delivery = { + generation: 'sdk-generation', + phase: 'complete', + outcome: 'success', + }; + const snapshot = { + status: 'idle', + messages: [ + { id: 'human', role: 'user', content: 'Inspect runway.', delivery }, + { + id: 'call-message', + role: 'assistant', + content: '', + toolCallIds: ['read'], + delivery, + }, + { + id: 'result', + role: 'tool', + content: result, + name: 'read_file', + toolCallId: 'read', + delivery, + }, + { + id: 'final', + role: 'assistant', + content: 'Assessment.', + toolCallIds: [], + delivery, + }, + ], + toolCalls: [ + { id: 'read', name: 'read_file', args, status: 'complete', result }, + ], + values: { skills_metadata: values.skills_metadata, files: {} }, + interrupts: [], + subgraphs: [], + }; + const turn = captureTurn(captureBaseline(), { + owner: 'owner', + generation: 'generation', + threadId: 'thread', + humanId: 'human', + humanContent: 'Inspect runway.', + }); + return { + turn, + owner: 'owner', + generation: 'generation', + threadId: 'thread', + outcome: 'success', + observedState: state, + history: [structuredClone(state)], + loadedCheckpoint: structuredClone(state), + snapshot, + loadedSnapshot: structuredClone(snapshot), + }; +} +export const skill = { + name: 'runway-analysis', + description: 'Bundled runway example.', + path: '/skills/runway-analysis/SKILL.md', + metadata: { author: 'Example' }, + license: 'MIT', + compatibility: null, + allowed_tools: ['read_file'], +}; +export const indexInput = ( + values: unknown = { skills_metadata: [structuredClone(skill)] } +) => ({ + owner: 'owner', + generation: 'generation', + currentOwner: 'owner', + currentGeneration: 'generation', + source: 'observed-root' as const, + values, +}); diff --git a/cockpit/deep-agents/skills/react/src/ui-contract.spec.ts b/cockpit/deep-agents/skills/react/src/ui-contract.spec.ts new file mode 100644 index 000000000..ac28b66f2 --- /dev/null +++ b/cockpit/deep-agents/skills/react/src/ui-contract.spec.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; +const read = (name: string) => + readFileSync(new URL(name, import.meta.url), 'utf8'); +it('uses a desktop chat/sidebar grid and bounded mobile stacking', () => { + const css = read('./styles.css'); + expect(css).toMatch( + /\.skills-workspace\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*2fr\)\s+minmax\(0,\s*1fr\)/ + ); + expect(css).toMatch(/\.skill-panels\s*\{[^}]*flex-direction:\s*column/); + expect(css).toMatch( + /@media[^}]*\.skills-workspace\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/ + ); + expect(css).toMatch( + /\.skill-panels\s*>\s*section\s*\{[^}]*max-height:[^}]*overflow:\s*auto/ + ); + expect(css).toMatch( + /\.skills-demo\s+\.tp-chat-list\s*\{[^}]*max-height:[^}]*overflow:\s*auto/ + ); +}); +it('mounts only after runtime configuration, marks readiness, and releases on pagehide', () => { + const source = read('./main.tsx'); + expect(source).toContain('awaitConfiguration()'); + expect(source.indexOf('awaitConfiguration()')).toBeLessThan( + source.indexOf('root.render(') + ); + expect(source).toContain('bridge.markReady()'); + expect(source).toContain("'pagehide'"); + expect(source).toContain('root.unmount()'); + expect(source).toContain('bridge.dispose()'); + expect(source).toContain('GENERATED_RUNTIME_PARENT_ORIGINS'); +}); +it('uses native React surfaces and keeps ownership inside the Skills app', () => { + const source = read('./app.tsx'); + for (const name of [ + 'useAgent', + 'MessageList', + 'Markdown', + 'ChatInput', + 'ToolObservation', + ]) + expect(source).toContain(name); + expect(source).not.toMatch( + /subagents|deep_agents\.skills|dangerouslySetInnerHTML/ + ); + expect(read('./styles.css')).toContain('overflow-wrap: anywhere'); + expect(read('./styles.css')).toContain('@media'); +}); diff --git a/cockpit/deep-agents/skills/react/tsconfig.json b/cockpit/deep-agents/skills/react/tsconfig.json new file mode 100644 index 000000000..5754c625b --- /dev/null +++ b/cockpit/deep-agents/skills/react/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "vitest/globals"], + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "composite": false, + "emitDeclarationOnly": false, + "declarationMap": false, + "paths": { + "@threadplane/react": ["libs/react/src/index.ts"], + "@threadplane/react/chat": ["libs/react/src/chat/index.ts"], + "@threadplane/react/markdown": ["libs/react/src/markdown/index.ts"], + "@threadplane/core": ["libs/core/src/index.ts"], + "@threadplane/core/tools": ["libs/core/src/tools/index.ts"], + "@threadplane/content": ["libs/content/src/index.ts"], + "@threadplane/content/messages": ["libs/content/src/messages/index.ts"], + "@threadplane/content/markdown": ["libs/content/src/markdown/index.ts"], + "@threadplane/langgraph": ["libs/langgraph/src/runtime/create-session.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/cockpit/deep-agents/skills/react/vite.config.mts b/cockpit/deep-agents/skills/react/vite.config.mts new file mode 100644 index 000000000..c57234013 --- /dev/null +++ b/cockpit/deep-agents/skills/react/vite.config.mts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [nxViteTsPaths()], + resolve: { + alias: { + '@threadplane/langgraph': fileURLToPath( + new URL( + '../../../../libs/langgraph/src/runtime/create-session.ts', + import.meta.url + ) + ), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + maxWorkers: 2, + }, +}); diff --git a/libs/cockpit-registry/project.json b/libs/cockpit-registry/project.json index a06f8190c..9a025545e 100644 --- a/libs/cockpit-registry/project.json +++ b/libs/cockpit-registry/project.json @@ -42,6 +42,7 @@ "{workspaceRoot}/cockpit/**/angular/src/**", "{workspaceRoot}/cockpit/**/react/src/**", "{workspaceRoot}/cockpit/**/python/src/**", + "{workspaceRoot}/cockpit/**/python/skills/**", "{workspaceRoot}/cockpit/**/docs/**", "{workspaceRoot}/deployments/ag-ui-mastra/*.mjs" ] diff --git a/libs/cockpit-registry/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index 5c708a2a5..07c33826a 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -60,7 +60,7 @@ export const capabilities: readonly Capability[] = [ { 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, react: { project: 'cockpit-deep-agents-subagents-react', port: 4630 }, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, { id: 'da-memory', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonPort: 5313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, - { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, + { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, react: { project: 'cockpit-deep-agents-skills-react', port: 4631 }, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, // Render capabilities { id: 'r-spec-rendering', runtimeAdapter: 'none', product: 'render', topic: 'spec-rendering', angularProject: 'cockpit-render-spec-rendering-angular', port: 4401, react: { project: 'cockpit-render-spec-rendering-react', port: 4614 }, pythonPort: 5401, pythonDir: 'cockpit/render/spec-rendering/python', graphName: 'r-spec-rendering' }, { id: 'r-element-rendering', runtimeAdapter: 'none', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, react: { project: 'cockpit-render-element-rendering-react', port: 4618 }, pythonPort: 5402, pythonDir: 'cockpit/render/element-rendering/python', graphName: 'r-element-rendering' }, diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index 096cb4834..9fd473c30 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -429,3 +429,21 @@ describe('registry content descriptors', () => { } }); }); + +it('registers native Skills with the complete shared backend and product-only assets', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-skills-python'); + if (!canonical) throw new Error('Missing canonical Skills'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react'); + const backend = ['src/graph.py', 'src/skills_readonly.py', 'skills/runway-analysis/SKILL.md', 'skills/runway-analysis/reference/margins.md', 'skills/weather-brief/SKILL.md'].map(file => `cockpit/deep-agents/skills/python/${file}`); + expect(canonical.backendAssetPaths).toEqual(backend); + expect(deepAgentsSkillsPythonModule.backendAssetPaths).toEqual(backend); + expect(react).toMatchObject({ frontend: 'react', runtimeUrl: 'deep-agents/skills/react', devPort: 4631, backendAssetPaths: backend, promptAssetPaths: canonical.promptAssetPaths }); + expect(react?.codeAssetPaths).toEqual([...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'index-projection.ts', 'read-projection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/skills/react/src/${file}`), 'cockpit/deep-agents/skills/react/project.json']); + expect(Object.isFrozen(react)).toBe(true); + expect(Object.isFrozen(react?.codeAssetPaths)).toBe(true); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'angular')).toBe(canonical); + 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); + } +}); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 340d3679a..40e04f557 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -522,7 +522,13 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/skills/angular/src/app/skills.component.ts', 'cockpit/deep-agents/skills/angular/src/app/app.config.ts', ], - backendAssetPaths: ['cockpit/deep-agents/skills/python/src/graph.py'], + backendAssetPaths: [ + 'cockpit/deep-agents/skills/python/src/graph.py', + 'cockpit/deep-agents/skills/python/src/skills_readonly.py', + 'cockpit/deep-agents/skills/python/skills/runway-analysis/SKILL.md', + 'cockpit/deep-agents/skills/python/skills/runway-analysis/reference/margins.md', + 'cockpit/deep-agents/skills/python/skills/weather-brief/SKILL.md', + ], runtimeUrl: 'deep-agents/skills', devPort: 4314, }, @@ -1302,12 +1308,31 @@ const deepAgentsSubagentsReact = (() => { }); })(); +const deepAgentsSkillsReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'deep-agents-skills-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-skills-react'); + if (!canonical || !frontend) throw new Error('Skills frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'deep-agents-skills-python-react', + frontend: 'react', + title: 'Skills (React preview)', + codeAssetPaths: [ + ...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'index-projection.ts', 'read-projection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/skills/react/src/${file}`), + 'cockpit/deep-agents/skills/react/project.json', + ], + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, deepAgentsPlanningReact, deepAgentsFilesystemReact, deepAgentsSubagentsReact, + deepAgentsSkillsReact, 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 0be2ec94f..a75a7dba1 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 Subagents as one canonical backend with Angular default and two exact expect(subagents[0]).toMatchObject({ angularProject: 'cockpit-deep-agents-subagents-angular', pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents', pythonPort: 5312 }); const frontends = getCockpitFrontends(); expect(frontends.filter(entry => entry.frontend === 'angular')).toHaveLength(41); - expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(31); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(32); expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'subagents')).toEqual([ { frontend: 'angular', project: 'cockpit-deep-agents-subagents-angular', port: 4312, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents', buildOutput: 'dist/cockpit/deep-agents/subagents/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-subagents-react', port: 4630, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents/react', buildOutput: 'dist/cockpit/deep-agents/subagents/react' }, @@ -27,7 +27,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(31); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(32); 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 43ca51e59..5b270aa94 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -267,8 +267,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toEqual(['graph.py','dashboard_tools.py','server.py'].map(file=>`cockpit/ag-ui/json-render/python/src/${file}`)); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/json-render'); expect(getFrontendCapabilityDescriptor({...identity,topic:'testing'},'react')).toBeUndefined(); - expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(31); - expect(getCockpitFrontends()).toHaveLength(72); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(32); + expect(getCockpitFrontends()).toHaveLength(73); 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', () => { @@ -297,9 +297,9 @@ describe('registered React public previews', () => { expect(getFrontendCapabilityDescriptor(identity, 'angular')?.runtimeUrl).toBe('ag-ui/interrupts'); expect(getFrontendCapabilityDescriptor({ ...identity, product: 'langgraph' }, 'react')?.runtimeUrl).toBe('langgraph/interrupts/react'); const frontends = getCockpitFrontends(); - expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(31); - expect(frontends).toHaveLength(72); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(72); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(32); + expect(frontends).toHaveLength(73); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(73); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -314,8 +314,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toContain('cockpit/ag-ui/streaming/python/src/server.py'); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/streaming'); expect(getFrontendCapabilityDescriptor(streaming,'react')?.runtimeUrl).toBe('langgraph/streaming/react'); - expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(31); - expect(getCockpitFrontends()).toHaveLength(72); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(32); + expect(getCockpitFrontends()).toHaveLength(73); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -343,8 +343,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(31); - expect(getCockpitFrontends()).toHaveLength(72); + ).toHaveLength(32); + expect(getCockpitFrontends()).toHaveLength(73); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -604,11 +604,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - expect(frontends).toHaveLength(72); - expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(72); - expect(new Set(frontends.map((entry) => entry.project)).size).toBe(72); - expect(new Set(frontends.map((entry) => entry.port)).size).toBe(72); - expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(72); + expect(frontends).toHaveLength(73); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(73); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(73); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(73); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(73); 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 f432c53e7..5b4280d49 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -57,7 +57,7 @@ test('Generative UI backend changes select both canonical frontends registered b assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-generative-ui-angular', 'cockpit-chat-generative-ui-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/generative-ui/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 72); + assert.equal(frontends.length, 73); 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', () => { @@ -66,7 +66,7 @@ test('Timeline backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-timeline-angular', 'cockpit-chat-timeline-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/timeline/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 72); + assert.equal(frontends.length, 73); 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 () => { @@ -85,7 +85,7 @@ test('Threads backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-threads-angular', 'cockpit-chat-threads-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/threads/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 72); + assert.equal(frontends.length, 73); 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 () => { @@ -404,7 +404,7 @@ test('CI discovers native React previews across protocols with their own Python const root = mkdtempSync(join(tmpdir(), 'cockpit-react-topics-')); t.after(() => rmSync(root, { recursive: true, force: true })); // All writes in this test target this isolated temporary root. - for (const topic of ['planning', 'filesystem', 'subagents']) for (const frontend of ['react', 'python']) { + for (const topic of ['planning', 'filesystem', 'subagents', 'skills']) 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({ @@ -447,7 +447,7 @@ test('CI discovers native React previews across protocols with their own Python ); } const caps = deriveCockpitCaps(root); - assert.equal(caps.length, 31); + assert.equal(caps.length, 32); const agUi = selectCockpitCaps( caps, new Set(['cockpit-ag-ui-streaming-python']), @@ -556,3 +556,10 @@ test('native chat generative UI has an exact isolated key and route', async () = assert.equal(reactCockpitConfiguration('ag-ui-json-render').port, 4612); assert.equal(reactCockpitConfiguration('chat-timeline').port, 4626); }); + + test('Skills 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-skills-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-skills-angular', 'cockpit-deep-agents-skills-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/skills/python')); + }); diff --git a/scripts/examples/capability-wiring.spec.ts b/scripts/examples/capability-wiring.spec.ts index 4f396524a..fceb38146 100644 --- a/scripts/examples/capability-wiring.spec.ts +++ b/scripts/examples/capability-wiring.spec.ts @@ -2680,3 +2680,17 @@ describe('cockpit capability wiring', () => { }); }); + +it('keeps Skills Angular and React on one canonical shared backend capability', () => { + const skills = capabilities.filter(cap => cap.id === 'da-skills'); + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, + react: { project: 'cockpit-deep-agents-skills-react', port: 4631 }, + pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills', pythonPort: 5314, + }); + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-skills-python')!; + expect(canonical.backendAssetPaths).toEqual(['src/graph.py', 'src/skills_readonly.py', 'skills/runway-analysis/SKILL.md', 'skills/runway-analysis/reference/margins.md', 'skills/weather-brief/SKILL.md'].map(file => `cockpit/deep-agents/skills/python/${file}`)); + const component = readFileSync(resolve(cockpitRoot, 'deep-agents/skills/angular/src/app/skills.component.ts'), 'utf8'); + expect(component).toContain('Requested read_file paths, without validating completion or returned content.'); +}); diff --git a/scripts/examples/e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts index bb6e2fa7d..b16d754a2 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -13,6 +13,20 @@ 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 Skills 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/skills/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-skills'); + expect(react.targets.e2e.dependsOn).toEqual(['build', 'fixture-test']); + expect(react.targets['fixture-test'].dependsOn).toContainEqual({ target: 'test', projects: ['cockpit-deep-agents-skills-python'] }); + expect(react.targets['fixture-test'].dependsOn).toContainEqual({ target: 'build', projects: ['core'] }); + const website = read('apps/website/project.json'); + expect(website.targets.e2e.dependsOn[0].projects).toContain('cockpit-deep-agents-skills-react'); + const config = readFileSync(join(repoRoot, 'apps/website/playwright.config.ts'), 'utf8'); + expect(config).toContain('cockpit-deep-agents-skills-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-skills --no-parent'); + expect(config).toContain("url: 'http://127.0.0.1:4631'"); +}); it('wires native Subagents build, fixture and website coverage to the shared Python graph', () => { const read = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), 'utf8')); const react = read('cockpit/deep-agents/subagents/react/project.json'); diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index 922983b7d..a5a8123ef 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,12 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'deep-agents-skills') + return Object.freeze({ + topic: 'skills', library: 'deep-agents', adapter: 'langgraph', + appPath: 'cockpit/deep-agents/skills/react', + base: '/deep-agents/skills/react/', port: 4631, + project: 'cockpit-deep-agents-skills-react', + }); if (topic === 'deep-agents-subagents') return Object.freeze({topic:'subagents',library:'deep-agents',adapter:'langgraph',appPath:'cockpit/deep-agents/subagents/react',base:'/deep-agents/subagents/react/',port:4630,project:'cockpit-deep-agents-subagents-react'}); if (topic === 'deep-agents-filesystem') diff --git a/scripts/react-cockpit/deep-agents-skills-configuration.spec.mjs b/scripts/react-cockpit/deep-agents-skills-configuration.spec.mjs new file mode 100644 index 000000000..acc272f40 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-skills-configuration.spec.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { reactCockpitConfiguration } from './configuration.mjs'; + +test('Skills has a closed native React selection', () => { + const configuration = reactCockpitConfiguration('deep-agents-skills'); + assert.deepEqual(configuration, { + topic: 'skills', + library: 'deep-agents', + adapter: 'langgraph', + appPath: 'cockpit/deep-agents/skills/react', + base: '/deep-agents/skills/react/', + port: 4631, + project: 'cockpit-deep-agents-skills-react', + }); + assert.ok(Object.isFrozen(configuration)); + assert.throws(() => reactCockpitConfiguration('deep-agents/../../foreign')); + assert.throws(() => + reactCockpitConfiguration('deep-agents-skills/../../foreign') + ); +}); + +import { readFileSync } from 'node:fs'; +test('Skills fixture uses only closed authored selection and awaited shutdown', () => { + const source = readFileSync(new URL('./serve.mjs', import.meta.url), 'utf8'); + assert.match( + source, + /configuration.library === 'deep-agents' && configuration.topic === 'skills' \? createDeepAgentsSkillsFixture\(\)/ + ); + assert.match( + source, + /await \(deepAgentsSubagentsFixture \?\? deepAgentsSkillsFixture \?\?/ + ); +}); diff --git a/scripts/react-cockpit/deep-agents-skills-fixture.mjs b/scripts/react-cockpit/deep-agents-skills-fixture.mjs new file mode 100644 index 000000000..5dd9ca109 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-skills-fixture.mjs @@ -0,0 +1,604 @@ +/** Loopback-only SDK transport backed by the actual compiled Skills graph. */ +import { spawn } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +function graphWorker(spawnWorker, timeout) { + const child = spawnWorker(); + const pending = new Map(); + let sequence = 0, + dead = false, + closing; + const ended = new Promise((resolve) => child.once('close', resolve)); + const maxResponseBytes = 16 * 1024 * 1024; + let frame = Buffer.alloc(0), + frameBytes = 0; + child.stderr.resume(); + function fail() { + dead = true; + frame = Buffer.alloc(0); + frameBytes = 0; + for (const item of pending.values()) { + clearTimeout(item.timer); + item.reject(Error('Skills graph worker unavailable.')); + } + pending.clear(); + } + const terminate = () => { + fail(); + child.kill('SIGTERM'); + }; + child.once('error', fail); + child.once('close', fail); + child.stdin.on('error', terminate); + child.stdout.once('error', terminate); + child.stdout.once('end', () => { + if (!dead) terminate(); + }); + child.stdout.on('data', (bytes) => { + if (dead) return; + try { + let start = 0; + while (start < bytes.length && !dead) { + const newline = bytes.indexOf(0x0a, start); + const end = newline < 0 ? bytes.length : newline; + const needed = frameBytes + end - start; + // Count each raw frame before copying or decoding, including frames + // whose delimiter has not arrived. Never grow storage beyond the cap. + if (needed > maxResponseBytes) throw Error('Oversized response'); + if (needed > frame.length) { + const grown = Buffer.allocUnsafe( + Math.min(maxResponseBytes, Math.max(needed, frame.length * 2, 4096)) + ); + frame.copy(grown, 0, 0, frameBytes); + frame = grown; + } + bytes.copy(frame, frameBytes, start, end); + frameBytes = needed; + if (newline < 0) return; + const result = JSON.parse(frame.toString('utf8', 0, frameBytes)); + frameBytes = 0; + const item = pending.get(result.requestId); + if (!item) throw Error('Unowned response'); + pending.delete(result.requestId); + clearTimeout(item.timer); + if (result.error) item.reject(Error('Skills graph operation failed.')); + else item.resolve(result); + start = newline + 1; + } + } catch { + terminate(); + } + }); + return { + pid: child.pid, + send(value) { + if (dead) return Promise.reject(Error('Skills graph worker closed.')); + return new Promise((resolve, reject) => { + const requestId = ++sequence; + const wire = JSON.stringify({ ...value, requestId }) + '\n'; + if (Buffer.byteLength(wire) > 1024 * 1024) { + reject(Error('Oversized request')); + return; + } + const timer = setTimeout(terminate, timeout); + pending.set(requestId, { resolve, reject, timer }); + child.stdin.write(wire); + }); + }, + close() { + return (closing ??= (async () => { + fail(); + child.stdin.end(); + const timer = setTimeout(() => child.kill('SIGKILL'), 1500); + await ended; + clearTimeout(timer); + })()); + }, + }; +} + +export function createDeepAgentsSkillsFixture({ + workerTimeoutMs = 30000, + spawnWorker = () => + spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/skills/python', + 'python', + 'scripts/react-cockpit/deep-agents-skills-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-skills-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ), +} = {}) { + const sourceRoot = new URL( + '../../cockpit/deep-agents/skills/python/', + import.meta.url + ); + const skillAssets = readdirSync(new URL('skills/', sourceRoot), { + recursive: true, + withFileTypes: true, + }) + .filter((e) => e.isFile()) + .map( + (e) => + 'skills/' + + e.parentPath.slice(new URL('skills/', sourceRoot).pathname.length) + + (e.parentPath.endsWith('/') ? '' : '/') + + e.name + ); + const sourceSha256 = Object.fromEntries( + [ + 'src/graph.py', + 'src/skills_readonly.py', + 'tests/test_streaming.py', + 'prompts/skills.md', + 'uv.lock', + ...skillAssets, + ].map((path) => [ + path, + createHash('sha256') + .update( + readFileSync( + new URL( + '../../cockpit/deep-agents/skills/python/' + path, + import.meta.url + ) + ) + ) + .digest('hex'), + ]) + ); + let worker, + closed = false, + closing, + queue = Promise.resolve(); + // Real execution is buffered before delivery; transport cancellation does not roll back server graph work. + const threads = new Set(), + runs = new Map(), + held = new Set(), + rawEvents = [], + proofs = [], + requests = []; + const options = { + scenario: 'reads', + holdStream: false, + holdState: false, + holdCheckpoint: false, + failCreation: false, + failStream: false, + failCheckpoint: false, + }; + const json = (res, value, status = 200) => { + if (!res.destroyed && !res.writableEnded) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); + } + }; + const event = (kind, data) => + `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`; + const only = (value, keys) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).every((k) => keys.includes(k)); + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function operate(value) { + const result = queue.then(async () => { + if (closed) throw Error('Closed fixture'); + worker ??= graphWorker(spawnWorker, workerTimeoutMs); + const result = await worker.send(value), + proof = result.proof; + if ( + closed || + proof?.actualCompiledGraph !== true || + proof.networkConnectAttempts !== 0 || + proof.op !== value.op || + proof.threadId !== (value.threadId ?? null) || + Object.keys(proof.sourceSha256 ?? {}).length !== + Object.keys(sourceSha256).length || + Object.entries(sourceSha256).some( + ([path, hash]) => proof.sourceSha256[path] !== hash + ) + ) + throw Error('Invalid graph proof'); + proofs.push(proof); + return result; + }); + queue = result.catch(() => undefined); + return result; + } + function hold(response, finish, run) { + const item = { response, finish, run }; + held.add(item); + response.once('close', () => { + if (held.delete(item) && run) run.status = 'interrupted'; + }); + } + const handle = async (request, response, pathname) => { + if (!pathname.startsWith('/api/') && !pathname.startsWith('/__')) + return false; + try { + if (closed) throw Error('Closed fixture'); + if ( + !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes( + request.socket.remoteAddress + ) + ) { + json(response, {}, 403); + return true; + } + const chunks = []; + let byteLength = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.length; + if (byteLength > 1024 * 1024) { + json(response, {}, 413); + return true; + } + chunks.push(bytes); + } + const input = Buffer.concat(chunks, byteLength).toString('utf8'); + const body = input ? JSON.parse(input) : {}; + if (!only(body, Object.keys(body ?? {}))) { + json(response, {}, 400); + return true; + } + if (pathname === '/__raw-events') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, rawEvents); + return true; + } + if (pathname === '/__graph-proof') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, proofs); + return true; + } + if (pathname === '/__requests') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, requests); + return true; + } + if (pathname === '/__lifetime') { + if (request.method !== 'GET' || input.length !== 0) { + json(response, {}, 400); + return true; + } + json(response, { + workers: worker ? 1 : 0, + workerPid: worker?.pid, + held: held.size, + }); + return true; + } + if (pathname === '/__configure') { + if (request.method !== 'POST') { + json(response, {}, 405); + return true; + } + if ( + !only(body, [ + 'scenario', + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ]) || + ('scenario' in body && + !['reads', 'no-read', 'partial', 'error', 'alternate'].includes( + body.scenario + )) || + [ + 'holdStream', + 'holdState', + 'holdCheckpoint', + 'failCreation', + 'failStream', + 'failCheckpoint', + ].some((k) => k in body && typeof body[k] !== 'boolean') + ) { + json(response, {}, 400); + return true; + } + if ('scenario' in body) + await operate({ op: 'configure', scenario: body.scenario }); + Object.assign(options, body); + json(response, {}); + return true; + } + if (pathname === '/__release') { + if (request.method !== 'POST' || !only(body, [])) { + json(response, {}, 400); + return true; + } + for (const item of held) { + held.delete(item); + item.finish(); + } + json(response, {}); + return true; + } + if (pathname.startsWith('/__')) { + json(response, {}, 404); + return true; + } + if (requests.length >= 2000 || threads.size >= 100 || runs.size >= 1000) { + json(response, {}, 429); + return true; + } + requests.push( + structuredClone({ path: pathname, method: request.method, body }) + ); + if (pathname === '/api/threads' && request.method === 'POST') { + if (options.failCreation) { + options.failCreation = false; + json(response, { error: 'Controlled creation failure.' }, 500); + return true; + } + if ( + !uuid.test(body.thread_id ?? '') || + threads.has(body.thread_id) || + !only(body, ['thread_id', 'if_exists', 'metadata']) || + body.if_exists !== 'raise' || + (body.metadata !== undefined && !only(body.metadata, [])) + ) { + json(response, {}, 400); + return true; + } + await operate({ op: 'create', threadId: body.thread_id }); + threads.add(body.thread_id); + json(response, { thread_id: body.thread_id }); + return true; + } + const route = /^\/api\/threads\/([^/]+)(.*)$/.exec(pathname); + if (!route || !threads.has(route[1])) { + json(response, {}, 404); + return true; + } + const [, threadId, suffix] = route; + if ( + (suffix === '/state' && request.method === 'GET') || + (suffix === '/state/checkpoint' && request.method === 'POST') + ) { + if (suffix === '/state' && input.length !== 0) { + json(response, {}, 400); + return true; + } + if (suffix === '/state/checkpoint') { + if (options.failCheckpoint) { + options.failCheckpoint = false; + json(response, { error: 'Controlled exact read failure.' }, 500); + return true; + } + if ( + !only(body, ['checkpoint']) || + !only(body.checkpoint, [ + 'thread_id', + 'checkpoint_id', + 'checkpoint_ns', + 'checkpoint_map', + ]) || + body.checkpoint.thread_id !== threadId || + typeof body.checkpoint.checkpoint_id !== 'string' || + typeof body.checkpoint.checkpoint_ns !== 'string' + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + if ( + !history.some( + (s) => + s.checkpoint.checkpoint_id === body.checkpoint.checkpoint_id && + s.checkpoint.checkpoint_ns === body.checkpoint.checkpoint_ns + ) || + (body.checkpoint.checkpoint_map !== undefined && + (!only(body.checkpoint.checkpoint_map, [ + body.checkpoint.checkpoint_ns, + ]) || + Object.values(body.checkpoint.checkpoint_map).some( + (id) => id !== body.checkpoint.checkpoint_id + ))) + ) { + json(response, {}, 400); + return true; + } + } + const { state } = await operate({ + op: 'state', + threadId, + ...(suffix === '/state/checkpoint' + ? { checkpoint: body.checkpoint } + : {}), + }); + const finish = () => json(response, state); + if ( + options.holdState || + (options.holdCheckpoint && suffix === '/state/checkpoint') + ) + hold(response, finish); + else finish(); + return true; + } + if (suffix === '/history' && request.method === 'POST') { + if ( + !only(body, ['limit']) || + (body.limit !== undefined && + (!Number.isInteger(body.limit) || + body.limit < 1 || + body.limit > 100)) + ) { + json(response, {}, 400); + return true; + } + const { history } = await operate({ op: 'history', threadId }); + json(response, history.slice(0, body.limit ?? 10)); + return true; + } + const runRoute = /^\/runs\/([^/]+)(\/cancel)?$/.exec(suffix); + if ( + runRoute && + ((!runRoute[2] && request.method === 'GET') || + (runRoute[2] && request.method === 'POST')) + ) { + if (input.length !== 0) { + json(response, {}, 400); + return true; + } + const run = runs.get(runRoute[1]); + if (run?.thread_id !== threadId) { + json(response, {}, 404); + return true; + } + if (runRoute[2]) { + const search = new URL(request.url, 'http://localhost').searchParams; + if (search.get('action') !== 'interrupt') { + json(response, {}, 400); + return true; + } + for (const item of held) + if (item.run === run) { + held.delete(item); + run.status = 'interrupted'; + item.response.end(); + } + json(response, {}); + } else json(response, run); + return true; + } + if (suffix === '/runs/stream' && request.method === 'POST') { + if (options.failStream) { + options.failStream = false; + json(response, { error: 'Controlled stream failure.' }, 500); + return true; + } + const message = body.input?.messages?.[0]; + if ( + !only(body, [ + 'assistant_id', + 'input', + 'command', + 'stream_mode', + 'stream_subgraphs', + 'stream_resumable', + 'on_disconnect', + ]) || + !( + body.command === undefined && + only(body.input, ['messages']) && + body.input.messages?.length === 1 && + only(message, ['type', 'id', 'content']) && + message.type === 'human' && + typeof message.id === 'string' && + message.id.trim() && + typeof message.content === 'string' + ) || + body.assistant_id !== 'da-skills' || + !Array.isArray(body.stream_mode) || + !body.stream_mode.length || + new Set(body.stream_mode).size !== body.stream_mode.length || + body.stream_mode.some( + (mode) => + ![ + 'messages-tuple', + 'values', + 'updates', + 'checkpoints', + 'custom', + ].includes(mode) + ) || + (body.stream_subgraphs !== undefined && + body.stream_subgraphs !== true) || + (body.stream_resumable !== undefined && + body.stream_resumable !== true) || + (body.on_disconnect !== undefined && + body.on_disconnect !== 'continue') + ) { + json(response, {}, 400); + return true; + } + const run = { + run_id: randomUUID(), + thread_id: threadId, + status: 'running', + }; + runs.set(run.run_id, run); + const wire = await operate({ + op: 'submit', + threadId, + messages: body.input.messages, + }); + rawEvents.push({ threadId, runId: run.run_id, events: wire.events }); + if (response.destroyed) { + run.status = 'interrupted'; + return true; + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'content-location': `/threads/${threadId}/runs/${run.run_id}`, + }); + response.write(event('metadata', { run_id: run.run_id })); + const modes = new Set( + body.stream_mode.map((mode) => + mode === 'messages-tuple' ? 'messages' : mode + ) + ); + const finish = () => { + if (response.destroyed || closed) return; + for (const [namespace, kind, data] of wire.events) + if ( + modes.has(kind) && + (!namespace.length || body.stream_subgraphs === true) + ) + response.write(event([kind, ...namespace].join('|'), data)); + run.status = 'success'; + response.end(); + }; + if (options.holdStream) hold(response, finish, run); + else finish(); + return true; + } + json(response, {}, 404); + return true; + } catch { + json(response, { error: 'Skills 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-skills-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-skills-fixture.spec.mjs new file mode 100644 index 000000000..4b7c23764 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-skills-fixture.spec.mjs @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { randomUUID, createHash } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +import { Client } from '@langchain/langgraph-sdk'; +import { + emitCandidate, + packCandidate, + installCandidateConsumer, +} from '../react-parity/langgraph-candidate-package.mjs'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const { createDeepAgentsSkillsFixture } = await import( + './deep-agents-skills-fixture.mjs' +).catch(() => ({})); +const captureDirectory = mkdtempSync( + join(tmpdir(), 'threadplane-skills-fixture-captures-') +); +console.log('Skills fixture capture directory: ' + captureDirectory); +const capture = (name, value) => + writeFileSync( + join(captureDirectory, name + '.json'), + JSON.stringify(value, null, 2) + '\n' + ); +let native; +function nativeRuntime() { + return (native ??= (async () => { + const directory = mkdtempSync(join(tmpdir(), 'threadplane-skills-native-')); + const candidate = join(directory, 'candidate'); + mkdirSync(candidate); + const emission = emitCandidate(root, candidate); + const packed = packCandidate(candidate, directory); + const core = JSON.parse( + execFileSync( + 'npm', + [ + 'pack', + join(root, 'dist/libs/core'), + '--pack-destination', + directory, + '--json', + ], + { encoding: 'utf8' } + ) + )[0]; + const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'))); + const consumer = join(directory, 'consumer'); + mkdirSync(consumer); + // Reuse the package verifier's exact locked graph and tarball-byte checks. + // A clean npm-ci cache contains tarballs but need not contain packuments. + const installation = installCandidateConsumer( + consumer, + { private: true, type: 'module' }, + { + '@threadplane/core': 'file:' + join(directory, core.filename), + '@threadplane/langgraph': 'file:' + packed.tarball, + }, + lock, + 'node' + ); + const installed = join( + consumer, + 'node_modules/@threadplane/langgraph/runtime/create-session.js' + ); + capture('installed-runtime', { + directory, + installed, + emission, + installation, + installedSha256: createHash('sha256') + .update(readFileSync(installed)) + .digest('hex'), + }); + return import(pathToFileURL(installed).href); + })()); +} +async function fixture(t, options) { + assert.equal( + typeof createDeepAgentsSkillsFixture, + 'function', + 'Missing native Skills worker fixture' + ); + const handle = createDeepAgentsSkillsFixture(options); + const server = createServer(async (req, res) => { + if ( + !(await handle(req, res, new URL(req.url, 'http://localhost').pathname)) + ) { + res.writeHead(404); + res.end(); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(r)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const client = new Client({ + apiUrl: base + '/api', + callerOptions: { maxRetries: 0 }, + }); + const thread = randomUUID(); + await client.threads.create({ threadId: thread, ifExists: 'raise' }); + const control = async (body) => + assert.equal( + ( + await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify(body), + }) + ).status, + 200 + ); + const stream = (id = randomUUID()) => + client.runs.stream(thread, 'da-skills', { + input: { + messages: [{ type: 'human', id, content: 'Dispatch request.' }], + }, + streamMode: [ + 'messages-tuple', + 'values', + 'updates', + 'checkpoints', + 'custom', + ], + streamSubgraphs: true, + streamResumable: true, + onDisconnect: 'continue', + }); + const submit = async (id) => { + const events = []; + for await (const e of stream(id)) events.push(e); + return events; + }; + return { base, client, thread, control, submit, stream, handle }; +} +const results = (state) => + state.values.messages.filter((m) => m.type === 'tool'); +for (const [scenario, count, status] of [ + ['reads', 2, 'success'], + ['no-read', 0, 'success'], + ['partial', 1, 'success'], + ['error', 1, 'error'], + ['alternate', 1, 'success'], +]) { + test( + `installed SDK actual Skills graph ${scenario} preserves root metadata and exact checkpoints`, + { timeout: 30000 }, + async (t) => { + const f = await fixture(t); + await f.control({ scenario }); + const events = await f.submit(); + const saved = await f.client.threads.getState(f.thread); + assert.deepEqual( + await f.client.threads.getState(f.thread, saved.checkpoint), + saved + ); + assert.deepEqual( + (await f.client.threads.getHistory(f.thread, { limit: 100 }))[0], + saved + ); + assert( + saved.values.skills_metadata.some((s) => s.name === 'runway-analysis') + ); + assert( + saved.values.skills_metadata.some((s) => s.name === 'weather-brief') + ); + assert.deepEqual( + events.filter((e) => e.event === 'values').at(-1).data.skills_metadata, + saved.values.skills_metadata + ); + assert.deepEqual( + events.filter((e) => e.event === 'checkpoints').at(-1).data.values + .skills_metadata, + saved.values.skills_metadata + ); + assert.deepEqual( + events.filter((e) => e.event === 'custom').at(-1).data.data + .skills_metadata, + saved.values.skills_metadata + ); + assert.equal(results(saved).length, count); + assert(results(saved).every((m) => m.status === status)); + if (scenario === 'reads') { + assert.match(results(saved)[0].content, /reference\/margins/); + assert.match(results(saved)[1].content, /4,800/); + } + const proof = await (await fetch(f.base + '/__graph-proof')).json(); + assert( + proof.every( + (p) => p.actualCompiledGraph && p.networkConnectAttempts === 0 + ) + ); + for (const p of proof) + assert(Object.keys(p.sourceSha256).includes('src/skills_readonly.py')); + capture('sdk-' + scenario, { + events, + saved, + proof, + raw: await (await fetch(f.base + '/__raw-events')).json(), + }); + } + ); +} +test( + 'installed native root Skills values survive canonical load and followup', + { timeout: 60000 }, + async (t) => { + const { createSession } = await nativeRuntime(); + const f = await fixture(t); + await f.control({ scenario: 'reads' }); + const session = createSession({ + apiUrl: f.base + '/api', + threadId: f.thread, + assistantId: 'da-skills', + }); + t.after(() => session.dispose()); + assert.equal(await session.submit('First assessment'), 'success'); + const before = structuredClone(session.getSnapshot()); + const saved = await f.client.threads.getState(f.thread); + assert.deepEqual( + before.values.skills_metadata, + saved.values.skills_metadata + ); + await session.load(); + assert.deepEqual( + session.getSnapshot().values.skills_metadata, + saved.values.skills_metadata + ); + assert.equal(await session.submit('Followup assessment'), 'success'); + const second = await f.client.threads.getState(f.thread); + assert.equal( + second.values.messages.filter((m) => m.type === 'human').length, + 2 + ); + assert.equal(results(second).length, 4); + capture('native-root-followup', { + before, + saved, + second, + after: session.getSnapshot(), + }); + } +); +test('replay resume unknown owner and checkpoint fail closed', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'reads' }); + await f.submit('owned-human'); + await assert.rejects(() => f.submit('owned-human')); + await assert.rejects(() => f.client.threads.getState(randomUUID())); + const saved = await f.client.threads.getState(f.thread); + await assert.rejects(() => + f.client.threads.getState(f.thread, { + ...saved.checkpoint, + checkpoint_id: randomUUID(), + }) + ); + const response = await fetch( + f.base + `/api/threads/${f.thread}/runs/stream`, + { + method: 'POST', + body: JSON.stringify({ + assistant_id: 'da-skills', + command: { resume: 'yes' }, + stream_mode: ['values'], + }), + } + ); + assert.equal(response.status, 400); +}); +test('canceled buffered delivery preserves actual graph and closes worker', async (t) => { + const f = await fixture(t); + await f.control({ scenario: 'reads', holdStream: true }); + const stream = f.stream(); + const metadata = await stream.next(); + await f.client.runs.cancel( + f.thread, + metadata.value.data.run_id, + true, + 'interrupt' + ); + assert.equal((await stream.next()).done, true); + assert.equal(results(await f.client.threads.getState(f.thread)).length, 2); + const life = await (await fetch(f.base + '/__lifetime')).json(); + await f.handle.close(); + assert.throws(() => process.kill(life.workerPid, 0), { code: 'ESRCH' }); +}); +test( + 'worker unterminated oversized frame rejects before the long operation timeout', + { timeout: 12000 }, + async (t) => { + let child; + const timeout = 6000; + const handle = createDeepAgentsSkillsFixture({ + workerTimeoutMs: timeout, + spawnWorker: () => + (child = spawn( + process.execPath, + [ + '-e', + 'process.stdin.once("data",()=>process.stdout.write(Buffer.alloc(16*1024*1024+1,0x78)));setInterval(()=>{},1000);', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + )), + }); + const server = createServer((req, res) => + handle(req, res, new URL(req.url, 'http://localhost').pathname) + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const client = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + const started = performance.now(); + await assert.rejects( + client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + const elapsed = performance.now() - started; + await handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + assert( + elapsed < timeout / 2, + `Oversized unterminated frame waited ${elapsed}ms toward ${timeout}ms operation timeout` + ); + } +); diff --git a/scripts/react-cockpit/deep-agents-skills-wire.py b/scripts/react-cockpit/deep-agents-skills-wire.py new file mode 100644 index 000000000..7dcb0367e --- /dev/null +++ b/scripts/react-cockpit/deep-agents-skills-wire.py @@ -0,0 +1,160 @@ +"""Bounded newline-JSON worker running the unchanged compiled Skills 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/skills/python" +sys.path.insert(0, str(PROJECT / "tests")) +from test_streaming import LocalModel, load + +SCENARIOS = { + "reads": [("read_file", {"file_path":"/skills/runway-analysis/SKILL.md"}), ("read_file", {"file_path":"/skills/runway-analysis/reference/margins.md"})], + "no-read": [], + "partial": [("read_file", {"file_path":"/skills/runway-analysis/SKILL.md", "offset":0,"limit":2})], + "error": [("read_file", {"file_path":"/skills/runway-analysis/reference/missing.md"})], + "alternate": [("read_file", {"file_path":"/skills/weather-brief/SKILL.md"})], +} + +SOURCES = ("src/graph.py", "src/skills_readonly.py", "tests/test_streaming.py", "prompts/skills.md", "uv.lock", *sorted(str(p.relative_to(PROJECT)) for p in (PROJECT/"skills").rglob("*") if p.is_file())) +MAX_REQUEST = 1024 * 1024 +MAX_RESPONSE = 16 * 1024 * 1024 + + +def encode(value): + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + if is_dataclass(value): + return asdict(value) + raise TypeError("Unsupported graph wire value") + + +def checkpoint(state): + return { + "values": state.values, "next": list(state.next), + "checkpoint": {k: v for k, v in state.config["configurable"].items() + if k in ("thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map")}, + "parent_checkpoint": dict(state.parent_config["configurable"]) if state.parent_config else None, + "created_at": state.created_at, "metadata": state.metadata, + "tasks": [{"id": t.id, "name": t.name, + **({"error": t.error} if t.error is not None else {}), + **({"state": t.state} if t.state is not None else {}), + "interrupts": [{"id": item.id, "value": item.value} for item in t.interrupts]} + for t in state.tasks], + } + + +async def main(): + model = LocalModel() + scenario = "reads" + owners = set() + seen = set() + request_ids = set() + provenance = {} + with patch.object(socket.socket, "connect", side_effect=AssertionError("Remote socket forbidden")) as network: + with redirect_stdout(sys.stderr): + _, graph = load(model) + while line := sys.stdin.buffer.readline(MAX_REQUEST + 1): + request = {} + try: + if len(line) > MAX_REQUEST or not line.endswith(b"\n"): + raise ValueError("Oversized request") + request = json.loads(line) + if not isinstance(request, dict) or set(request) - {"requestId", "op", "threadId", "scenario", "checkpoint", "messages"}: + raise ValueError("Malformed request") + if type(request.get("requestId")) is not int or request["requestId"] <= 0 or request["requestId"] in request_ids or len(request_ids) >= 2000: + raise ValueError("Invalid/replayed request id or lifetime limit") + request_ids.add(request["requestId"]) + with redirect_stdout(sys.stderr): + config = {"configurable": {"thread_id": request.get("threadId", "")}} + op = request["op"] + fields = {"create": {"threadId"}, "configure": {"scenario"}, "state": {"threadId", "checkpoint"}, + "history": {"threadId"}, "submit": {"threadId", "messages"}, "resume": {"threadId"}} + if op not in fields or set(request) - ({"requestId", "op"} | fields[op]): + raise ValueError("Unsupported operation fields") + if op == "create": + if not isinstance(request.get("threadId"), str) or not request["threadId"] or request["threadId"] in owners or len(owners) >= 100: + raise ValueError("Invalid or duplicate owner") + owners.add(request["threadId"]) + elif op != "configure" and request.get("threadId") not in owners: + raise ValueError("Unknown owner") + if "checkpoint" in request: + requested_checkpoint = request["checkpoint"] + if (not isinstance(requested_checkpoint, dict) + or requested_checkpoint.get("thread_id") != config["configurable"]["thread_id"] + or set(requested_checkpoint) - {"thread_id", "checkpoint_id", "checkpoint_ns", "checkpoint_map"} + or not isinstance(requested_checkpoint.get("checkpoint_id"), str) + or not requested_checkpoint["checkpoint_id"] + or not isinstance(requested_checkpoint.get("checkpoint_ns"), str)): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + if "checkpoint_map" in requested_checkpoint and ( + not isinstance(requested_checkpoint["checkpoint_map"], dict) + or set(requested_checkpoint["checkpoint_map"]) != {requested_checkpoint["checkpoint_ns"]} + or requested_checkpoint["checkpoint_map"][requested_checkpoint["checkpoint_ns"]] != requested_checkpoint["checkpoint_id"]): + raise ValueError("Checkpoint owner mismatch or unsupported fields") + known = [s async for s in graph.aget_state_history(config)] + if not any(s.config["configurable"].get("checkpoint_id") == requested_checkpoint.get("checkpoint_id") and s.config["configurable"].get("checkpoint_ns", "") == requested_checkpoint.get("checkpoint_ns") for s in known): + raise ValueError("Unknown checkpoint") + config["configurable"].update({k: v for k, v in requested_checkpoint.items() if k != "thread_id"}) + op = request["op"] + if op == "configure": + scenario = request["scenario"] + if scenario not in SCENARIOS: + raise ValueError("Unsupported scenario") + result = {} + elif op == "create": + result = {} + elif op == "state": + result = {"state": checkpoint(await graph.aget_state(config))} + elif op == "history": + result = {"history": [checkpoint(s) async for s in graph.aget_state_history(config)]} + elif op == "submit": + current = await graph.aget_state(config) + if any(t.interrupts for t in current.tasks): + raise ValueError("Cannot submit over paused batch") + messages = request.get("messages") + if not isinstance(messages, list) or len(messages) != 1: + raise ValueError("Expected one human message") + message = messages[0] + if not isinstance(message, dict) or set(message) != {"type", "id", "content"} or message["type"] != "human" or not isinstance(message["id"], str) or not message["id"].strip() or not isinstance(message["content"], str): + raise ValueError("Invalid human message") + identity = message["id"] + if (request["threadId"], identity) in seen: + raise ValueError("Replayed human message") + seen.add((request["threadId"], identity)) + model.calls = SCENARIOS[scenario] + provenance[request["threadId"]] = scenario + events = [] + async for namespace, kind, event in graph.astream( + {"messages": request["messages"]}, config, + subgraphs=True, stream_mode=["messages", "values", "updates", "checkpoints", "custom"]): + events.append([list(namespace), kind, deepcopy(event)]) + result = {"events": events, "state": checkpoint(await graph.aget_state(config))} + else: + raise ValueError("Unsupported operation") + network.assert_not_called() + result["proof"] = { + "actualCompiledGraph": True, "op": op, + "threadId": request.get("threadId"), "scenario": provenance.get(request.get("threadId"), scenario), + "sourceSha256": {p: hashlib.sha256((PROJECT / p).read_bytes()).hexdigest() for p in SOURCES}, + "networkConnectAttempts": network.call_count, + } + result["requestId"] = request["requestId"] + wire = json.dumps(result, default=encode) + if len(wire.encode()) > MAX_RESPONSE: + raise ValueError("Oversized response") + print(wire, flush=True) + except Exception: + traceback.print_exc(file=sys.stderr) + print(json.dumps({"requestId": (request.get("requestId") if isinstance(request, dict) else None), "error": "Graph operation failed"}), flush=True) + + +asyncio.run(main()) diff --git a/scripts/react-cockpit/serve.mjs b/scripts/react-cockpit/serve.mjs index f320ced0e..28b129cb9 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -28,9 +28,11 @@ import { createAgUiSubagentsFixture } from './ag-ui-subagents-fixture.mjs'; import { createDeepAgentsFilesystemFixture } from './deep-agents-filesystem-fixture.mjs'; import { createDeepAgentsPlanningFixture } from './deep-agents-planning-fixture.mjs'; +import { createDeepAgentsSkillsFixture } from './deep-agents-skills-fixture.mjs'; import { createDeepAgentsSubagentsFixture } from './deep-agents-subagents-fixture.mjs'; const configuration = reactCockpitConfiguration(process.argv[2]); +const deepAgentsSkillsFixture = configuration.library === 'deep-agents' && configuration.topic === 'skills' ? createDeepAgentsSkillsFixture() : null; const deepAgentsSubagentsFixture = configuration.library === 'deep-agents' && configuration.topic === 'subagents' ? createDeepAgentsSubagentsFixture() : null; const deepAgentsFilesystemFixture = configuration.library === 'deep-agents' && configuration.topic === 'filesystem' ? createDeepAgentsFilesystemFixture() : null; const deepAgentsPlanningFixture = configuration.library === 'deep-agents' && configuration.topic === 'planning' ? createDeepAgentsPlanningFixture() : null; @@ -111,6 +113,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (deepAgentsSkillsFixture && (await deepAgentsSkillsFixture(request, response, pathname))) return; if (deepAgentsSubagentsFixture && (await deepAgentsSubagentsFixture(request, response, pathname))) return; if (deepAgentsFilesystemFixture && (await deepAgentsFilesystemFixture(request, response, pathname))) return; if (deepAgentsPlanningFixture && (await deepAgentsPlanningFixture(request, response, pathname))) return; @@ -344,11 +347,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 (deepAgentsSubagentsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (deepAgentsSubagentsFixture || deepAgentsSkillsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (deepAgentsSubagentsFixture ?? deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (deepAgentsSubagentsFixture ?? deepAgentsSkillsFixture ?? 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 ba786973a..a65bad70a 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,12 +1,8 @@ { "schemaVersion": 1, - "baselineHead": "49d0b92652f290f586b5b8cb3d9c9d9748834531", + "baselineHead": "8a0175a5248f9b9d11075d45e8961a53864907a4", "sourceState": { - "modified": [ - "apps/website/content/docs/deep-agents/capabilities/subagents.mdx", - "libs/cockpit-registry/src/lib/capability-registry.ts", - "libs/cockpit-registry/src/lib/content-descriptors.ts" - ], + "modified": [], "untracked": [] }, "scope": { @@ -448,7 +444,7 @@ "id": "asset:libs/cockpit-registry/project.json", "kind": "asset", "path": "libs/cockpit-registry/project.json", - "sha256": "ed12ce136c1bac914e325a2af8f92eacbb1a63d93aa96ffd752f1e741bd56317" + "sha256": "b020b8128d18cdaff16607373387b99044e685fa5fa8b923cf773717ab486d25" }, { "id": "asset:libs/cockpit-registry/tsconfig.json", @@ -2698,7 +2694,7 @@ "id": "doc:apps/website/content/docs/deep-agents/capabilities/skills.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/capabilities/skills.mdx", - "sha256": "108415815e56f3b72b99a0c0204029b7764454c6ad0942804bff8654066fc7ee" + "sha256": "4977c435bd7c84f436cd56062542b07a3b5de69248ead90e2210da58d62f0942" }, { "id": "doc:apps/website/content/docs/deep-agents/capabilities/subagents.mdx", @@ -2710,7 +2706,7 @@ "id": "doc:apps/website/content/docs/deep-agents/getting-started/introduction.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/getting-started/introduction.mdx", - "sha256": "dd2c712bf83374b61a2ce10fcf30fdb174cde9e68085a413f0cf73c09b89d372" + "sha256": "a9515a3a1a8b2f1c0eaea972e50e931e2a453ad2ba0221122cdc16e2200f8cb1" }, { "id": "doc:apps/website/content/docs/langgraph/api/fetch-stream-transport.mdx", @@ -13465,13 +13461,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "e59447b7d2de74b83b953bb1adb07d8c01a7bb3ea61c7657ff9fab35956f8ee0" + "sha256": "94146df9963d3893e36db016aa787c72067ab2c366bfb0b548f5c3e1702cdac6" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "98d6fae452f3d5919ddb70463703ce34a5d8b09a31f7b73437a11fad0dd0b593" + "sha256": "eb8710b9ba1d987bade2a95b59c2f31a2697cf291aeaab73cf8ae0c790f750cc" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",