diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index e82af2492..d23087a4e 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -128,3 +128,6 @@ The private React render entry also accepts an optional `ReactRenderFunctions` h 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. The local compiled graph exposes metadata in root values and checkpoints; observed deployed API root values omit private Skills keys while saved checkpoints retain metadata, consistent with output-schema filtering. Optional load errors can appear when produced and normal loads can omit them. Its named custom event includes both keys with an empty-list error fallback for Angular's live index. React reads native root values and, when the public stream omits metadata, the confirmed saved checkpoint. The custom event does not supply React index authority or confirmed-read proof. Confirmation permits only absent live skills_metadata/skills_load_errors; every present private value and ordinary value must match the exact checkpoint, and loaded values must agree fully. Saved checkpoint metadata remains authoritative. 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. + + +The native Memory React preview at https://threadplane.ai/docs/deep-agents/capabilities/memory?frontend=react shares the canonical Python Memory graph, helper and prompt with Angular, which remains the default. The captured persisted UUIDv4 key is threadplane.deep-agents-memory.scope.v1, scoped by origin and browser storage partition; context contains only memory_scope. The StoreBackend namespace is cockpit/deep-agents-memory/browser-v1/scope. This is a logical partition, not authentication, and public state APIs are outside the guarantee. There is no historical global namespace read, migration or reset. Storage failure or corruption blocks creation; replacement/deletion blocks future authoring while active captured work may settle with a warning. Initial, every-model and terminal reads are fresh. Missing and present-empty files differ; strict UTF-8 is bounded to 65,536 bytes after materialization, not store allocation. The pinned middleware strips HTML comments and trailing whitespace from model prompt text; leading whitespace is preserved, while custom/raw/React-panel observations preserve the literal file. Filtered API values omit private Memory keys; custom and raw checkpoint evidence retain them, without a secrecy guarantee. React live custom phases are read-time evidence only. Saved requires native success, fresh current/newest/exact checkpoint equality and actual native transcript/load, allowing only absent private live keys and requiring full loaded equality. Restore is read-only historical evidence, not current store. Failed reads or confirmation never show Saved; writes before terminal failure may persist with no rollback. Concurrent conversations are not serialized. React Teach/Recall fill drafts; Angular suggestions auto-send and its panel trims nonblank lines. Only Send lazily creates the native owned thread with ifExists raise and maxRetries 0, with no Retry, replay or Resume. New/reload use fresh threads under the captured scope. Stop cancels delivery while backend work may continue; stale generations cannot resurrect views. Public Code includes actual scope/transport/gate or native app/application/connection/authority/browser-scope/memory-projection sources and both graph.py and memory_observation.py, excluding fixtures and controls. Build with `npx nx build cockpit-deep-agents-memory-react` and verify with `npx nx e2e cockpit-deep-agents-memory-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 e82af2492..d23087a4e 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -128,3 +128,6 @@ The private React render entry also accepts an optional `ReactRenderFunctions` h 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. The local compiled graph exposes metadata in root values and checkpoints; observed deployed API root values omit private Skills keys while saved checkpoints retain metadata, consistent with output-schema filtering. Optional load errors can appear when produced and normal loads can omit them. Its named custom event includes both keys with an empty-list error fallback for Angular's live index. React reads native root values and, when the public stream omits metadata, the confirmed saved checkpoint. The custom event does not supply React index authority or confirmed-read proof. Confirmation permits only absent live skills_metadata/skills_load_errors; every present private value and ordinary value must match the exact checkpoint, and loaded values must agree fully. Saved checkpoint metadata remains authoritative. 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. + + +The native Memory React preview at https://threadplane.ai/docs/deep-agents/capabilities/memory?frontend=react shares the canonical Python Memory graph, helper and prompt with Angular, which remains the default. The captured persisted UUIDv4 key is threadplane.deep-agents-memory.scope.v1, scoped by origin and browser storage partition; context contains only memory_scope. The StoreBackend namespace is cockpit/deep-agents-memory/browser-v1/scope. This is a logical partition, not authentication, and public state APIs are outside the guarantee. There is no historical global namespace read, migration or reset. Storage failure or corruption blocks creation; replacement/deletion blocks future authoring while active captured work may settle with a warning. Initial, every-model and terminal reads are fresh. Missing and present-empty files differ; strict UTF-8 is bounded to 65,536 bytes after materialization, not store allocation. The pinned middleware strips HTML comments and trailing whitespace from model prompt text; leading whitespace is preserved, while custom/raw/React-panel observations preserve the literal file. Filtered API values omit private Memory keys; custom and raw checkpoint evidence retain them, without a secrecy guarantee. React live custom phases are read-time evidence only. Saved requires native success, fresh current/newest/exact checkpoint equality and actual native transcript/load, allowing only absent private live keys and requiring full loaded equality. Restore is read-only historical evidence, not current store. Failed reads or confirmation never show Saved; writes before terminal failure may persist with no rollback. Concurrent conversations are not serialized. React Teach/Recall fill drafts; Angular suggestions auto-send and its panel trims nonblank lines. Only Send lazily creates the native owned thread with ifExists raise and maxRetries 0, with no Retry, replay or Resume. New/reload use fresh threads under the captured scope. Stop cancels delivery while backend work may continue; stale generations cannot resurrect views. Public Code includes actual scope/transport/gate or native app/application/connection/authority/browser-scope/memory-projection sources and both graph.py and memory_observation.py, excluding fixtures and controls. Build with `npx nx build cockpit-deep-agents-memory-react` and verify with `npx nx e2e cockpit-deep-agents-memory-react`; React remains a private source-build package. diff --git a/apps/website/content/docs/deep-agents/capabilities/memory.mdx b/apps/website/content/docs/deep-agents/capabilities/memory.mdx index 35db5416d..293b1a0b3 100644 --- a/apps/website/content/docs/deep-agents/capabilities/memory.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/memory.mdx @@ -1,115 +1,63 @@ --- title: Memory -description: A Deep Agents memory file the agent writes itself, kept in the LangGraph store so a new thread starts already knowing, and read back through private state. +description: Agent-authored memory shared across conversations under a captured browser identity, with fresh read-time observations. --- # Memory -Memory in Deep Agents is a file the agent maintains about you. `memory=["/memories/AGENTS.md"]` installs `MemoryMiddleware`, which loads that file and appends it to the system message with guidance telling the model to keep it current using `edit_file`. Nothing in the application parses the conversation for facts; the agent decides what is worth remembering, and the backend decides how long it lasts. Reading it back is the part that takes work, because `memory_contents` is private state. This page walks the three files of the running example. +Deep Agents maintains `/memories/AGENTS.md` itself through `write_file` and `edit_file`. The shared Python graph uses `StoreBackend`, so a fresh conversation can recall the file from the store rather than from an earlier transcript. Angular remains the default on this guide. The experimental React preview uses native React chat components and the same graph and prompt. -## What the demo does +## Teach and recall -The Run tab shows a dispatch desk beside a panel titled Agent Memory, which starts out saying that nothing is remembered yet. The first suggested prompt tells the agent that you fly a Citation CJ3 out of KASE and always want briefings in bullet points, and the panel fills in with the lines the agent writes into `/memories/AGENTS.md`. +In Angular Run, **Tell it about your operation** sends the suggested prompt immediately. Reload before asking what the agent remembers to start a new thread under the same browser identity. The Angular panel renders trimmed nonblank lines. In React, **Teach** and **Recall** only fill a draft; explicit **Send** or Enter authors a turn. React preserves literal blank lines, Unicode and whitespace, displays full tool arguments and results, and renders assistant Markdown. -The second suggestion is the one that matters. Reload the page to start a genuinely new thread, then ask what the agent already knows about your operation: the file is still there, because it came from the LangGraph store rather than from the transcript. A small label under the panel heading says which of two sources the panel is reading, and that label turns out to be the interesting part. +Opening either example does not create a thread. React also keeps mode changes, history navigation, unsent drafts, New and saved-history selection free of authoring. New clears the React draft and local view; reload clears the retained local catalog. A successful followup reuses the owned thread; New or reload creates a fresh thread lazily on the next Send. -## How it is built +## Capture the browser scope -Three files carry the capability: a Python graph that builds the agent and republishes one private key, an application config, and the Angular component that renders the file. Open the Code tab to read them in place. +The storage key `threadplane.deep-agents-memory.scope.v1` stores a canonical UUIDv4. Each app generation captures the persisted readback winner and supplies exactly `context: { memory_scope }`. The namespace is `('cockpit', 'deep-agents-memory', 'browser-v1', scope)`. New conversations retain the same scope while storage is available. -### The names the graph and the panel agree on +Storage is partitioned by origin and browser storage partition. Angular and React on the same origin share the key; local development ports have different origins. Unavailable or corrupt storage blocks authoring before SDK thread creation. Replacement or deletion later blocks future turns. An already active turn may settle under its captured scope with a warning. There is no read, migration or reset of the historical global namespace. -The memory file path, the store namespace, and the name of the custom stream event are module constants. The event name is the contract with the Angular panel, which listens for exactly that string. - - - - -The namespace factory ignores its runtime argument, so every thread of this demo shares one memory. That is deliberate here and wrong everywhere else. A real deployment derives the namespace from the caller, for example `lambda rt: (rt.server_info.user.identity,)`. + +A browser UUID is not authorization. Public state APIs fall outside this scope guarantee. Derive an authenticated namespace and enforce server authorization when your application requires it. -### An agent that owns its memory file - -`create_deep_agent` takes the memory sources and the backend. `memory=[...]` installs `MemoryMiddleware` with the agent's own backend, so where the memory lives is decided by the backend argument rather than by the memory argument. - - - -`StoreBackend` keeps files in LangGraph's `BaseStore`, which is scoped by namespace and shared across every thread. The default backend is `StateBackend`, which would put the same file on the thread's own state, where a new conversation would never see it. Leaving `store` unset means "resolve the store from the graph execution context", which the LangGraph server supplies. - -### What the prompt allows into memory + + + + -What the agent records is a matter of prompt, not code. The system prompt in `prompts/memory.md` is the whole policy: - -```markdown -`/memories/AGENTS.md` is yours. It is loaded into your context at the start of -every conversation, including conversations you have not had yet, and it is the -only thing about you that survives a new thread. - -Write to it with `edit_file` whenever the user tells you something durable: - -- a home base, a fleet type, an operating limitation -- a standing preference about how they want briefings written -- a correction to something you got wrong - -Keep it as a short markdown list under a `## Crew notes` heading. One line per -fact. Do not record one-off requests, small talk, or anything that will be stale -next week. Never record credentials of any kind. -``` +## Fresh observations -The last line is not decoration. A memory file is a persistent, model-writable document, so what must never go into it belongs in the prompt explicitly. +The graph reads the actual store initially, before every model call and at terminal completion. A missing file is `{}`; a present empty file is `''`. Reads validate strict UTF-8 and at most 65,536 bytes after downloading and materializing the file. The bound does not limit store allocation. The pinned middleware strips HTML comments and trailing whitespace from the model prompt; custom events, raw checkpoints and the React panel retain literal contents. -### Announcing a private state key + + -`MemoryMiddleware` annotates `memory_contents` with `PrivateStateAttr`, which is `OmitFromSchema(input=True, output=True)`: the key is deliberately absent from the agent's declared input and output. That is right for a transcript, since the memory file is context for the model rather than conversation, and it is why this example does not leave the panel bound to `agent.value()` alone while a run is in flight. A small middleware announces the key on a channel the client does receive during the run. +The named `deep_agents.memory` custom event contains `memory_contents`, `memory_scope_owner` and `phase`. Angular's **store observed during this run** comes from this live event. Historical state/checkpoint reads are a distinct fallback. A live observation is read-time evidence, not durability, and checkpoints do not promise that the store remains current. - +The graph's private input/output schema annotations omit Memory keys from API `values` filtered through `graph.output_channels`. Custom events, checkpoint events and raw checkpoints retain those keys. This is not a secrecy guarantee; unfiltered local graph streams can include private values. -This is an application-side shim rather than a framework change: the key stays private on the state and is simply announced alongside it. `get_stream_writer` raises outside a streaming context, which is why the emit is guarded. + -### Providing the agent +## React confirmation and restoration -`provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and it is the only provider the `` composition requires. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. +React labels custom evidence **store observed during this run**. **confirmed checkpoint at read time** and **Response saved.** require native success plus matching current root state, a fresh newest history head, an exact checkpoint and actual native transcript/load. Only absent private Memory keys may be omitted from live values; ordinary values and the complete tool transcript must match. Loaded values must match fully. - +Saved conversations restore an exact retained historical checkpoint through reads only. A failed initial, model or terminal read, partial failed observation, oversized file or failed checkpoint read cannot confirm Saved. A terminal read failure after a successful write can leave the write persisted: confirmation is cleared and there is no rollback. Stop cancels local delivery while backend work may continue. New invalidates stale results. Conversations can update the same file at the same time; concurrent conversations are not serialized. -### Two sources for one panel +React uses `ifExists: 'raise'` and `maxRetries: 0`, with no automatic Retry, replay or Resume. Its package remains a private source-build preview. -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, which the adapter does on first connect and again when a run completes. A panel that wants both reads both. +## Source and verification - +Code includes the complete Angular scope, transport, authoring gate and UI, or the actual native React application and authority sources, together with both shared Python sources and the canonical Memory prompt. Browser fixtures and verification controls are excluded from public Code and production builds. -`agent.customEvents()` holds the custom events of the current run — the adapter clears it 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. `agent.value()` is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not. - - - -Blending the two would hide the distinction. `live` means the visibility middleware announced the key during this run; `checkpoint` is what a reopened thread looks like, and also what the panel degrades to if the middleware is removed. - -### Rendering the file - -The panel shows the source label, then one block per remembered file: the path, then one row per non-empty line. - - - -## Proving the store rather than the panel - -The only assertion that proves cross-thread memory is a genuinely new thread that already knows. Clearing the panel and watching it refill proves that the component works, not that the store is doing the remembering. The example's end-to-end test submits the second prompt after a fresh page load, so the file it asserts on was written during a conversation that is no longer open. - - -The panel fills in either way, so contents alone do not tell you whether the live path is working. Asserting that the source label reads `live` is what keeps the visibility middleware honest. - +```sh +npx nx build cockpit-deep-agents-memory-angular +npx nx e2e cockpit-deep-agents-memory-angular +npx nx build cockpit-deep-agents-memory-react +npx nx e2e cockpit-deep-agents-memory-react +``` -## What's Next - - - - The same private-state visibility problem, for `skills_metadata`. - - - The state-backed workspace the agent writes during a run. - - - Thread-scoped memory in graph state, and the Store API behind this page. - - - Checkpointers and thread storage, which is where the durable source comes from. - - +Local browser tests use the actual compiled graph, installed SDK and a deterministic offline model. The deployed example uses the shared cockpit development deployment. 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 a2257195e..98418cb36 100644 --- a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx +++ b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx @@ -1,6 +1,6 @@ --- title: Introduction -description: Render the Deep Agents middleware capabilities — planning, filesystem, subagents, memory, and skills — in an Angular UI over LangGraph. +description: Render Deep Agents middleware capabilities in the canonical Angular UI or an experimental native React preview over LangGraph. --- # Introduction @@ -12,7 +12,7 @@ description: Render the Deep Agents middleware capabilities — planning, filesy | [Planning](/docs/deep-agents/capabilities/planning) | `TodoListMiddleware` (from LangChain) | A `todos` array the agent rewrites as it works. | | [Filesystem](/docs/deep-agents/capabilities/filesystem) | `FilesystemMiddleware` | A `files` map, plus write approvals when a permission is set to interrupt. | | [Subagents](/docs/deep-agents/capabilities/subagents) | `SubAgentMiddleware` | A `task` tool that dispatches real child graphs. | -| [Memory](/docs/deep-agents/capabilities/memory) | `MemoryMiddleware` | A memory file the agent maintains, held across threads. | +| [Memory](/docs/deep-agents/capabilities/memory) | `MemoryMiddleware` | An agent-authored store file shared across threads under a captured browser scope, with fresh read-time observations. | | [Skills](/docs/deep-agents/capabilities/skills) | `SkillsMiddleware` | A skill index loaded from `SKILL.md` frontmatter. | The five capabilities do not all arrive the same way. `create_deep_agent` always installs `FilesystemMiddleware`, and the subagent `task` tool ships with it by default. Memory and skills are switched on by their `create_deep_agent` arguments (`memory=[...]`, `skills=[...]`). Planning is a LangChain middleware — `langchain.agents.middleware.TodoListMiddleware` — that you pass explicitly in `middleware=[...]`; `create_deep_agent` does not install it on its own. @@ -27,7 +27,7 @@ These pages document what a Deep Agents graph exposes and how an Angular applica A Deep Agents agent is a LangGraph graph. `create_deep_agent` returns a compiled graph, LangGraph Server serves it like any other, and the browser talks to it over the same protocol. There is no Deep Agents adapter, because none is needed: `@threadplane/langgraph` is the whole client wiring. -That means the setup is identical for all five capabilities, and it is the setup already documented under the LangGraph adapter. Work through the [LangGraph quickstart](/docs/langgraph/getting-started/quickstart) once, and every page below assumes it. +The shared protocol and basic provider setup are documented under the LangGraph adapter. Work through the [LangGraph quickstart](/docs/langgraph/getting-started/quickstart) once. Memory additionally requires its captured browser scope, context transport and authoring gate; use the complete source on the [Memory guide](/docs/deep-agents/capabilities/memory) rather than this basic provider alone. ```ts // app.config.ts @@ -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 application visibility signals, `toolCalls()` for read requests and their results. +Each panel reads the relevant part of the agent handle: `value()` for state keys, `subagents()` for dispatches, `customEvents()` for application visibility signals, and `toolCalls()` for read requests and their results. Memory also supplies its validated scope through the provider's transport and guards every author action. ## Three ways a capability reaches the browser @@ -84,3 +84,7 @@ All five share one scenario — an aviation dispatch desk with a handful of airp - [Chat subagent card](/docs/chat/components/chat-subagent-card) — the component the `task` dispatches render into. - [Chat interrupt panel](/docs/chat/components/chat-interrupt-panel) — the component a filesystem write approval renders into. - [Persistence](/docs/langgraph/guides/persistence) — checkpoints and the store, which the memory capability depends on. + +## Native Memory preview + +[Memory React preview](/docs/deep-agents/capabilities/memory?frontend=react) shares the canonical Python graph and prompt while Angular stays the default. It captures a persisted UUIDv4 browser scope, blocks authoring if storage is unavailable or changes, and distinguishes custom live observations from exact confirmed historical checkpoints. Teach and Recall fill drafts without sending; only Send creates an owned thread lazily. New and reload use fresh threads with the same captured scope. This is a logical partition, not authentication. API values filtered by output channels omit private Memory keys while custom and raw checkpoint evidence retain them. Build with `npx nx build cockpit-deep-agents-memory-react` and verify with `npx nx e2e cockpit-deep-agents-memory-react`. diff --git a/apps/website/e2e/react-deep-agents-memory-preview.spec.ts b/apps/website/e2e/react-deep-agents-memory-preview.spec.ts new file mode 100644 index 000000000..9626b3412 --- /dev/null +++ b/apps/website/e2e/react-deep-agents-memory-preview.spec.ts @@ -0,0 +1,310 @@ +import { test, expect, type Page } from '@playwright/test'; +const route = '/docs/deep-agents/capabilities/memory'; +const reactFrame = /(?:localhost:4632|deep-agents\/memory\/react)/; +const observations = new WeakMap< + Page, + { requests: string[]; errors: string[] } +>(); +test.describe('React Deep Agents Memory', () => { + 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) + ) + 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 Memory 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 Memory preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/deep-agents/memory/react/src/application.ts"]' + ) + ).toBeVisible(); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-memory-docs-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-memory-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', + 'browser-scope.ts', + 'memory-projection.ts', + 'main.tsx', + 'styles.css', + 'project.json', + 'memory_observation.py', + ]) + await expect( + page + .getByRole('complementary', { name: 'File tree' }) + .getByRole('button', { name, exact: true }) + ).toBeVisible(); + await expect(page.getByRole('tabpanel')).toContainText( + 'createConnectedApplication' + ); + await expect( + page.getByRole('complementary', { name: 'File tree' }) + ).not.toContainText(/fixture|wire\.py|\.spec\.|proof/); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-memory-code-desktop.png'), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await noOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('react-deep-agents-memory-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-memory-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-memory-run-mobile.png'), + }); + }); + test('the canonical Memory 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 Memory preview', + exact: true, + }) + ).toHaveCount(0); + await page.getByLabel('Example UI').selectOption('react'); + await expect( + page.getByRole('heading', { + name: 'React Deep Agents Memory 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('Teach and Recall fill exact inert drafts', 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: 'Teach', exact: true }) + .click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue( + 'I fly a Citation CJ3 out of KASE, and I always want briefings in bullet points. Please remember that.' + ); + await frame.getByRole('button', { name: 'Recall', exact: true }).click(); + await expect(frame.getByLabel('Message', { exact: true })).toHaveValue('What do you already know about my operation?'); + 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 Memory 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 Memory 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 799dd1405..04d6911f7 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -430,6 +430,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'npx nx run cockpit-deep-agents-memory-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-memory --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4632', + 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 ca3127931..76dabe277 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -106,7 +106,8 @@ "cockpit-deep-agents-planning-react", "cockpit-deep-agents-filesystem-react", "cockpit-deep-agents-subagents-react", - "cockpit-deep-agents-skills-react" + "cockpit-deep-agents-skills-react", + "cockpit-deep-agents-memory-react" ] } ], diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 5fc114860..92876a11d 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -128,3 +128,6 @@ The private React render entry also accepts an optional `ReactRenderFunctions` h 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. The local compiled graph exposes metadata in root values and checkpoints; observed deployed API root values omit private Skills keys while saved checkpoints retain metadata, consistent with output-schema filtering. Optional load errors can appear when produced and normal loads can omit them. Its named custom event includes both keys with an empty-list error fallback for Angular's live index. React reads native root values and, when the public stream omits metadata, the confirmed saved checkpoint. The custom event does not supply React index authority or confirmed-read proof. Confirmation permits only absent live skills_metadata/skills_load_errors; every present private value and ordinary value must match the exact checkpoint, and loaded values must agree fully. Saved checkpoint metadata remains authoritative. 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. + + +The native Memory React preview at https://threadplane.ai/docs/deep-agents/capabilities/memory?frontend=react shares the canonical Python Memory graph, helper and prompt with Angular, which remains the default. The captured persisted UUIDv4 key is threadplane.deep-agents-memory.scope.v1, scoped by origin and browser storage partition; context contains only memory_scope. The StoreBackend namespace is cockpit/deep-agents-memory/browser-v1/scope. This is a logical partition, not authentication, and public state APIs are outside the guarantee. There is no historical global namespace read, migration or reset. Storage failure or corruption blocks creation; replacement/deletion blocks future authoring while active captured work may settle with a warning. Initial, every-model and terminal reads are fresh. Missing and present-empty files differ; strict UTF-8 is bounded to 65,536 bytes after materialization, not store allocation. The pinned middleware strips HTML comments and trailing whitespace from model prompt text; leading whitespace is preserved, while custom/raw/React-panel observations preserve the literal file. Filtered API values omit private Memory keys; custom and raw checkpoint evidence retain them, without a secrecy guarantee. React live custom phases are read-time evidence only. Saved requires native success, fresh current/newest/exact checkpoint equality and actual native transcript/load, allowing only absent private live keys and requiring full loaded equality. Restore is read-only historical evidence, not current store. Failed reads or confirmation never show Saved; writes before terminal failure may persist with no rollback. Concurrent conversations are not serialized. React Teach/Recall fill drafts; Angular suggestions auto-send and its panel trims nonblank lines. Only Send lazily creates the native owned thread with ifExists raise and maxRetries 0, with no Retry, replay or Resume. New/reload use fresh threads under the captured scope. Stop cancels delivery while backend work may continue; stale generations cannot resurrect views. Public Code includes actual scope/transport/gate or native app/application/connection/authority/browser-scope/memory-projection sources and both graph.py and memory_observation.py, excluding fixtures and controls. Build with `npx nx build cockpit-deep-agents-memory-react` and verify with `npx nx e2e cockpit-deep-agents-memory-react`; React remains a private source-build package. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 5fc114860..92876a11d 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -128,3 +128,6 @@ The private React render entry also accepts an optional `ReactRenderFunctions` h 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. The local compiled graph exposes metadata in root values and checkpoints; observed deployed API root values omit private Skills keys while saved checkpoints retain metadata, consistent with output-schema filtering. Optional load errors can appear when produced and normal loads can omit them. Its named custom event includes both keys with an empty-list error fallback for Angular's live index. React reads native root values and, when the public stream omits metadata, the confirmed saved checkpoint. The custom event does not supply React index authority or confirmed-read proof. Confirmation permits only absent live skills_metadata/skills_load_errors; every present private value and ordinary value must match the exact checkpoint, and loaded values must agree fully. Saved checkpoint metadata remains authoritative. 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. + + +The native Memory React preview at https://threadplane.ai/docs/deep-agents/capabilities/memory?frontend=react shares the canonical Python Memory graph, helper and prompt with Angular, which remains the default. The captured persisted UUIDv4 key is threadplane.deep-agents-memory.scope.v1, scoped by origin and browser storage partition; context contains only memory_scope. The StoreBackend namespace is cockpit/deep-agents-memory/browser-v1/scope. This is a logical partition, not authentication, and public state APIs are outside the guarantee. There is no historical global namespace read, migration or reset. Storage failure or corruption blocks creation; replacement/deletion blocks future authoring while active captured work may settle with a warning. Initial, every-model and terminal reads are fresh. Missing and present-empty files differ; strict UTF-8 is bounded to 65,536 bytes after materialization, not store allocation. The pinned middleware strips HTML comments and trailing whitespace from model prompt text; leading whitespace is preserved, while custom/raw/React-panel observations preserve the literal file. Filtered API values omit private Memory keys; custom and raw checkpoint evidence retain them, without a secrecy guarantee. React live custom phases are read-time evidence only. Saved requires native success, fresh current/newest/exact checkpoint equality and actual native transcript/load, allowing only absent private live keys and requiring full loaded equality. Restore is read-only historical evidence, not current store. Failed reads or confirmation never show Saved; writes before terminal failure may persist with no rollback. Concurrent conversations are not serialized. React Teach/Recall fill drafts; Angular suggestions auto-send and its panel trims nonblank lines. Only Send lazily creates the native owned thread with ifExists raise and maxRetries 0, with no Retry, replay or Resume. New/reload use fresh threads under the captured scope. Stop cancels delivery while backend work may continue; stale generations cannot resurrect views. Public Code includes actual scope/transport/gate or native app/application/connection/authority/browser-scope/memory-projection sources and both graph.py and memory_observation.py, excluding fixtures and controls. Build with `npx nx build cockpit-deep-agents-memory-react` and verify with `npx nx e2e cockpit-deep-agents-memory-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 aec6799ef..798f41a22 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,17 @@ import { ReactTimeTravelPreview } from '../../../../../components/docs/ReactTime import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; import DocsPage, { generateMetadata } from './page'; +it('selects Memory-specific native React Docs and complete sources with Angular default', async () => { + const tree = await route('deep-agents', 'capabilities', 'memory'); + const workspace = findElement(tree, WebsiteWorkspace as ComponentType); + const article = workspace?.props.reactDocsSlot as React.ReactElement | undefined; + expect((article?.type as { name?: string })?.name).toBe('ReactDeepAgentsMemoryPreview'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/memory/react/src/browser-scope.ts'); + expect(article?.props.exampleCode?.sources?.['cockpit/deep-agents/memory/python/src/memory_observation.py']).toContain('validate_scope'); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths).toContain('cockpit/deep-agents/memory/angular/src/app/memory-scope.ts'); +}); + 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); 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 e09f1de71..62ce21d64 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -24,6 +24,7 @@ import { ReactComputedFunctionsPreview } from '../../../../../components/docs/Re import { ReactChatMessagesPreview } from '../../../../../components/docs/ReactChatMessagesPreview'; import { ReactChatGenerativeUiPreview } from '../../../../../components/docs/ReactChatGenerativeUiPreview'; import { ReactDeepAgentsSkillsPreview } from '../../../../../components/docs/ReactDeepAgentsSkillsPreview'; +import { ReactDeepAgentsMemoryPreview } from '../../../../../components/docs/ReactDeepAgentsMemoryPreview'; import { ReactDeepAgentsSubagentsPreview } from '../../../../../components/docs/ReactDeepAgentsSubagentsPreview'; import { ReactDeepAgentsFilesystemPreview } from '../../../../../components/docs/ReactDeepAgentsFilesystemPreview'; import { ReactDeepAgentsPlanningPreview } from '../../../../../components/docs/ReactDeepAgentsPlanningPreview'; @@ -286,11 +287,13 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents', 'skills'].includes(slug)) || + (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents', 'skills', 'memory'].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 === 'skills' ? ( + library === 'deep-agents' && section === 'capabilities' && slug === 'memory' ? ( + + ) : library === 'deep-agents' && section === 'capabilities' && slug === 'skills' ? ( ) : library === 'deep-agents' && section === 'capabilities' && slug === 'subagents' ? ( diff --git a/apps/website/src/components/docs/ReactDeepAgentsMemoryPreview.tsx b/apps/website/src/components/docs/ReactDeepAgentsMemoryPreview.tsx new file mode 100644 index 000000000..1d747e687 --- /dev/null +++ b/apps/website/src/components/docs/ReactDeepAgentsMemoryPreview.tsx @@ -0,0 +1,52 @@ +import { MdxRenderer } from './MdxRenderer'; +import type { ExampleCodeContext } from '../../lib/example-code'; +const source = `# React Deep Agents Memory preview + +This experimental native React example shares the canonical Python Memory graph and prompt. Angular remains the default frontend on this guide. + +## Teach, then recall + +Choose **Run**, type a message and choose **Send** or press **Enter**. **Teach** and **Recall** fill the draft without sending. Opening the example, changing mode or frontend, navigating history, filling a draft and choosing **New conversation** do not create a thread or author a run. Mode and history navigation preserve the mounted unsent draft; reload and New clear it. + +Only explicit Send lazily creates an owned thread with **ifExists: raise**, using **da-memory**, **maxRetries: 0** and no automatic Retry or Resume. Followups reuse that thread. New and reload use a fresh thread on the next Send while retaining the captured browser scope. + + + + + +## Browser identity and read-time evidence + +The storage key **threadplane.deep-agents-memory.scope.v1** holds a canonical UUIDv4. The app captures the persisted readback winner once and sends exactly **context: { memory_scope }**. Storage is partitioned by origin and browser storage partition. Angular and React hosted on the same origin share the key. Unavailable or corrupt storage blocks Send before thread creation. Later replacement or deletion blocks future turns; an active turn may settle under its captured scope with a warning. + +This is a logical partition, not authentication. Public state APIs fall outside this guarantee. There is no migration, read or reset of the historical global namespace. + + + +**Agent Memory** preserves literal blank lines, Unicode and whitespace. Missing **{}** and a present empty file **''** are distinct. Fresh reads occur initially, before every model call and at terminal completion. Strict UTF-8 validation accepts at most 65,536 bytes after download materialization; this does not bound store allocation. The pinned middleware strips HTML comments and trailing whitespace from the model prompt, so recalled prompt text differs from the literal observation. + +**store observed during this run** is custom live evidence, not durability. **confirmed checkpoint at read time** and **Response saved.** require native success and equality between current root state, fresh newest history, exact checkpoint and actual native transcript/load. Only absent private Memory keys may be omitted from live values; ordinary values and the full tool transcript must match. Loaded values must match fully. API values filtered through the graph output channels omit private Memory keys, while checkpoint events, custom events and raw checkpoints retain them. This is not a secrecy guarantee. + + + + +## Restore and stop + +Saved conversations restore an exact retained historical checkpoint through reads only. They do not show the current store. Failed initial, model or terminal reads and oversized observations are unavailable and cannot confirm Saved. A terminal failure after a write may leave that write persisted; there is no rollback. Stop cancels local delivery while backend work may continue. New invalidates late results. Conversations can update the same file at the same time. + +## Shared backend and build + +The StoreBackend namespace is **cockpit / deep-agents-memory / browser-v1 / scope**. Both frontends use these complete sources: + + + + +~~~sh +npx nx build cockpit-deep-agents-memory-react +npx nx e2e cockpit-deep-agents-memory-react +~~~ + +Browser verification uses the actual compiled graph, installed SDK and offline deterministic model. Verification controls and fixtures are excluded from public Code. +`; +export function ReactDeepAgentsMemoryPreview({ 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 ab644f665..2c237c099 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -200,6 +200,39 @@ describe('docs pages teach through their example', () => { }); }); +it('describes the pinned Memory prompt transformation without trimming leading whitespace', () => { + const files = [ + 'apps/website/content/docs/deep-agents/capabilities/memory.mdx', + 'apps/website/src/components/docs/ReactDeepAgentsMemoryPreview.tsx', + ...['AGENTS', 'CLAUDE'].flatMap(name => [`apps/website/content/${name}.md.template`, `apps/website/public/${name}.md`]), + ]; + for (const file of files) { + const source = readFileSync(join(WORKSPACE_ROOT, file), 'utf8'); + expect(source).toContain('strips HTML comments and trailing whitespace'); + expect(source).not.toContain('trims surrounding whitespace'); + expect(source).not.toContain('Pinned prompt text trims whitespace'); + } +}); + +it('resolves every Memory article include and both complete shared backend sources', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-memory-python')!; + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react')!; + expect(react).toBeDefined(); + for (const descriptor of [canonical, react]) { + const page = { docsPath: descriptor.docsPath, assetPaths: [...descriptor.codeAssetPaths, ...(descriptor.backendAssetPaths ?? [])] }; + const context = contextFor(page); + const article = descriptor === react ? readFileSync(join(WORKSPACE_ROOT, 'apps/website/src/components/docs/ReactDeepAgentsMemoryPreview.tsx'), 'utf8') : mdxFor(canonical.docsPath); + expect(includesIn(article).length).toBeGreaterThan(5); + for (const include of includesIn(article)) { + expect(resolveExampleFile(include.file, context)).toBe(include.file); + expect(context.sources[include.file]).toBe(readFileSync(join(WORKSPACE_ROOT, include.file), 'utf8')); + } + expect(page.assetPaths).toContain('cockpit/deep-agents/memory/python/src/memory_observation.py'); + expect(page.assetPaths.some(path => /fixture|wire\.py|\.spec\.|proof/.test(path))).toBe(false); + for (const path of page.assetPaths) expect(context.sources[path]).toBe(readFileSync(join(WORKSPACE_ROOT, path), 'utf8')); + } +}); + 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')!; diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 6ae0b33a1..347f2b66d 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(32); - expect(new Set(prerequisites).size).toBe(32); + expect(prerequisites).toHaveLength(33); + expect(new Set(prerequisites).size).toBe(33); 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-two owned React servers after exact fresh Nx prerequisites', () => { + it('starts thirty-three 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]|3[01])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(32); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|3[012])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(33); 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(32); - expect(new Set(projects).size).toBe(32); + expect(projects).toHaveLength(33); + expect(new Set(projects).size).toBe(33); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -91,6 +91,8 @@ describe('Website Playwright configuration', () => { 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(projects).toContain('cockpit-deep-agents-memory-react'); + expect(reactServers.find(server => server.url === 'http://127.0.0.1:4632')?.command).toBe('npx nx run cockpit-deep-agents-memory-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-memory --no-parent'); 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'); @@ -382,6 +384,7 @@ describe('Website Playwright configuration', () => { 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-deep-agents-memory-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-memory --no-parent',url:'http://127.0.0.1:4632'}), 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/memory/angular/e2e/da-memory.spec.ts b/cockpit/deep-agents/memory/angular/e2e/da-memory.spec.ts index 1e2f180a6..e232299e5 100644 --- a/cockpit/deep-agents/memory/angular/e2e/da-memory.spec.ts +++ b/cockpit/deep-agents/memory/angular/e2e/da-memory.spec.ts @@ -1,42 +1,91 @@ -import { test, expect } from '@playwright/test'; -import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness'; - -// Match the welcome suggestions in memory.component.ts and the fixture in -// e2e/fixtures/da-memory.json. -const TEACH = - 'I fly a Citation CJ3 out of KASE, and I always want briefings in bullet points. Please remember that.'; -const RECALL = 'What do you already know about my operation?'; - -test('da-memory: the agent writes its memory file and a new thread still has it', async ({ - page, -}) => { - // First conversation: the agent edits /memories/AGENTS.md itself. Nothing in - // this app parses the conversation for facts. - await submitAndWaitForResponse(page, TEACH); - - // `submitAndWaitForResponse` navigates, so this second call runs on a FRESH - // page load and therefore a fresh thread. The memory file is not on the - // thread — StoreBackend put it in the LangGraph store — so this asserts - // cross-thread persistence, not that the panel remembers its own DOM. - await submitAndWaitForResponse(page, RECALL); - - // The panel only fills in because the graph republishes `memory_contents` as - // a custom stream event. The key is PrivateStateAttr, so it never reaches the - // `values` stream and a panel bound to agent.value() alone would be empty - // during the run. - const file = page.locator('[data-testid="memory-file"][data-path="/memories/AGENTS.md"]'); - await expect(file).toBeVisible({ timeout: 30_000 }); - - // The contents are what the agent wrote in the FIRST conversation. - await expect(file).toContainText('Citation CJ3'); - await expect(file).toContainText('KASE'); - await expect(file).toContainText(/bullet points/i); - await expect(page.getByTestId('memory-line').first()).toContainText('Crew notes'); - - // The panel is showing the LIVE source, which only exists because the graph - // republishes `memory_contents` as a custom stream event. Without that - // middleware the panel still fills in, but only from the settle-time - // checkpoint hydration — so this assertion is what keeps the live path - // honest. +import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +const backend = 'http://localhost:5313'; +const key = 'threadplane.deep-agents-memory.scope.v1'; +async function records(request: APIRequestContext) { return (await request.get(backend + '/__requests')).json(); } +async function configure(request: APIRequestContext, scenario: string) { + expect((await request.post(backend + '/__configure', { data: { scenario, holdStream: false, holdState: false, holdCheckpoint: false, failCreation: false, failStream: false, failCheckpoint: false } })).ok()).toBe(true); +} +async function send(page: Page, text: string) { await page.getByRole('textbox', { name: /message|prompt/i }).fill(text); await page.getByRole('button', { name: /send message/i }).click(); } +test.afterEach(async ({ request }, info) => { + mkdirSync(info.outputDir, { recursive: true }); + for (const endpoint of ['requests', 'raw-events', 'graph-proof']) { + const data = await (await request.get(backend + '/__' + endpoint)).json(); + writeFileSync(info.outputPath(endpoint + '.json'), JSON.stringify(data, null, 2)); + if (endpoint === 'graph-proof') for (const proof of data) { expect(proof.actualCompiledGraph).toBe(true); expect(proof.networkConnectAttempts).toBe(0); } + } +}); +test('canonical Angular auto-send suggestion, provider-owned followup and scoped reload recall use real store', async ({ page, request }) => { + await configure(request, 'teach'); const before = await records(request); await page.goto('/'); + expect(await records(request)).toEqual(before); + const scope = await page.evaluate(k => localStorage.getItem(k), key); + // The older Angular suggestion explicitly sends; React suggestions are drafts. + await page.getByText('Tell it about your operation', { exact: true }).click(); + await expect(page.getByTestId('memory-line').first()).toHaveText('Fleet note', { timeout: 45000 }); await expect(page.getByTestId('memory-source')).toHaveAttribute('data-source', 'live'); + // Angular retains trimmed nonblank lines rather than React's literal pre. + await expect(page.getByTestId('memory-line')).toHaveText(['Fleet note', 'Ω literal whitespace']); + await expect(page.getByRole('button', { name: /send message/i })).toBeVisible(); + await configure(request, 'edit'); await send(page, 'Edit the fleet note'); + await expect(page.getByTestId('memory-line').first()).toHaveText('Updated fleet note', { timeout: 45000 }); + let own = (await records(request)).slice(before.length); const authored = own.filter((r: any) => r.path.endsWith('/runs/stream')); + expect(authored).toHaveLength(2); expect(authored[0].path).toBe(authored[1].path); + expect(own.filter((r: any) => r.path === '/api/threads' && r.method === 'POST')).toHaveLength(1); + await page.reload(); expect(await page.evaluate(k => localStorage.getItem(k), key)).toBe(scope); + await configure(request, 'recall'); await send(page, 'Recall the fleet note'); + await expect(page.getByTestId('memory-line').first()).toHaveText('Updated fleet note', { timeout: 45000 }); + own = (await records(request)).slice(before.length); const runs = own.filter((r: any) => r.path.endsWith('/runs/stream')); + expect(runs).toHaveLength(3); expect(runs[2].path).not.toBe(runs[0].path); + for (const run of runs) expect(run.body.context).toEqual({ memory_scope: scope }); + const raw = await (await request.get(backend + '/__raw-events')).json(); + expect(raw.at(-1).modelPrompts.some((p: string) => p.includes('Updated fleet note'))).toBe(true); + expect(raw.some((r: any) => r.storeOperations.some((op: any) => op.type === 'PutOp'))).toBe(true); + await configure(request, 'empty'); await send(page, 'Empty the current store'); + // Count zero also occurs during custom-event reset. Establish the actual + // preceding author's terminal empty observation and provider settlement + // before measuring the later read-only restoration window. + await expect.poll(async () => (await records(request)).slice(before.length).filter((r: any) => r.path.endsWith('/runs/stream')).length).toBe(4); + const emptyRun = (await records(request)).slice(before.length).filter((r: any) => r.path.endsWith('/runs/stream')).at(-1); + expect(emptyRun.path).toBe(runs[2].path); + expect(emptyRun.body.input.messages[0].content).toBe('Empty the current store'); + expect(emptyRun.body.context).toEqual({ memory_scope: scope }); + await expect.poll(async () => { + const actual = (await (await request.get(backend + '/__raw-events')).json()).at(-1); + if (actual.threadId !== emptyRun.path.split('/')[3]) return undefined; + const events = actual.events; + return events.filter((event: any) => event[1] === 'custom' && event[2].data.phase === 'terminal').at(-1)?.[2].data.memory_contents; + }).toEqual({ '/memories/AGENTS.md': '' }); + await expect.poll(() => page.evaluate(() => { + const debug = (window as unknown as { ng: { getComponent(element: Element): { agent: { isLoading(): boolean } } } }).ng; + return debug.getComponent(document.querySelector('app-da-memory')!).agent.isLoading(); + })).toBe(false); + await expect(page.getByTestId('memory-line')).toHaveCount(0); + const beforeRestore = await records(request); + const firstThread = runs[0].path.split('/')[3]; + // Exercise the real public provider restoration method through Angular's + // development debug API; the product has no saved-history selector. + // This performs SDK reads, never injects synthetic state or custom events. + await page.evaluate(thread => { + const debug = (window as unknown as { ng: { getComponent(element: Element): { agent: { switchThread(id: string): void } } } }).ng; + debug.getComponent(document.querySelector('app-da-memory')!).agent.switchThread(thread); + }, firstThread); + await expect(page.getByTestId('memory-source')).toHaveAttribute('data-source', 'checkpoint'); + await expect(page.getByTestId('memory-line').first()).toHaveText('Updated fleet note'); + const restoredReads = (await records(request)).slice(beforeRestore.length); + expect(restoredReads.length).toBeGreaterThan(0); + expect(restoredReads.every((r: any) => !r.path.endsWith('/runs/stream') && !(r.path === '/api/threads' && r.method === 'POST'))).toBe(true); +}); +test('ordinary composer with corrupt scope cannot create or run', async ({ page, request }) => { + await page.addInitScript(k => localStorage.setItem(k, 'corrupt'), key); + const before = await records(request); await page.goto('/'); + await expect(page.getByTestId('memory-scope-warning')).toBeVisible(); + const composer = page.getByRole('textbox', { name: /message|prompt/i }); + if (await composer.isEnabled()) { await composer.fill('ordinary chat'); await composer.press('Enter'); } + expect(await records(request)).toEqual(before); +}); +for (const scenario of ['fail-initial', 'fail-model', 'fail-terminal', 'oversize']) test(`Angular ${scenario} shows unavailable read evidence`, async ({ page, request }) => { + await configure(request, scenario); await page.goto('/'); const before = await records(request); await send(page, 'Observe memory'); + await expect(page.getByTestId('memory-source')).toHaveAttribute('data-source', 'unavailable', { timeout: 45000 }); + await expect(page.getByTestId('memory-file')).toHaveCount(0); + expect((await records(request)).slice(before.length).filter((r: any) => r.path.endsWith('/runs/stream'))).toHaveLength(1); }); diff --git a/cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts b/cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts index c899adb58..71ca3d408 100644 --- a/cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts +++ b/cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts @@ -1,13 +1,54 @@ +import { createServer, get } from 'node:http'; +import { createServer as probe } from 'node:net'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdirSync, openSync, closeSync } from 'node:fs'; import { resolve } from 'node:path'; -import { portsFor } from '../../../../../cockpit/ports.mjs'; -import { createGlobalSetup } from '@threadplane-internal/e2e-harness'; - -const ports = portsFor('cockpit-deep-agents-memory-angular'); - -export default createGlobalSetup({ - langgraphCwd: 'cockpit/deep-agents/memory/python', - langgraphPort: ports.langgraph, - angularProject: 'cockpit-deep-agents-memory-angular', - angularPort: ports.angular, - fixturesDir: resolve(__dirname, 'fixtures'), -}); +import { createOwnedProcessGroup, settleOwnedCleanup } from './owned-lifecycle.mjs'; +// Capability-local transport: production proxy strips /api before forwarding. +// Fixture controls remain direct loopback endpoints and never enter public Code. +export default async function setup() { + const root = resolve(__dirname, '../../../../..'); + for (const port of [4313, 5313]) { + const listener = probe(); listener.listen(port); + await once(listener, 'listening'); await new Promise(r => listener.close(() => r())); + } + const { createDeepAgentsMemoryFixture } = await import('../../../../../scripts/react-cockpit/deep-agents-memory-fixture.mjs'); + const fixture = createDeepAgentsMemoryFixture(); + const backend = createServer(async (req, res) => { + const path = new URL(req.url!, 'http://localhost:5313').pathname; + try { + if (!await fixture(req, res, path.startsWith('/threads') ? '/api' + path : path)) { res.writeHead(404); res.end(); } + } catch { res.writeHead(500); res.end(); } + }); + let child: ReturnType | undefined; + let group: ReturnType | undefined; + let log: number | undefined; + let closing: Promise | undefined; + const cleanup = () => closing ??= settleOwnedCleanup([ + () => fixture.close(), + () => new Promise((done, reject) => { backend.closeAllConnections(); backend.close(error => error ? reject(error) : done()); }), + async () => { const result = await group?.close(); if (result) console.log('Memory owned group closed: ' + JSON.stringify(result)); }, + () => { if (log !== undefined) { closeSync(log); log = undefined; } }, + ]); + try { + backend.listen(5313); await once(backend, 'listening'); + mkdirSync('/tmp/threadplane-memory-task4b-angular', { recursive: true }); + log = openSync('/tmp/threadplane-memory-task4b-angular/serve.log', 'a', 0o600); + child = spawn(process.execPath, [resolve(root, 'node_modules/nx/bin/nx.js'), 'serve', 'cockpit-deep-agents-memory-angular', '--configuration=cockpit', '--port=4313', '--host=localhost'], { cwd: root, detached: true, stdio: ['ignore', log, log], env: { ...process.env, NX_DAEMON: 'false' } }); + group = createOwnedProcessGroup(child); + for (let n = 0; n < 360; n++) { + if (child.exitCode !== null) throw Error('Owned Angular serve failed; see private serve.log'); + const ready = await Promise.all(['http://[::1]:4313/', 'http://127.0.0.1:4313/'].map(url => new Promise(resolveReady => { + const req = get(url, response => { response.resume(); resolveReady(response.statusCode === 200); }); + req.setTimeout(2000, () => req.destroy()); req.on('error', () => resolveReady(false)); + }))); + if (ready.some(Boolean)) { console.log('Memory Angular and compiled backend ready.'); return cleanup; } + await new Promise(r => setTimeout(r, 500)); + } + throw Error('Owned Angular serve readiness timed out'); + } catch (error) { + try { await cleanup(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Memory startup and cleanup failed'); } + throw error; + } +} diff --git a/cockpit/deep-agents/memory/angular/e2e/lifecycle.spec.mjs b/cockpit/deep-agents/memory/angular/e2e/lifecycle.spec.mjs new file mode 100644 index 000000000..dbfec530e --- /dev/null +++ b/cockpit/deep-agents/memory/angular/e2e/lifecycle.spec.mjs @@ -0,0 +1,83 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { mkdtempSync, writeFileSync, openSync, closeSync, fstatSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createOwnedProcessGroup, settleOwnedCleanup } from './owned-lifecycle.mjs'; +const captures = mkdtempSync(join(tmpdir(), 'threadplane-memory-task4b-v2-lifecycle-')); +console.log('Memory lifecycle captures: ' + captures); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(check, timeout = 3000) { + const end = Date.now() + timeout; + while (!check()) { if (Date.now() > end) throw Error('Owned process condition timed out'); await sleep(20); } +} +function group(pid) { + try { return Number(execFileSync('ps', ['-p', String(pid), '-o', 'pgid='], { encoding: 'utf8' }).trim()); } catch { return undefined; } +} +async function bindable(port) { + const listener = createServer(); + try { listener.listen(port, '127.0.0.1'); await once(listener, 'listening'); await new Promise(resolve => listener.close(resolve)); return true; } catch { listener.close(); return false; } +} +async function owned(t, stubborn = false) { + const descendant = `const net=require('node:net');${stubborn ? "process.on('SIGTERM',()=>{});" : ''}const server=net.createServer();server.listen(0,'127.0.0.1',()=>process.send({pid:process.pid,port:server.address().port}));`; + const leader = spawn(process.execPath, ['-e', `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:['ignore','ignore','ignore','ipc']});child.once('message',value=>process.send(value));process.on('message',()=>{child.disconnect();child.unref();process.disconnect();process.exit(1);});`], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); + const exited = once(leader, 'exit'); + const [member] = await once(leader, 'message'); + assert.equal(group(member.pid), leader.pid); + t.after(async () => { + // Only this freshly spawned, observed group's descendant can authorize + // fallback cleanup when a regression deliberately leaves it alive. + if (group(member.pid) === leader.pid) { try { process.kill(-leader.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } + if (leader.exitCode === null && leader.signalCode === null) leader.kill('SIGKILL'); + await exited; + await until(() => group(member.pid) === undefined); + assert.equal(await bindable(member.port), true); + }); + return { leader, exited, member }; +} +test('cleanup terminates an owned surviving descendant after its detached leader exits', async t => { + const f = await owned(t); + const lease = createOwnedProcessGroup(f.leader, { graceMs: 100, killMs: 1000 }); + await until(() => lease.members().includes(f.member.pid)); + f.leader.send('exit'); await f.exited; + assert.equal(f.leader.exitCode, 1); + assert.equal(group(f.member.pid), f.leader.pid); + const closure = await lease.close(); + const receipt = { leaderPid: f.leader.pid, descendantPid: f.member.pid, port: f.member.port, leaderExitCode: f.leader.exitCode, descendantGroupAfterCleanup: group(f.member.pid) ?? null, listenerBindable: await bindable(f.member.port), closure }; + writeFileSync(join(captures, 'exited-leader.json'), JSON.stringify(receipt, null, 2)); + assert.equal(receipt.descendantGroupAfterCleanup, null); + assert.equal(receipt.listenerBindable, true); +}); +test('running leader cleanup escalates only its SIGTERM-ignoring descendant and is idempotent', async t => { + const f = await owned(t, true); + const lease = createOwnedProcessGroup(f.leader, { graceMs: 100, killMs: 1000 }); + const first = lease.close(); assert.equal(lease.close(), first); + const closure = await first; + assert.deepEqual(closure.signals, ['SIGTERM', 'SIGKILL']); + assert.deepEqual(closure.remainingPids, []); + assert.equal(group(f.member.pid), undefined); + assert.equal(await bindable(f.member.port), true); + writeFileSync(join(captures, 'running-leader-stubborn-descendant.json'), JSON.stringify({ leaderPid: f.leader.pid, descendantPid: f.member.pid, port: f.member.port, closure, listenerBindable: true }, null, 2)); +}); +test('resource close failure still closes the real owned group, backend listener and descriptor', async t => { + const f = await owned(t); + const lease = createOwnedProcessGroup(f.leader, { graceMs: 100, killMs: 1000 }); + const backend = createServer(); backend.listen(0, '127.0.0.1'); await once(backend, 'listening'); + const port = backend.address().port; + const descriptor = openSync(join(captures, 'owned-log.txt'), 'w'); + t.after(() => { backend.close(); try { closeSync(descriptor); } catch { /* verified closed */ } }); + await assert.rejects(settleOwnedCleanup([ + async () => { throw Error('Controlled first resource close failure'); }, + () => new Promise(resolve => backend.close(resolve)), + () => lease.close(), + () => closeSync(descriptor), + ]), AggregateError); + assert.equal(group(f.member.pid), undefined); + assert.equal(await bindable(port), true); + assert.equal(await bindable(f.member.port), true); + assert.throws(() => fstatSync(descriptor), { code: 'EBADF' }); + writeFileSync(join(captures, 'all-owned-resources-attempted.json'), JSON.stringify({ leaderPid: f.leader.pid, descendantPid: f.member.pid, descendantPort: f.member.port, backendPort: port, descendantAbsent: true, bothPortsBindable: true, descriptorClosed: true }, null, 2)); +}); diff --git a/cockpit/deep-agents/memory/angular/e2e/owned-lifecycle.mjs b/cockpit/deep-agents/memory/angular/e2e/owned-lifecycle.mjs new file mode 100644 index 000000000..fd6855ab0 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/e2e/owned-lifecycle.mjs @@ -0,0 +1,73 @@ +import { execFileSync } from 'node:child_process'; +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +function processes(groupId) { + return execFileSync('ps', ['-eo', 'pid=,ppid=,pgid=,stat=,lstart='], { encoding: 'utf8', timeout: 1000, maxBuffer: 1024 * 1024 }).split('\n').flatMap(line => { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line); + return match && Number(match[3]) === groupId && !match[4].startsWith('Z') + ? [{ pid: Number(match[1]), parent: Number(match[2]), identity: match[1] + ':' + match[5] }] + : []; + }); +} +/** A lease for this freshly spawned detached group, never an arbitrary PGID. + * Observed process birth identities maintain ownership after leader exit. + * Once empty, ownership is permanently revoked to prevent PGID reuse kills. + * Unknown replacement membership fails closed instead of signalling it. */ +export function createOwnedProcessGroup(child, { graceMs = 1000, killMs = 1500 } = {}) { + const groupId = child.pid; + if (!Number.isInteger(groupId) || groupId <= 0) throw Error('Missing owned process group'); + const initial = processes(groupId); + if (!initial.some(member => member.pid === groupId && member.parent === process.pid)) throw Error('Fresh detached child ownership not established'); + const known = new Map(initial.map(member => [member.pid, member.identity])); + let current = initial, active = true, closing, scanError; + const ended = new Promise(resolve => child.once('close', resolve)); + const signals = []; + function refresh() { + if (!active) return []; + const found = processes(groupId); + if (!found.length) { current = []; active = false; return []; } + if (!found.some(member => known.get(member.pid) === member.identity)) { + active = false; + throw Error('Owned process group identity changed; signalling refused'); + } + for (const member of found) known.set(member.pid, member.identity); + current = found; + return found; + } + const monitor = setInterval(() => { try { refresh(); } catch (error) { scanError = error; } }, 25); + monitor.unref(); + async function waitForGroup(timeout) { + const deadline = Date.now() + timeout; + while (refresh().length) { + if (Date.now() >= deadline) return false; + await pause(25); + } + return true; + } + function signal(value) { + if (!refresh().length) return; + try { process.kill(-groupId, value); signals.push(value); } + catch (error) { if (error.code !== 'ESRCH') throw error; refresh(); } + } + return Object.freeze({ + members: () => refresh().map(member => member.pid), + close() { + return closing ??= (async () => { + clearInterval(monitor); + if (scanError) throw scanError; + signal('SIGTERM'); + if (!await waitForGroup(graceMs)) { + signal('SIGKILL'); + if (!await waitForGroup(killMs)) throw Error('Owned process group did not terminate within bounds'); + } + await ended; + return { groupId, ownedPids: [...known.keys()], signals, remainingPids: current.map(member => member.pid), leaderExitCode: child.exitCode, leaderSignalCode: child.signalCode }; + })(); + }, + }); +} +/** Every owned resource gets a close attempt even if another close rejects. */ +export async function settleOwnedCleanup(tasks) { + const results = await Promise.allSettled(tasks.map(async close => close())); + const failures = results.filter(result => result.status === 'rejected').map(result => result.reason); + if (failures.length) throw new AggregateError(failures, 'Owned Memory resources could not all close'); +} diff --git a/cockpit/deep-agents/memory/angular/e2e/playwright.config.ts b/cockpit/deep-agents/memory/angular/e2e/playwright.config.ts index d27010a53..f2c7bfd04 100644 --- a/cockpit/deep-agents/memory/angular/e2e/playwright.config.ts +++ b/cockpit/deep-agents/memory/angular/e2e/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ testMatch: '**/*.spec.ts', fullyParallel: false, workers: 1, - retries: process.env.CI ? 2 : 0, + retries: 0, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { baseURL: `http://localhost:${angularPort}`, @@ -17,5 +17,4 @@ export default defineConfig({ }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], globalSetup: './global-setup-impl.ts', - globalTeardown: require.resolve('../../../../../libs/e2e-harness/src/global-teardown'), }); diff --git a/cockpit/deep-agents/memory/angular/project.json b/cockpit/deep-agents/memory/angular/project.json index 05736e8bd..c3c1d6e92 100644 --- a/cockpit/deep-agents/memory/angular/project.json +++ b/cockpit/deep-agents/memory/angular/project.json @@ -89,9 +89,21 @@ }, "e2e": { "executor": "@nx/playwright:playwright", + "dependsOn": ["lifecycle-test"], "options": { "config": "cockpit/deep-agents/memory/angular/e2e/playwright.config.ts" } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/deep-agents/memory/angular/vite.config.mts" + } + }, + "lifecycle-test": { + "executor": "nx:run-commands", + "cache": false, + "options": { "command": "node --test cockpit/deep-agents/memory/angular/e2e/lifecycle.spec.mjs" } } }, "tags": [ diff --git a/cockpit/deep-agents/memory/angular/src/app/app.config.spec.ts b/cockpit/deep-agents/memory/angular/src/app/app.config.spec.ts new file mode 100644 index 000000000..3a1c161bf --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/app.config.spec.ts @@ -0,0 +1,165 @@ +import '@angular/compiler'; +import { TestBed } from '@angular/core/testing'; +import { + injectAgent, + ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, +} from '@threadplane/langgraph'; +import { COCKPIT_RUNTIME_CONNECTION } from '@threadplane/cockpit-telemetry'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +const sdk = vi.hoisted(() => ({ + create: vi.fn(), + stream: vi.fn(), + queued: vi.fn(), + history: vi.fn(), + join: vi.fn(), + cancel: vi.fn(), + ctor: vi.fn(), +})); +vi.mock('@langchain/langgraph-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + Client: class { + constructor(options: any) { + sdk.ctor(options); + } + threads = { create: sdk.create, getHistory: sdk.history }; + runs = { + stream: sdk.stream, + create: sdk.queued, + joinStream: sdk.join, + cancel: sdk.cancel, + }; + }, +})); +import { submitMessage } from '@threadplane/chat'; +import { MemoryComponent } from './memory.component'; +import { appConfig } from './app.config'; +import { MEMORY_SCOPE_OPTIONS } from './memory-scope'; +const scope = '12345678-1234-4234-8234-123456789abc'; +async function* stream() { + yield { + event: 'metadata', + data: { run_id: 'run-1', thread_id: 'actual-thread' }, + }; + yield { event: 'values', data: { messages: [] } }; +} +describe('Memory provideAgent integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.setItem('threadplane.deep-agents-memory.scope.v1', scope); + sdk.create.mockResolvedValue({ thread_id: 'actual-thread' }); + sdk.stream.mockImplementation(stream); + sdk.history.mockResolvedValue([]); + TestBed.configureTestingModule({ + providers: [ + ...appConfig.providers, + { + provide: COCKPIT_RUNTIME_CONNECTION, + useValue: { + adapter: 'langgraph', + apiUrl: 'http://memory.test.invalid/api', + assistantId: 'da-memory', + clientOptions: { + defaultHeaders: { 'x-fixture-session': 'synthetic' }, + }, + }, + }, + ], + }); + }); + function agent() { + return TestBed.runInInjectionContext(() => injectAgent()); + } + it('reuses actual created thread on two submissions with immutable scoped context', async () => { + const a = agent(); + await a.submit( + { message: 'one' }, + { context: { memory_scope: 'caller' }, metadata: { label: 'retained' } } + ); + TestBed.tick(); + await a.submit({ message: 'two' }); + expect(sdk.create).toHaveBeenCalledTimes(1); + expect(sdk.stream.mock.calls.map((c) => c[0])).toEqual([ + 'actual-thread', + 'actual-thread', + ]); + expect(sdk.stream.mock.calls[0][2]).toMatchObject({ + context: { memory_scope: scope }, + metadata: { label: 'retained' }, + }); + expect(sdk.ctor).toHaveBeenCalledWith( + expect.objectContaining({ + apiUrl: 'http://memory.test.invalid/api', + defaultHeaders: { 'x-fixture-session': 'synthetic' }, + callerOptions: expect.objectContaining({ maxRetries: 0 }), + }) + ); + }); + it('refuses corrupted storage before any SDK create or run', async () => { + localStorage.setItem('threadplane.deep-agents-memory.scope.v1', 'corrupt'); + const a = agent(); + await a.submit({ message: 'blocked' }); + expect(sdk.create).not.toHaveBeenCalled(); + expect(sdk.stream).not.toHaveBeenCalled(); + }); + it.each(['corrupt', null])( + 'blocks actual composer and suggestion with zero SDK creates/runs after storage becomes %s', + async (value) => { + const c = TestBed.runInInjectionContext( + () => new MemoryComponent() + ) as any; + if (value === null) + localStorage.removeItem('threadplane.deep-agents-memory.scope.v1'); + else + localStorage.setItem('threadplane.deep-agents-memory.scope.v1', value); + submitMessage(c.agent, 'ordinary composer'); + c.send('older suggestion'); + await Promise.resolve(); + expect(sdk.create).not.toHaveBeenCalled(); + expect(sdk.stream).not.toHaveBeenCalled(); + expect(c.agent.isInputBlocked()).toBe(true); + expect(c.scopeWarning()).toContain('changed'); + } + ); + it('passes the optional DI operation reporter into the actual protected transport boundary', async () => { + const reporter = vi.fn(); + TestBed.overrideProvider(ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, { + useValue: reporter, + }); + const a = agent(); + const requestFetch = sdk.ctor.mock.calls.at(-1)![0].callerOptions.fetch; + const originalFetch = globalThis.fetch; + globalThis.fetch = vi + .fn() + .mockRejectedValue(new TypeError('synthetic failure')); + try { + const failed = await requestFetch('http://memory.test.invalid/api').catch( + (error: unknown) => error + ); + sdk.create.mockRejectedValueOnce(failed); + await a.submit({ message: 'synthetic failed create' }); + expect(reporter).toHaveBeenCalledWith('network_blocked'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('blocks the ordinary composer and suggestion with a visible setup warning when storage access fails', async () => { + TestBed.overrideProvider(MEMORY_SCOPE_OPTIONS, { + useValue: { + storage: () => { + throw new Error('synthetic unavailable storage'); + }, + uuid: () => { + throw new Error('must not generate'); + }, + }, + }); + const c = TestBed.runInInjectionContext(() => new MemoryComponent()) as any; + expect(submitMessage(c.agent, 'ordinary composer')).toBeNull(); + c.send('older suggestion'); + await Promise.resolve(); + expect(sdk.create).not.toHaveBeenCalled(); + expect(sdk.stream).not.toHaveBeenCalled(); + expect(c.scopeWarning()).toContain('setup is unavailable'); + expect(c.memorySource()).toBe('unavailable'); + }); +}); diff --git a/cockpit/deep-agents/memory/angular/src/app/app.config.ts b/cockpit/deep-agents/memory/angular/src/app/app.config.ts index 9395cf923..c955b6448 100644 --- a/cockpit/deep-agents/memory/angular/src/app/app.config.ts +++ b/cockpit/deep-agents/memory/angular/src/app/app.config.ts @@ -1,6 +1,11 @@ import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; +import { ApplicationConfig, inject, signal } from '@angular/core'; +import { MemoryScope } from './memory-scope'; +import { MemoryTransport } from './memory-transport'; +import { + provideAgent, + ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, +} from '@threadplane/langgraph'; export const appConfig: ApplicationConfig = { providers: [ @@ -9,10 +14,25 @@ export const appConfig: ApplicationConfig = { if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + const scope = inject(MemoryScope); + const threadId = signal(null); + const onThreadId = (id: string) => threadId.set(id); + const clientOptions = { ...connection.clientOptions, maxRetries: 0 }; + const transport = new MemoryTransport( + connection.apiUrl, + scope, + onThreadId, + clientOptions, + inject(ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, { optional: true }) ?? + undefined + ); return { apiUrl: connection.apiUrl, assistantId: connection.assistantId, - clientOptions: connection.clientOptions, + clientOptions, + threadId, + onThreadId, + transport, }; }), ], diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.spec.ts b/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.spec.ts new file mode 100644 index 000000000..06e797a62 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.spec.ts @@ -0,0 +1,37 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { mockLangGraphAgent } from '@threadplane/langgraph'; +import { submitMessage } from '@threadplane/chat'; +import { it, expect, vi } from 'vitest'; +import { MemoryScope, SCOPE_KEY } from './memory-scope'; +import { guardMemoryAgent } from './memory-agent-gate'; +const scope = '12345678-1234-4234-8234-123456789abc'; +it('preserves runtime fields and merges original blocking while using the real composer helper', async () => { + localStorage.setItem(SCOPE_KEY, scope); + const controller = TestBed.inject(MemoryScope); + const original = mockLangGraphAgent(); + const runtimeBlocked = signal(false); + original.isInputBlocked = runtimeBlocked; + const submit = vi.spyOn(original, 'submit'); + const guarded = guardMemoryAgent(original, controller); + expect(guarded.customEvents).toBe(original.customEvents); + expect(guarded.stop).toBe(original.stop); + expect(guarded.value).toBe(original.value); + runtimeBlocked.set(true); + expect(guarded.isInputBlocked()).toBe(true); + expect(submitMessage(guarded, 'blocked by runtime')).toBeNull(); + runtimeBlocked.set(false); + expect(guarded.isInputBlocked()).toBe(false); + submitMessage(guarded, ' admitted '); + await Promise.resolve(); + expect(submit).toHaveBeenCalledWith({ message: 'admitted' }, undefined); + submit.mockClear(); + localStorage.removeItem(SCOPE_KEY); + await expect(guarded.submit({ message: 'denied' })).resolves.toBeUndefined(); + expect(guarded.isInputBlocked()).toBe(true); + expect(submitMessage(guarded, 'blocked by scope')).toBeNull(); + expect(submit).not.toHaveBeenCalled(); + localStorage.setItem(SCOPE_KEY, scope); + await guarded.submit({ message: 'still denied' }); + expect(submit).not.toHaveBeenCalled(); +}); diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.ts b/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.ts new file mode 100644 index 000000000..67583057f --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.ts @@ -0,0 +1,20 @@ +import { computed } from '@angular/core'; +import type { LangGraphAgent } from '@threadplane/langgraph'; +import { MemoryScope } from './memory-scope'; +/** One gate drives both the ordinary Chat composer and older automatic suggestions. + * A denied submission resolves locally so void composer handlers cannot reject. */ +export function guardMemoryAgent( + agent: LangGraphAgent, + scope: MemoryScope +): LangGraphAgent { + return { + ...agent, + isInputBlocked: computed( + () => scope.snapshot().status !== 'ready' || !!agent.isInputBlocked?.() + ), + submit: async (input, options) => { + if (!scope.admit()) return; + await agent.submit(input, options); + }, + }; +} diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-scope.spec.ts b/cockpit/deep-agents/memory/angular/src/app/memory-scope.spec.ts new file mode 100644 index 000000000..f9018b418 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-scope.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; +import { createBrowserScope, SCOPE_KEY, validScope } from './memory-scope'; +const scope = '11111111-1111-4111-8111-111111111111'; +const other = '22222222-2222-4222-8222-222222222222'; +function setup(value: string | null = scope) { + let current = value, + reads = 0, + writes = 0, + generated = 0; + let failRead = false, + failWrite = false, + winner: string | null | undefined; + const listeners = new Set<(event: any) => void>(); + const storage = { + getItem(key: string) { + expect(key).toBe(SCOPE_KEY); + reads++; + if (failRead) throw Error(); + return current; + }, + setItem(key: string, value: string) { + expect(key).toBe(SCOPE_KEY); + writes++; + if (failWrite) throw Error(); + current = winner === undefined ? value : winner; + }, + }; + const events = { + addEventListener(_: string, listener: any) { + listeners.add(listener); + }, + removeEventListener(_: string, listener: any) { + listeners.delete(listener); + }, + }; + return { + create: () => + createBrowserScope({ + storage: () => storage, + uuid: () => { + generated++; + return scope; + }, + events, + }), + set: (v: string | null) => { + current = v; + }, + emit: ( + key = SCOPE_KEY, + newValue: string | null = current, + storageArea: unknown = storage + ) => listeners.forEach((l) => l({ key, newValue, storageArea })), + failRead: () => { + failRead = true; + }, + failWrite: () => { + failWrite = true; + }, + winner: (v: string | null) => { + winner = v; + }, + counts: () => ({ reads, writes, generated, listeners: listeners.size }), + }; +} +describe('immutable browser memory scope', () => { + it.each([scope, other])( + 'captures existing valid scope %s without writes', + (value) => { + const s = setup(value); + const b = s.create(); + expect(b?.getSnapshot()).toMatchObject({ status: 'ready', scope: value }); + expect(b?.admit()).toEqual({ memory_scope: value }); + expect(s.counts()).toMatchObject({ writes: 0, generated: 0 }); + } + ); + it('generates only when missing and captures the competing readback winner', () => { + const s = setup(null); + s.winner(other); + const b = s.create(); + expect(b?.admit()).toEqual({ memory_scope: other }); + expect(s.counts()).toMatchObject({ writes: 1, generated: 1 }); + }); + it.each([ + '', + 'garbage', + 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', + '11111111-1111-5111-8111-111111111111', + '11111111-1111-4111-7111-111111111111', + ` ${scope}`, + ])('blocks corrupt existing value %s without repair', (value) => { + const s = setup(value); + const b = s.create(); + expect(b?.getSnapshot().status).toBe('blocked'); + expect(b?.admit()).toBeUndefined(); + expect(s.counts()).toMatchObject({ writes: 0, generated: 0 }); + }); + it('blocks unavailable storage, write/read errors and invalid generated UUID', () => { + for (const options of [ + { + storage: () => { + throw Error(); + }, + uuid: () => scope, + }, + { + storage: () => ({ + getItem: () => null, + setItem: () => { + throw Error(); + }, + }), + uuid: () => scope, + }, + { + storage: () => ({ getItem: () => null, setItem: () => undefined }), + uuid: () => scope, + }, + { + storage: () => ({ + getItem: () => null, + setItem: () => { + throw Error('must not write'); + }, + }), + uuid: () => 'bad', + }, + ]) + expect(createBrowserScope(options)?.getSnapshot().status).toBe('blocked'); + const s = setup(null); + s.failWrite(); + expect(s.create()?.getSnapshot().status).toBe('blocked'); + const t = setup(); + t.failRead(); + expect(t.create()?.getSnapshot().status).toBe('blocked'); + }); + it.each([null, other])( + 'rechecks every action and permanently blocks same-window replacement %s', + (value) => { + const s = setup(); + const b = s.create(); + const inflight = b?.admit(); + s.set(value); + expect(b?.admit()).toBeUndefined(); + expect(b?.getSnapshot()).toMatchObject({ + status: 'blocked', + scope, + warning: expect.any(String), + }); + expect(inflight).toEqual({ memory_scope: scope }); + expect(Object.isFrozen(inflight)).toBe(true); + s.set(scope); + expect(b?.admit()).toBeUndefined(); + } + ); + it('reacts to storage changes during an active generation and cleans subscriptions on dispose', () => { + const s = setup(); + const b = s.create(); + const context = b?.admit(); + let updates = 0; + const unsubscribe = b?.subscribe(() => updates++); + s.emit('unrelated', other); + expect(b?.getSnapshot().status).toBe('ready'); + s.set(other); + s.emit(); + expect(b?.getSnapshot().status).toBe('blocked'); + expect(context).toEqual({ memory_scope: scope }); + expect(updates).toBe(1); + unsubscribe?.(); + b?.dispose(); + expect(s.counts().listeners).toBe(0); + expect(b?.admit()).toBeUndefined(); + }); + it('blocks a deletion event and a read failure after capture', () => { + const s = setup(); + const b = s.create(); + s.emit(SCOPE_KEY, null); + expect(b?.getSnapshot().status).toBe('blocked'); + const t = setup(); + const c = t.create(); + t.failRead(); + expect(c?.admit()).toBeUndefined(); + }); + it('validates UUID variant/version/case exactly', () => { + expect(validScope(scope)).toBe(true); + expect(validScope('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')).toBe(true); + expect(validScope('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA')).toBe(false); + expect(validScope(null)).toBe(false); + }); +}); + +it('blocks readback errors and rejects invalid winners after exactly one initialization write', () => { + let reads = 0, + writes = 0; + const b = createBrowserScope({ + storage: () => ({ + getItem: () => { + if (++reads === 1) return null; + throw Error('readback unavailable'); + }, + setItem: () => { + writes++; + }, + }), + uuid: () => scope, + }); + expect(b.getSnapshot().status).toBe('blocked'); + expect(writes).toBe(1); + expect(reads).toBe(2); + const s = setup(null); + s.winner('invalid'); + expect(s.create().getSnapshot().status).toBe('blocked'); + expect(s.counts()).toMatchObject({ writes: 1, generated: 1 }); +}); +it('handles storage clear and ignores events belonging to other storage areas', () => { + const s = setup(); + const b = s.create(); + s.emit(SCOPE_KEY, other, {}); + expect(b.getSnapshot().status).toBe('ready'); + s.emit(null as any, null); + expect(b.getSnapshot().status).toBe('blocked'); + b.dispose(); + b.dispose(); + expect(s.counts().listeners).toBe(0); +}); + +import { TestBed } from '@angular/core/testing'; +import { MemoryScope, MEMORY_SCOPE_OPTIONS } from './memory-scope'; +it('disposes the DI generation storage listener on destroy', () => { + const listeners = new Set(); + TestBed.configureTestingModule({ + providers: [ + { + provide: MEMORY_SCOPE_OPTIONS, + useValue: { + storage: () => ({ getItem: () => scope }), + uuid: () => scope, + events: { + addEventListener: (_: any, listener: any) => + listeners.add(listener), + removeEventListener: (_: any, listener: any) => + listeners.delete(listener), + }, + }, + }, + ], + }); + const controller = TestBed.inject(MemoryScope); + expect(controller.admit()).toEqual({ memory_scope: scope }); + expect(listeners.size).toBe(1); + TestBed.resetTestingModule(); + expect(listeners.size).toBe(0); + expect(controller.admit()).toBeUndefined(); +}); diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-scope.ts b/cockpit/deep-agents/memory/angular/src/app/memory-scope.ts new file mode 100644 index 000000000..21d16da1f --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-scope.ts @@ -0,0 +1,162 @@ +import { + DestroyRef, + Injectable, + InjectionToken, + inject, + signal, +} from '@angular/core'; + +export const SCOPE_KEY = 'threadplane.deep-agents-memory.scope.v1'; +export const validScope = (value: unknown): value is string => + typeof value === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value + ); +export interface ScopeStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} +export interface ScopeEvent { + readonly key: string | null; + readonly newValue: string | null; + readonly storageArea: unknown; +} +export interface ScopeEvents { + addEventListener( + type: 'storage', + listener: (event: ScopeEvent) => void + ): void; + removeEventListener( + type: 'storage', + listener: (event: ScopeEvent) => void + ): void; +} +export interface ScopeOptions { + readonly storage: () => ScopeStorage; + readonly uuid: () => string; + readonly events?: ScopeEvents; +} +export type ScopeSnapshot = Readonly< + | { status: 'ready'; scope: string } + | { status: 'blocked'; scope?: string; warning: string } +>; +export interface BrowserScope { + getSnapshot(): ScopeSnapshot; + subscribe(listener: () => void): () => void; + /** Call immediately before EVERY author action, including creation/queued runs. */ + admit(): Readonly<{ memory_scope: string }> | undefined; + dispose(): void; +} +/** One immutable identity per app generation. Storage failure permanently blocks + * future authoring; a previously captured context can still settle under its owner. */ +export function createBrowserScope(options: ScopeOptions): BrowserScope { + let storage: ScopeStorage | undefined, + scope: string | undefined, + disposed = false; + let snapshot: ScopeSnapshot = Object.freeze({ + status: 'blocked', + warning: + 'Browser memory setup is unavailable. Reload after checking browser storage.', + }); + const listeners = new Set<() => void>(); + const block = () => { + if (snapshot.status === 'blocked') return; + snapshot = Object.freeze({ + status: 'blocked', + scope, + warning: + 'Browser memory identity changed or storage is unavailable. Active work keeps its original scope. Reload before sending again.', + }); + for (const listener of [...listeners]) listener(); + }; + const onStorage = (event: ScopeEvent) => { + if ( + disposed || + (event.storageArea !== null && event.storageArea !== storage) || + (event.key !== null && event.key !== SCOPE_KEY) + ) + return; + if (event.key === null || event.newValue !== scope) block(); + }; + let listening = false; + try { + storage = options.storage(); + let value = storage.getItem(SCOPE_KEY); + if (value === null) { + const generated = options.uuid(); + if (!validScope(generated)) throw Error('Invalid generated identity'); + storage.setItem(SCOPE_KEY, generated); + value = storage.getItem(SCOPE_KEY); + } + if (!validScope(value)) throw Error('Invalid stored identity'); + scope = value; + snapshot = Object.freeze({ status: 'ready', scope }); + if (options.events) { + options.events.addEventListener('storage', onStorage); + listening = true; + } + } catch { + block(); + } + return Object.freeze({ + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + if (!disposed) listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + admit() { + if (disposed || snapshot.status !== 'ready') return undefined; + try { + if (storage?.getItem(SCOPE_KEY) !== scope) block(); + } catch { + block(); + } + return snapshot.status === 'ready' + ? Object.freeze({ memory_scope: scope! }) + : undefined; + }, + dispose() { + if (disposed) return; + disposed = true; + if (listening) options.events?.removeEventListener('storage', onStorage); + block(); + listeners.clear(); + }, + }); +} + +/** Local Angular injection seams keep storage failures testable without a shared runtime. */ +export const MEMORY_SCOPE_OPTIONS = new InjectionToken( + 'MEMORY_SCOPE_OPTIONS', + { + providedIn: 'root', + factory: () => ({ + storage: () => window.localStorage, + uuid: () => window.crypto.randomUUID(), + events: window, + }), + } +); +@Injectable({ providedIn: 'root' }) +export class MemoryScope { + private readonly controller = createBrowserScope( + inject(MEMORY_SCOPE_OPTIONS) + ); + private readonly state = signal(this.controller.getSnapshot()); + readonly snapshot = this.state.asReadonly(); + readonly scope = this.controller.getSnapshot().scope; + constructor() { + const unsubscribe = this.controller.subscribe(() => + this.state.set(this.controller.getSnapshot()) + ); + inject(DestroyRef).onDestroy(() => { + unsubscribe(); + this.controller.dispose(); + }); + } + admit(): Readonly<{ memory_scope: string }> | undefined { + return this.controller.admit(); + } +} diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-transport.spec.ts b/cockpit/deep-agents/memory/angular/src/app/memory-transport.spec.ts new file mode 100644 index 000000000..68f36668e --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-transport.spec.ts @@ -0,0 +1,161 @@ +import { TestBed } from '@angular/core/testing'; +import { FetchStreamTransport } from '@threadplane/langgraph'; +import { beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { MemoryTransport } from './memory-transport'; +import { MemoryScope, SCOPE_KEY } from './memory-scope'; +const scope = '12345678-1234-4234-8234-123456789abc'; +const calls: { path: string; method: string; body: any; headers: Headers }[] = + []; +const signal = () => new AbortController().signal; +async function collect(iterable: AsyncIterable) { + const events = []; + for await (const event of iterable) events.push(event); + return events; +} +function json(value: unknown) { + return new Response(JSON.stringify(value), { + headers: { 'content-type': 'application/json' }, + }); +} +function sse() { + return new Response( + 'event: metadata\ndata: {"run_id":"fixture-run","thread_id":"fixture-thread"}\n\nevent: values\ndata: {"messages":[]}\n\n', + { headers: { 'content-type': 'text/event-stream' } } + ); +} +beforeEach(() => { + calls.length = 0; + localStorage.setItem(SCOPE_KEY, scope); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const path = new URL(request.url).pathname; + if (new URL(request.url).hostname !== 'memory.test.invalid') + throw new Error('Offline fixture forbids other destinations'); + const body = request.method === 'GET' ? null : await request.text(); + calls.push({ + path, + method: request.method, + body: body ? JSON.parse(body) : null, + headers: request.headers, + }); + if (path.endsWith('/stream')) return sse(); + if (path.endsWith('/history')) return json([]); + if (path.endsWith('/cancel')) return json(null); + if (path === '/api/threads') return json({ thread_id: 'fixture-thread' }); + if (path.endsWith('/runs')) + return json({ + run_id: 'fixture-run', + thread_id: 'fixture-thread', + assistant_id: 'da-memory', + status: 'pending', + }); + throw new Error('Unexpected fixture request: ' + path); + }) + ); +}); +afterEach(() => vi.unstubAllGlobals()); +function transport( + created = vi.fn(), + reporter?: ConstructorParameters[4] +) { + return new MemoryTransport( + 'http://memory.test.invalid/api', + TestBed.inject(MemoryScope), + created, + { defaultHeaders: { 'x-fixture-session': 'synthetic' }, maxRetries: 9 }, + reporter + ); +} +it('uses genuine SDK HTTP serialization, creation callback, headers, scope-last merge and inherited read/join/cancel APIs', async () => { + const created = vi.fn(); + const t = transport(created); + expect(t).toBeInstanceOf(FetchStreamTransport); + await collect( + t.stream('da-memory', null, { messages: [] }, signal(), { + context: { + memory_scope: 'override', + extra: 'retained-but-backend-rejects', + }, + metadata: { probe: 'retained' }, + streamMode: ['custom'], + }) + ); + expect(created).toHaveBeenCalledExactlyOnceWith('fixture-thread'); + const request = calls.find((c) => c.path.endsWith('/runs/stream'))!; + expect(request.body).toMatchObject({ + context: { memory_scope: scope, extra: 'retained-but-backend-rejects' }, + metadata: { probe: 'retained' }, + stream_mode: ['custom'], + }); + expect( + calls.every((c) => c.headers.get('x-fixture-session') === 'synthetic') + ).toBe(true); + expect(calls.every((c) => !c.headers.has('x-api-key'))).toBe(true); + await t.getHistory('fixture-thread', signal()); + await collect( + t.joinStream('fixture-thread', 'fixture-run', 'last-id', signal()) + ); + await t.cancelRun('fixture-thread', 'fixture-run', signal()); + expect(calls.some((c) => c.path.endsWith('/history'))).toBe(true); + expect( + calls + .find((c) => c.path.endsWith('/fixture-run/stream')) + ?.headers.get('last-event-id') + ).toBe('last-id'); + expect(calls.some((c) => c.path.endsWith('/cancel'))).toBe(true); + expect(calls.filter((c) => c.path === '/api/threads')).toHaveLength(1); +}); +it('scopes queued runs and preserves enqueue, durability, metadata and checkpoint normalization', async () => { + const t = transport(); + const entry = await t.createQueuedRun( + 'da-memory', + 'fixture-thread', + {}, + signal(), + { + context: { memory_scope: 'wrong' }, + durability: 'sync', + metadata: { keep: true }, + checkpointId: 'checkpoint-1', + } + ); + expect(entry.id).toBe('fixture-run'); + expect(calls[0].body).toMatchObject({ + context: { memory_scope: scope }, + multitask_strategy: 'enqueue', + durability: 'sync', + metadata: { keep: true }, + checkpoint: { + checkpoint_id: 'checkpoint-1', + checkpoint_ns: '', + checkpoint_map: {}, + }, + }); +}); +it('denies transport bypass and queued authorship before any actual SDK HTTP request', async () => { + const t = transport(); + localStorage.removeItem(SCOPE_KEY); + await expect( + collect(t.stream('da-memory', null, {}, signal())) + ).rejects.toThrow('Browser memory identity'); + expect(() => + t.createQueuedRun('da-memory', 'fixture-thread', {}, signal()) + ).toThrow('Browser memory identity'); + expect(calls).toEqual([]); +}); +it('preserves genuine protected reporter behavior and forces zero SDK retries', async () => { + const reporter = vi.fn(); + const t = transport(undefined, reporter); + const fetch = vi + .fn() + .mockRejectedValue(new TypeError('synthetic-offline-error')); + vi.stubGlobal('fetch', fetch); + await expect( + collect(t.stream('da-memory', null, {}, signal())) + ).rejects.toThrow('LangGraph'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(reporter).toHaveBeenCalledWith('network_blocked'); + expect(t.protectsOperationErrors).toBe(true); +}); diff --git a/cockpit/deep-agents/memory/angular/src/app/memory-transport.ts b/cockpit/deep-agents/memory/angular/src/app/memory-transport.ts new file mode 100644 index 000000000..1d9195852 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory-transport.ts @@ -0,0 +1,63 @@ +import { + FetchStreamTransport, + type LangGraphSubmitOptions, + type StreamEvent, +} from '@threadplane/langgraph'; +import type { MemoryScope } from './memory-scope'; +/** Admission precedes even thread creation. The inherited adapter owns all SDK + * operation boundaries and read/join/cancel behavior. Context extras are retained, + * but the Memory graph intentionally accepts exactly memory_scope. */ +export class MemoryTransport extends FetchStreamTransport { + constructor( + apiUrl: string, + private readonly scope: Pick, + onThreadId?: ConstructorParameters[1], + clientOptions?: ConstructorParameters[2], + reportOperationFailure?: ConstructorParameters< + typeof FetchStreamTransport + >[3] + ) { + super( + apiUrl, + onThreadId, + { ...clientOptions, maxRetries: 0 }, + reportOperationFailure + ); + } + private scoped(options?: LangGraphSubmitOptions): LangGraphSubmitOptions { + const context = this.scope.admit(); + if (!context) + throw new Error( + 'Browser memory identity is unavailable. Reload before sending again.' + ); + return { + ...options, + context: { ...(options?.context as Record), ...context }, + }; + } + override async *stream( + assistantId: string, + threadId: string | null, + payload: unknown, + signal: AbortSignal, + options?: LangGraphSubmitOptions + ): AsyncIterable { + const admitted = this.scoped(options); + yield* super.stream(assistantId, threadId, payload, signal, admitted); + } + override createQueuedRun( + assistantId: string, + threadId: string, + payload: unknown, + signal: AbortSignal, + options?: LangGraphSubmitOptions + ) { + return super.createQueuedRun( + assistantId, + threadId, + payload, + signal, + this.scoped(options) + ); + } +} diff --git a/cockpit/deep-agents/memory/angular/src/app/memory.component.spec.ts b/cockpit/deep-agents/memory/angular/src/app/memory.component.spec.ts new file mode 100644 index 000000000..c6507e2a4 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app/memory.component.spec.ts @@ -0,0 +1,209 @@ +import '@angular/compiler'; +import { submitMessage } from '@threadplane/chat'; +import { TestBed } from '@angular/core/testing'; +import { mockLangGraphAgent } from '@threadplane/langgraph'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +const holder = vi.hoisted(() => ({ agent: undefined as any })); +vi.mock('@threadplane/langgraph', async (importOriginal) => ({ + ...(await importOriginal()), + injectAgent: () => holder.agent, +})); +import { MemoryComponent } from './memory.component'; +const scope = '12345678-1234-4234-8234-123456789abc'; +describe('MemoryComponent source honesty', () => { + beforeEach(() => { + holder.agent = mockLangGraphAgent(); + localStorage.setItem('threadplane.deep-agents-memory.scope.v1', scope); + }); + function component(): any { + return TestBed.runInInjectionContext(() => new MemoryComponent()); + } + it('uses an empty live store observation instead of an older checkpoint', () => { + const c = component(); + holder.agent.value.set({ + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': 'old' }, + }); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: {}, + phase: 'terminal', + }, + }, + ]); + expect(c.memorySource()).toBe('live'); + expect(c.memoryFiles()).toEqual([]); + }); + it('blocks the ordinary composer and automatic suggestion after storage changes', async () => { + const c = component(); + const submit = vi.spyOn(holder.agent, 'submit'); + localStorage.removeItem('threadplane.deep-agents-memory.scope.v1'); + submitMessage(c.agent, 'ordinary composer'); + c.send('automatic suggestion'); + await Promise.resolve(); + expect(submit).not.toHaveBeenCalled(); + }); + it('rejects wrong scope instead of claiming live success', () => { + const c = component(); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: 'bad', + memory_contents: { '/memories/AGENTS.md': 'wrong' }, + phase: 'terminal', + }, + }, + ]); + expect(c.memorySource()).toBe('unavailable'); + expect(c.memoryFiles()).toEqual([]); + }); + it.each([ + { memory_contents: {}, memory_scope_owner: scope, phase: 'wrong' }, + { + memory_contents: { '/wrong': 'text' }, + memory_scope_owner: scope, + phase: 'model', + }, + { + memory_contents: { '/memories/AGENTS.md': 1 }, + memory_scope_owner: scope, + phase: 'model', + }, + { + memory_contents: { '/memories/AGENTS.md': '\ud800' }, + memory_scope_owner: scope, + phase: 'model', + }, + { + memory_contents: { '/memories/AGENTS.md': 'é'.repeat(32769) }, + memory_scope_owner: scope, + phase: 'model', + }, + { memory_contents: [], memory_scope_owner: scope, phase: 'model' }, + { + memory_contents: {}, + memory_scope_owner: scope, + phase: 'model', + extra: true, + }, + ])('rejects malformed scoped observations %j', (data) => { + const c = component(); + holder.agent.customEvents.set([{ name: 'deep_agents.memory', data }]); + expect(c.memorySource()).toBe('unavailable'); + expect(c.memoryFiles()).toEqual([]); + }); + it('retains literal backing text while preserving older trimmed line presentation', () => { + const c = component(); + const contents = ' first \n\n second\n'; + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': contents }, + phase: 'initial', + }, + }, + ]); + expect(c.memoryFiles()).toEqual([ + { path: '/memories/AGENTS.md', contents, lines: ['first', 'second'] }, + ]); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': '' }, + phase: 'terminal', + }, + }, + ]); + expect(c.memorySource()).toBe('live'); + expect(c.memoryFiles()).toHaveLength(1); + expect(c.memoryFiles()[0].contents).toBe(''); + }); + it('revokes live evidence on error and avoids checkpoint fallback while a new run loads', () => { + const c = component(); + holder.agent.value.set({ + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': 'historical' }, + }); + expect(c.memorySource()).toBe('checkpoint'); + holder.agent.isLoading.set(true); + expect(c.memorySource()).toBe('none'); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: {}, + phase: 'terminal', + }, + }, + ]); + expect(c.memorySource()).toBe('live'); + holder.agent.error.set({ message: 'read failed', kind: 'unknown' }); + expect(c.memorySource()).toBe('unavailable'); + expect(c.memoryFiles()).toEqual([]); + }); + it('does not present wrong-owner checkpoint, tool prose, or absent source as empty success', () => { + const c = component(); + expect(c.memorySource()).toBe('none'); + holder.agent.customEvents.set([ + { name: 'tool', data: { memory_contents: {} } }, + ]); + expect(c.memorySource()).toBe('none'); + holder.agent.value.set({ + memory_scope_owner: 'wrong', + memory_contents: {}, + }); + expect(c.memorySource()).toBe('unavailable'); + }); + + it('handles hostile malformed records without crashing or reporting empty success', () => { + const c = component(); + const malformed = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('invalid record'); + }, + } + ); + holder.agent.customEvents.set([ + { name: 'deep_agents.memory', data: malformed }, + ]); + expect(() => c.memorySource()).not.toThrow(); + expect(c.memorySource()).toBe('unavailable'); + }); + it('accepts the exact UTF8 boundary and valid scalar Unicode', () => { + const c = component(); + const contents = 'é'.repeat(32768); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': contents }, + phase: 'model', + }, + }, + ]); + expect(c.memoryFiles()[0].contents).toBe(contents); + holder.agent.customEvents.set([ + { + name: 'deep_agents.memory', + data: { + memory_scope_owner: scope, + memory_contents: { '/memories/AGENTS.md': '😀' }, + phase: 'model', + }, + }, + ]); + expect(c.memoryFiles()[0].contents).toBe('😀'); + }); +}); diff --git a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts index 8ead7992b..7d24be0c3 100644 --- a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts +++ b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts @@ -1,7 +1,12 @@ -import { Component, computed } from '@angular/core'; -import { ChatComponent, ChatWelcomeSuggestionComponent } from '@threadplane/chat'; +import { Component, computed, inject } from '@angular/core'; +import { + ChatComponent, + ChatWelcomeSuggestionComponent, +} from '@threadplane/chat'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { injectAgent } from '@threadplane/langgraph'; +import { MemoryScope } from './memory-scope'; +import { guardMemoryAgent } from './memory-agent-gate'; /** Name of the custom stream event the graph's visibility middleware emits. */ const MEMORY_EVENT = 'deep_agents.memory'; @@ -23,36 +28,23 @@ const SUGGESTIONS = [ { label: 'Start over and ask what it knows', value: 'What do you already know about my operation?', - description: 'Reload first: a new thread still loads the same memory.', + description: 'Reload first: new threads use the saved browser identity.', }, ] as const; -/** - * MemoryComponent shows the file the agent keeps about you. - * - * The memory is not a state key this app maintains. `MemoryMiddleware` loads - * `/memories/AGENTS.md` into the system prompt at the start of every turn and - * the agent rewrites it with `edit_file` when it learns something durable. - * `StoreBackend` puts that file in LangGraph's store rather than on the thread, - * so a brand new thread starts already knowing. - * - * Reading it back needs a detour. `MemoryMiddleware` annotates `memory_contents` - * with `PrivateStateAttr`, which keeps it out of the `values` stream entirely, - * so a panel bound to `agent.value()` alone would stay empty during a live run. - * Two sources cover the two cases: - * - * - live: the graph republishes the key as a `custom` stream event, which - * arrives on `agent.customEvents()`; - * - settled: the key IS present in the thread's checkpoint, so once the client - * has hydrated a thread, `agent.value()` carries it. - * - * The custom event wins when both are present, because it is the fresher of - * the two during a run. - */ +/** The graph reads a browser-scoped StoreBackend and publishes scoped custom + * observations before model work and at terminal read time. PrivateStateAttr + * hides schema fields; compiled values can still include them, while the installed + * API may omit them via output_channels. State/checkpoint observations are historical + * read-time evidence, not a guarantee that the store remains current or durable. */ @Component({ selector: 'app-da-memory', standalone: true, - imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], + imports: [ + ChatComponent, + ChatWelcomeSuggestionComponent, + ExampleChatLayoutComponent, + ], template: ` @@ -71,10 +63,21 @@ const SUGGESTIONS = [

Agent Memory

- {{ memorySource() === 'live' ? 'streamed live' : memorySource() === 'checkpoint' ? 'from checkpoint' : 'no source yet' }} + {{ + memorySource() === 'live' + ? 'store observed during this run' + : memorySource() === 'checkpoint' + ? 'historical state/checkpoint read' + : memorySource() === 'unavailable' + ? 'memory observation unavailable' + : 'no source yet' + }}

- @if (memoryFiles().length === 0) { -

Nothing remembered yet

+ @if (scopeWarning()) { +

{{ scopeWarning() }}

+ } + @if (memoryFiles().length === 0 && (memorySource() === 'live' || memorySource() === 'checkpoint')) { +

No memory file at this observation's read time

} @for (file of memoryFiles(); track file.path) {
@@ -85,8 +88,8 @@ const SUGGESTIONS = [
}

- This file lives in the LangGraph store, not on the thread. Reload the page to start a - new conversation and it will still be here. + This panel shows the scoped store at an observed read time. A new conversation uses + the same browser identity while its storage is available. Observations do not promise durability.

@@ -157,74 +160,118 @@ const SUGGESTIONS = [ ], }) export class MemoryComponent { - protected readonly agent = injectAgent(); - + private readonly scope = inject(MemoryScope); + protected readonly agent = guardMemoryAgent(injectAgent(), this.scope); + protected readonly scopeWarning = computed(() => { + const snapshot = this.scope.snapshot(); + return snapshot.status === 'blocked' ? snapshot.warning : null; + }); protected readonly suggestions = SUGGESTIONS; // #region memory-sources - /** Latest `memory_contents` announced on the custom stream. */ - private readonly liveMemory = computed | null>(() => { + // The adapter clears custom events at run/thread boundaries. While loading, + // absence of a new custom event must not promote old checkpoint/state data. + private readonly observation = computed<{ + source: 'live' | 'checkpoint' | 'none' | 'unavailable'; + contents?: Record; + }>(() => { + if (this.agent.error()) return { source: 'unavailable' }; + const owner = this.scope.scope; + if (!owner) return { source: 'unavailable' }; for (const event of [...this.agent.customEvents()].reverse()) { if (event.name !== MEMORY_EVENT) continue; - const contents = (event.data as { memory_contents?: unknown } | undefined)?.[ - 'memory_contents' - ]; - if (contents && typeof contents === 'object') return contents as Record; + const data = plainRecord(event.data); + if ( + !data || + Object.keys(data).length !== 3 || + !['memory_contents', 'memory_scope_owner', 'phase'].every((k) => + Object.hasOwn(data, k) + ) || + data.memory_scope_owner !== owner || + !['initial', 'model', 'terminal'].includes(data.phase as string) + ) + return { source: 'unavailable' }; + const contents = captureMemoryMap(data.memory_contents); + return contents + ? { source: 'live', contents } + : { source: 'unavailable' }; } - return null; - }); - - /** - * `memory_contents` off the hydrated thread state. - * - * The key is private to the `values` stream but is written to the - * checkpoint, so this covers a reopened thread where no custom event has - * fired in this session. - */ - private readonly settledMemory = computed | null>(() => { - const contents = (this.agent.value() as Record | undefined)?.[ - 'memory_contents' - ]; - return contents && typeof contents === 'object' - ? (contents as Record) - : null; + if (this.agent.isLoading()) return { source: 'none' }; + const value = plainRecord(this.agent.value()); + if (!value || !Object.hasOwn(value, 'memory_contents')) + return { source: 'none' }; + if (value.memory_scope_owner !== owner) return { source: 'unavailable' }; + const contents = captureMemoryMap(value.memory_contents); + return contents + ? { source: 'checkpoint', contents } + : { source: 'unavailable' }; }); // #endregion // #region memory-source-label - /** - * Which of the two sources the panel is currently showing. - * - * `live` means the graph's visibility middleware announced the key on the - * custom stream during this run. `checkpoint` means only the settle-time - * hydration has it — which is what a reopened thread looks like, and also - * what the panel degrades to if the middleware is removed. - */ - protected readonly memorySource = computed<'live' | 'checkpoint' | 'none'>(() => { - const live = this.liveMemory(); - if (live && Object.keys(live).length > 0) return 'live'; - return this.settledMemory() ? 'checkpoint' : 'none'; - }); + protected readonly memorySource = computed(() => this.observation().source); // #endregion - - protected readonly memoryFiles = computed(() => { - const live = this.liveMemory(); - const source = live && Object.keys(live).length > 0 ? live : this.settledMemory(); - if (!source) return []; - return Object.entries(source) - .filter(([, contents]) => typeof contents === 'string' && contents.trim().length > 0) - .map(([path, contents]) => ({ + protected readonly memoryFiles = computed(() => + Object.entries(this.observation().contents ?? {}).map( + ([path, contents]) => ({ path, contents, + // Retain the older Angular line presentation; backing text stays literal. lines: contents .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0), - })) - .sort((a, b) => a.path.localeCompare(b.path)); - }); - + }) + ) + ); protected send(text: string): void { void this.agent.submit({ message: text }); } } + +function plainRecord(value: unknown): Record | undefined { + try { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && prototype !== Object.prototype) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Reflect.ownKeys(value).some( + (key) => + typeof key !== 'string' || + !descriptors[key].enumerable || + !Object.hasOwn(descriptors[key], 'value') + ) + ) + return undefined; + return value as Record; + } catch { + return undefined; + } +} +/** Exact one-path, bounded Unicode scalar data, copied without trimming. */ +function captureMemoryMap(input: unknown): Record | undefined { + try { + const record = plainRecord(input); + if (!record) return undefined; + const keys = Object.keys(record); + if (keys.length > 1 || keys.some((key) => key !== '/memories/AGENTS.md')) + return undefined; + if (!keys.length) return Object.freeze({}); + const contents = record['/memories/AGENTS.md']; + if (typeof contents !== 'string' || contents.length > 65536) + return undefined; + for (let i = 0; i < contents.length; i++) { + const unit = contents.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = contents.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return undefined; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return undefined; + } + if (new TextEncoder().encode(contents).length > 65536) return undefined; + return Object.freeze({ '/memories/AGENTS.md': contents }); + } catch { + return undefined; + } +} diff --git a/cockpit/deep-agents/memory/angular/src/test-setup.ts b/cockpit/deep-agents/memory/angular/src/test-setup.ts new file mode 100644 index 000000000..4664b6d13 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/test-setup.ts @@ -0,0 +1,15 @@ +import { getTestBed } from '@angular/core/testing'; +// Unit tests never send automatic development collection to a live destination. +( + window as Window & { __THREADPLANE_TELEMETRY_DISABLED__?: boolean } +).__THREADPLANE_TELEMETRY_DISABLED__ = true; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } } +); diff --git a/cockpit/deep-agents/memory/angular/tsconfig.app.json b/cockpit/deep-agents/memory/angular/tsconfig.app.json index ef34262b1..32530926e 100644 --- a/cockpit/deep-agents/memory/angular/tsconfig.app.json +++ b/cockpit/deep-agents/memory/angular/tsconfig.app.json @@ -8,6 +8,7 @@ }, "files": ["src/main.ts"], "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts"], "references": [ { "path": "../../../../libs/cockpit-telemetry" diff --git a/cockpit/deep-agents/memory/angular/tsconfig.spec.json b/cockpit/deep-agents/memory/angular/tsconfig.spec.json new file mode 100644 index 000000000..324ce43f7 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals", + "node" + ], + "declarationMap": false + }, + "include": [ + "src/**/*.spec.ts", + "src/test-setup.ts" + ], + "files": [] +} diff --git a/cockpit/deep-agents/memory/angular/vite.config.mts b/cockpit/deep-agents/memory/angular/vite.config.mts new file mode 100644 index 000000000..d137f14c5 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/vite.config.mts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + maxWorkers: 2, + }, +}); diff --git a/cockpit/deep-agents/memory/python/project.json b/cockpit/deep-agents/memory/python/project.json index 100b13670..a578be80d 100644 --- a/cockpit/deep-agents/memory/python/project.json +++ b/cockpit/deep-agents/memory/python/project.json @@ -15,7 +15,16 @@ "tsConfig": "cockpit/deep-agents/memory/python/tsconfig.json" } }, + "test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "cwd": "cockpit/deep-agents/memory/python", + "command": "uv run --frozen --python 3.12 python -m unittest discover -s tests" + } + }, "smoke": { + "dependsOn": ["test"], "executor": "nx:run-commands", "options": { "command": "npx tsx -e \"import { deepAgentsMemoryPythonModule } from './cockpit/deep-agents/memory/python/src/index.ts'; const mod = deepAgentsMemoryPythonModule; if (mod.id !== 'deep-agents-memory-python') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Memory (Python)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" diff --git a/cockpit/deep-agents/memory/python/src/graph.py b/cockpit/deep-agents/memory/python/src/graph.py index dc5c45335..d2e8a1229 100644 --- a/cockpit/deep-agents/memory/python/src/graph.py +++ b/cockpit/deep-agents/memory/python/src/graph.py @@ -1,98 +1,30 @@ -"""Deep Agents memory capability: cross-thread memory the agent writes itself. +"""Agent-authored cross-thread Memory with validated browser scope and fresh reads. -Two pieces make this work: - -* `memory=["/memories/AGENTS.md"]` installs `MemoryMiddleware`, which loads that - file into the system prompt on every turn and tells the model to keep it up to - date with `edit_file`. The agent authors its own memory; nothing here parses - the conversation for facts. -* `StoreBackend` on a fixed namespace puts the file in LangGraph's `BaseStore` - rather than on the thread's state, so it survives into the next thread. That - is the whole point: a new conversation starts already knowing. - -The visibility problem: `MemoryMiddleware` annotates `memory_contents` with -`PrivateStateAttr`, so it never appears in the `values` stream and a panel bound -to `agent.value()` would stay empty. `MemoryVisibilityMiddleware` below -republishes it as a `custom` stream event, which does reach the client. Custom -events are live-only, so when a thread is reopened, the value comes from -thread history instead, which is what the client's history hydration reads. +Custom events announce new reads. API values filtered through output_channels +omit private Memory keys; custom events and raw checkpoints retain them. +Private I/O schema attributes are not authentication or a secrecy guarantee. """ - from pathlib import Path -from typing import Any - from deepagents import create_deep_agent from deepagents.backends import StoreBackend -from langchain.agents.middleware import AgentMiddleware from langchain_openai import ChatOpenAI -from langgraph.config import get_stream_writer +from src.memory_observation import ( + MEMORY_EVENT, MEMORY_FILE, MEMORY_NAMESPACE, MemoryContext, + MemoryObservationMiddleware, memory_namespace, +) PROMPTS_DIR = Path(__file__).parent.parent / "prompts" -# region memory-constants -MEMORY_FILE = "/memories/AGENTS.md" - -#: Fixed namespace: every thread of this demo shares one memory. A real -#: deployment would scope it, e.g. `lambda rt: (rt.server_info.user.identity,)`. -MEMORY_NAMESPACE = ("cockpit", "deep-agents-memory") - -#: Custom stream event name the Angular panel listens for. -MEMORY_EVENT = "deep_agents.memory" -# endregion - - -# region visibility-middleware -class MemoryVisibilityMiddleware(AgentMiddleware): - """Republish `memory_contents` as a `custom` stream event. - - `PrivateStateAttr` keeps the key out of the `values` stream, which is right - for a transcript and wrong for a panel. This is a demo-side shim: the key - stays private on the state and is simply announced alongside it. - """ - - @property - def name(self) -> str: - return "MemoryVisibilityMiddleware" - - def _emit(self, state: dict[str, Any]) -> None: - contents = state.get("memory_contents") - if contents is None: - return - try: - writer = get_stream_writer() - except (RuntimeError, KeyError): - # No streaming context. The value is still on the checkpoint, which - # is what the client's history hydration reads. - return - writer({"name": MEMORY_EVENT, "data": {"memory_contents": contents}}) - - def after_model(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN401, ARG002 - self._emit(state) - return None - - def after_agent(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN401, ARG002 - self._emit(state) - return None -# endregion - - -# region memory-agent def build_memory_agent(): - """Build the memory agent. - - `store=None` on `StoreBackend` means "resolve the store from the graph - execution context", which is what the LangGraph server supplies. The - namespace factory ignores its runtime argument on purpose, so every thread - of this demo reads and writes the same memory. - """ + """Bind a thread once and refresh actual store contents at each phase.""" + backend = StoreBackend(namespace=memory_namespace) return create_deep_agent( model=ChatOpenAI(model="gpt-4.1", temperature=0), system_prompt=(PROMPTS_DIR / "memory.md").read_text(), - backend=StoreBackend(namespace=lambda _runtime: MEMORY_NAMESPACE), + backend=backend, memory=[MEMORY_FILE], - middleware=[MemoryVisibilityMiddleware()], + context_schema=MemoryContext, + middleware=[MemoryObservationMiddleware(backend)], ) -# endregion - graph = build_memory_agent() diff --git a/cockpit/deep-agents/memory/python/src/index.ts b/cockpit/deep-agents/memory/python/src/index.ts index cef183d7a..db0d3aaf4 100644 --- a/cockpit/deep-agents/memory/python/src/index.ts +++ b/cockpit/deep-agents/memory/python/src/index.ts @@ -31,9 +31,13 @@ export const deepAgentsMemoryPythonModule: CockpitCapabilityModule = { codeAssetPaths: [ 'cockpit/deep-agents/memory/angular/src/app/memory.component.ts', 'cockpit/deep-agents/memory/angular/src/app/app.config.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-scope.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-transport.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.ts', ], backendAssetPaths: [ 'cockpit/deep-agents/memory/python/src/graph.py', + 'cockpit/deep-agents/memory/python/src/memory_observation.py', ], runtimeUrl: 'deep-agents/memory', devPort: 4313, diff --git a/cockpit/deep-agents/memory/python/src/memory_observation.py b/cockpit/deep-agents/memory/python/src/memory_observation.py new file mode 100644 index 000000000..14194eba1 --- /dev/null +++ b/cockpit/deep-agents/memory/python/src/memory_observation.py @@ -0,0 +1,99 @@ +"""Fresh browser-scoped store observations; scope partitioning is not authentication. + +The byte bound applies after backend materialization, not to store allocation. +Private attributes omit I/O schemas and API values filtered by output_channels. +Custom events and raw checkpoints retain them; this is not a secrecy guarantee. +""" +from typing import Annotated, NotRequired, TypedDict +from uuid import UUID +from langchain.agents.middleware import AgentMiddleware, AgentState +from langchain.agents.middleware.types import PrivateStateAttr +from langgraph.config import get_stream_writer + +MEMORY_FILE = '/memories/AGENTS.md' +MEMORY_EVENT = 'deep_agents.memory' +MEMORY_NAMESPACE = ('cockpit', 'deep-agents-memory', 'browser-v1') +MAX_MEMORY_BYTES = 65_536 + +class MemoryContext(TypedDict): + memory_scope: str + +class MemoryObservationState(AgentState): + memory_scope_owner: NotRequired[Annotated[str, PrivateStateAttr]] + memory_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]] + +def validate_scope(context): + """Require an exact context and canonical lowercase UUID v4.""" + if not isinstance(context, dict) or set(context) != {'memory_scope'}: + raise ValueError('Invalid memory scope context') + scope = context['memory_scope'] + if not isinstance(scope, str) or len(scope) != 36: + raise ValueError('Invalid memory scope context') + try: + parsed = UUID(scope) + except ValueError: + raise ValueError('Invalid memory scope context') from None + if parsed.version != 4 or str(parsed) != scope: + raise ValueError('Invalid memory scope context') + return scope + +def memory_namespace(runtime): + return (*MEMORY_NAMESPACE, validate_scope(getattr(runtime, 'context', None))) + +class MemoryObservationError(RuntimeError): + """An actual read could not establish a fresh observation.""" + +class MemoryObservationMiddleware(AgentMiddleware): + state_schema = MemoryObservationState + def __init__(self, backend): + self.backend = backend + def _admit(self, state, runtime): + scope = validate_scope(runtime.context) + if 'memory_scope_owner' in state and state['memory_scope_owner'] != scope: + raise ValueError('Memory scope differs from thread owner') + return scope + def _snapshot(self, responses): + try: + if len(responses) != 1 or responses[0].path != MEMORY_FILE: + raise ValueError() + response = responses[0] + if response.error == 'file_not_found': + return {} + if response.error is not None or not isinstance(response.content, bytes): + raise ValueError() + if len(response.content) > MAX_MEMORY_BYTES: + raise ValueError() + return {MEMORY_FILE: response.content.decode('utf-8', errors='strict')} + except Exception: + raise MemoryObservationError('Memory observation unavailable') from None + def _publish(self, contents, scope, phase): + get_stream_writer()({'name': MEMORY_EVENT, 'data': { + 'memory_contents': contents, 'memory_scope_owner': scope, 'phase': phase, + }}) + return {'memory_contents': contents, 'memory_scope_owner': scope} + def _refresh(self, state, runtime, phase): + scope = self._admit(state, runtime) + try: + contents = self._snapshot(self.backend.download_files([MEMORY_FILE])) + except Exception: + raise MemoryObservationError('Memory observation unavailable') from None + return self._publish(contents, scope, phase) + async def _arefresh(self, state, runtime, phase): + scope = self._admit(state, runtime) + try: + contents = self._snapshot(await self.backend.adownload_files([MEMORY_FILE])) + except Exception: + raise MemoryObservationError('Memory observation unavailable') from None + return self._publish(contents, scope, phase) + def before_agent(self, state, runtime): + return self._refresh(state, runtime, 'initial') + async def abefore_agent(self, state, runtime): + return await self._arefresh(state, runtime, 'initial') + def before_model(self, state, runtime): + return self._refresh(state, runtime, 'model') + async def abefore_model(self, state, runtime): + return await self._arefresh(state, runtime, 'model') + def after_agent(self, state, runtime): + return self._refresh(state, runtime, 'terminal') + async def aafter_agent(self, state, runtime): + return await self._arefresh(state, runtime, 'terminal') diff --git a/cockpit/deep-agents/memory/python/tests/model_support.py b/cockpit/deep-agents/memory/python/tests/model_support.py new file mode 100644 index 000000000..398468157 --- /dev/null +++ b/cockpit/deep-agents/memory/python/tests/model_support.py @@ -0,0 +1,57 @@ +"""Deterministic local model; exercises the production compiled graph offline.""" +import importlib.util +import os +from pathlib import Path +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 +from langgraph.store.memory import InMemoryStore +import deepagents + +ROOT = Path(__file__).parents[1] +SCOPE = 'b110fa66-41bb-4f89-8b8e-577cefd8a770' +OTHER = '1cdd373f-862c-4dfe-8bb1-bc9b79d17849' +FILE = '/memories/AGENTS.md' + +class LocalModel(BaseChatModel): + calls: list = [] + prompts: list = [] + @property + def _llm_type(self): + return 'offline-memory-contract' + def bind_tools(self, tools, **kwargs): + return self + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + self.prompts.append(messages[0].text) + 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 completion.') + return ChatResult(generations=[ChatGeneration(message=answer)]) + +class CountingStore(InMemoryStore): + def __init__(self): + super().__init__() + self.operations = [] + def batch(self, ops): + ops = list(ops) + self.operations.extend(ops) + return super().batch(ops) + async def abatch(self, ops): + return self.batch(ops) + +def load(model, root=ROOT, store=None): + with patch('langchain_openai.ChatOpenAI', return_value=model): + spec = importlib.util.spec_from_file_location('offline_memory_graph', root/'src/graph.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + graph = module.build_memory_agent().builder.compile(checkpointer=InMemorySaver(), store=store or CountingStore()) + return module, graph diff --git a/cockpit/deep-agents/memory/python/tests/test_memory_observation.py b/cockpit/deep-agents/memory/python/tests/test_memory_observation.py new file mode 100644 index 000000000..18d9355fa --- /dev/null +++ b/cockpit/deep-agents/memory/python/tests/test_memory_observation.py @@ -0,0 +1,243 @@ +"""Actual compiled Memory graph contracts; no provider or remote sockets.""" +import socket +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from importlib.metadata import version +import unittest +from unittest.mock import patch +from model_support import LocalModel, CountingStore, load, FILE, SCOPE, OTHER, ROOT +from deepagents.backends import StoreBackend +from langchain_core.messages import RemoveMessage +from langgraph.graph.message import REMOVE_ALL_MESSAGES +from src.memory_observation import MEMORY_NAMESPACE, MemoryObservationError, memory_namespace +from types import SimpleNamespace + +class MemoryGraphTests(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.store = CountingStore() + self.module, self.graph = load(self.model, store=self.store) + async def test_same_turn_store_write_is_terminal_snapshot_and_next_model_prompt(self): + self.model.calls = [('write_file', {'file_path':FILE,'content':'Crew note\n\n literal whitespace \n'})] + config = {'configurable': {'thread_id':'write'}} + await self.graph.ainvoke({'messages':[{'type':'human','id':'write','content':'Write memory.'}]}, config, context={'memory_scope':SCOPE}) + saved = await self.graph.aget_state(config) + self.assertEqual(saved.values['memory_contents'], {FILE:'Crew note\n\n literal whitespace \n'}) + self.assertIn('Crew note', self.model.prompts[-1]) + + async def send(self, thread, scope=SCOPE, extra=None, asynchronous=True): + config = {'configurable':{'thread_id':thread}} + data = {'messages':[{'type':'human','id':str(len(self.model.prompts)),'content':'Offline request.'}]} + data.update(extra or {}) + if asynchronous: + events = [e async for e in self.graph.astream(data, config, context=scope, stream_mode=['values','custom'])] + else: + events = list(self.graph.stream(data, config, context=scope, stream_mode=['values','custom'])) + saved = self.graph.get_state(config) + return saved.values, events + + def backend(self, scope=SCOPE): + return StoreBackend(store=self.store, namespace=lambda _: (*MEMORY_NAMESPACE, scope)) + + async def test_sync_async_freshness_isolation_events_and_private_inputs(self): + for asynchronous in (False, True): + with self.subTest(asynchronous=asynchronous): + self.model = LocalModel() + self.store = CountingStore() + self.module, self.graph = load(self.model, store=self.store) + self.model.calls = [('write_file', {'file_path':FILE,'content':'Literal\n\n Ω \n'})] + state, events = await self.send('a', {'memory_scope':SCOPE}, asynchronous=asynchronous) + expected = {FILE:'Literal\n\n Ω \n'} + self.assertEqual(state['memory_contents'], expected) + self.assertEqual(state['memory_scope_owner'], SCOPE) + self.assertIn('Literal', self.model.prompts[-1]) + custom = [d['data'] for kind,d in events if kind == 'custom' and d.get('name') == 'deep_agents.memory'] + self.assertEqual([d['phase'] for d in custom], ['initial','model','model','terminal']) + self.assertEqual(custom[0]['memory_contents'], {}) + self.assertEqual(custom[-1]['memory_contents'], expected) + self.assertTrue(all(d['memory_scope_owner'] == SCOPE for d in custom)) + # Four observations, plus the write's existence check and put. + self.assertEqual(len(self.model.prompts), 2) + self.assertEqual(sum(type(op).__name__ == 'GetOp' for op in self.store.operations), 5) + self.assertEqual([d for kind,d in events if kind == 'values'][-1]['memory_contents'], expected) + self.model.calls = [] + edges={(e.source,e.target) for e in self.graph.get_graph().edges} + self.assertTrue({ + ('PatchToolCallsMiddleware.before_agent','MemoryObservationMiddleware.before_agent'), + ('MemoryObservationMiddleware.before_agent','MemoryMiddleware.before_agent'), + ('MemoryMiddleware.before_agent','MemoryObservationMiddleware.before_model'), + } <= edges) + for thread in ('a','b'): + state,_ = await self.send(thread, {'memory_scope':SCOPE}, { + 'memory_scope_owner':OTHER,'memory_contents':{FILE:'Injected'}}, asynchronous) + self.assertEqual(state['memory_contents'], expected) + self.assertEqual(state['memory_scope_owner'], SCOPE) + for attempted_owner in (None,'',OTHER): + state,_=await self.send('a',{'memory_scope':SCOPE},{'memory_scope_owner':attempted_owner,'memory_contents':None},asynchronous) + self.assertEqual(state['memory_scope_owner'],SCOPE) + self.assertEqual(state['memory_contents'],expected) + self.backend().edit(FILE,'Literal','Updated external') + expected={FILE:'Updated external\n\n Ω \n'} + state,_=await self.send('a',{'memory_scope':SCOPE},asynchronous=asynchronous) + self.assertEqual(state['memory_contents'],expected) + self.assertIn('Updated external',self.model.prompts[-1]) + state,_ = await self.send('c', {'memory_scope':OTHER}, asynchronous=asynchronous) + self.assertEqual(state['memory_contents'], {}) + # Removing public transcript messages cannot remove private ownership. + state,_ = await self.send('a', {'memory_scope':SCOPE}, {'messages':[ + RemoveMessage(id=REMOVE_ALL_MESSAGES), {'type':'human','id':'removed','content':'Offline'}]}, asynchronous) + self.assertEqual(state['memory_scope_owner'], SCOPE) + for schema in (self.graph.get_input_jsonschema(),self.graph.get_output_jsonschema()): + self.assertNotIn('memory_scope_owner',schema['properties']) + self.assertNotIn('memory_contents',schema['properties']) + self.assertTrue(self.store.operations) + self.assertTrue(all(getattr(op,'namespace',(*MEMORY_NAMESPACE,SCOPE))[:3] == MEMORY_NAMESPACE for op in self.store.operations)) + + async def test_denied_admission_has_zero_model_store_and_namespace_calls(self): + await self.send('bound', {'memory_scope':SCOPE}) + for asynchronous in (False,True): + for context in (None,{}, {'memory_scope':SCOPE.upper()}, {'memory_scope':'bad'}, {'memory_scope':OTHER}, {'memory_scope':SCOPE,'extra':1}, {'memory_scope':'00000000-0000-1000-8000-000000000000'}): + with self.subTest(asynchronous=asynchronous,context=context): + self.store.operations.clear() + self.model.prompts.clear() + with patch.object(StoreBackend,'_get_namespace',side_effect=AssertionError('Denied namespace work')) as factory: + with self.assertRaises(ValueError): + await self.send('bound', context, {'memory_scope_owner':OTHER}, asynchronous) + factory.assert_not_called() + self.assertEqual(self.store.operations, []) + self.assertEqual(self.model.prompts, []) + + async def test_historic_unbound_checkpoint_replaces_stale_snapshot_without_global_read(self): + self.backend().write(FILE,'Scoped fresh') + for asynchronous in (False,True): + thread='historic-'+str(asynchronous) + config={'configurable':{'thread_id':thread}} + self.graph.update_state(config, {'messages':[{'type':'human','content':'Old transcript'}], 'memory_contents':{FILE:'Legacy'}},as_node='MemoryObservationMiddleware.before_agent') + before=self.graph.get_state(config).values + self.assertEqual(before['memory_contents'],{FILE:'Legacy'}) + self.assertNotIn('memory_scope_owner',before) + self.store.operations.clear() + state,_ = await self.send(thread, {'memory_scope':SCOPE},asynchronous=asynchronous) + self.assertEqual(state['memory_contents'], {FILE:'Scoped fresh'}) + self.assertEqual(state['memory_scope_owner'], SCOPE) + self.assertTrue(all(op.namespace == (*MEMORY_NAMESPACE,SCOPE) for op in self.store.operations)) + + async def test_literal_missing_empty_multibyte_boundary_and_failed_reads_all_phases(self): + for asynchronous in (False, True): + for content in (None,'','\n\n Ω \n','Ω'*32768): + self.store = CountingStore() + self.module,self.graph=load(self.model,store=self.store) + if content is not None: + self.backend().write(FILE,content) + state,_=await self.send('literal',{'memory_scope':SCOPE},asynchronous=asynchronous) + self.assertEqual(state['memory_contents'], {} if content is None else {FILE:content}) + for payload in (b'x'*65537,b'\xff','backend_failure'): + for phase_read in (1,2,3): + self.store=CountingStore() + self.module,self.graph=load(self.model,store=self.store) + self.model.prompts.clear() + original = self.store.get + reads=[] + def failing_get(namespace,key,**kwargs): + reads.append((namespace,key)) + if len(reads)==phase_read: + if payload=='backend_failure': + raise RuntimeError('private backend diagnostic') + # StoreBackend actually materializes/decode these bytes. + self.backend().upload_files([(FILE,payload)]) + return original(namespace,key,**kwargs) + with patch.object(self.store,'get',side_effect=failing_get): + with self.assertRaisesRegex(MemoryObservationError,'^Memory observation unavailable$'): + await self.send('fail',{'memory_scope':SCOPE},asynchronous=asynchronous) + self.assertEqual(len(self.model.prompts), 1 if phase_read==3 else 0) + + async def test_post_write_terminal_failure_preserves_write_without_confirmation(self): + for asynchronous in (False,True): + self.store=CountingStore() + self.model=LocalModel(calls=[('write_file',{'file_path':FILE,'content':'Persisted despite failure'})]) + self.module,self.graph=load(self.model,store=self.store) + # Count actual observation downloads, without disrupting write checks. + original=StoreBackend.download_files + downloads=[] + def fail_terminal(backend, paths): + downloads.append(paths) + if len(downloads)==4: + raise RuntimeError('private terminal diagnostic') + return original(backend, paths) + with patch.object(StoreBackend,'download_files',fail_terminal): + observed=[] + with self.assertRaises(MemoryObservationError): + config={'configurable':{'thread_id':'terminal'}} + data={'messages':[{'type':'human','id':'terminal','content':'Write offline'}]} + if asynchronous: + async for event in self.graph.astream(data,config,context={'memory_scope':SCOPE},stream_mode=['values','custom']): + observed.append(event) + else: + for event in self.graph.stream(data,config,context={'memory_scope':SCOPE},stream_mode=['values','custom']): + observed.append(event) + self.assertNotIn('terminal',[d['data']['phase'] for kind,d in observed if kind=='custom' and d.get('name')=='deep_agents.memory']) + tools=[m for m in self.graph.get_state(config).values['messages'] if m.type=='tool'] + self.assertEqual(tools[-1].status,'success') + self.assertEqual(self.backend().download_files([FILE])[0].content,b'Persisted despite failure') + self.assertEqual(len(self.model.prompts),2) + + async def test_present_falsey_checkpoint_owner_cannot_be_rebound_by_input(self): + for owner in (None,''): + config={'configurable':{'thread_id':'falsey-'+str(owner)}} + self.graph.update_state(config, {'memory_scope_owner':owner,'messages':[]},as_node='MemoryObservationMiddleware.before_agent') + self.assertIn('memory_scope_owner',self.graph.get_state(config).values) + self.assertEqual(self.graph.get_state(config).values['memory_scope_owner'],owner) + self.store.operations.clear() + with self.assertRaises(ValueError): + await self.send('falsey-'+str(owner), {'memory_scope':SCOPE}, {'memory_scope_owner':SCOPE}) + self.assertEqual(self.store.operations, []) + self.assertEqual(self.model.prompts, []) + + def test_namespace_factory_independently_rejects_invalid_context(self): + self.assertEqual(memory_namespace(SimpleNamespace(context={'memory_scope':SCOPE})),(*MEMORY_NAMESPACE,SCOPE)) + for context in (None,{}, {'memory_scope':OTHER.upper()}, {'memory_scope':SCOPE,'extra':1}): + with self.assertRaises(ValueError): + memory_namespace(SimpleNamespace(context=context)) + +class DeploymentImportTests(unittest.TestCase): + def test_pinned_versions_and_staged_shared_deployment_namespace_import(self): + self.assertEqual({name:version(name) for name in ('deepagents','langchain','langgraph','langgraph-sdk')}, + {'deepagents':'0.7.11','langchain':'1.3.18','langgraph':'1.2.11','langgraph-sdk':'0.4.4'}) + self.assertFalse((ROOT/'src/__init__.py').exists()) + repo=ROOT.parents[3] + self.assertIn('cpSync(sourceDir, stagedDir, { recursive: true })', + (repo/'scripts/generate-shared-deployment-config.ts').read_text()) + manifest=__import__('json').loads((ROOT/'langgraph.json').read_text()) + self.assertEqual(manifest['graphs']['da-memory'],'./src/graph.py:graph') + with tempfile.TemporaryDirectory() as temp: + staged=Path(temp)/'deps/da-memory' + 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 pathlib, socket, sys +from unittest.mock import patch +sys.path[:0] = [sys.argv[1],sys.argv[2],sys.argv[3]] +from model_support import LocalModel, load +from src.other_capability import VALUE +from src.memory_observation import MemoryObservationMiddleware +assert VALUE == 42 +assert pathlib.Path(__import__('src.memory_observation',fromlist=['x']).__file__).resolve() == pathlib.Path(sys.argv[1],'src/memory_observation.py').resolve() +with patch.object(socket.socket,'connect',side_effect=AssertionError('Remote socket forbidden')) as network: + module,graph=load(LocalModel(),pathlib.Path(sys.argv[1])) + assert module.MemoryObservationMiddleware is MemoryObservationMiddleware + state=graph.invoke({'messages':[{'type':'human','content':'Offline'}]}, {'configurable':{'thread_id':'stage'}}, context={'memory_scope':'b110fa66-41bb-4f89-8b8e-577cefd8a770'}) + assert state['messages'][-1].content == 'Offline completion.' + network.assert_not_called() +''' + result=subprocess.run([sys.executable,'-c',script,str(staged),str(other.parent),str(staged/'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/memory/python/tests/test_streaming.py b/cockpit/deep-agents/memory/python/tests/test_streaming.py new file mode 100644 index 000000000..b2e7ed96b --- /dev/null +++ b/cockpit/deep-agents/memory/python/tests/test_streaming.py @@ -0,0 +1,110 @@ +"""Offline real compiled Memory stream, using the API's public output filter.""" +import os +os.environ['LANGSMITH_TRACING'] = 'false' +os.environ['LANGCHAIN_TRACING_V2'] = 'false' +import socket +import unittest +from unittest.mock import patch +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from deepagents.backends import StoreBackend +from model_support import LocalModel, CountingStore, load, FILE, SCOPE, OTHER + +LITERAL = 'Fleet note\n\n Ω literal whitespace \n' +UPDATED = 'Updated fleet note\n\n Ω literal whitespace \n' +SCENARIOS = { + 'teach': [('write_file', {'file_path': FILE, 'content': LITERAL})], + 'edit': [('edit_file', {'file_path': FILE, 'old_string': 'Fleet note', 'new_string': 'Updated fleet note'})], + 'recall': [], + 'read': [('read_file', {'file_path': FILE})], + 'empty': [('write_file', {'file_path': FILE, 'content': ''})], + 'boundary': [('write_file', {'file_path': FILE, 'content': 'Ω' * 32768})], + 'oversize': [('write_file', {'file_path': FILE, 'content': 'Ω' * 32769})], +} + +class MemoryModel(LocalModel): + """The final response reflects the actual prompt, never a persistence oracle.""" + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + result = super()._generate(messages, stop, run_manager, **kwargs) + if not result.generations[0].message.tool_calls: + prompt = messages[0].text + start = prompt.find('') + end = prompt.find('', start) + # Capturing all prompts also proves the pinned middleware read. + result = ChatResult(generations=[ChatGeneration(message=AIMessage( + content='Offline prompt observation: ' + (prompt[start + len(''):end] if start >= 0 and end >= 0 else '') + ))]) + return result + +class MemoryStreamingTests(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 = MemoryModel() + self.store = CountingStore() + _, self.graph = load(self.model, store=self.store) + + async def stream(self, thread, scope=SCOPE, scenario='recall'): + self.model.calls = SCENARIOS[scenario] + config = {'configurable': {'thread_id': thread}} + events = [e async for e in self.graph.astream( + {'messages': [{'type': 'human', 'id': str(len(self.model.prompts)), 'content': scenario}]}, + config, context={'memory_scope': scope}, output_keys=self.graph.output_channels, + subgraphs=True, stream_mode=['messages', 'values', 'updates', 'checkpoints', 'custom'])] + return events, await self.graph.aget_state(config) + + async def test_filtered_live_values_custom_and_raw_root_checkpoint_after_genuine_writes(self): + events, state = await self.stream('a', scenario='teach') + expected = {FILE: LITERAL} + self.assertEqual(state.values['memory_contents'], expected) + custom = [data['data'] for ns, kind, data in events if kind == 'custom'] + self.assertEqual([e['phase'] for e in custom], ['initial', 'model', 'model', 'terminal']) + self.assertEqual(custom[0]['memory_contents'], {}) + self.assertEqual(custom[-1]['memory_contents'], expected) + self.assertTrue(all(e['memory_scope_owner'] == SCOPE for e in custom)) + values = [data for ns, kind, data in events if kind == 'values'] + self.assertTrue(values) + self.assertTrue(all('memory_contents' not in e and 'memory_scope_owner' not in e for e in values)) + checkpoints = [data for ns, kind, data in events if kind == 'checkpoints'] + self.assertEqual(checkpoints[-1]['values']['memory_contents'], expected) + self.assertEqual(checkpoints[-1]['values']['memory_scope_owner'], SCOPE) + self.assertIn("Fleet note", self.model.prompts[-1]) + tools = [m for m in state.values['messages'] if m.type == 'tool'] + self.assertEqual([m.status for m in tools], ['success']) + self.assertTrue(any(type(op).__name__ == 'PutOp' for op in self.store.operations)) + _, state = await self.stream('a', scenario='edit') + self.assertEqual(state.values['memory_contents'], {FILE: UPDATED}) + _, recalled = await self.stream('b') + self.assertEqual(recalled.values['memory_contents'], {FILE: UPDATED}) + self.assertIn("Updated fleet note", self.model.prompts[-1]) + _, isolated = await self.stream('c', OTHER) + self.assertEqual(isolated.values['memory_contents'], {}) + self.assertNotIn(UPDATED, self.model.prompts[-1]) + + async def test_filtered_terminal_failure_has_partial_actual_events_and_persistent_write(self): + original = StoreBackend.download_files + reads = [] + def fail_terminal(backend, paths): + reads.append(paths) + if len(reads) == 4: + raise RuntimeError('Private failure') + return original(backend, paths) + self.model.calls = SCENARIOS['teach'] + events = [] + with patch.object(StoreBackend, 'download_files', fail_terminal): + with self.assertRaisesRegex(RuntimeError, '^Memory observation unavailable$'): + async for e in self.graph.astream( + {'messages': [{'type': 'human', 'id': 'failure', 'content': 'teach'}]}, + {'configurable': {'thread_id': 'failure'}}, context={'memory_scope': SCOPE}, + output_keys=self.graph.output_channels, subgraphs=True, + stream_mode=['values', 'custom', 'checkpoints']): + events.append(e) + phases = [data['data']['phase'] for ns, kind, data in events if kind == 'custom'] + self.assertEqual(phases, ['initial', 'model', 'model']) + backend = StoreBackend(store=self.store, namespace=lambda _: ('cockpit', 'deep-agents-memory', 'browser-v1', SCOPE)) + self.assertEqual(backend.download_files([FILE])[0].content.decode(), LITERAL) + _, state = await self.stream('new') + self.assertEqual(state.values['memory_contents'], {FILE: LITERAL}) + +if __name__ == '__main__': + unittest.main() diff --git a/cockpit/deep-agents/memory/react/.gitignore b/cockpit/deep-agents/memory/react/.gitignore new file mode 100644 index 000000000..44d91eb35 --- /dev/null +++ b/cockpit/deep-agents/memory/react/.gitignore @@ -0,0 +1 @@ +e2e/test-results/ diff --git a/cockpit/deep-agents/memory/react/e2e/da-memory.spec.ts b/cockpit/deep-agents/memory/react/e2e/da-memory.spec.ts new file mode 100644 index 000000000..7d97867da --- /dev/null +++ b/cockpit/deep-agents/memory/react/e2e/da-memory.spec.ts @@ -0,0 +1,221 @@ +import { test, expect, type Page, type APIRequestContext, type TestInfo } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +const base = '/deep-agents/memory/react/'; +const key = 'threadplane.deep-agents-memory.scope.v1'; +const literal = 'Fleet note\n\n Ω literal whitespace \n'; +const updated = 'Updated fleet note\n\n Ω literal whitespace \n'; +const file = '/memories/AGENTS.md'; +type Recorded = { path: string; method: string; body: Record }; +async function records(request: APIRequestContext): Promise { return (await request.get('/__requests')).json(); } +async function configure(request: APIRequestContext, scenario = 'teach', 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 = 'Remember this fleet note') { + 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 capture(request: APIRequestContext, info: TestInfo) { + mkdirSync(info.outputDir, { recursive: true }); + for (const endpoint of ['requests', 'graph-proof', 'raw-events']) { + const data = await (await request.get('/__' + endpoint)).json(); + writeFileSync(info.outputPath(endpoint + '.json'), JSON.stringify(data, null, 2)); + if (endpoint === 'graph-proof') for (const proof of data) { + expect(proof.actualCompiledGraph).toBe(true); + expect(proof.networkConnectAttempts).toBe(0); + expect(Object.keys(proof.sourceSha256)).toHaveLength(6); + } + } + // Native snapshots are verified inside application confirmation; no extraction endpoint exists. + writeFileSync(info.outputPath('native-evidence.json'), JSON.stringify({ nativeSnapshotsExtracted: false, evidence: 'Actual native app Saved plus independent current/newest/exact reads.' })); +} +test.afterEach(async ({ request }, info) => { await capture(request, info); }); +test('literal genuine teach, same-thread edit, new-thread scoped prompt recall and historical read-only restore', async ({ page, request }, info) => { + const streams: Promise<{ url: string; status: number; contentLocation: string | undefined; text: string }>[] = []; + page.on('response', response => { + if (new URL(response.url()).pathname.endsWith('/runs/stream')) streams.push((async () => ({ + url: response.url(), status: response.status(), contentLocation: (await response.allHeaders())['content-location'], text: await response.text(), + }))()); + }); + await configure(request, 'teach', { holdCheckpoint: true }); + const before = await records(request); + await page.goto(base); + await page.getByRole('button', { name: 'Teach', exact: true }).click(); + await expect(page.getByRole('textbox')).toHaveValue(/Citation CJ3/); + await page.getByRole('button', { name: 'Recall', exact: true }).click(); + await page.getByRole('button', { name: 'New conversation' }).click(); + await page.reload(); + expect(await records(request)).toEqual(before); + const scope = await page.evaluate(k => localStorage.getItem(k), key); + expect(scope).toMatch(/^[0-9a-f-]{36}$/); + await send(page); + await expect(page.getByRole('status')).toHaveText('Confirming saved conversation…'); + await expect(page.getByTestId('memory-source')).toHaveText('store observed during this run'); + await expect(page.getByTestId('memory-file').locator('pre')).toHaveText(literal, { useInnerText: false }); + await request.post('/__release', { data: {} }); + await saved(page); + await configure(request, 'teach'); + const first = await page.getByLabel('Saved conversations').inputValue(); + const current = await (await request.get('/api/threads/' + first + '/state')).json(); + const newest = (await (await request.post('/api/threads/' + first + '/history', { data: { limit: 1 } })).json())[0]; + const exact = await (await request.post('/api/threads/' + first + '/state/checkpoint', { data: { checkpoint: newest.checkpoint } })).json(); + expect(current).toEqual(newest); + expect(current).toEqual(exact); + expect(current.checkpoint.thread_id).toBe(first); + expect(current.checkpoint.checkpoint_ns).toBe(''); + expect(current.checkpoint.checkpoint_id).toBeTruthy(); + expect(current.next).toEqual([]); + expect(current.tasks).toEqual([]); + const firstRequest = (await records(request)).slice(before.length).find(r => r.path.endsWith('/runs/stream'))!; + const human = firstRequest.body.input.messages[0]; + expect(exact.values.messages.filter((message: any) => message.type === 'human')).toEqual([expect.objectContaining(human)]); + const firstRaw = (await (await request.get('/__raw-events')).json()).find((record: any) => record.threadId === first); + const firstProofs = (await (await request.get('/__graph-proof')).json()).filter((proof: any) => proof.threadId === first && proof.op === 'submit'); + const sse = await streams[0]; + expect(sse.status).toBe(200); + expect(sse.contentLocation).toBe(`/threads/${first}/runs/${firstRaw.runId}`); + const metadata = JSON.parse(sse.text.split('\n\n')[0].split('data: ')[1]); + expect(metadata.run_id).toBe(firstRaw.runId); + expect(sse.text).toContain('event: custom'); + const decodedEvents = sse.text.split('\n\n').filter(Boolean).map(block => { + const lines = block.split('\n'); + return { event: lines.find(line => line.startsWith('event: '))!.slice(7), data: JSON.parse(lines.find(line => line.startsWith('data: '))!.slice(6)) }; + }); + const terminalCustom = decodedEvents.filter(event => event.event === 'custom' && event.data.name === 'deep_agents.memory' && event.data.data.phase === 'terminal').at(-1)!; + const actualRawTerminal = firstRaw.events.filter((event: any) => event[0].length === 0 && event[1] === 'custom' && event[2].data.phase === 'terminal').at(-1)[2]; + const terminalValues = decodedEvents.filter(event => event.event === 'values').at(-1)!; + const actualRawValues = firstRaw.events.filter((event: any) => event[0].length === 0 && event[1] === 'values').at(-1)[2]; + mkdirSync(info.outputDir, { recursive: true }); + writeFileSync(info.outputPath('whole-read-proof.json'), JSON.stringify({ + nativeSnapshotsExtracted: false, + owner: { threadId: first, memoryScope: scope, actualSdkHuman: human }, + readResponses: { current, newest, exact }, rootCheckpoint: current.checkpoint, + actualBrowserSse: { ...sse, representation: 'Decoded Playwright response.text(), not raw UTF-8 wire bytes', parsedEvents: decodedEvents }, actualRawGraphRun: firstRaw, actualGraphProofs: firstProofs, + }, null, 2)); + expect(sse.text).toContain('Ω'); + expect(terminalCustom.data).toEqual(actualRawTerminal); + expect(terminalCustom.data.data.memory_contents).toEqual({ [file]: literal }); + expect(terminalValues.data).toEqual(actualRawValues); + expect(exact.values.memory_contents).toEqual({ [file]: literal }); + const write = exact.values.messages.find((m: any) => m.type === 'ai' && m.tool_calls?.some((c: any) => c.name === 'write_file')); + expect(write.tool_calls[0].args).toEqual({ file_path: file, content: literal }); + expect(exact.values.messages.some((m: any) => m.type === 'tool' && m.status === 'success')).toBe(true); + await configure(request, 'edit'); + await send(page, 'Edit the fleet note'); + await saved(page); + expect(await page.getByLabel('Saved conversations').inputValue()).toBe(first); + await expect(page.getByTestId('memory-file').locator('pre')).toHaveText(updated, { useInnerText: false }); + await page.getByRole('button', { name: 'New conversation' }).click(); + await configure(request, 'recall'); + await send(page, 'Recall the fleet note'); + await saved(page); + const second = await page.getByLabel('Saved conversations').inputValue(); + expect(second).not.toBe(first); + await expect(page.getByRole('region', { name: 'Conversation' })).toContainText('Updated fleet note'); + const authored = (await records(request)).slice(before.length).filter(r => r.path.endsWith('/runs/stream')); + expect(authored).toHaveLength(3); + expect(authored.map(r => r.path.split('/')[3])).toEqual([first, first, second]); + for (const run of authored) { expect(run.body.assistant_id).toBe('da-memory'); expect(run.body.context).toEqual({ memory_scope: scope }); expect(run.body.input.messages).toHaveLength(1); } + const creates = (await records(request)).slice(before.length).filter(r => r.path === '/api/threads' && r.method === 'POST'); + expect(creates).toHaveLength(2); + for (const creation of creates) expect(creation.body.if_exists).toBe('raise'); + const raw = await (await request.get('/__raw-events')).json(); + const recall = raw.filter((r: any) => r.threadId === second).at(-1); + expect(recall.modelPrompts.some((p: string) => p.includes('Updated fleet note'))).toBe(true); + expect(raw.some((r: any) => r.storeOperations.some((op: any) => op.type === 'PutOp'))).toBe(true); + await page.getByRole('button', { name: 'New conversation' }).click(); + await configure(request, 'empty'); + await send(page, 'Empty the current store'); await saved(page); + await expect(page.getByText('The memory file is present and empty.')).toBeVisible(); + const emptyThread = await page.getByLabel('Saved conversations').inputValue(); + const emptyObservation = await (await request.get('/api/threads/' + emptyThread + '/state')).json(); + expect(emptyObservation.values.memory_contents).toEqual({ [file]: '' }); + await page.getByRole('button', { name: 'New conversation' }).click(); + const beforeRestore = await records(request); + await page.getByLabel('Saved conversations').selectOption(first); + await saved(page); + await expect(page.getByTestId('memory-file').locator('pre')).toHaveText(updated, { useInnerText: false }); + const restoreReads = (await records(request)).slice(beforeRestore.length); + expect(restoreReads.every(r => !r.path.endsWith('/runs/stream') && !(r.path === '/api/threads' && r.method === 'POST'))).toBe(true); + const restoreRequest = restoreReads.find(r => r.path.endsWith('/state/checkpoint'))!; + expect(restoreRequest.body.checkpoint.checkpoint_id).not.toBe(current.checkpoint.checkpoint_id); + const restored = await (await request.post(restoreRequest.path, { data: restoreRequest.body })).json(); + expect(restored.checkpoint).toEqual(restoreRequest.body.checkpoint); + expect(restored.values.memory_contents).toEqual({ [file]: updated }); + writeFileSync(info.outputPath('historical-read-proof.json'), JSON.stringify({ + nativeSnapshotsExtracted: false, firstTeachCheckpoint: current.checkpoint, + actualEmptyStoreReadTimeObservation: emptyObservation, + actualHistoricalReadRequest: restoreRequest, actualHistoricalReadResponse: restored, + }, null, 2)); + await page.screenshot({ path: info.outputPath('memory-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: info.outputPath('memory-mobile.png'), fullPage: true }); + await page.reload(); + await expect(page.getByRole('textbox')).toHaveValue(''); + expect(await page.evaluate(k => localStorage.getItem(k), key)).toBe(scope); + await configure(request, 'recall'); + await send(page, 'Recall after reload'); await saved(page); + expect(await page.getByLabel('Saved conversations').inputValue()).not.toBe(second); + await expect(page.getByText('The memory file is present and empty.')).toBeVisible(); +}); +for (const scenario of ['recall', 'empty'] as const) test(`missing and present empty remain distinct: ${scenario}`, async ({ page, request }) => { + await configure(request, scenario); await page.goto(base); await send(page); await saved(page); + if (scenario === 'recall') { await expect(page.getByText('No memory file at this observation’s read time.')).toBeVisible(); await expect(page.getByTestId('memory-file')).toHaveCount(0); } + else { await expect(page.getByText('The memory file is present and empty.')).toBeVisible(); await expect(page.getByTestId('memory-file').locator('pre')).toHaveText(''); } +}); +for (const scenario of ['fail-initial', 'fail-model', 'fail-terminal', 'oversize'] as const) test(`${scenario} cannot confirm Saved`, async ({ page, request }) => { + await configure(request, scenario); await page.goto(base); const before = await records(request); await send(page); + await expect(page.getByRole('alert')).toBeVisible({ timeout: 45000 }); + await expect(page.getByTestId('memory-source')).toHaveText('memory observation unavailable'); + await expect(page.getByRole('status')).not.toHaveText('Response saved.'); + await expect(page.getByRole('textbox')).toBeDisabled(); + expect((await records(request)).slice(before.length).filter(r => r.path.endsWith('/runs/stream'))).toHaveLength(1); + if (scenario === 'fail-terminal') { + await page.getByRole('button', { name: 'New conversation' }).click(); + await configure(request, 'recall'); await send(page, 'Recall the write despite failed terminal read'); await saved(page); + await expect(page.getByTestId('memory-file').locator('pre')).toHaveText(literal, { useInnerText: false }); + const raw = await (await request.get('/__raw-events')).json(); + expect(raw.at(-1).modelPrompts.some((p: string) => p.includes('Fleet note'))).toBe(true); + } +}); +for (const mode of ['corrupt', 'unavailable'] as const) test(`storage ${mode} blocks creation`, async ({ page, request }) => { + await page.addInitScript(({ key, mode }) => { if (mode === 'corrupt') localStorage.setItem(key, 'corrupt'); else Object.defineProperty(Storage.prototype, 'getItem', { value() { throw Error('denied'); } }); }, { key, mode }); + const before = await records(request); await page.goto(base); + await expect(page.getByTestId('memory-scope-warning')).toBeVisible(); await expect(page.getByRole('textbox')).toBeDisabled(); + await page.getByRole('button', { name: 'New conversation' }).click(); expect(await records(request)).toEqual(before); +}); +test('active captured scope settles with warning and later replacement blocks future turns', async ({ page, request }) => { + await configure(request, 'teach', { holdCheckpoint: true }); await page.goto(base); await send(page); + await expect(page.getByRole('status')).toHaveText('Confirming saved conversation…'); + const other = await page.context().newPage(); + await other.goto(base); + await other.evaluate(k => localStorage.setItem(k, crypto.randomUUID()), key); + await other.close(); + await request.post('/__release', { data: {} }); + await expect(page.getByTestId('memory-scope-warning')).toBeVisible(); + await expect(page.getByTestId('memory-source')).toHaveText('confirmed checkpoint at read time'); + const before = await records(request); await page.getByRole('button', { name: 'New conversation' }).click(); await expect(page.getByRole('textbox')).toBeDisabled(); expect(await records(request)).toEqual(before); +}); +for (const action of ['replace', 'delete'] as const) test(`same-document scope ${action} is rejected on author admission before creation`, async ({ page, request }) => { + await configure(request); await page.goto(base); const before = await records(request); + await page.evaluate(({ key, action }) => { if (action === 'delete') localStorage.removeItem(key); else localStorage.setItem(key, crypto.randomUUID()); }, { key, action }); + await send(page); await expect(page.getByTestId('memory-scope-warning')).toBeVisible(); + await expect(page.getByRole('textbox')).toBeDisabled(); expect(await records(request)).toEqual(before); +}); +test('Stop and New invalidate held delivery and never replay', async ({ page, request }) => { + await configure(request, 'teach', { holdStream: true }); await page.goto(base); const before = await records(request); await send(page); + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); await expect(page.getByRole('textbox')).toBeDisabled(); + await page.getByRole('button', { name: 'New conversation' }).click(); await request.post('/__release', { data: {} }); + await expect(page.getByRole('status')).toHaveText('Ready.'); await expect(page.getByTestId('memory-file')).toHaveCount(0); + expect((await records(request)).slice(before.length).filter(r => r.path.endsWith('/runs/stream'))).toHaveLength(1); +}); +test('held exact read cannot resurrect the old view after New', async ({ page, request }) => { + await configure(request, 'teach', { 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.getByTestId('memory-file')).toHaveCount(0); await expect(page.getByLabel('Saved conversations').locator('option')).toHaveCount(1); +}); +for (const control of ['failCheckpoint', 'failCreation'] as const) test(`${control} remains unavailable without retries`, async ({ page, request }) => { + await configure(request, 'teach', { [control]: true }); await page.goto(base); const before = await records(request); await send(page); await expect(page.getByRole('alert')).toBeVisible({ timeout: 45000 }); await expect(page.getByRole('textbox')).toBeDisabled(); + const own = (await records(request)).slice(before.length); expect(own.filter(r => r.path === '/api/threads' && r.method === 'POST')).toHaveLength(1); expect(own.filter(r => r.path.endsWith('/runs/stream'))).toHaveLength(control === 'failCreation' ? 0 : 1); await expect(page.getByRole('status')).not.toHaveText('Response saved.'); +}); diff --git a/cockpit/deep-agents/memory/react/e2e/playwright.config.ts b/cockpit/deep-agents/memory/react/e2e/playwright.config.ts new file mode 100644 index 000000000..36fabf47b --- /dev/null +++ b/cockpit/deep-agents/memory/react/e2e/playwright.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', testMatch: '**/*.spec.ts', outputDir: 'test-results', + reporter: [['list']], workers: 1, fullyParallel: false, retries: 0, timeout: 60000, + use: { baseURL: 'http://127.0.0.1:4632', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { command: 'node scripts/react-cockpit/serve.mjs deep-agents-memory --no-parent', cwd: '../../../../..', url: 'http://127.0.0.1:4632/__lifetime', reuseExistingServer: false, timeout: 30000 }, +}); diff --git a/cockpit/deep-agents/memory/react/e2e/tsconfig.json b/cockpit/deep-agents/memory/react/e2e/tsconfig.json new file mode 100644 index 000000000..5a068c664 --- /dev/null +++ b/cockpit/deep-agents/memory/react/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "strict": true, "skipLibCheck": true, "noEmit": true, + "esModuleInterop": true, "types": ["node"], "lib": ["ES2022", "DOM"] + }, + "include": ["*.ts"], + "exclude": ["test-results"] +} diff --git a/cockpit/deep-agents/memory/react/eslint.config.mjs b/cockpit/deep-agents/memory/react/eslint.config.mjs new file mode 100644 index 000000000..2900f3445 --- /dev/null +++ b/cockpit/deep-agents/memory/react/eslint.config.mjs @@ -0,0 +1,2 @@ +import base from '../../../../eslint.config.mjs'; +export default [...base]; diff --git a/cockpit/deep-agents/memory/react/index.html b/cockpit/deep-agents/memory/react/index.html new file mode 100644 index 000000000..bcfcc9a19 --- /dev/null +++ b/cockpit/deep-agents/memory/react/index.html @@ -0,0 +1,12 @@ + + + + + + Deep Agents Memory · React + + +
+ + + diff --git a/cockpit/deep-agents/memory/react/project.json b/cockpit/deep-agents/memory/react/project.json new file mode 100644 index 000000000..270bf8eae --- /dev/null +++ b/cockpit/deep-agents/memory/react/project.json @@ -0,0 +1,63 @@ +{ + "name": "cockpit-deep-agents-memory-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/deep-agents/memory/react/src", + "projectType": "application", + "tags": ["scope:cockpit-examples", "scope:cockpit-e2e", "framework:react"], + "targets": { + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": [ { "target": "build", "projects": ["cockpit-deep-agents-memory-react"] }, { "target": "fixture-test", "projects": ["cockpit-deep-agents-memory-react"] } ], + "options": { "config": "cockpit/deep-agents/memory/react/e2e/playwright.config.ts" } + }, + "test": { + "executor": "@nx/vitest:test", + "cache": false, + "options": { + "configFile": "cockpit/deep-agents/memory/react/vite.config.mts", + "reporters": ["default"] + } + }, + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "build", + "projects": ["core", "content", "react"] + } + ], + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/memory/react"], + "options": { + "command": "node scripts/react-cockpit/build.mjs deep-agents-memory" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "eslintConfig": "cockpit/deep-agents/memory/react/eslint.config.mjs" + } + }, + "configuration-test": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs" + } + }, + "fixture-test": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "test", + "projects": ["cockpit-deep-agents-memory-python"] + } + ], + "options": { + "command": "node --test scripts/react-cockpit/deep-agents-memory-fixture.spec.mjs scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs" + } + } + } +} diff --git a/cockpit/deep-agents/memory/react/src/app.spec.tsx b/cockpit/deep-agents/memory/react/src/app.spec.tsx new file mode 100644 index 000000000..d30dd9a48 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/app.spec.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { createConnectedApplication } from './connection'; +vi.mock('./connection', () => ({ createConnectedApplication: vi.fn() })); +import { MemoryDemo, MemoryPanel, renderMessage } from './app'; +import { createMessageContent } from '@threadplane/content/messages'; +afterEach(cleanup); +function setup(overrides = {}) { + const state = { + rows: [], + toolCalls: [], + memory: { kind: 'none' }, + authority: { kind: 'unconfirmed' }, + busy: false, + canSubmit: true, + viewGeneration: 0, + activity: 'idle', + history: [{ id: 'known', label: 'Conversation 1' }], + error: null, + outcome: null, + scopeWarning: null, + ...overrides, + }; + 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 view = render( + + ); + return { ...view, app, state, ready }; +} +it('uses native conversation/composer and a responsive literal Memory sidebar', () => { + const v = setup(); + const sidebar = v.getByRole('complementary', { name: 'Memory sidebar' }); + expect(sidebar.parentElement?.className).toBe('memory-workspace'); + expect( + v + .getByRole('region', { name: 'Conversation' }) + .closest('.memory-chat') + ?.contains(v.getByRole('textbox')) + ).toBe(true); + expect(v.getByText('no source yet')).toBeTruthy(); +}); +it('readiness, Teach and Recall fill only the draft; New/history clear it', () => { + const v = setup(); + expect(v.ready).toHaveBeenCalledTimes(1); + fireEvent.click(v.getByRole('button', { name: 'Teach' })); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toContain( + 'Citation CJ3' + ); + expect(v.app.submit).not.toHaveBeenCalled(); + fireEvent.click(v.getByRole('button', { name: 'Recall' })); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe( + 'What do you already know about my operation?' + ); + fireEvent.change(v.getByRole('combobox'), { target: { value: 'known' } }); + expect(v.app.selectConversation).toHaveBeenCalledWith('known'); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe(''); + fireEvent.click(v.getByRole('button', { name: 'Teach' })); + fireEvent.click(v.getByRole('button', { name: 'New conversation' })); + expect(v.app.newConversation).toHaveBeenCalledTimes(1); + expect((v.getByRole('textbox') as HTMLTextAreaElement).value).toBe(''); + expect(v.app.submit).not.toHaveBeenCalled(); +}); +it('preserves exact blank lines, indentation, trailing newline and markup as text', () => { + const contents = ' \n\n indented\n'; + const v = render( + + ); + expect(v.container.querySelector('pre')?.textContent).toBe(contents); + expect(v.container.querySelector('img')).toBeNull(); + expect(v.getByText('/memories/AGENTS.md')).toBeTruthy(); + expect(v.getByText('store observed during this run')).toBeTruthy(); +}); +it.each([ + { kind: 'none', label: 'no source yet' }, + { kind: 'unavailable', label: 'memory observation unavailable' }, + { + kind: 'checkpoint', + file: 'missing', + scope: 's', + generation: 'g', + checkpointId: 'cp', + label: 'No memory file at this observation’s read time.', + }, + { + kind: 'checkpoint', + file: 'present', + contents: '', + scope: 's', + generation: 'g', + checkpointId: 'cp', + label: 'The memory file is present and empty.', + }, +])('distinguishes $kind/$file honestly', ({ label, ...memory }) => { + const v = render(); + expect(v.getByText(label)).toBeTruthy(); +}); +it('blocked storage visibly disables authoring even with settled confirmation', () => { + const v = setup({ + canSubmit: false, + outcome: 'success', + scopeWarning: 'Browser identity changed.', + memory: { + kind: 'checkpoint', + file: 'present', + contents: 'Observed', + scope: 's', + generation: 'g', + }, + }); + expect(v.getByRole('alert').textContent).toContain( + 'Browser identity changed.' + ); + expect((v.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true); + expect( + (v.getByRole('button', { name: 'Teach' }) as HTMLButtonElement).disabled + ).toBe(true); + expect(v.queryByText('Response saved.')).toBeNull(); +}); +it('successful transcript without Memory confirmation has no Saved status', () => { + const v = setup({ outcome: 'success', memory: { kind: 'none' } }); + expect(v.queryByText('Response saved.')).toBeNull(); + expect(v.getByText('Ready.')).toBeTruthy(); +}); +it.each(['creating', 'running'])( + 'native composer cannot author during %s', + (activity) => { + const v = setup({ busy: true, canSubmit: false, activity }); + const form = v.getByRole('textbox').closest('form'); + if (!form) throw Error('Missing native composer'); + fireEvent.submit(form); + expect(v.app.submit).not.toHaveBeenCalled(); + expect( + (v.getByRole('button', { name: 'Teach' }) as HTMLButtonElement).disabled + ).toBe(true); + } +); +it('literal native write tool arguments/results remain complete and do not establish Memory', () => { + const content = createMessageContent(); + const args = { + file_path: '/memories/AGENTS.md', + content: ' literal\n\n trailing\n', + }, + result = ''; + const rows = content.project({ + status: 'idle', + messages: [ + { + id: 'assistant', + role: 'assistant', + content: '', + toolCallIds: ['write'], + delivery: { generation: 'g', phase: 'complete', outcome: 'success' }, + }, + ], + toolCalls: [ + { id: 'write', name: 'write_file', args, result, status: 'complete' }, + ], + }); + const v = render(renderMessage(rows[0])); + const literal = [...v.container.querySelectorAll('pre')].map( + (p) => p.textContent + ); + expect(literal).toContain(JSON.stringify(args, null, 2)); + expect(literal).toContain(result); + expect(v.container.querySelector('script')).toBeNull(); + expect(v.queryByText('Response saved.')).toBeNull(); + content.dispose(); +}); diff --git a/cockpit/deep-agents/memory/react/src/app.tsx b/cockpit/deep-agents/memory/react/src/app.tsx new file mode 100644 index 000000000..7d082cc9c --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/app.tsx @@ -0,0 +1,282 @@ +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 { MEMORY_PATH, type MemoryProjection } from './memory-projection'; +import { copyData, LIMITS, type Authority } from './authority'; +import { + createConnectedApplication, + type memoryConnection, +} 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 && ( + + )} +
+ ); +} + +export function MemoryPanel({ memory }: { memory: MemoryProjection }) { + return ( +
+

Agent Memory

+

+ {memory.kind === 'live' + ? 'store observed during this run' + : memory.kind === 'checkpoint' + ? 'confirmed checkpoint at read time' + : memory.kind === 'unavailable' + ? 'memory observation unavailable' + : 'no source yet'} +

+ {(memory.kind === 'live' || memory.kind === 'checkpoint') && + (memory.file === 'missing' ? ( +

No memory file at this observation’s read time.

+ ) : ( +
+

+ {MEMORY_PATH} +

+ {memory.contents === '' && ( +

The memory file is present and empty.

+ )} +
{memory.contents}
+
+ ))} +

+ This panel shows one browser identity’s store at an observed read time. + New conversations share that identity while browser storage is + available. Checkpoints are historical; observations do not promise + durability or that the store is current. Concurrent conversations are + not serialized. +

+
+ ); +} + +export function MemoryDemo({ + 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 + ? snapshot.scopeWarning + ? 'Sending is unavailable. Reload after checking browser storage.' + : 'Start a new conversation to continue.' + : snapshot.outcome === 'success' && + snapshot.authority.kind === 'terminal' && + snapshot.memory.kind === 'checkpoint' + ? 'Response saved.' + : 'Ready.'; + return ( +
+
+
+

React · Experimental

+

Deep Agents Memory

+
+ +
+

+ Teach the agent about your operation, then start a new conversation and + ask what it remembers. Stop cancels local delivery; backend work may + continue. +

+
+ + +
+ +
+
+ + {snapshot.scopeWarning && ( +

+ {snapshot.scopeWarning} +

+ )} + {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/memory/react/src/application.spec.ts b/cockpit/deep-agents/memory/react/src/application.spec.ts new file mode 100644 index 000000000..33a2b3bef --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/application.spec.ts @@ -0,0 +1,542 @@ +import { createBrowserScope } from './browser-scope'; +import { createMemoryApplication } from './application'; +import { it, expect, vi } from 'vitest'; +const SCOPE = '11111111-1111-4111-8111-111111111111'; +function setup() { + let stored: string | null = SCOPE; + const scope = createBrowserScope({ + storage: () => ({ + getItem: () => stored, + setItem: (_k, v) => { + stored = v; + }, + }), + uuid: () => SCOPE, + }); + const delivery = { + generation: 'sdk-generation', + phase: 'complete', + outcome: 'success', + }; + const args = { + file_path: '/memories/AGENTS.md', + content: 'Literal\n\nMemory\n', + }; + const result = ' saved'; + const raw = [ + { id: 'human', type: 'human', content: 'Teach memory.' }, + { + id: 'call', + type: 'ai', + content: '', + tool_calls: [ + { id: 'write', name: 'write_file', args, type: 'tool_call' }, + ], + }, + { + id: 'result', + type: 'tool', + content: result, + name: 'write_file', + tool_call_id: 'write', + status: 'success', + }, + { id: 'final', type: 'ai', content: 'Remembered.', tool_calls: [] }, + ]; + const values = { + messages: raw, + files: {}, + memory_contents: { '/memories/AGENTS.md': 'Literal\n\nMemory\n' }, + memory_scope_owner: SCOPE, + }; + const saved = { + values, + next: [], + tasks: [], + checkpoint: { + thread_id: 'thread', + checkpoint_ns: '', + checkpoint_id: 'checkpoint', + }, + }; + const native = { + status: 'idle', + messages: [ + { id: 'human', role: 'user', content: 'Teach memory.', delivery }, + { + id: 'call', + role: 'assistant', + content: '', + toolCallIds: ['write'], + delivery, + }, + { + id: 'result', + role: 'tool', + name: 'write_file', + toolCallId: 'write', + content: result, + delivery, + }, + { + id: 'final', + role: 'assistant', + content: 'Remembered.', + toolCallIds: [], + delivery, + }, + ], + toolCalls: [ + { id: 'write', name: 'write_file', args, status: 'complete', result }, + ], + values: { + files: {}, + memory_contents: values.memory_contents, + memory_scope_owner: SCOPE, + }, + interrupts: [], + subgraphs: [], + }; + let state: any = { + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }; + const listeners = new Set<() => void>(); + let observe: ((event: unknown) => void) | undefined; + const event = { + name: 'deep_agents.memory', + data: { + memory_scope_owner: SCOPE, + memory_contents: values.memory_contents, + phase: 'terminal', + }, + }; + const set = (v: any) => { + state = v; + listeners.forEach((f) => f()); + }; + const session = { + getSnapshot: () => state, + subscribe: (f: () => void) => { + listeners.add(f); + return () => listeners.delete(f); + }, + submit: vi.fn(async () => { + set(native); + observe?.(event); + return 'success'; + }), + load: vi.fn(async () => { + state = native; + }), + stop: vi.fn(), + dispose: vi.fn(), + }; + const client = { + createThread: vi.fn(async () => 'thread'), + sessionFactory: vi.fn(() => session), + observeMemory: vi.fn((_id: string, f: (event: unknown) => void) => { + observe = f; + return () => { + observe = undefined; + }; + }), + readCurrent: vi.fn(async () => saved), + readHistory: vi.fn(async () => [saved]), + readCheckpoint: vi.fn(async () => saved), + }; + return { + app: createMemoryApplication(client as never, scope), + scope, + client, + session, + event, + native, + saved, + set, + observe: (e: unknown) => observe?.(e), + setStorage: (v: string | null) => { + stored = v; + }, + }; +} +it('Send lazily creates and sends exact scoped context, then confirms raw and native Memory', async () => { + const x = setup(); + await x.app.newConversation(); + await x.app.submit(' '); + await x.app.selectConversation('foreign'); + expect(x.client.createThread).not.toHaveBeenCalled(); + await x.app.submit('Teach memory.'); + expect(x.session.submit).toHaveBeenCalledWith( + 'Teach memory.', + expect.objectContaining({ context: { memory_scope: SCOPE } }) + ); + expect(x.app.getSnapshot()).toMatchObject({ + canSubmit: true, + memory: { kind: 'checkpoint', contents: 'Literal\n\nMemory\n' }, + authority: { kind: 'terminal' }, + history: [{ id: 'thread' }], + }); + expect(x.session.load).toHaveBeenCalledTimes(1); +}); +it('storage setup failure admits zero thread/run requests', async () => { + const x = setup(); + const scope = createBrowserScope({ + storage: () => { + throw Error('blocked'); + }, + uuid: () => SCOPE, + }); + const app = createMemoryApplication(x.client as never, scope); + await app.submit('Teach memory.'); + expect(x.client.createThread).not.toHaveBeenCalled(); + expect(x.session.submit).not.toHaveBeenCalled(); + expect(app.getSnapshot().canSubmit).toBe(false); +}); +it('storage changes during asynchronous creation prevent the run', async () => { + const x = setup(); + let finish!: (id: string) => void; + x.client.createThread.mockImplementation( + () => + new Promise((r) => { + finish = r; + }) + ); + const pending = x.app.submit('Teach memory.'); + x.setStorage(null); + finish('thread'); + await pending; + expect(x.session.submit).not.toHaveBeenCalled(); + expect(x.app.getSnapshot().scopeWarning).toContain('identity changed'); +}); +it('followup clears current confirmation and checks scope every turn', async () => { + const x = setup(); + await x.app.submit('Teach memory.'); + let finish!: (v: string) => void; + x.session.submit.mockImplementation( + () => + new Promise((r) => { + finish = r; + }) + ); + const pending = x.app.submit('Again'); + expect(x.app.getSnapshot().memory.kind).toBe('none'); + expect(x.app.getSnapshot().authority.kind).toBe('unconfirmed'); + await x.app.stop(); + finish('success'); + await pending; + x.setStorage(null); + await x.app.newConversation(); + await x.app.submit('Again'); + expect(x.session.submit).toHaveBeenCalledTimes(2); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it('active captured work settles after scope failure, future authoring stays disabled', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.setStorage(null); + x.scope.admit(); + x.set(x.native); + x.observe(x.event); + return 'success'; + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('checkpoint'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().scopeWarning).toContain('identity changed'); + await x.app.submit('Again'); + expect(x.session.submit).toHaveBeenCalledTimes(1); +}); +it('fresh raw and loaded native confirmation establishes checkpoint even without optional custom', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.set(x.native); + return 'success'; + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('checkpoint'); + expect(x.app.getSnapshot().outcome).toBe('success'); +}); +it.each(['malformed', 'foreign'])( + '%s matching Memory revokes confirmation without fallback', + async (mode) => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.set(x.native); + x.observe(x.event); + x.observe( + mode === 'foreign' + ? { + ...x.event, + data: { + ...x.event.data, + memory_scope_owner: '22222222-2222-4222-8222-222222222222', + }, + } + : { name: 'deep_agents.memory', data: null } + ); + return 'success'; + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('unavailable'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().authority.kind).toBe('unconfirmed'); + } +); +it('New clears evidence, restore freshly reads owned history even when storage is blocked', async () => { + const x = setup(); + await x.app.submit('Teach memory.'); + await x.app.newConversation(); + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.app.getSnapshot().memory.kind).toBe('none'); + x.setStorage(null); + x.scope.admit(); + await x.app.selectConversation('thread'); + expect(x.session.submit).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().memory.kind).toBe('checkpoint'); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it.each(['readCurrent', 'readHistory', 'readCheckpoint'] as const)( + 'failed %s blocks without fallback', + async (method) => { + const x = setup(); + x.client[method].mockRejectedValue(Error('private failure')); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('unavailable'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().error).not.toContain('private failure'); + } +); +it('Stop/New/dispose revoke late events and attempt all cleanup', async () => { + const x = setup(); + let late: ((v: unknown) => void) | undefined; + x.client.observeMemory.mockImplementation((_id, f) => { + late = f; + return () => { + throw Error('release'); + }; + }); + let finish!: (v: string) => void; + x.session.submit.mockImplementation( + () => + new Promise((r) => { + finish = r; + }) + ); + const pending = x.app.submit('Teach memory.'); + await Promise.resolve(); + await x.app.stop(); + await x.app.newConversation(); + late?.(x.event); + finish('success'); + await pending; + await x.app.dispose(); + expect(x.session.stop).toHaveBeenCalledTimes(1); + expect(x.session.dispose).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().memory.kind).toBe('none'); +}); +it('reload has empty page catalog and construction never authors', () => { + const x = setup(); + expect(x.app.getSnapshot().history).toEqual([]); + expect(x.client.createThread).not.toHaveBeenCalled(); + expect(x.session.submit).not.toHaveBeenCalled(); +}); + +it('valid custom plus malformed terminal Memory cannot confirm or enter catalog', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + x.set(x.native); + x.observe(x.event); + x.saved.values.memory_contents = { '/foreign': 'wrong' } as never; + x.native.values.memory_contents = x.saved.values.memory_contents; + return 'success'; + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('unavailable'); + expect(x.app.getSnapshot().authority.kind).toBe('unconfirmed'); + expect(x.app.getSnapshot().history).toEqual([]); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); +it.each(['error', 'aborted', 'interrupted'] as const)( + 'non-success %s revokes Memory and requires New', + async (outcome) => { + const x = setup(); + x.session.submit.mockResolvedValue(outcome); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().memory.kind).toBe('unavailable'); + expect(x.app.getSnapshot().history).toEqual([]); + expect(x.app.getSnapshot().canSubmit).toBe(false); + await x.app.submit('Again'); + expect(x.session.submit).toHaveBeenCalledTimes(1); + await x.app.newConversation(); + expect(x.app.getSnapshot().canSubmit).toBe(true); + } +); +it.each([ + 'human', + 'generation', + 'partial', + 'child', + 'args', + 'result', + 'checkpoint', + 'ordinary-values', + 'loaded-private', +] as const)( + 'rejects %s evidence and clears current authority', + async (kind) => { + const x = setup(); + if (kind === 'human') x.native.messages[0].content = 'Foreign'; + if (kind === 'generation') + x.native.messages[1].delivery = { + generation: 'foreign', + phase: 'complete', + outcome: 'success', + }; + if (kind === 'partial') + x.native.messages[1].delivery = { + generation: 'sdk-generation', + phase: 'streaming', + outcome: 'success', + }; + if (kind === 'child') x.native.subgraphs.push({ id: 'child' } as never); + if (kind === 'args') + x.native.toolCalls[0].args = { content: 'altered' } as never; + if (kind === 'result') x.native.toolCalls[0].result = 'altered'; + if (kind === 'checkpoint') x.saved.checkpoint.checkpoint_ns = 'child'; + if (kind === 'ordinary-values') delete (x.native.values as any).files; + if (kind === 'loaded-private') + x.session.load.mockImplementation(async () => { + const native = structuredClone(x.native); + delete (native.values as any).memory_scope_owner; + x.set(native); + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().canSubmit).toBe(false); + expect(x.app.getSnapshot().authority.kind).toBe('unconfirmed'); + expect(x.app.getSnapshot().history).toEqual([]); + } +); +it('filtered live private values are allowed but actual loaded values must contain both private keys', async () => { + const x = setup(); + x.session.submit.mockImplementation(async () => { + const native = structuredClone(x.native); + delete (native.values as any).memory_scope_owner; + delete (native.values as any).memory_contents; + x.set(native); + x.observe(x.event); + return 'success'; + }); + await x.app.submit('Teach memory.'); + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().memory.kind).toBe('checkpoint'); +}); +it('terminal custom remains live until fresh raw/native reads finish', async () => { + const x = setup(); + let finish!: (v: typeof x.saved) => void; + x.client.readCurrent.mockImplementation( + () => + new Promise((r) => { + finish = r; + }) + ); + const run = x.app.submit('Teach memory.'); + await Promise.resolve(); + await Promise.resolve(); + expect(x.app.getSnapshot().memory).toMatchObject({ + kind: 'live', + phase: 'terminal', + }); + expect(x.app.getSnapshot().authority.kind).toBe('unconfirmed'); + finish(x.saved); + await run; + expect(x.app.getSnapshot().memory.kind).toBe('checkpoint'); +}); +it('New during pending confirmation prevents stale publication and no model runs during restore', async () => { + const x = setup(); + let finish!: (v: typeof x.saved) => void; + x.client.readCurrent.mockImplementation( + () => + new Promise((r) => { + finish = r; + }) + ); + const run = x.app.submit('Teach memory.'); + await Promise.resolve(); + await Promise.resolve(); + await x.app.newConversation(); + finish(x.saved); + await run; + expect(x.app.getSnapshot().rows).toEqual([]); + expect(x.app.getSnapshot().memory.kind).toBe('none'); + expect(x.session.load).not.toHaveBeenCalled(); +}); +it('catalog evicts old entries at the actual entry limit', async () => { + const x = setup(); + let count = 0; + x.client.createThread.mockImplementation(async () => { + const id = `thread-${++count}`; + x.saved.checkpoint.thread_id = id; + return id; + }); + x.client.sessionFactory.mockImplementation(() => { + x.set({ + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }); + return x.session; + }); + for (let i = 0; i < 101; i++) { + await x.app.newConversation(); + await x.app.submit('Teach memory.'); + } + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().history.length).toBeLessThanOrEqual(100); + await x.app.newConversation(); + await x.app.selectConversation('thread-1'); + expect(x.app.getSnapshot().threadId).toBeNull(); +}); +it('catalog bounds aggregate retained raw/native text', async () => { + const x = setup(); + const contents = 'x'.repeat(60000); + x.saved.values.memory_contents['/memories/AGENTS.md'] = contents; + x.event.data.memory_contents = x.saved.values.memory_contents; + x.native.values.memory_contents = x.saved.values.memory_contents; + let count = 0; + x.client.createThread.mockImplementation(async () => { + x.saved.checkpoint.thread_id = `large-${++count}`; + return x.saved.checkpoint.thread_id; + }); + x.client.sessionFactory.mockImplementation(() => { + x.set({ + status: 'idle', + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }); + return x.session; + }); + for (let i = 0; i < 40; i++) { + await x.app.newConversation(); + await x.app.submit('Teach memory.'); + } + expect(x.app.getSnapshot().canSubmit).toBe(true); + expect(x.app.getSnapshot().history.length).toBeLessThan(40); + expect(x.app.getSnapshot().history.length).toBeGreaterThan(0); +}); +it('session acquisition failure still disposes acquired owned session', async () => { + const x = setup(); + x.session.subscribe = () => { + throw Error('subscribe unavailable'); + }; + await x.app.submit('Teach memory.'); + expect(x.session.dispose).toHaveBeenCalledTimes(1); + expect(x.app.getSnapshot().canSubmit).toBe(false); +}); diff --git a/cockpit/deep-agents/memory/react/src/application.ts b/cockpit/deep-agents/memory/react/src/application.ts new file mode 100644 index 000000000..7d2f803a8 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/application.ts @@ -0,0 +1,734 @@ +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 { projectMemory, type MemoryProjection } from './memory-projection'; +import { createBrowserScope, type BrowserScope } from './browser-scope'; +import type { LangGraphRunOptions } from '@threadplane/langgraph'; +export interface MemorySession + extends Omit { + getSnapshot(): NativeSnapshot; + submit( + input: string, + options?: LangGraphRunOptions + ): Promise; + load(options?: { readonly signal?: AbortSignal }): Promise; +} +export interface MemoryClient { + observeMemory(id: string, listener: (event: unknown) => void): () => void; + createThread(signal: AbortSignal): Promise; + sessionFactory(id: string): MemorySession; + readCurrent(id: string, signal: AbortSignal): Promise; + readHistory(id: string, signal: AbortSignal): Promise; + readCheckpoint(checkpoint: Checkpoint, signal: AbortSignal): Promise; +} +export interface MemorySnapshot { + readonly threadId: string | null; + readonly rows: readonly MessageRow[]; + readonly toolCalls: readonly ToolCall[]; + readonly memory: MemoryProjection; + readonly scopeWarning: string | null; + 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: MemorySnapshot['history'] = [] +): MemorySnapshot => ({ + threadId: null, + rows: [], + toolCalls: [], + memory: { kind: 'none' }, + scopeWarning: null, + authority: { kind: 'unconfirmed' }, + activity: 'idle', + busy: false, + canSubmit: true, + outcome: null, + error: null, + viewGeneration, + history, +}); +export function createMemoryApplication( + client: MemoryClient, + scope: BrowserScope = createBrowserScope({ + storage: () => window.localStorage, + uuid: () => crypto.randomUUID(), + events: window, + }) +) { + type Terminal = Extract; + type Saved = { authority: Terminal; native: NativeSnapshot; label: string }; + type Owner = { + id: string; + key: string; + generation: string; + session: MemorySession; + content: ReturnType; + baseline: Baseline; + turn?: Turn; + nativeGeneration?: string; + expectedText?: string; + confirmed?: NativeSnapshot; + authority?: Terminal; + release?: () => void; + releaseMemory?: () => void; + observation?: unknown; + 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 scopeState = scope.getSnapshot(); + const memoryScope = scopeState.scope; + const updateScope = () => { + const state = scope.getSnapshot(); + publish({ + scopeWarning: state.status === 'blocked' ? state.warning : null, + canSubmit: + state.status === 'ready' && + !snapshot.busy && + !owner?.unsafe && + snapshot.outcome !== 'aborted' && + snapshot.outcome !== 'error', + }); + }; + const releaseScope = scope.subscribe(updateScope); + updateScope(); + 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.releaseMemory?.(); + } catch { + /* Continue all cleanup attempts. */ + } + 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, + memory: { kind: 'unavailable' }, + authority: { kind: 'unconfirmed' }, + 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 memory = projectMemory({ + owner: o.key, + generation: o.generation, + currentOwner: o.key, + currentGeneration: o.generation, + scope: memoryScope, + source: + source === 'checkpoint' + ? 'checkpoint' + : o.observation + ? 'custom' + : 'none', + event: o.observation, + authority, + }); + const rows = o.content.project({ + status: s.status as AgentStatus, + messages: s.messages, + toolCalls: s.toolCalls, + }); + publish({ + rows, + toolCalls: s.toolCalls, + memory: + source === 'checkpoint' || o.observation ? memory : { kind: 'none' }, + 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, { + memoryScope: memoryScope!, + 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'); + install(o, s, { kind: 'unconfirmed' }, '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(), + }; + try { + o.release = o.session.subscribe(() => { + const active = operation; + if (active && !o.loading) + read( + o, + () => + !disposed && + operation === active && + !active.signal.aborted && + owner === o && + !o.closed + ); + }); + } catch { + void close(o); + throw Error('Runtime subscription unavailable.'); + } + 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({ + memoryScope, + 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 projection = projectMemory({ + owner: o.key, + currentOwner: o.key, + generation: o.generation, + currentGeneration: o.generation, + scope: memoryScope, + source: 'checkpoint', + authority, + }); + if (projection.kind !== 'checkpoint') + throw Error('Memory confirmation unavailable.'); + 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: scope.getSnapshot().status === 'ready', + 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 context = scope.admit(); + if (!context) { + updateScope(); + 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, + memory: { kind: 'none' }, + authority: { kind: 'unconfirmed' }, + }); + 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; + const admitted = scope.admit(); + if (!admitted || admitted.memory_scope !== context.memory_scope) { + updateScope(); + return; + } + o.generation = String(++serial); + o.observation = undefined; + 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'); + o.releaseMemory?.(); + const observedGeneration = o.generation; + o.releaseMemory = client.observeMemory(o.id, (event) => { + if (!valid() || o.generation !== observedGeneration || o.unsafe) return; + // Only actual matching custom observations are a Memory source. + let captured: unknown; + try { + captured = copyData(event, false); + } catch { + block(o); + return; + } + if (!record(captured) || captured.name !== 'deep_agents.memory') return; + const projection = projectMemory({ + owner: o.key, + currentOwner: o.key, + generation: observedGeneration, + currentGeneration: o.generation, + scope: memoryScope, + source: 'custom', + event: captured, + }); + if (projection.kind === 'unavailable') { + block(o); + return; + } + o.observation = captured; + publish({ memory: projection }); + }); + const outcome = await o.session.submit(text, { + context, + 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, { + memoryScope: memoryScope!, + 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' }); + updateScope(); + } + } + } + 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, + }); + updateScope(); + 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' }); + updateScope(); + } + } + } + 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, + memory: { kind: 'none' }, + authority: { kind: 'unconfirmed' }, + 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({}); + updateScope(); + await p; + } + function dispose() { + if (disposal) return disposal; + disposed = true; + operation?.abort(); + operation = undefined; + void detach(); + try { + releaseScope(); + } catch { + /* Finish cleanup. */ + } + try { + scope.dispose(); + } catch { + /* Finish cleanup. */ + } + 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 MemoryApplication = ReturnType; diff --git a/cockpit/deep-agents/memory/react/src/authority.spec.ts b/cockpit/deep-agents/memory/react/src/authority.spec.ts new file mode 100644 index 000000000..c83745ed3 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/authority.spec.ts @@ -0,0 +1,511 @@ +import { projectMemory } from './memory-projection'; +import { describe, it, expect } from 'vitest'; +import { + captureAuthority, + captureBaseline, + captureTurn, + copyData, +} from './authority'; +// Synthetic unit fixture for adversarial contract checks; not a store/native proof. +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, + memory_contents: { '/memories/AGENTS.md': 'Literal\n\nMemory' }, + memory_scope_owner: '11111111-1111-4111-8111-111111111111', + 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: { + memory_contents: values.memory_contents, + memory_scope_owner: values.memory_scope_owner, + files: {}, + }, + interrupts: [], + subgraphs: [], + }; + const turn = captureTurn(captureBaseline(), { + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-owner', + generation: 'generation', + threadId: 'thread', + humanId: 'human', + humanContent: 'Inspect runway.', + }); + return { + turn, + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-owner', + generation: 'generation', + threadId: 'thread', + outcome: 'success', + observedState: state, + history: [structuredClone(state)], + loadedCheckpoint: structuredClone(state), + snapshot, + loadedSnapshot: structuredClone(snapshot), + }; +} + +describe('terminal owned Memory authority', () => { + function withPrivateMemory() { + const input = authorityInput(); + for (const values of [ + input.observedState.values, + input.history[0].values, + input.loadedCheckpoint.values, + input.snapshot.values, + input.loadedSnapshot.values, + ]) { + (values as any).memory_scope_owner = + '11111111-1111-4111-8111-111111111111'; + } + return input; + } + it.each([ + { keys: [] }, + { keys: ['memory_contents'] }, + { keys: ['memory_scope_owner'] }, + { keys: ['memory_contents', 'memory_scope_owner'] }, + ])( + 'confirms only absent live private keys $keys and retains exact checkpoint metadata', + ({ keys }) => { + const input = withPrivateMemory(); + for (const key of keys) delete (input.snapshot.values as any)[key]; + const authority = captureAuthority(input); + expect(authority.kind).toBe('terminal'); + if (authority.kind !== 'terminal') + throw Error('Unconfirmed private projection'); + expect(authority.values).toEqual(input.loadedCheckpoint.values); + expect(authority.rawToolStatuses).toEqual({ read: 'success' }); + } + ); + it.each(['memory_contents', 'memory_scope_owner'])( + 'rejects conflicting or present undefined private %s', + (key) => { + for (const value of [[], undefined]) { + const input = withPrivateMemory(); + delete (input.snapshot.values as any)[ + key === 'memory_contents' ? 'memory_scope_owner' : 'memory_contents' + ]; + (input.snapshot.values as any)[key] = value; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + } + ); + it.each(['memory_contents', 'memory_scope_owner'])( + 'requires loaded private %s even when live omits both', + (key) => { + for (const remove of [true, false]) { + const input = withPrivateMemory(); + delete (input.snapshot.values as any).memory_contents; + delete (input.snapshot.values as any).memory_scope_owner; + if (remove) delete (input.loadedSnapshot.values as any)[key]; + else (input.loadedSnapshot.values as any)[key] = []; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + } + ); + it('rejects missing/conflicting ordinary keys, nested omissions, unknown extras and nonrecord live values', () => { + for (const modify of [ + (i: any) => { + delete i.snapshot.values.files; + }, + (i: any) => { + i.snapshot.values.files = { stale: true }; + }, + (i: any) => { + i.snapshot.values.extra = []; + }, + (i: any) => { + i.snapshot.values = structuredClone(i.snapshot.values); + delete i.snapshot.values.memory_contents['/memories/AGENTS.md']; + }, + ...[undefined, null, [], 'invalid'].map((value) => (i: any) => { + i.snapshot.values = value; + }), + ]) { + const input = authorityInput(); + modify(input); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + }); + it('does not accept custom contents as persistence when the checkpoint lacks them', () => { + const input = authorityInput(); + for (const values of [ + input.observedState.values, + input.history[0].values, + input.loadedCheckpoint.values, + input.loadedSnapshot.values, + ]) + delete (values as any).memory_contents; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + delete (input.snapshot.values as any).memory_contents; + (input.snapshot as any).customEvents = [ + { name: 'skills_visibility', data: { memory_contents: ['untrusted'] } }, + ]; + const authority = captureAuthority(input); + expect(authority.kind).toBe('terminal'); + if (authority.kind !== 'terminal') + throw Error('Unconfirmed ordinary values'); + expect(Object.hasOwn(authority.values, 'memory_contents')).toBe(false); + }); + it('keeps transcript, tool, control and ownership gates when live private keys are omitted', () => { + for (const modify of [ + (i: any) => { + i.snapshot.messages[3].content = 'Different answer'; + }, + (i: any) => { + i.snapshot.toolCalls[0].result = 'Different result'; + }, + (i: any) => { + i.snapshot.interrupts = [{ id: 'interrupt' }]; + }, + (i: any) => { + i.owner = 'other-owner'; + }, + (i: any) => { + i.observedState.next.push('pending'); + }, + ]) { + const input = authorityInput(); + delete (input.snapshot.values as any).memory_contents; + modify(input); + expect(captureAuthority(input).kind).toBe('unconfirmed'); + } + }); + 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.memory_contents = []; + 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 }), { + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-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) }), + { + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-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, + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-owner', + generation: 'new-generation', + }).kind + ).toBe('unconfirmed'); + const turn = captureTurn(captureBaseline(), { + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-owner', + generation: 'new-generation', + threadId: 'thread', + humanId: 'human', + humanContent: 'Inspect runway.', + }); + expect( + captureAuthority({ + ...input, + turn, + memoryScope: '11111111-1111-4111-8111-111111111111', + owner: 'app-owner', + generation: 'new-generation', + }).kind + ).toBe('terminal'); + }); +}); + +describe('Memory owner binding', () => { + it('rejects a raw checkpoint owner different from captured scope', () => { + const input = authorityInput(); + for (const v of [ + input.observedState.values, + input.history[0].values, + input.loadedCheckpoint.values, + input.snapshot.values, + input.loadedSnapshot.values, + ]) + (v as any).memory_scope_owner = '22222222-2222-4222-8222-222222222222'; + expect(captureAuthority(input).kind).toBe('unconfirmed'); + }); +}); + +describe('historical Memory checkpoint projection', () => { + it.each([ + {}, + { '/memories/AGENTS.md': '' }, + { '/memories/AGENTS.md': ' \n\nLiteral\t' }, + ])('projects only confirmed historical contents %j', (contents) => { + const i = authorityInput(); + for (const v of [ + i.observedState.values, + i.history[0].values, + i.loadedCheckpoint.values, + i.snapshot.values, + i.loadedSnapshot.values, + ]) + (v as any).memory_contents = contents; + const a = captureAuthority(i); + expect(a.kind).toBe('terminal'); + const result = projectMemory({ + owner: i.owner, + currentOwner: i.owner, + generation: i.generation, + currentGeneration: i.generation, + scope: i.memoryScope, + source: 'checkpoint', + authority: a, + }); + expect(result).toMatchObject({ + kind: 'checkpoint', + file: Object.hasOwn(contents, '/memories/AGENTS.md') + ? 'present' + : 'missing', + checkpointId: 'checkpoint', + }); + if (Object.hasOwn(contents, '/memories/AGENTS.md')) + expect((result as any).contents).toBe( + (contents as any)['/memories/AGENTS.md'] + ); + expect( + projectMemory({ + owner: i.owner, + currentOwner: i.owner, + generation: i.generation, + currentGeneration: 'stale', + scope: i.memoryScope, + source: 'checkpoint', + authority: a, + }).kind + ).toBe('unavailable'); + }); + it('requires complete captured scope and raw owner binding, independent of app identity', () => { + for (const mutate of [ + (i: any) => { + delete i.memoryScope; + }, + (i: any) => { + i.memoryScope = '22222222-2222-4222-8222-222222222222'; + }, + (i: any) => { + delete i.turn.memoryScope; + }, + ]) { + const i = structuredClone(authorityInput()); + mutate(i); + expect(captureAuthority(i).kind).toBe('unconfirmed'); + } + }); + it('never derives missing contents from tools or answer text and rejects corrupted maps', () => { + const i = authorityInput(); + for (const v of [ + i.observedState.values, + i.history[0].values, + i.loadedCheckpoint.values, + i.snapshot.values, + i.loadedSnapshot.values, + ]) + delete (v as any).memory_contents; + const a = captureAuthority(i); + expect(a.kind).toBe('terminal'); + expect( + projectMemory({ + owner: i.owner, + currentOwner: i.owner, + generation: i.generation, + currentGeneration: i.generation, + scope: i.memoryScope, + source: 'checkpoint', + authority: a, + }).kind + ).toBe('unavailable'); + }); +}); diff --git a/cockpit/deep-agents/memory/react/src/authority.ts b/cockpit/deep-agents/memory/react/src/authority.ts new file mode 100644 index 000000000..aa8aca37a --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/authority.ts @@ -0,0 +1,667 @@ +import type { Message, ToolCall } from '@threadplane/core'; +import { validScope } from './browser-scope'; + +/** 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 memoryScope: string; + 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 memoryScope: string; + 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) || + !validScope(identity.memoryScope) || + ['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; +} + +/** Compare already-owned live values with the exact checkpoint projection. + * Local unfiltered compiled values retain private Memory fields. The installed + * API stream uses graph.output_channels and can omit these two top-level fields. + * No native fields are synthesized. Every observed key + * still agrees exactly; every ordinary checkpoint key must be observed. */ +function matchesLiveValues( + live: unknown, + checkpoint: Record +): boolean { + if (!record(live)) return false; + return ( + Object.keys(live).every( + (key) => + Object.hasOwn(checkpoint, key) && sameData(live[key], checkpoint[key]) + ) && + Object.keys(checkpoint).every( + (key) => + Object.hasOwn(live, key) || + key === 'memory_contents' || + key === 'memory_scope_owner' + ) + ); +} + +/** 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 || + !validScope(captured.memoryScope) || + turn.memoryScope !== captured.memoryScope || + ['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) || + loaded.values.memory_scope_owner !== captured.memoryScope || + !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) || + !matchesLiveValues(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/memory/react/src/browser-scope.spec.ts b/cockpit/deep-agents/memory/react/src/browser-scope.spec.ts new file mode 100644 index 000000000..4bafefb1c --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/browser-scope.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; +import { createBrowserScope, SCOPE_KEY, validScope } from './browser-scope'; +const scope = '11111111-1111-4111-8111-111111111111'; +const other = '22222222-2222-4222-8222-222222222222'; +function setup(value: string | null = scope) { + let current = value, + reads = 0, + writes = 0, + generated = 0; + let failRead = false, + failWrite = false, + winner: string | null | undefined; + const listeners = new Set<(event: any) => void>(); + const storage = { + getItem(key: string) { + expect(key).toBe(SCOPE_KEY); + reads++; + if (failRead) throw Error(); + return current; + }, + setItem(key: string, value: string) { + expect(key).toBe(SCOPE_KEY); + writes++; + if (failWrite) throw Error(); + current = winner === undefined ? value : winner; + }, + }; + const events = { + addEventListener(_: string, listener: any) { + listeners.add(listener); + }, + removeEventListener(_: string, listener: any) { + listeners.delete(listener); + }, + }; + return { + create: () => + createBrowserScope({ + storage: () => storage, + uuid: () => { + generated++; + return scope; + }, + events, + }), + set: (v: string | null) => { + current = v; + }, + emit: ( + key = SCOPE_KEY, + newValue: string | null = current, + storageArea: unknown = storage + ) => listeners.forEach((l) => l({ key, newValue, storageArea })), + failRead: () => { + failRead = true; + }, + failWrite: () => { + failWrite = true; + }, + winner: (v: string | null) => { + winner = v; + }, + counts: () => ({ reads, writes, generated, listeners: listeners.size }), + }; +} +describe('immutable browser memory scope', () => { + it.each([scope, other])( + 'captures existing valid scope %s without writes', + (value) => { + const s = setup(value); + const b = s.create(); + expect(b?.getSnapshot()).toMatchObject({ status: 'ready', scope: value }); + expect(b?.admit()).toEqual({ memory_scope: value }); + expect(s.counts()).toMatchObject({ writes: 0, generated: 0 }); + } + ); + it('generates only when missing and captures the competing readback winner', () => { + const s = setup(null); + s.winner(other); + const b = s.create(); + expect(b?.admit()).toEqual({ memory_scope: other }); + expect(s.counts()).toMatchObject({ writes: 1, generated: 1 }); + }); + it.each([ + '', + 'garbage', + 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', + '11111111-1111-5111-8111-111111111111', + '11111111-1111-4111-7111-111111111111', + ` ${scope}`, + ])('blocks corrupt existing value %s without repair', (value) => { + const s = setup(value); + const b = s.create(); + expect(b?.getSnapshot().status).toBe('blocked'); + expect(b?.admit()).toBeUndefined(); + expect(s.counts()).toMatchObject({ writes: 0, generated: 0 }); + }); + it('blocks unavailable storage, write/read errors and invalid generated UUID', () => { + for (const options of [ + { + storage: () => { + throw Error(); + }, + uuid: () => scope, + }, + { + storage: () => ({ + getItem: () => null, + setItem: () => { + throw Error(); + }, + }), + uuid: () => scope, + }, + { + storage: () => ({ getItem: () => null, setItem: () => undefined }), + uuid: () => scope, + }, + { + storage: () => ({ + getItem: () => null, + setItem: () => { + throw Error('must not write'); + }, + }), + uuid: () => 'bad', + }, + ]) + expect(createBrowserScope(options)?.getSnapshot().status).toBe('blocked'); + const s = setup(null); + s.failWrite(); + expect(s.create()?.getSnapshot().status).toBe('blocked'); + const t = setup(); + t.failRead(); + expect(t.create()?.getSnapshot().status).toBe('blocked'); + }); + it.each([null, other])( + 'rechecks every action and permanently blocks same-window replacement %s', + (value) => { + const s = setup(); + const b = s.create(); + const inflight = b?.admit(); + s.set(value); + expect(b?.admit()).toBeUndefined(); + expect(b?.getSnapshot()).toMatchObject({ + status: 'blocked', + scope, + warning: expect.any(String), + }); + expect(inflight).toEqual({ memory_scope: scope }); + expect(Object.isFrozen(inflight)).toBe(true); + s.set(scope); + expect(b?.admit()).toBeUndefined(); + } + ); + it('reacts to storage changes during an active generation and cleans subscriptions on dispose', () => { + const s = setup(); + const b = s.create(); + const context = b?.admit(); + let updates = 0; + const unsubscribe = b?.subscribe(() => updates++); + s.emit('unrelated', other); + expect(b?.getSnapshot().status).toBe('ready'); + s.set(other); + s.emit(); + expect(b?.getSnapshot().status).toBe('blocked'); + expect(context).toEqual({ memory_scope: scope }); + expect(updates).toBe(1); + unsubscribe?.(); + b?.dispose(); + expect(s.counts().listeners).toBe(0); + expect(b?.admit()).toBeUndefined(); + }); + it('blocks a deletion event and a read failure after capture', () => { + const s = setup(); + const b = s.create(); + s.emit(SCOPE_KEY, null); + expect(b?.getSnapshot().status).toBe('blocked'); + const t = setup(); + const c = t.create(); + t.failRead(); + expect(c?.admit()).toBeUndefined(); + }); + it('validates UUID variant/version/case exactly', () => { + expect(validScope(scope)).toBe(true); + expect(validScope('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')).toBe(true); + expect(validScope('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA')).toBe(false); + expect(validScope(null)).toBe(false); + }); +}); + +it('blocks readback errors and rejects invalid winners after exactly one initialization write', () => { + let reads = 0, + writes = 0; + const b = createBrowserScope({ + storage: () => ({ + getItem: () => { + if (++reads === 1) return null; + throw Error('readback unavailable'); + }, + setItem: () => { + writes++; + }, + }), + uuid: () => scope, + }); + expect(b.getSnapshot().status).toBe('blocked'); + expect(writes).toBe(1); + expect(reads).toBe(2); + const s = setup(null); + s.winner('invalid'); + expect(s.create().getSnapshot().status).toBe('blocked'); + expect(s.counts()).toMatchObject({ writes: 1, generated: 1 }); +}); +it('handles storage clear and ignores events belonging to other storage areas', () => { + const s = setup(); + const b = s.create(); + s.emit(SCOPE_KEY, other, {}); + expect(b.getSnapshot().status).toBe('ready'); + s.emit(null as any, null); + expect(b.getSnapshot().status).toBe('blocked'); + b.dispose(); + b.dispose(); + expect(s.counts().listeners).toBe(0); +}); diff --git a/cockpit/deep-agents/memory/react/src/browser-scope.ts b/cockpit/deep-agents/memory/react/src/browser-scope.ts new file mode 100644 index 000000000..756df9024 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/browser-scope.ts @@ -0,0 +1,120 @@ +export const SCOPE_KEY = 'threadplane.deep-agents-memory.scope.v1'; +export const validScope = (value: unknown): value is string => + typeof value === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value + ); +export interface ScopeStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} +export interface ScopeEvent { + readonly key: string | null; + readonly newValue: string | null; + readonly storageArea: unknown; +} +export interface ScopeEvents { + addEventListener( + type: 'storage', + listener: (event: ScopeEvent) => void + ): void; + removeEventListener( + type: 'storage', + listener: (event: ScopeEvent) => void + ): void; +} +export interface ScopeOptions { + readonly storage: () => ScopeStorage; + readonly uuid: () => string; + readonly events?: ScopeEvents; +} +export type ScopeSnapshot = Readonly< + | { status: 'ready'; scope: string } + | { status: 'blocked'; scope?: string; warning: string } +>; +export interface BrowserScope { + getSnapshot(): ScopeSnapshot; + subscribe(listener: () => void): () => void; + /** Call immediately before EVERY author action, including creation/queued runs. */ + admit(): Readonly<{ memory_scope: string }> | undefined; + dispose(): void; +} +/** One immutable identity per app generation. Storage failure permanently blocks + * future authoring; a previously captured context can still settle under its owner. */ +export function createBrowserScope(options: ScopeOptions): BrowserScope { + let storage: ScopeStorage | undefined, + scope: string | undefined, + disposed = false; + let snapshot: ScopeSnapshot = Object.freeze({ + status: 'blocked', + warning: + 'Browser memory setup is unavailable. Reload after checking browser storage.', + }); + const listeners = new Set<() => void>(); + const block = () => { + if (snapshot.status === 'blocked') return; + snapshot = Object.freeze({ + status: 'blocked', + scope, + warning: + 'Browser memory identity changed or storage is unavailable. Active work keeps its original scope. Reload before sending again.', + }); + for (const listener of [...listeners]) listener(); + }; + const onStorage = (event: ScopeEvent) => { + if ( + disposed || + (event.storageArea !== null && event.storageArea !== storage) || + (event.key !== null && event.key !== SCOPE_KEY) + ) + return; + if (event.key === null || event.newValue !== scope) block(); + }; + let listening = false; + try { + storage = options.storage(); + let value = storage.getItem(SCOPE_KEY); + if (value === null) { + const generated = options.uuid(); + if (!validScope(generated)) throw Error('Invalid generated identity'); + storage.setItem(SCOPE_KEY, generated); + value = storage.getItem(SCOPE_KEY); + } + if (!validScope(value)) throw Error('Invalid stored identity'); + scope = value; + snapshot = Object.freeze({ status: 'ready', scope }); + if (options.events) { + options.events.addEventListener('storage', onStorage); + listening = true; + } + } catch { + block(); + } + return Object.freeze({ + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + if (!disposed) listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + admit() { + if (disposed || snapshot.status !== 'ready') return undefined; + try { + if (storage?.getItem(SCOPE_KEY) !== scope) block(); + } catch { + block(); + } + return snapshot.status === 'ready' + ? Object.freeze({ memory_scope: scope! }) + : undefined; + }, + dispose() { + if (disposed) return; + disposed = true; + if (listening) options.events?.removeEventListener('storage', onStorage); + block(); + listeners.clear(); + }, + }); +} diff --git a/cockpit/deep-agents/memory/react/src/connection.spec.ts b/cockpit/deep-agents/memory/react/src/connection.spec.ts new file mode 100644 index 000000000..0eb711488 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/connection.spec.ts @@ -0,0 +1,700 @@ +import { it, expect, vi, afterEach } from 'vitest'; +import { createMemoryClient } from './connection'; +afterEach(() => vi.unstubAllGlobals()); +it('real SDK lazily creates UUID with raise, captured headers and no retry, then uses genuine native protocol', async () => { + const requests: { path: string; body: any; headers: Headers }[] = []; + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: any, init: any) => { + const path = new URL(String(url)).pathname; + const body = init?.body ? JSON.parse(init.body) : undefined; + requests.push({ path, body, headers: new Headers(init?.headers) }); + if (path === '/threads') return Response.json({ thread_id: id }); + if (path.endsWith('/runs/stream')) { + const messages = [ + { + id: 'call', + type: 'ai', + content: '', + tool_calls: [ + { + id: 'tool', + name: 'write_file', + args: { content: 'literal\n\n' }, + type: 'tool_call', + }, + ], + }, + { + id: 'result', + type: 'tool', + name: 'write_file', + tool_call_id: 'tool', + content: 'done', + status: 'success', + }, + { id: 'final', type: 'ai', content: 'Answer.', tool_calls: [] }, + ]; + return new Response( + 'event: custom\ndata: ' + + JSON.stringify({ + name: 'deep_agents.memory', + data: { phase: 'initial' }, + }) + + '\n\nevent: values\ndata: ' + + JSON.stringify({ messages, files: {} }) + + '\n\n', + { headers: { 'content-type': 'text/event-stream' } } + ); + } + return Response.json([]); + }) + ); + const headers = { 'x-api-key': 'captured' }; + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers, + }); + headers['x-api-key'] = 'changed'; + expect(requests).toEqual([]); + const signal = new AbortController().signal; + await expect(client.readCurrent('foreign', signal)).rejects.toThrow(); + expect(requests).toEqual([]); + const created = await client.createThread(signal); + expect(created).toBe(id); + expect(requests[0].body).toEqual({ + metadata: {}, + thread_id: id, + if_exists: 'raise', + }); + expect(requests[0].headers.get('x-api-key')).toBe('captured'); + const events: unknown[] = []; + client.observeMemory(id, (e: unknown) => events.push(e)); + const session = client.sessionFactory(id); + expect( + await session.submit('Teach.', { context: { memory_scope: id }, signal }) + ).toBe('success'); + expect(events).toEqual([ + { name: 'deep_agents.memory', data: { phase: 'initial' } }, + ]); + expect(session.getSnapshot().values).toEqual({ files: {} }); + expect(session.getSnapshot().messages.at(-1)?.content).toBe('Answer.'); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + args: { content: 'literal\n\n' }, + result: 'done', + status: 'complete', + }); + expect(requests[1].body.context).toEqual({ memory_scope: id }); + await session.load?.(); + expect(requests.at(-1)?.path).toContain('/history'); + await session.dispose(); +}); +it('checkpoint getters are rejected without evaluation', async () => { + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + let reads = 0; + const bad = Object.defineProperty({}, 'thread_id', { + enumerable: true, + get() { + reads++; + return 'bad'; + }, + }); + await expect( + client.readCheckpoint(bad as never, new AbortController().signal) + ).rejects.toThrow(); + expect(reads).toBe(0); +}); +it.each(['messages', 'messages/partial', 'messages/complete'])( + 'actual SDK %s events deliver native messages while custom stays separate', + async (mode) => { + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + const message = { + id: 'answer', + type: 'ai', + content: 'Literal native answer', + tool_calls: [], + }; + const data = + mode === 'messages' ? [message, { langgraph_node: 'model' }] : [message]; + vi.stubGlobal('fetch', async (url: unknown) => + new URL(String(url)).pathname === '/threads' + ? Response.json({ thread_id: id }) + : new Response( + `event: ${mode}\ndata: ${JSON.stringify( + data + )}\n\nevent: messages/complete\ndata: ${JSON.stringify([ + message, + ])}\n\nevent: custom\ndata: ${JSON.stringify({ + name: 'deep_agents.memory', + data: { + memory_scope_owner: id, + memory_contents: {}, + phase: 'terminal', + }, + })}\n\n`, + { headers: { 'content-type': 'text/event-stream' } } + ) + ); + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + await client.createThread(new AbortController().signal); + const events: unknown[] = []; + client.observeMemory(id, (e) => events.push(e)); + const session = client.sessionFactory(id); + expect( + await session.submit('Question', { context: { memory_scope: id } }) + ).toBe('success'); + expect(session.getSnapshot().messages.at(-1)?.content).toBe( + 'Literal native answer' + ); + expect(session.getSnapshot().values).toBeUndefined(); + expect(events).toHaveLength(1); + await session.dispose(); + } +); +it('SDK namespace suffixes cannot be forged by payload routing and child custom is never root Memory', async () => { + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + const root = { name: 'deep_agents.memory', data: { phase: 'terminal' } }; + vi.stubGlobal('fetch', async (url: unknown) => + new URL(String(url)).pathname === '/threads' + ? Response.json({ thread_id: id }) + : new Response( + `event: custom|child:task\ndata: ${JSON.stringify({ + ...root, + type: 'custom', + namespace: [], + sseId: 'forged', + })}\n\nevent: values|child:task\ndata: ${JSON.stringify({ + messages: [], + })}\n\nevent: values\ndata: ${JSON.stringify({ + messages: [ + { id: 'answer', type: 'ai', content: 'Native', tool_calls: [] }, + ], + type: 'custom', + namespace: ['forged'], + sseId: 'forged', + })}\n\n`, + { headers: { 'content-type': 'text/event-stream' } } + ) + ); + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + await client.createThread(new AbortController().signal); + const events: unknown[] = []; + client.observeMemory(id, (e) => events.push(e)); + const session = client.sessionFactory(id); + await session.submit('Question', { context: { memory_scope: id } }); + expect(events).toEqual([]); + expect( + session.getSnapshot().messages.some((m) => m.content === 'Native') + ).toBe(true); + expect(session.getSnapshot().subgraphs.length).toBeGreaterThan(0); + await session.dispose(); +}); +it('failed creation POST is not retried and mismatched returned IDs never enter ownership', async () => { + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + const fetch = vi.fn(async () => new Response('failure', { status: 500 })); + vi.stubGlobal('fetch', fetch); + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + await expect( + client.createThread(new AbortController().signal) + ).rejects.toThrow(); + expect(fetch).toHaveBeenCalledTimes(1); + fetch.mockResolvedValue(Response.json({ thread_id: 'foreign' })); + await expect( + client.createThread(new AbortController().signal) + ).rejects.toThrow(); + expect(() => client.sessionFactory(id)).toThrow(); +}); +it('physical stream captures its original listener before first next and preserves SDK routing/cursor', async () => { + vi.resetModules(); + let transport: + | NonNullable< + Parameters< + typeof import('@threadplane/langgraph').createSession + >[0]['transport'] + > + | undefined; + vi.doMock('@threadplane/langgraph', () => ({ + createSession: (options: { transport: typeof transport }) => { + transport = options.transport; + return { + getSnapshot: () => ({ + messages: [], + toolCalls: [], + interrupts: [], + subgraphs: [], + }), + subscribe: () => () => undefined, + submit: async () => 'success', + load: async () => undefined, + stop: async () => undefined, + dispose: async () => undefined, + }; + }, + })); + const { createMemoryClient: create } = await import('./connection'); + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + vi.stubGlobal('fetch', async (url: unknown) => + new URL(String(url)).pathname === '/threads' + ? Response.json({ thread_id: id }) + : new Response( + 'id: sdk-cursor\nevent: custom\ndata: ' + + JSON.stringify({ + name: 'deep_agents.memory', + data: { phase: 'initial' }, + type: 'values', + namespace: ['forged'], + sseId: 'forged', + }) + + '\n\n', + { headers: { 'content-type': 'text/event-stream' } } + ) + ); + try { + const client = create({ apiUrl: 'http://offline.invalid', headers: {} }); + const signal = new AbortController().signal; + await client.createThread(signal); + const first = vi.fn(), + replacement = vi.fn(); + const release = client.observeMemory(id, first); + client.sessionFactory(id); + if (!transport) throw Error('Missing captured transport'); + const iterator = transport + .stream('da-memory', id, { messages: [] }, signal, { + context: { memory_scope: id }, + }) + [Symbol.asyncIterator](); + release(); + client.observeMemory(id, replacement); + const next = await iterator.next(); + expect(first).toHaveBeenCalledTimes(1); + expect(replacement).not.toHaveBeenCalled(); + expect(next.value).toMatchObject({ type: 'custom', sseId: 'sdk-cursor' }); + expect(next.value?.namespace).toBeUndefined(); + await iterator.return?.(); + } finally { + vi.doUnmock('@threadplane/langgraph'); + vi.resetModules(); + } +}); +it('genuine SDK/native application confirms filtered live values against full raw/exact/history/native load', async () => { + const { createMemoryApplication } = await import('./application'); + const { createBrowserScope } = await import('./browser-scope'); + const id = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: () => id }); + const calls: string[] = []; + const args = { + file_path: '/memories/AGENTS.md', + content: ' Literal\n\nMemory\n', + }, + result = 'created'; + const messages = [ + { id, type: 'human', content: 'Teach.' }, + { + id: 'call', + type: 'ai', + content: '', + tool_calls: [ + { id: 'write', name: 'write_file', args, type: 'tool_call' }, + ], + }, + { + id: 'result', + type: 'tool', + name: 'write_file', + tool_call_id: 'write', + content: result, + status: 'success', + }, + { id: 'final', type: 'ai', content: 'Remembered.', tool_calls: [] }, + ]; + const values = { + messages, + files: {}, + memory_scope_owner: id, + memory_contents: { '/memories/AGENTS.md': args.content }, + }; + const saved = { + values, + next: [], + tasks: [], + checkpoint: { + thread_id: id, + checkpoint_ns: '', + checkpoint_id: 'exact-checkpoint', + }, + }; + vi.stubGlobal('fetch', async (url: unknown, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + calls.push(path); + if (path === '/threads') return Response.json({ thread_id: id }); + if (path.endsWith('/runs/stream')) + return new Response( + 'event: values\ndata: ' + + JSON.stringify({ messages, files: {} }) + + '\n\nevent: custom\ndata: ' + + JSON.stringify({ + name: 'deep_agents.memory', + data: { + memory_scope_owner: id, + memory_contents: values.memory_contents, + phase: 'terminal', + }, + }) + + '\n\n', + { headers: { 'content-type': 'text/event-stream' } } + ); + if (path.endsWith('/state/checkpoint')) + expect(JSON.parse(String(init?.body)).checkpoint).toEqual( + saved.checkpoint + ); + return Response.json(path.endsWith('/history') ? [saved] : saved); + }); + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + const scope = createBrowserScope({ + storage: () => ({ getItem: () => id, setItem: () => undefined }), + uuid: () => id, + }); + const app = createMemoryApplication(client, scope); + await app.submit('Teach.'); + expect(app.getSnapshot()).toMatchObject({ + canSubmit: true, + outcome: 'success', + authority: { kind: 'terminal' }, + memory: { kind: 'checkpoint', contents: args.content }, + }); + expect(app.getSnapshot().toolCalls[0]).toMatchObject({ + args, + result, + status: 'complete', + }); + expect(calls.filter((p) => p.endsWith('/history'))).toHaveLength(2); + expect(calls.filter((p) => p.endsWith('/state/checkpoint'))).toHaveLength(1); + await app.dispose(); +}); +it('confirms two successful genuine native turns on one owned thread with fresh full-prefix checkpoint evidence', async () => { + const { createMemoryApplication } = await import('./application'); + const { createBrowserScope } = await import('./browser-scope'); + const memoryScope = '77777777-7777-4777-8777-777777777777'; + let uuidSerial = 0; + vi.stubGlobal('crypto', { + randomUUID: () => + `00000000-0000-4000-8000-${String(++uuidSerial).padStart(12, '0')}`, + }); + type RawMessage = { + id: string; + type: 'human' | 'ai' | 'tool'; + content: string; + tool_calls?: { + id: string; + name: string; + args: { file_path: string; content: string }; + type: 'tool_call'; + }[]; + name?: string; + tool_call_id?: string; + status?: string; + }; + type RunBody = { + input: { messages: RawMessage[] }; + context: unknown; + command?: unknown; + }; + const requests: { + path: string; + method: string; + turn: number; + body: unknown; + }[] = []; + const runs: RunBody[] = []; + const rawMessages: RawMessage[] = []; + const contents = [ + ' first memory\n\n literal one\n', + ' second memory\n\n literal two\n', + ]; + let threadId: string | undefined; + let turn = 0; + let current: + | { + values: { + messages: RawMessage[]; + files: Record; + memory_scope_owner: string; + memory_contents: Record; + }; + next: never[]; + tasks: never[]; + checkpoint: { + thread_id: string; + checkpoint_ns: ''; + checkpoint_id: string; + }; + } + | undefined; + const client = createMemoryClient({ + apiUrl: 'http://offline.invalid', + headers: {}, + }); + let nativeSession: ReturnType | undefined; + const liveBeforeLoad: ReturnType< + NonNullable['getSnapshot'] + >[] = []; + const nativeClient = { + ...client, + sessionFactory(id: string) { + nativeSession = client.sessionFactory(id); + return nativeSession; + }, + }; + vi.stubGlobal('fetch', async (url: unknown, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + const method = init?.method ?? 'GET'; + const body: unknown = init?.body + ? JSON.parse(String(init.body)) + : undefined; + if (path === '/threads') { + const requested = body as { thread_id: string; if_exists: string }; + expect(requested.if_exists).toBe('raise'); + threadId = requested.thread_id; + requests.push({ path, method, turn, body }); + return Response.json({ thread_id: threadId }); + } + expect(threadId).toBeDefined(); + if (!threadId) throw Error('Missing confirmed owned thread'); + expect(path.startsWith(`/threads/${threadId}/`)).toBe(true); + if (path.endsWith('/runs/stream')) { + turn++; + expect(turn).toBeLessThanOrEqual(2); + const run = body as RunBody; + expect(run.context).toEqual({ memory_scope: memoryScope }); + expect(run).not.toHaveProperty('command'); + expect(run.input.messages).toHaveLength(1); + const human = run.input.messages[0]; + expect(human.type).toBe('human'); + expect(human.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + expect(human.content).toBe( + turn === 1 ? 'Teach the first memory.' : 'Update that memory.' + ); + runs.push(structuredClone(run)); + const args = { + file_path: '/memories/AGENTS.md', + content: contents[turn - 1], + }; + rawMessages.push( + structuredClone(human), + { + id: `call-${turn}`, + type: 'ai', + content: '', + tool_calls: [ + { + id: `write-${turn}`, + name: 'write_file', + args, + type: 'tool_call', + }, + ], + }, + { + id: `result-${turn}`, + type: 'tool', + name: 'write_file', + tool_call_id: `write-${turn}`, + content: ` literal result ${turn}\n\n`, + status: 'success', + }, + { + id: `answer-${turn}`, + type: 'ai', + content: `Memory observation ${turn}.`, + tool_calls: [], + } + ); + current = { + values: { + messages: structuredClone(rawMessages), + files: {}, + memory_scope_owner: memoryScope, + memory_contents: { '/memories/AGENTS.md': args.content }, + }, + next: [], + tasks: [], + checkpoint: { + thread_id: threadId, + checkpoint_ns: '', + checkpoint_id: `checkpoint-${turn}`, + }, + }; + requests.push({ path, method, turn, body }); + return new Response( + 'event: values\ndata: ' + + JSON.stringify({ messages: current.values.messages, files: {} }) + + '\n\nevent: custom\ndata: ' + + JSON.stringify({ + name: 'deep_agents.memory', + data: { + memory_scope_owner: memoryScope, + memory_contents: current.values.memory_contents, + phase: 'terminal', + }, + }) + + '\n\n', + { headers: { 'content-type': 'text/event-stream' } } + ); + } + if (!current || !nativeSession) + throw Error('Read before actual native run'); + requests.push({ path, method, turn, body }); + if (path.endsWith('/state')) { + liveBeforeLoad.push(structuredClone(nativeSession.getSnapshot())); + return Response.json(current); + } + if (path.endsWith('/history')) { + expect(body).toMatchObject({ limit: 1 }); + return Response.json([current]); + } + if (path.endsWith('/state/checkpoint')) { + expect(body).toEqual({ checkpoint: current.checkpoint }); + return Response.json(current); + } + throw Error(`Unexpected offline route: ${path}`); + }); + const scope = createBrowserScope({ + storage: () => ({ getItem: () => memoryScope, setItem: () => undefined }), + uuid: () => memoryScope, + }); + const app = createMemoryApplication(nativeClient, scope); + try { + await app.submit('Teach the first memory.'); + const first = app.getSnapshot(); + expect(first).toMatchObject({ + canSubmit: true, + outcome: 'success', + authority: { + kind: 'terminal', + checkpoint: { checkpoint_id: 'checkpoint-1' }, + }, + memory: { kind: 'checkpoint', contents: contents[0] }, + }); + if (first.authority.kind !== 'terminal' || !nativeSession) + throw Error('Unconfirmed first real native turn'); + const firstRaw = structuredClone(first.authority.messages); + const firstLoaded = structuredClone(nativeSession.getSnapshot()); + await app.submit('Update that memory.'); + const second = app.getSnapshot(); + expect(second).toMatchObject({ + threadId, + canSubmit: true, + outcome: 'success', + authority: { + kind: 'terminal', + checkpoint: { checkpoint_id: 'checkpoint-2' }, + }, + memory: { kind: 'checkpoint', contents: contents[1] }, + history: [{ id: threadId }], + }); + if (second.authority.kind !== 'terminal') + throw Error('Unconfirmed second real native turn'); + expect(second.authority.messages.slice(0, firstRaw.length)).toEqual( + firstRaw + ); + expect(second.authority.turn.baseline.messages).toEqual(firstRaw); + expect(runs[0].input.messages[0].id).not.toBe(runs[1].input.messages[0].id); + expect(liveBeforeLoad).toHaveLength(2); + const firstLive = liveBeforeLoad[0], + secondLive = liveBeforeLoad[1]; + expect(firstLive.messages[0].id).toBe(runs[0].input.messages[0].id); + expect(secondLive.messages[firstLoaded.messages.length].id).toBe( + runs[1].input.messages[0].id + ); + const firstGeneration = firstLive.messages[0].delivery.generation; + const secondGeneration = + secondLive.messages[firstLoaded.messages.length].delivery.generation; + expect(firstGeneration).toBeTruthy(); + expect(secondGeneration).toBeTruthy(); + expect(firstGeneration).not.toBe(secondGeneration); + expect( + firstLive.messages.every( + (m) => + m.delivery.generation === firstGeneration && + m.delivery.phase === 'complete' && + m.delivery.outcome === 'success' + ) + ).toBe(true); + expect( + secondLive.messages + .slice(firstLoaded.messages.length) + .every( + (m) => + m.delivery.generation === secondGeneration && + m.delivery.phase === 'complete' && + m.delivery.outcome === 'success' + ) + ).toBe(true); + const semantics = (messages: typeof firstLive.messages) => + messages.map((message) => ({ ...message, delivery: undefined })); + expect( + semantics(secondLive.messages.slice(0, firstLoaded.messages.length)) + ).toEqual(semantics(firstLoaded.messages)); + expect(secondLive.toolCalls.slice(0, firstLoaded.toolCalls.length)).toEqual( + firstLoaded.toolCalls + ); + expect(second.toolCalls).toEqual( + [1, 2].map((i) => ({ + id: `write-${i}`, + name: 'write_file', + args: { file_path: '/memories/AGENTS.md', content: contents[i - 1] }, + result: ` literal result ${i}\n\n`, + status: 'complete', + })) + ); + expect(nativeSession.getSnapshot().values).toEqual({ + files: {}, + memory_scope_owner: memoryScope, + memory_contents: { '/memories/AGENTS.md': contents[1] }, + }); + expect( + requests.filter((r) => r.path === '/threads' && r.method === 'POST') + ).toHaveLength(1); + expect( + requests.filter( + (r) => r.path.endsWith('/runs/stream') && r.method === 'POST' + ) + ).toHaveLength(2); + for (const i of [1, 2]) { + const reads = requests.filter( + (r) => r.turn === i && !r.path.endsWith('/runs/stream') + ); + expect(reads.map((r) => r.path)).toEqual([ + `/threads/${threadId}/state`, + `/threads/${threadId}/history`, + `/threads/${threadId}/state/checkpoint`, + `/threads/${threadId}/history`, + ]); + } + } finally { + await app.dispose(); + } +}); diff --git a/cockpit/deep-agents/memory/react/src/connection.ts b/cockpit/deep-agents/memory/react/src/connection.ts new file mode 100644 index 000000000..f6d60fcef --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/connection.ts @@ -0,0 +1,237 @@ +import { Client } from '@langchain/langgraph-sdk'; +import { createSession } from '@threadplane/langgraph'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Neutral bridge copied by isolated build. +import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { + createMemoryApplication, + type MemoryClient, + type MemorySession, +} from './application'; +import { copyData, record, text, LIMITS, type Checkpoint } from './authority'; +import { validScope } from './browser-scope'; +export interface MemoryConnection { + readonly apiUrl: string; + readonly headers: Record; +} +export function memoryConnection( + configuration: RuntimeBridgeConfiguration +): MemoryConnection { + 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 Object.freeze({ + apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href, + headers: Object.freeze( + target ? { 'x-api-key': target.apiKey } : ({} as Record) + ), + }); +} +type Transport = NonNullable[0]['transport']>; +type Event = ReturnType extends AsyncIterable + ? T + : never; +/** Routing comes only from the SDK event name and cursor, never payload fields. */ +function normalize(event: string, data: unknown, id: unknown): Event { + const [base, ...namespace] = event.split('|'); + const route = { + type: event as Event['type'], + ...(namespace.length ? { namespace } : {}), + sseId: typeof id === 'string' ? id : undefined, + }; + if ( + base === 'messages' && + Array.isArray(data) && + data.length === 2 && + record(data[1]) + ) + return { ...route, messages: [data[0]], messageMetadata: data[1], data }; + if ( + (base === 'messages' || base.startsWith('messages/')) && + Array.isArray(data) + ) + return { ...route, messages: data, data }; + if (record(data)) + return { + ...data, + ...route, + ...(Object.hasOwn(data, 'namespace') && !namespace.length + ? { namespace: undefined } + : {}), + data, + }; + return { ...route, data }; +} +export function createMemoryClient(input: MemoryConnection): MemoryClient { + const connection = Object.freeze({ + apiUrl: input.apiUrl, + headers: Object.freeze({ ...input.headers }), + }); + const sdk = new Client({ + apiUrl: connection.apiUrl, + apiKey: null, + defaultHeaders: connection.headers, + callerOptions: { maxRetries: 0 }, + }); + const known = new Set(), + observers = new Map void>(); + function owned(id: string | null, signal: AbortSignal): asserts id is string { + if (!id || !known.has(id) || signal.aborted) + throw Error('Conversation unavailable.'); + } + const readHistory: MemoryClient['readHistory'] = async (id, signal) => { + owned(id, signal); + const result = await sdk.threads.getHistory(id, { limit: 1, signal }); + owned(id, signal); + return result; + }; + const readCheckpoint: MemoryClient['readCheckpoint'] = async ( + 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 cp = v as unknown as Checkpoint; + owned(cp.thread_id, signal); + const result = await sdk.threads.getState( + cp.thread_id, + cp as Parameters[1], + { signal } + ); + owned(cp.thread_id, signal); + return result; + }; + return { + async createThread(signal) { + if (signal.aborted) throw Error('Conversation unavailable.'); + const id = crypto.randomUUID(); + if (!validScope(id)) throw Error('Conversation unavailable.'); + const result = await sdk.threads.create({ + threadId: id, + ifExists: 'raise', + signal, + }); + const captured = copyData(result, false); + if (signal.aborted || !record(captured) || captured.thread_id !== id) + throw Error('Conversation creation was not confirmed.'); + known.add(id); + return id; + }, + observeMemory(id, listener) { + if (!known.has(id)) throw Error('Unknown conversation.'); + observers.set(id, listener); + return () => { + if (observers.get(id) === listener) observers.delete(id); + }; + }, + sessionFactory(id) { + if (!known.has(id)) throw Error('Unknown conversation.'); + const transport: Transport = { + // Capture now, at physical invocation: a late event never adopts a later turn listener. + stream(assistant, thread, payload, signal, options) { + owned(thread, signal); + const observe = observers.get(thread); + const currentThread = thread; + return (async function* () { + try { + for await (const packet of sdk.runs.stream( + currentThread, + assistant, + { + input: payload as Record, + context: options?.context, + config: options?.config, + metadata: options?.metadata, + streamMode: ['values', 'messages-tuple', 'updates', 'custom'], + streamSubgraphs: true, + signal, + } + )) { + const event = normalize(packet.event, packet.data, packet.id); + if (event.type === 'custom' && !event.namespace?.length) + observe?.(event.data); + yield event; + } + } catch { + throw Error('Conversation delivery unavailable.'); + } + })(); + }, + async getHistory(thread, signal) { + return (await readHistory(thread, signal)) as Awaited< + ReturnType> + >; + }, + }; + const session = createSession({ + assistantId: 'da-memory', + threadId: id, + transport, + }); + 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 MemorySession; + } catch { + void Promise.resolve(session.dispose()).catch(() => undefined); + throw Error('Runtime session is unavailable.'); + } + }, + async readCurrent(id, signal) { + owned(id, signal); + const result = await sdk.threads.getState(id, undefined, { signal }); + owned(id, signal); + return result; + }, + readHistory, + readCheckpoint, + }; +} +export function createConnectedApplication(connection: MemoryConnection) { + return createMemoryApplication(createMemoryClient(connection)); +} diff --git a/cockpit/deep-agents/memory/react/src/main.tsx b/cockpit/deep-agents/memory/react/src/main.tsx new file mode 100644 index 000000000..63e51d25b --- /dev/null +++ b/cockpit/deep-agents/memory/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 { MemoryDemo } from './app'; +import { memoryConnection } 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 Memory example could not start.

+ ); + }); diff --git a/cockpit/deep-agents/memory/react/src/memory-projection.spec.ts b/cockpit/deep-agents/memory/react/src/memory-projection.spec.ts new file mode 100644 index 000000000..a7e36b4b6 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/memory-projection.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { captureMemoryMap, projectMemory } from './memory-projection'; +import { captureAuthority } from './authority'; +const scope = '11111111-1111-4111-8111-111111111111', + path = '/memories/AGENTS.md'; +const base = { + owner: scope, + currentOwner: scope, + generation: 'g', + currentGeneration: 'g', + scope, +}; +const custom = (contents: unknown = {}, phase: unknown = 'model') => ({ + ...base, + source: 'custom', + event: { + name: 'deep_agents.memory', + data: { memory_contents: contents, memory_scope_owner: scope, phase }, + }, +}); +describe('literal bounded memory observations', () => { + it('distinguishes no source, unavailable, missing and present empty', () => { + expect(projectMemory({ ...base, source: 'none' })?.kind).toBe('none'); + expect(projectMemory({ ...base, source: 'unavailable' })?.kind).toBe( + 'unavailable' + ); + expect(projectMemory(custom({}))).toMatchObject({ file: 'missing' }); + expect(projectMemory(custom({ [path]: '' }))).toMatchObject({ + file: 'present', + }); + expect(projectMemory(custom({ [path]: '' }))).toMatchObject({ + contents: '', + }); + }); + it.each(['initial', 'model', 'terminal'])( + 'accepts actual custom %s as live observation only', + (phase) => { + expect( + projectMemory(custom({ [path]: ' \n\nText\t\n' }, phase)) + ).toMatchObject({ + kind: 'live', + file: 'present', + contents: ' \n\nText\t\n', + phase, + scope, + generation: 'g', + }); + } + ); + it('rejects unsupported, malformed, stale or wrong-scope sources', () => { + for (const input of [ + { ...custom(), currentOwner: 'wrong' }, + { ...custom(), currentGeneration: 'old' }, + custom({}, 'other'), + { ...custom(), source: 'metadata' }, + { ...custom(), event: { name: 'other', data: {} } }, + { + ...custom(), + event: { + name: 'deep_agents.memory', + data: { + memory_contents: {}, + memory_scope_owner: 'wrong', + phase: 'model', + }, + }, + }, + { + ...custom(), + event: { + name: 'deep_agents.memory', + data: { + memory_contents: {}, + memory_scope_owner: scope, + phase: 'model', + extra: 1, + }, + }, + }, + { ...custom(), scope: 'bad' }, + ]) + expect(projectMemory(input)?.kind).toBe('unavailable'); + }); + it('does not infer checkpoint confirmation from prose, tools or terminal custom phase', () => { + expect( + projectMemory({ + ...base, + source: 'checkpoint', + authority: captureAuthority({}), + })?.kind + ).toBe('unavailable'); + expect(projectMemory(custom({}, 'terminal'))?.kind).toBe('live'); + }); + it('owns maps and rejects adversarial prototypes/accessors/hidden/symbol/extra keys', () => { + const input = { [path]: 'literal' }; + const captured = captureMemoryMap(input); + expect(captured).toEqual(input); + input[path] = 'changed'; + expect(captured?.[path]).toBe('literal'); + expect(Object.isFrozen(captured)).toBe(true); + let accessed = false; + for (const bad of [ + [], + null, + new Date(), + { extra: '' }, + { [path]: 1 }, + Object.create({ [path]: 'inherited' }), + Object.defineProperty({}, path, { + get() { + accessed = true; + throw Error(); + }, + enumerable: true, + }), + Object.defineProperty({}, path, { value: 'hidden' }), + { [Symbol('x')]: '' }, + ]) + expect(captureMemoryMap(bad)).toBeUndefined(); + expect(accessed).toBe(false); + }); + it('enforces UTF8 byte boundaries and rejects unpaired surrogate replacement', () => { + for (const value of [ + 'a'.repeat(65536), + 'é'.repeat(32768), + '😀'.repeat(16384), + ]) + expect(captureMemoryMap({ [path]: value })?.[path]).toBe(value); + for (const value of [ + 'a'.repeat(65537), + 'é'.repeat(32769), + '😀'.repeat(16385), + '\ud800', + '\udfff', + 'x\ud800x', + ]) + expect(captureMemoryMap({ [path]: value })).toBeUndefined(); + }); + it('sanitizes throwing source access without evaluating getters', () => { + let accessed = false; + const input = Object.defineProperty( + { ...base, source: 'custom' }, + 'event', + { + enumerable: true, + get() { + accessed = true; + throw Error(); + }, + } + ); + expect(projectMemory(input)?.kind).toBe('unavailable'); + expect(accessed).toBe(false); + }); +}); diff --git a/cockpit/deep-agents/memory/react/src/memory-projection.ts b/cockpit/deep-agents/memory/react/src/memory-projection.ts new file mode 100644 index 000000000..14d0d8857 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/memory-projection.ts @@ -0,0 +1,128 @@ +import { copyData, record, text, type Authority } from './authority'; +import { validScope } from './browser-scope'; +export const MEMORY_PATH = '/memories/AGENTS.md'; +export const MAX_MEMORY_BYTES = 65536; +export type MemoryMap = Readonly>; +/** Strict Unicode scalar validation precedes TextEncoder: lone JS surrogates + * cannot silently become replacement bytes. This bounds retained display data. */ +export function captureMemoryMap(input: unknown): MemoryMap | undefined { + try { + const captured = copyData(input, false); + if (!record(captured)) return undefined; + const keys = Object.keys(captured); + if (keys.length > 1 || keys.some((k) => k !== MEMORY_PATH)) + return undefined; + if (!keys.length) return captured as MemoryMap; + const value = captured[MEMORY_PATH]; + if (typeof value !== 'string' || value.length > MAX_MEMORY_BYTES) + return undefined; + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return undefined; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return undefined; + } + if (new TextEncoder().encode(value).length > MAX_MEMORY_BYTES) + return undefined; + return captured as MemoryMap; + } catch { + return undefined; + } +} +export interface MemoryProjectionInput { + readonly owner: string; + readonly currentOwner: string; + readonly generation: string; + readonly currentGeneration: string; + readonly scope: string; + readonly source: 'none' | 'unavailable' | 'custom' | 'checkpoint'; + /** Actual separately observed root custom event, never fabricated native values. */ + readonly event?: unknown; + readonly authority?: Authority; +} +export type MemoryProjection = + | Readonly<{ kind: 'none' | 'unavailable' }> + | Readonly<{ + kind: 'live' | 'checkpoint'; + file: 'missing' | 'present'; + contents?: string; + scope: string; + generation: string; + phase?: 'initial' | 'model' | 'terminal'; + checkpointId?: string; + }>; +const unavailable: MemoryProjection = Object.freeze({ kind: 'unavailable' }); +/** Checkpoint evidence is historical: the file was observed at that run's read + * time. Neither custom nor checkpoint evidence promises the store is current. */ +export function projectMemory(input: unknown): MemoryProjection { + try { + const v = copyData(input); + if ( + !record(v) || + !validScope(v.scope) || + !text(v.owner) || + !text(v.generation) || + v.owner !== v.currentOwner || + v.generation !== v.currentGeneration + ) + return unavailable; + if (v.source === 'none') return Object.freeze({ kind: 'none' }); + let contents: unknown, + phase: 'initial' | 'model' | 'terminal' | undefined, + checkpointId: string | undefined; + if (v.source === 'custom') { + const event = v.event; + if ( + !record(event) || + event.name !== 'deep_agents.memory' || + Object.keys(event).some((k) => !['name', 'data'].includes(k)) || + !record(event.data) + ) + return unavailable; + const d = event.data; + if ( + Object.keys(d).length !== 3 || + Object.keys(d).some( + (k) => !['memory_scope_owner', 'memory_contents', 'phase'].includes(k) + ) || + d.memory_scope_owner !== v.scope || + !['initial', 'model', 'terminal'].includes(d.phase as string) + ) + return unavailable; + contents = d.memory_contents; + phase = d.phase as typeof phase; + } else if (v.source === 'checkpoint') { + const a = v.authority; + if ( + !record(a) || + a.kind !== 'terminal' || + !record(a.turn) || + a.turn.owner !== v.owner || + a.turn.generation !== v.generation || + a.turn.memoryScope !== v.scope || + !record(a.values) || + a.values.memory_scope_owner !== v.scope || + !record(a.checkpoint) || + !text(a.checkpoint.checkpoint_id) + ) + return unavailable; + contents = a.values.memory_contents; + checkpointId = a.checkpoint.checkpoint_id; + } else return unavailable; + const map = captureMemoryMap(contents); + if (!map) return unavailable; + const present = Object.hasOwn(map, MEMORY_PATH); + return Object.freeze({ + kind: v.source === 'custom' ? 'live' : 'checkpoint', + file: present ? 'present' : 'missing', + ...(present ? { contents: map[MEMORY_PATH] } : {}), + scope: v.scope, + generation: v.generation, + ...(phase ? { phase } : {}), + ...(checkpointId ? { checkpointId } : {}), + }); + } catch { + return unavailable; + } +} diff --git a/cockpit/deep-agents/memory/react/src/styles.css b/cockpit/deep-agents/memory/react/src/styles.css new file mode 100644 index 000000000..f3aea67c7 --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/styles.css @@ -0,0 +1,130 @@ +:root { + font-family: system-ui, sans-serif; + color: #202832; + background: #fafafa; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.memory-demo { + max-width: 64rem; + margin: auto; + padding: 1.5rem; + min-height: 100dvh; + display: flex; + flex-direction: column; + gap: 1rem; +} +.memory-demo, +.memory-demo * { + min-width: 0; + overflow-wrap: anywhere; +} +.memory-demo header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; +} +.memory-demo h1 { + font-size: 1.5rem; + margin: 0; +} +.memory-demo h2 { + font-size: 1.1rem; +} +.memory-demo h3 { + font-size: 1rem; +} +.memory-demo p { + margin-block: 0.5rem; +} +.eyebrow { + color: #606b76; + font-size: 0.8rem; +} +.memory-demo button, +.memory-demo select { + font: inherit; +} +.memory-demo button { + cursor: pointer; +} +.memory-demo button:disabled { + cursor: default; +} +.memory-demo .tp-chat-list { + min-height: 12rem; + max-height: 32rem; + overflow: auto; + flex: 1; +} +.memory-workspace { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + align-items: start; + gap: 1.5rem; +} +.memory-chat { + display: flex; + flex-direction: column; + gap: 1rem; +} +.seed-prompts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.memory-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} +.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; +} +.memory-demo summary { + cursor: pointer; +} +.memory-demo [role='alert'] { + color: #a52323; +} +.memory-demo [role='status'] { + color: #606b76; + font-size: 0.9rem; +} +@media (max-width: 42rem) { + .memory-workspace { + grid-template-columns: minmax(0, 1fr); + } + .memory-demo { + padding: 1rem; + } +} + +.memory-panel { + border: 1px solid #cbd5e1; + border-radius: 0.75rem; + padding: 1rem; + max-height: 32rem; + overflow: auto; +} +.memory-panel pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 22rem; + overflow: auto; +} diff --git a/cockpit/deep-agents/memory/react/src/styles.d.ts b/cockpit/deep-agents/memory/react/src/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/cockpit/deep-agents/memory/react/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/cockpit/deep-agents/memory/react/tsconfig.json b/cockpit/deep-agents/memory/react/tsconfig.json new file mode 100644 index 000000000..5754c625b --- /dev/null +++ b/cockpit/deep-agents/memory/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/memory/react/vite.config.mts b/cockpit/deep-agents/memory/react/vite.config.mts new file mode 100644 index 000000000..c57234013 --- /dev/null +++ b/cockpit/deep-agents/memory/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/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index 07c33826a..51434db5f 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -59,7 +59,7 @@ export const capabilities: readonly Capability[] = [ { id: 'da-planning', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'planning', angularProject: 'cockpit-deep-agents-planning-angular', port: 4310, react: { project: 'cockpit-deep-agents-planning-react', port: 4628 }, pythonPort: 5310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }, { id: 'da-filesystem', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, react: { project: 'cockpit-deep-agents-filesystem-react', port: 4629 }, pythonPort: 5311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'da-filesystem' }, { id: 'da-subagents', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, 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-memory', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, react: { project: 'cockpit-deep-agents-memory-react', port: 4632 }, 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, 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' }, diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index 9fd473c30..69f08c1bf 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -430,6 +430,22 @@ describe('registry content descriptors', () => { }); }); +it('registers native Memory with complete shared source and Angular default', () => { + const canonical = capabilityModules.find(entry => entry.id === 'deep-agents-memory-python')!; + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as never, 'react'); + const backend = ['graph.py', 'memory_observation.py'].map(file => `cockpit/deep-agents/memory/python/src/${file}`); + expect(canonical.backendAssetPaths).toEqual(backend); + expect(deepAgentsMemoryPythonModule.backendAssetPaths).toEqual(backend); + expect(canonical.codeAssetPaths).toEqual(['memory.component.ts', 'app.config.ts', 'memory-scope.ts', 'memory-transport.ts', 'memory-agent-gate.ts'].map(file => `cockpit/deep-agents/memory/angular/src/app/${file}`)); + expect(react).toMatchObject({ frontend: 'react', runtimeUrl: 'deep-agents/memory/react', devPort: 4632, backendAssetPaths: backend, promptAssetPaths: canonical.promptAssetPaths }); + expect(react?.codeAssetPaths).toEqual([...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'browser-scope.ts', 'memory-projection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/memory/react/src/${file}`), 'cockpit/deep-agents/memory/react/project.json']); + 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); + } +}); + 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'); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 40e04f557..d95677bc1 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -431,8 +431,11 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ codeAssetPaths: [ 'cockpit/deep-agents/memory/angular/src/app/memory.component.ts', 'cockpit/deep-agents/memory/angular/src/app/app.config.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-scope.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-transport.ts', + 'cockpit/deep-agents/memory/angular/src/app/memory-agent-gate.ts', ], - backendAssetPaths: ['cockpit/deep-agents/memory/python/src/graph.py'], + backendAssetPaths: ['cockpit/deep-agents/memory/python/src/graph.py', 'cockpit/deep-agents/memory/python/src/memory_observation.py'], runtimeUrl: 'deep-agents/memory', devPort: 4313, }, @@ -1308,6 +1311,24 @@ const deepAgentsSubagentsReact = (() => { }); })(); +const deepAgentsMemoryReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'deep-agents-memory-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-deep-agents-memory-react'); + if (!canonical || !frontend) throw new Error('Memory frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'deep-agents-memory-python-react', + frontend: 'react', + title: 'Memory (React preview)', + codeAssetPaths: [ + ...['app.tsx', 'application.ts', 'connection.ts', 'authority.ts', 'browser-scope.ts', 'memory-projection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/deep-agents/memory/react/src/${file}`), + 'cockpit/deep-agents/memory/react/project.json', + ], + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + 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'); @@ -1333,6 +1354,7 @@ export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = deepAgentsFilesystemReact, deepAgentsSubagentsReact, deepAgentsSkillsReact, + deepAgentsMemoryReact, 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 a75a7dba1..09ef17aad 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(32); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(33); 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(32); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(33); 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 5b270aa94..fb7837e87 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(32); - expect(getCockpitFrontends()).toHaveLength(73); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(33); + expect(getCockpitFrontends()).toHaveLength(74); 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(32); - expect(frontends).toHaveLength(73); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(73); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(33); + expect(frontends).toHaveLength(74); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(74); 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(32); - expect(getCockpitFrontends()).toHaveLength(73); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(33); + expect(getCockpitFrontends()).toHaveLength(74); }); 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(32); - expect(getCockpitFrontends()).toHaveLength(73); + ).toHaveLength(33); + expect(getCockpitFrontends()).toHaveLength(74); }); 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(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).toHaveLength(74); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(74); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(74); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(74); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(74); 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 5b4280d49..953ae601b 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -6,6 +6,28 @@ import { test } from 'node:test'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { deriveCockpitCaps, selectCockpitCaps } from './cockpit-matrix.mjs'; +test('Memory backend changes select its two actual registered frontends and exact shared backend', () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-deep-agents-memory-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-memory-angular', 'cockpit-deep-agents-memory-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/memory/python' && cap.pythonName === 'cockpit-deep-agents-memory-python')); + const registry = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); const registry = module.default ?? module; console.log(JSON.stringify({ frontends: registry.getCockpitFrontends(), capability: registry.capabilities.find(cap => cap.id === "da-memory") }));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); + assert.equal(registry.frontends.length, 74); + assert.deepEqual(registry.frontends.filter(frontend => frontend.product === 'deep-agents' && frontend.topic === 'memory'), [ + { frontend: 'angular', project: 'cockpit-deep-agents-memory-angular', port: 4313, product: 'deep-agents', topic: 'memory', runtimePath: 'deep-agents/memory', buildOutput: 'dist/cockpit/deep-agents/memory/angular' }, + { frontend: 'react', project: 'cockpit-deep-agents-memory-react', port: 4632, product: 'deep-agents', topic: 'memory', runtimePath: 'deep-agents/memory/react', buildOutput: 'dist/cockpit/deep-agents/memory/react' }, + ]); + assert.equal(registry.capability.pythonDir, 'cockpit/deep-agents/memory/python'); + assert.equal(registry.capability.pythonPort, 5313); + assert.equal(registry.capability.graphName, 'da-memory'); +}); +test('Memory owns exact closed native root metadata without changing ordinary LangGraph Memory', async () => { + const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); + const config = reactCockpitConfiguration('deep-agents-memory'); + assert.deepEqual(config, { topic: 'memory', library: 'deep-agents', adapter: 'langgraph', appPath: 'cockpit/deep-agents/memory/react', base: '/deep-agents/memory/react/', port: 4632, project: 'cockpit-deep-agents-memory-react' }); + assert.equal(Object.isFrozen(config), true); + assert.equal(reactCockpitConfiguration('memory').appPath, 'cockpit/langgraph/memory/react'); +}); test('Planning backend changes select exactly both canonical frontends', () => { const root = fileURLToPath(new URL('..', import.meta.url)); const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-deep-agents-planning-python']), { fullFleet: false }); @@ -57,7 +79,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, 73); + assert.equal(frontends.length, 74); 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 +88,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, 73); + assert.equal(frontends.length, 74); 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 +107,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, 73); + assert.equal(frontends.length, 74); 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 () => { diff --git a/scripts/examples/capability-wiring.spec.ts b/scripts/examples/capability-wiring.spec.ts index fceb38146..9192ed74d 100644 --- a/scripts/examples/capability-wiring.spec.ts +++ b/scripts/examples/capability-wiring.spec.ts @@ -13,8 +13,18 @@ import { type ExactImportBinding, type ProviderRegistrationOwner, } from './runtime-wiring-audit'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { + existsSync, + readFileSync, + readdirSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; +import * as ts from 'typescript'; import { fileURLToPath } from 'node:url'; const cockpitRoot = fileURLToPath(new URL('../../cockpit/', import.meta.url)); @@ -27,7 +37,40 @@ function angularSourceFiles(projectRoot: string): string[] { return entry.isFile() && entry.name.endsWith('.ts') ? [entryPath] : []; }); - return visit(resolve(projectRoot, 'src')); + if (!projectRoot.endsWith('/deep-agents/memory/angular')) + return visit(resolve(projectRoot, 'src')); + // App excludes remove test roots, but an import makes an excluded file runtime. + const configPath = resolve(projectRoot, 'tsconfig.app.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + projectRoot + ); + if (config.error || parsed.errors.length) + throw Error('Invalid Memory app tsconfig'); + const files = new Set( + parsed.fileNames.filter((name) => + name.startsWith(resolve(projectRoot, 'src') + '/') + ) + ); + for (const name of files) { + for (const imported of ts.preProcessFile( + readFileSync(name, 'utf8'), + true, + true + ).importedFiles) { + const resolved = ts.resolveModuleName( + imported.fileName, + name, + parsed.options, + ts.sys + ).resolvedModule?.resolvedFileName; + if (resolved?.startsWith(resolve(projectRoot, 'src') + '/')) + files.add(resolved); + } + } + return [...files]; } interface ExpectedRuntimeWiring { @@ -726,7 +769,8 @@ describe('cockpit capability wiring', () => { inspection: inspectRuntimeTargetSource( file.source, file.relativeFileName, - adapter + adapter, + capability.angularProject ), })); if (expected) { @@ -766,6 +810,158 @@ describe('cockpit capability wiring', () => { expect(unmigrated).toEqual([]); }); + it('audits excluded Memory tests when runtime source imports them', () => { + const temporary = mkdtempSync(resolve(tmpdir(), 'memory-runtime-audit-')); + const root = resolve(temporary, 'deep-agents/memory/angular'); + try { + mkdirSync(resolve(root, 'src'), { recursive: true }); + writeFileSync( + resolve(root, 'tsconfig.app.json'), + JSON.stringify({ + files: ['src/main.ts'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts'], + }) + ); + writeFileSync(resolve(root, 'src/main.ts'), 'export const app = true;'); + writeFileSync( + resolve(root, 'src/secret.spec.ts'), + "export const secret = window.localStorage.getItem('apiKey');" + ); + expect( + angularSourceFiles(root).some((name) => name.endsWith('secret.spec.ts')) + ).toBe(false); + for (const source of [ + "import { secret } from './secret.spec'; export const app = secret;", + "export const app = import('./secret.spec');", + "declare function require(path: string): unknown; export const app = require('./secret.spec');", + ]) { + writeFileSync(resolve(root, 'src/main.ts'), source); + const runtime = angularSourceFiles(root); + expect(runtime.some((name) => name.endsWith('secret.spec.ts'))).toBe( + true + ); + expect( + runtime.flatMap((name) => + auditRuntimeTargetSource(readFileSync(name, 'utf8'), name) + ) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'browser-state-read' }), + ]) + ); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + + it.each(['listener', 'block'])( + 'rejects a disconnected Memory %s control', + (control) => { + const scope = readFileSync( + resolve( + cockpitRoot, + 'deep-agents/memory/angular/src/app/memory-scope.ts' + ), + 'utf8' + ); + const changed = + control === 'listener' + ? scope.replace( + "addEventListener('storage', onStorage)", + "addEventListener('storage', () => {})" + ) + : scope.replace( + /const block = \(\) => \{[\s\S]*?\n {2}\};/, + 'const block = () => {};' + ); + expect(changed).not.toBe(scope); + expect( + inspectRuntimeTargetSource( + changed, + 'src/app/memory-scope.ts', + 'langgraph', + 'cockpit-deep-agents-memory-angular' + ).issues + ).not.toEqual([]); + } + ); + + it('audits the scoped Memory provider and production browser scope without exempting other projects', () => { + const project = 'cockpit-deep-agents-memory-angular'; + const root = resolve(cockpitRoot, 'deep-agents/memory/angular'); + const provider = readFileSync( + resolve(root, 'src/app/app.config.ts'), + 'utf8' + ); + const scope = readFileSync( + resolve(root, 'src/app/memory-scope.ts'), + 'utf8' + ); + const inspect = (source: string, path: string, owner = project) => + inspectRuntimeTargetSource(source, path, 'langgraph', owner).issues; + expect(inspect(provider, 'src/app/app.config.ts')).toEqual([]); + expect(inspect(scope, 'src/app/memory-scope.ts')).toEqual([]); + expect( + inspect( + provider, + 'src/app/app.config.ts', + 'cockpit-langgraph-memory-angular' + ) + ).not.toEqual([]); + expect(inspect(scope, 'src/app/other.ts')).not.toEqual([]); + expect( + angularSourceFiles(root).some((name) => name.endsWith('.spec.ts')) + ).toBe(false); + for (const changed of [ + provider.replace('const scope =', 'mutate(connection); const scope ='), + provider.replace('connection.apiUrl,', "'/unsafe',"), + provider.replace('maxRetries: 0', 'maxRetries: 1'), + provider.replace('...connection.clientOptions', '...unsafeOptions'), + provider.replace( + 'clientOptions,\n threadId,', + 'clientOptions, secret: window.runtimeApiKey,\n threadId,' + ), + provider.replace( + 'import { MemoryScope }', + 'import { UnsafeScope as MemoryScope }' + ), + provider.replace('inject, signal', 'inject, signal as unsafeSignal'), + provider.replace('transport,', '...unsafeOverrides, transport,'), + ]) + expect(inspect(changed, 'src/app/app.config.ts')).not.toEqual([]); + for (const changed of [ + scope.replace('window.localStorage', 'window.sessionStorage'), + scope.replace(' InjectionToken,', ' InjectionToken as UnsafeToken,'), + scope + '\nconst InjectionToken = UnsafeToken;', + scope.replace('scope.v1', 'runtime-target'), + scope.replace('event.key !== SCOPE_KEY', 'false'), + scope.replace('event.key !== SCOPE_KEY', 'false') + + ` + function unused() { + const onStorage = (event: ScopeEvent) => { + if (disposed || (event.storageArea !== null && event.storageArea !== storage) || (event.key !== null && event.key !== SCOPE_KEY)) return; + if (event.key === null || event.newValue !== scope) block(); + }; + }`, + scope.replace("disposed || snapshot.status !== 'ready'", 'disposed'), + scope.replace('storage?.getItem(SCOPE_KEY) !== scope', 'false'), + scope.replace('storage.getItem(SCOPE_KEY)', "storage.getItem('apiKey')"), + scope.replace( + 'storage?.getItem(SCOPE_KEY)', + "storage?.getItem('runtime-target')" + ), + scope.replace( + 'window.crypto.randomUUID()', + "window.localStorage.getItem('apiKey')" + ), + scope + + "\nexport const unsafe = () => window.localStorage.getItem('runtime-target');", + ]) + expect(inspect(changed, 'src/app/memory-scope.ts')).not.toEqual([]); + }); + it.each([ [ 'helper storage alias', @@ -2678,7 +2874,6 @@ describe('cockpit capability wiring', () => { [] ); }); - }); it('keeps Skills Angular and React on one canonical shared backend capability', () => { diff --git a/scripts/examples/e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts index b16d754a2..32ade6f63 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -1,4 +1,5 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import * as ts from 'typescript'; it('wires native Planning build, fixture and website coverage to the shared Python graph', () => { const read = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), 'utf8')); @@ -61,6 +62,323 @@ import { capabilities } from '@threadplane/cockpit-registry'; // eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root port registry is intentionally outside an Nx project. import { portsFor } from '../../cockpit/ports.mjs'; +function compiledMemoryBackend( + project: string | undefined, + setup: string, + fixture: string +): string | undefined { + if (project !== 'cockpit-deep-agents-memory-angular') return undefined; + const signature = (node: ts.Node): string => { + const shape = (child: ts.Node): unknown => { + const children: unknown[] = []; + ts.forEachChild(child, (nested) => { + children.push(shape(nested)); + }); + return [child.kind, children.length ? children : child.getText().trim()]; + }; + return JSON.stringify(shape(node)); + }; + const has = (nodes: readonly ts.Node[], expected: string) => { + const grammar = ts.createSourceFile( + 'grammar.ts', + expected, + ts.ScriptTarget.Latest, + true + ); + const target = signature(grammar.statements[0]); + return nodes.filter((node) => signature(node) === target).length === 1; + }; + const declaration = ( + body: ts.Block, + name: string + ): ts.VariableDeclaration | undefined => { + const matches = body.statements + .filter(ts.isVariableStatement) + .flatMap((statement) => [...statement.declarationList.declarations]) + .filter((node) => ts.isIdentifier(node.name) && node.name.text === name); + return matches.length === 1 ? matches[0] : undefined; + }; + const setupAst = ts.createSourceFile( + 'setup.ts', + setup, + ts.ScriptTarget.Latest, + true + ); + const owners = setupAst.statements.filter( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.name?.text === 'setup' + ); + if ( + owners.length !== 1 || + !owners[0].body || + !owners[0].modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword + ) + ) + return undefined; + const body = owners[0].body; + const required = [ + "const { createDeepAgentsMemoryFixture } = await import('../../../../../scripts/react-cockpit/deep-agents-memory-fixture.mjs');", + 'const fixture = createDeepAgentsMemoryFixture();', + `const backend = createServer(async (req, res) => { + const path = new URL(req.url!, 'http://localhost:5313').pathname; + try { if (!await fixture(req, res, path.startsWith('/threads') ? '/api' + path : path)) { res.writeHead(404); res.end(); } } + catch { res.writeHead(500); res.end(); } + });`, + ]; + if (!required.every((grammar) => has(body.statements, grammar))) + return undefined; + const startup = body.statements.filter(ts.isTryStatement); + if ( + startup.length !== 1 || + !has(startup[0].tryBlock.statements, 'backend.listen(5313);') || + !has( + startup[0].tryBlock.statements, + "child = spawn(process.execPath, [resolve(root, 'node_modules/nx/bin/nx.js'), 'serve', 'cockpit-deep-agents-memory-angular', '--configuration=cockpit', '--port=4313', '--host=localhost'], { cwd: root, detached: true, stdio: ['ignore', log, log], env: { ...process.env, NX_DAEMON: 'false' } });" + ) + ) + return undefined; + // The actual worker selects the canonical pinned Python project and wire script. + const parsedFixture = ts.createSourceFile( + 'fixture.mjs', + fixture, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS + ); + const factories = parsedFixture.statements.filter( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && + node.name?.text === 'createDeepAgentsMemoryFixture' && + node.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) === true + ); + if ( + factories.length !== 1 || + !factories[0].body || + factories[0].parameters.length !== 1 + ) + return undefined; + const factory = factories[0], + parameter = factory.parameters[0]; + if (!ts.isObjectBindingPattern(parameter.name)) return undefined; + const spawnBinding = parameter.name.elements.filter( + (element) => element.name.getText() === 'spawnWorker' + ); + const arrow = + spawnBinding.length === 1 ? spawnBinding[0].initializer : undefined; + if ( + !arrow || + !ts.isArrowFunction(arrow) || + arrow.parameters.length !== 0 || + !ts.isCallExpression(arrow.body) || + arrow.body.expression.getText() !== 'spawn' || + arrow.body.arguments[0]?.getText() !== "'uv'" + ) + return undefined; + const args = arrow.body.arguments[1]; + if ( + !args || + !ts.isArrayLiteralExpression(args) || + JSON.stringify( + args.elements.map((element) => + ts.isStringLiteral(element) ? element.text : null + ) + ) !== + JSON.stringify([ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/memory/python', + 'python', + 'scripts/react-cockpit/deep-agents-memory-wire.py', + ]) + ) + return undefined; + const factoryBody = factory.body; + if (!factoryBody || !has(factoryBody.statements, 'return handle;')) + return undefined; + const handle = declaration(factoryBody, 'handle')?.initializer; + const operations = factoryBody.statements.filter( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.name?.text === 'operate' + ); + const workers = parsedFixture.statements.filter( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.name?.text === 'graphWorker' + ); + if ( + !handle || + !ts.isArrowFunction(handle) || + !ts.isBlock(handle.body) || + operations.length !== 1 || + !operations[0].body || + workers.length !== 1 || + !workers[0].body || + !has(workers[0].body.statements, 'const child = spawnWorker();') + ) + return undefined; + const queued = declaration(operations[0].body, 'result')?.initializer; + if ( + !queued || + !ts.isCallExpression(queued) || + queued.expression.getText() !== 'queue.then' || + queued.arguments.length !== 1 + ) + return undefined; + const execute = queued.arguments[0]; + if ( + !ts.isArrowFunction(execute) || + !ts.isBlock(execute.body) || + !has( + execute.body.statements, + 'worker ??= graphWorker(spawnWorker, workerTimeoutMs);' + ) || + !has( + execute.body.statements, + 'const result = await worker.send(value), proof = result.proof;' + ) || + !has(operations[0].body.statements, 'return result;') + ) + return undefined; + // Runtime calls in the returned handler count; nested uncalled helpers do not. + let operationCalls = 0; + const visitHandler = (node: ts.Node) => { + if ( + node !== handle.body && + (ts.isFunctionDeclaration(node) || + ts.isArrowFunction(node) || + ts.isFunctionExpression(node)) + ) + return; + if (ts.isCallExpression(node) && node.expression.getText() === 'operate') + operationCalls++; + ts.forEachChild(node, visitHandler); + }; + visitHandler(handle.body); + return operationCalls === 5 ? 'cockpit/deep-agents/memory/python' : undefined; +} + +it.each(['setup', 'fixture'])( + 'rejects unused canonical Memory %s subtrees', + (owner) => { + let setup = readRepoFile( + 'cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts' + ); + let fixture = readRepoFile( + 'scripts/react-cockpit/deep-agents-memory-fixture.mjs' + ); + if (owner === 'setup') + setup = setup + .replace( + 'export default async function setup() {', + 'export default async function setup() { async function neverCalled() {' + ) + .trimEnd() + .replace(/}$/, '} }'); + else + fixture = + fixture.replace( + 'export function createDeepAgentsMemoryFixture(', + 'function unusedFixture(' + ) + + '\nexport function createDeepAgentsMemoryFixture() { return async () => true; }'; + expect( + compiledMemoryBackend( + 'cockpit-deep-agents-memory-angular', + setup, + fixture + ) + ).toBeUndefined(); + } +); + +it('recognizes the actual compiled Memory backend and rejects project, fixture, port and proxy drift', () => { + const setup = readRepoFile( + 'cockpit/deep-agents/memory/angular/e2e/global-setup-impl.ts' + ); + const fixture = readRepoFile( + 'scripts/react-cockpit/deep-agents-memory-fixture.mjs' + ); + const parse = ( + text = setup, + implementation = fixture, + project = 'cockpit-deep-agents-memory-angular' + ) => compiledMemoryBackend(project, text, implementation); + expect(parse()).toBe('cockpit/deep-agents/memory/python'); + expect( + parse(setup, fixture, 'cockpit-deep-agents-skills-angular') + ).toBeUndefined(); + for (const changed of [ + setup.replace('deep-agents-memory-fixture.mjs', 'generic-fixture.mjs'), + setup.replace( + 'const fixture = createDeepAgentsMemoryFixture();', + 'const fixture = createGenericFixture();' + ), + setup.replace('backend.listen(5313)', 'backend.listen(9999)'), + setup.replace( + 'const backend = createServer', + 'const unused = createServer' + ), + setup.replace( + 'export default async function setup()', + 'async function deadSetup()' + ), + setup.replace("'/api' + path", 'path'), + setup.replace("'--port=4313'", "'--port=9999'"), + ]) + expect(parse(changed)).toBeUndefined(); + expect( + parse( + setup, + fixture.replace( + "'cockpit/deep-agents/memory/python'", + "'cockpit/deep-agents/skills/python'" + ) + ) + ).toBeUndefined(); +}); + +it('wires native Memory build, compiled fixture and website coverage to the shared Python graph', () => { + const read = (path: string) => JSON.parse(readRepoFile(path)); + const react = read('cockpit/deep-agents/memory/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-memory' + ); + expect(react.targets.e2e.dependsOn).toEqual([ + { target: 'build', projects: ['cockpit-deep-agents-memory-react'] }, + { target: 'fixture-test', projects: ['cockpit-deep-agents-memory-react'] }, + ]); + expect(react.targets['fixture-test'].dependsOn).toEqual([ + { target: 'test', projects: ['cockpit-deep-agents-memory-python'] }, + ]); + expect(react.targets['fixture-test'].options.command).toBe( + 'node --test scripts/react-cockpit/deep-agents-memory-fixture.spec.mjs scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs' + ); + expect( + read('apps/website/project.json').targets.e2e.dependsOn[0].projects + ).toContain('cockpit-deep-agents-memory-react'); + expect( + read('cockpit/deep-agents/memory/angular/project.json').targets.e2e + .dependsOn + ).toContain('lifecycle-test'); + const config = readRepoFile('apps/website/playwright.config.ts'); + expect(config).toContain( + 'cockpit-deep-agents-memory-python:smoke && node scripts/react-cockpit/serve.mjs deep-agents-memory --no-parent' + ); + expect(config).toContain("url: 'http://127.0.0.1:4632'"); +}); + interface E2eWiring { angularPort: number; langgraphCwd: string; @@ -154,7 +472,12 @@ function activeCockpitE2eWiring(): E2eWiring[] { const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd') ?? parseStringProperty(globalSetup, 'pythonCwd') ?? - parseStringProperty(globalSetup, 'backendCwd'); + parseStringProperty(globalSetup, 'backendCwd') ?? + compiledMemoryBackend( + project.name, + globalSetup, + readRepoFile('scripts/react-cockpit/deep-agents-memory-fixture.mjs') + ); // Post-port-registry migration: ports are imported from // cockpit/ports.mjs rather than living as literals in diff --git a/scripts/examples/runtime-wiring-audit.ts b/scripts/examples/runtime-wiring-audit.ts index 968d60670..4c6799a0b 100644 --- a/scripts/examples/runtime-wiring-audit.ts +++ b/scripts/examples/runtime-wiring-audit.ts @@ -849,10 +849,223 @@ function hasCanonicalTopLevelBootstrapOwner( ); } +// This is an ordered AST grammar for the one capability that intentionally +// persists a non-secret Memory identity. It is not a generic storage exemption. +function sameSyntax(actual: ts.Node, expected: string): boolean { + const parsed = ts.createSourceFile( + 'grammar.ts', + expected, + ts.ScriptTarget.Latest, + true + ); + const shape = (node: ts.Node): unknown => { + const children: unknown[] = []; + ts.forEachChild(node, (child) => { + children.push(shape(child)); + }); + return [node.kind, children.length ? children : node.getText().trim()]; + }; + return ( + JSON.stringify(shape(actual)) === + JSON.stringify(shape(parsed.statements[0])) + ); +} + +const memoryFactoryGrammar = `const factory = () => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + const scope = inject(MemoryScope); + const threadId = signal(null); + const onThreadId = (id: string) => threadId.set(id); + const clientOptions = { ...connection.clientOptions, maxRetries: 0 }; + const transport = new MemoryTransport(connection.apiUrl, scope, onThreadId, + clientOptions, inject(ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, { optional: true }) ?? undefined); + return { apiUrl: connection.apiUrl, assistantId: connection.assistantId, + clientOptions, threadId, onThreadId, transport }; +};`; + +function matchesMemoryFactory(factory: ts.Node): boolean { + const parsed = ts.createSourceFile( + 'grammar.ts', + memoryFactoryGrammar, + ts.ScriptTarget.Latest, + true + ); + const expected = (parsed.statements[0] as ts.VariableStatement) + .declarationList.declarations[0].initializer!; + // Compare semantic AST children, including order, operators, literals and types. + const signature = (node: ts.Node): unknown => { + const children: unknown[] = []; + ts.forEachChild(node, (child) => { + children.push(signature(child)); + }); + return [node.kind, children.length ? children : node.getText().trim()]; + }; + return ( + JSON.stringify(signature(factory)) === JSON.stringify(signature(expected)) + ); +} + +function memoryScopeStorageAccess( + sourceFile: ts.SourceFile +): ts.Node | undefined { + const controllers = sourceFile.statements.filter( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.name?.text === 'createBrowserScope' + ); + if (controllers.length !== 1 || !controllers[0].body) return undefined; + const controllerBody = controllers[0].body; + let storageAccess: ts.Node | undefined; + let key = false, + options = false, + events = false, + blocking = false, + registration = false, + admission = false, + invalid = false, + reads = 0, + writes = 0; + const initialization = controllerBody.statements.filter(ts.isTryStatement); + registration = + initialization.length === 1 && + initialization[0].tryBlock.statements.some((statement) => + sameSyntax( + statement, + `if (options.events) { + options.events.addEventListener('storage', onStorage); + listening = true; + }` + ) + ); + walk(sourceFile, (node) => { + if ( + ts.isVariableStatement(node) && + node.declarationList.declarations.some( + (d) => ts.isIdentifier(d.name) && d.name.text === 'SCOPE_KEY' + ) && + node.parent === sourceFile + ) { + key = sameSyntax( + node, + "export const SCOPE_KEY = 'threadplane.deep-agents-memory.scope.v1';" + ); + } + if ( + ts.isVariableStatement(node) && + node.parent === controllerBody && + node.declarationList.declarations.some( + (declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === 'block' + ) + ) { + blocking = sameSyntax( + node, + `const block = () => { + if (snapshot.status === 'blocked') return; + snapshot = Object.freeze({ status: 'blocked', scope, warning: + 'Browser memory identity changed or storage is unavailable. Active work keeps its original scope. Reload before sending again.' }); + for (const listener of [...listeners]) listener(); + };` + ); + } + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'onStorage' && + node.parent.parent.parent === controllerBody + ) { + events = sameSyntax( + node.parent.parent, + `const onStorage = (event: ScopeEvent) => { + if (disposed || (event.storageArea !== null && event.storageArea !== storage) || (event.key !== null && event.key !== SCOPE_KEY)) return; + if (event.key === null || event.newValue !== scope) block(); + };` + ); + } + if (ts.isMethodDeclaration(node) && node.name.getText() === 'admit') { + const grammar = ts.createSourceFile( + 'admission.ts', + `const controller = { admit() { + if (disposed || snapshot.status !== 'ready') return undefined; + try { if (storage?.getItem(SCOPE_KEY) !== scope) block(); } catch { block(); } + return snapshot.status === 'ready' ? Object.freeze({ memory_scope: scope! }) : undefined; + } };`, + ts.ScriptTarget.Latest, + true + ); + const method = ( + (grammar.statements[0] as ts.VariableStatement).declarationList + .declarations[0].initializer as ts.ObjectLiteralExpression + ).properties[0]; + // The wrapper's forwarding method is distinct from the controller admission. + if ( + node.body && + ts.isObjectLiteralExpression(node.parent) && + ts.isCallExpression(node.parent.parent) && + node.parent.parent.expression.getText() === 'Object.freeze' && + ts.isReturnStatement(node.parent.parent.parent) && + node.parent.parent.parent.parent === controllerBody + ) + admission = sameSyntax( + node.body, + (method as ts.MethodDeclaration).body!.getText() + ); + } + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'MEMORY_SCOPE_OPTIONS' + ) { + options = + !!node.initializer && + sameSyntax( + node.parent.parent, + `export const MEMORY_SCOPE_OPTIONS = new InjectionToken('MEMORY_SCOPE_OPTIONS', { + providedIn: 'root', factory: () => ({storage: () => window.localStorage, uuid: () => window.crypto.randomUUID(), events: window}) + });` + ); + if (options) + walk(node, (child) => { + if ( + ts.isPropertyAccessExpression(child) && + child.getText() === 'window.localStorage' + ) + storageAccess = child; + }); + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ['getItem', 'setItem'].includes(node.expression.name.text) + ) { + const method = node.expression.name.text; + if ( + node.arguments[0]?.getText() !== 'SCOPE_KEY' || + node.expression.expression.getText() !== 'storage' + ) + invalid = true; + if (method === 'getItem') reads++; + else writes++; + } + }); + return key && + options && + events && + blocking && + registration && + admission && + !invalid && + reads === 3 && + writes === 1 + ? storageAccess + : undefined; +} + export function inspectRuntimeTargetSource( source: string, fileName: string, - expectedAdapter?: CompatibleRuntimeAdapter + expectedAdapter?: CompatibleRuntimeAdapter, + project?: string ): RuntimeTargetSourceInspection { const sourceFile = ts.createSourceFile( fileName, @@ -861,6 +1074,8 @@ export function inspectRuntimeTargetSource( true, ts.ScriptKind.TS ); + const memoryOwner = project === 'cockpit-deep-agents-memory-angular'; + let memoryStorageAccess: ts.Node | undefined; const issues: RuntimeWiringAuditIssue[] = []; const providerCalls: CanonicalProviderCall[] = []; const bootstrapCalls: BootstrapCallRecord[] = []; @@ -1579,7 +1794,31 @@ export function inspectRuntimeTargetSource( sourceFile, adapter ); - if (!connectionMetadata.canonicalFactory) { + const memoryFactory = + memoryOwner && + fileName === 'src/app/app.config.ts' && + adapter === 'langgraph' && + matchesMemoryFactory(factory) && + [ + ['inject', '@angular/core'], + ['signal', '@angular/core'], + ['MemoryScope', './memory-scope'], + ['MemoryTransport', './memory-transport'], + ['ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER', '@threadplane/langgraph'], + ].every(([name, module]) => { + const references: ts.Identifier[] = []; + walk(factory.body, (child) => { + if (ts.isIdentifier(child) && child.text === name) + references.push(child); + }); + return ( + references.length > 0 && + references.every((identifier) => + hasExactImportBinding(exactBinding(identifier), module, name) + ) + ); + }); + if (!connectionMetadata.canonicalFactory && !memoryFactory) { report( 'noncanonical-provider-wiring', 'provider factory must contain only the ordered connection declaration, adapter guard, and sole return' @@ -1595,6 +1834,10 @@ export function inspectRuntimeTargetSource( } const properties: Record = {}; for (const property of returned.properties) { + if (memoryFactory && ts.isShorthandPropertyAssignment(property)) { + properties[property.name.text] = property.name.text; + continue; + } if ( !ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name) @@ -1612,7 +1855,12 @@ export function inspectRuntimeTargetSource( ? ['url'] : ['apiUrl', 'assistantId', 'clientOptions']; for (const property of required) { - if (properties[property] !== `connection.${property}`) { + if ( + properties[property] !== + (memoryFactory && property === 'clientOptions' + ? 'clientOptions' + : `connection.${property}`) + ) { report( 'noncanonical-provider-wiring', `${property} must be an explicit ${property}: connection.${property} assignment` @@ -1899,6 +2147,27 @@ export function inspectRuntimeTargetSource( }); } + if (memoryOwner && fileName === 'src/app/memory-scope.ts') { + const tokenReferences: ts.Identifier[] = []; + walk(sourceFile, (child) => { + if ( + ts.isNewExpression(child) && + ts.isIdentifier(child.expression) && + child.expression.text === 'InjectionToken' + ) + tokenReferences.push(child.expression); + }); + if ( + tokenReferences.length === 1 && + hasExactImportBinding( + exactBinding(tokenReferences[0]), + '@angular/core', + 'InjectionToken' + ) + ) + memoryStorageAccess = memoryScopeStorageAccess(sourceFile); + } + const isGlobalObjectIdentifier = ( expression: ts.Expression ): expression is ts.Identifier => @@ -1909,6 +2178,7 @@ export function inspectRuntimeTargetSource( !isLexicallyDeclared(expression); walk(sourceFile, (node) => { + if (node === memoryStorageAccess) return; const reportUnconditionalMember = (property: string): void => { if (unconditionalBrowserMemberNames.has(property)) { report('browser-state-read', `sensitive member ${property}`); diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index a5a8123ef..b14831ecd 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,11 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'deep-agents-memory') + return Object.freeze({ + topic: 'memory', library: 'deep-agents', adapter: 'langgraph', + appPath: 'cockpit/deep-agents/memory/react', base: '/deep-agents/memory/react/', + port: 4632, project: 'cockpit-deep-agents-memory-react', + }); if (topic === 'deep-agents-skills') return Object.freeze({ topic: 'skills', library: 'deep-agents', adapter: 'langgraph', diff --git a/scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs b/scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs new file mode 100644 index 000000000..72acb9b0e --- /dev/null +++ b/scripts/react-cockpit/deep-agents-memory-configuration.spec.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@langchain/langgraph-sdk'; +async function vacant(port) { + const listener = createServer(); + listener.listen(port, '127.0.0.1'); + await once(listener, 'listening'); + await new Promise((r) => listener.close(r)); +} +async function running(t, selection) { + const { port } = reactCockpitConfiguration(selection); + await vacant(port); + const child = spawn( + process.execPath, + ['scripts/react-cockpit/serve.mjs', selection, '--no-parent'], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + let error = ''; + child.stderr.on('data', (b) => (error += b)); + const exited = once(child, 'exit'); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGTERM'); + await exited; + }); + const base = `http://127.0.0.1:${port}`; + for (let attempt = 0; attempt < 100; attempt++) { + if (child.exitCode !== null) throw Error(error); + try { + await fetch(base + '/__lifetime'); + return { child, exited, base, port }; + } catch { + await new Promise((r) => setTimeout(r, 25)); + } + } + throw Error('Serve listener did not start: ' + error); +} +test( + 'serve dispatch keeps original LangGraph Memory separate and awaits actual Memory worker shutdown', + { timeout: 30000 }, + async (t) => { + const deep = await running(t, 'deep-agents-memory'); + const original = await running(t, 'memory'); + const sdk = new Client({ + apiUrl: deep.base + '/api', + callerOptions: { maxRetries: 0 }, + }); + const thread = (await sdk.threads.create()).thread_id; + const events = []; + for await (const e of sdk.runs.stream(thread, 'da-memory', { + input: { + messages: [{ type: 'human', id: randomUUID(), content: 'offline' }], + }, + context: { memory_scope: randomUUID() }, + streamMode: ['values', 'custom'], + })) + events.push(e); + assert( + events.some( + (e) => e.event === 'custom' && e.data.name === 'deep_agents.memory' + ) + ); + const originalSdk = new Client({ + apiUrl: original.base + '/api', + callerOptions: { maxRetries: 0 }, + }); + const originalThread = randomUUID(); + await originalSdk.threads.create({ threadId: originalThread }); + const originalEvents = []; + for await (const e of originalSdk.runs.stream(originalThread, 'memory', { + input: { + messages: [{ type: 'human', id: randomUUID(), content: 'Recall' }], + }, + streamMode: ['values'], + })) + originalEvents.push(e); + assert( + originalEvents.some( + (e) => e.event === 'values' && Object.hasOwn(e.data, 'memory') + ) + ); + assert.equal( + (await fetch(deep.base + '/__hold-extraction', { method: 'POST' })) + .status, + 404 + ); + const deepLife = await (await fetch(deep.base + '/__lifetime')).json(); + assert.equal(deepLife.workers, 1); + assert.deepEqual( + await (await fetch(original.base + '/__lifetime')).json(), + { active: false } + ); + deep.child.kill('SIGTERM'); + await deep.exited; + assert.throws(() => process.kill(deepLife.workerPid, 0), { code: 'ESRCH' }); + assert.throws(() => process.kill(deepLife.pythonPid, 0), { code: 'ESRCH' }); + await vacant(deep.port); + original.child.kill('SIGTERM'); + await original.exited; + await vacant(original.port); + } +); +import { test } from 'node:test'; +import { reactCockpitConfiguration } from './configuration.mjs'; +test('Memory browser target runs the actual compiled fixture after installed builds', () => { + const project = JSON.parse(readFileSync(new URL('../../cockpit/deep-agents/memory/react/project.json', import.meta.url))); + assert.ok(project.tags.includes('scope:cockpit-e2e')); + assert.equal(project.targets.e2e.executor, '@nx/playwright:playwright'); + assert.equal(project.targets.e2e.options.config, 'cockpit/deep-agents/memory/react/e2e/playwright.config.ts'); + assert.ok(existsSync(new URL('../../' + project.targets.e2e.options.config, import.meta.url))); + assert.ok(project.targets.e2e.dependsOn.some(d => d.target === 'build')); + assert.ok(project.targets.e2e.dependsOn.some(d => d.target === 'fixture-test')); +}); +test('Deep Agents Memory is an exact frozen closed native selection', () => { + const config = reactCockpitConfiguration('deep-agents-memory'); + assert.deepEqual(config, { + topic: 'memory', + library: 'deep-agents', + adapter: 'langgraph', + appPath: 'cockpit/deep-agents/memory/react', + base: '/deep-agents/memory/react/', + port: 4632, + project: 'cockpit-deep-agents-memory-react', + }); + assert.ok(Object.isFrozen(config)); + for (const invalid of [ + 'deep-agents/../../foreign', + 'deep-agents-memory/../../foreign', + '/deep-agents/memory/react/', + ]) + assert.throws(() => reactCockpitConfiguration(invalid)); +}); +test('original LangGraph Memory mapping is unchanged', () => { + assert.deepEqual(reactCockpitConfiguration('memory'), { + topic: 'memory', + appPath: 'cockpit/langgraph/memory/react', + base: '/langgraph/memory/react/', + port: 4602, + project: 'cockpit-langgraph-memory-react', + }); +}); diff --git a/scripts/react-cockpit/deep-agents-memory-fixture.mjs b/scripts/react-cockpit/deep-agents-memory-fixture.mjs new file mode 100644 index 000000000..8f61c5729 --- /dev/null +++ b/scripts/react-cockpit/deep-agents-memory-fixture.mjs @@ -0,0 +1,649 @@ +/** Loopback-only SDK transport backed by the actual compiled Memory graph. */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +function graphWorker(spawnWorker, timeout) { + const child = spawnWorker(); + const pending = new Map(); + let sequence = 0, + dead = false, + closing, + killTimer; + 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('Memory graph worker unavailable.')); + } + pending.clear(); + } + const terminate = () => { + fail(); + child.kill('SIGTERM'); + killTimer ??= setTimeout(() => child.kill('SIGKILL'), 1500); + }; + child.once('error', fail); + child.once('close', () => { + clearTimeout(killTimer); + 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('Memory graph operation failed.')); + else item.resolve(result); + start = newline + 1; + } + } catch { + terminate(); + } + }); + return { + pid: child.pid, + send(value) { + if (dead) return Promise.reject(Error('Memory 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 () => { + child.stdin.end(); + terminate(); + await ended; + clearTimeout(killTimer); + child.stdout.removeAllListeners('data'); + })()); + }, + }; +} + +export function createDeepAgentsMemoryFixture({ + workerTimeoutMs = 30000, + spawnWorker = () => + spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/memory/python', + 'python', + 'scripts/react-cockpit/deep-agents-memory-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + UV_CACHE_DIR: + process.env.UV_CACHE_DIR ?? '/tmp/threadplane-memory-uv-cache', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ), +} = {}) { + const sourceRoot = new URL( + '../../cockpit/deep-agents/memory/python/', + import.meta.url + ); + const sourcePaths = [ + 'src/graph.py', + 'src/memory_observation.py', + 'tests/test_streaming.py', + 'tests/model_support.py', + 'prompts/memory.md', + 'uv.lock', + ]; + const sourceSha256 = Object.fromEntries( + sourcePaths.map((path) => [ + path, + createHash('sha256') + .update(readFileSync(new URL(path, sourceRoot))) + .digest('hex'), + ]) + ); + const workerSha256 = createHash('sha256') + .update( + readFileSync(new URL('./deep-agents-memory-wire.py', import.meta.url)) + ) + .digest('hex'); + let requestCount = 0, + pythonPid; + 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: 'teach', + 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}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + 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.workerSha256 !== workerSha256 || + !Number.isInteger(proof.pythonPid) || + proof.pythonPid <= 0 || + (pythonPid !== undefined && proof.pythonPid !== pythonPid) || + (value.op === 'submit' && + proof.memoryScope !== value.context.memory_scope) || + 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'); + pythonPid = proof.pythonPid; + 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 host = request.headers.host; + if ( + !host || + !/^(127\.0\.0\.1|localhost|\[::1\]):[0-9]+$/.test(host) || + host.slice(host.lastIndexOf(':') + 1) !== + String(request.socket.localPort) || + request.url.length > 2048 + ) { + json(response, {}, 400); + return true; + } + if (++requestCount > 2000) { + json(response, {}, 429); + 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, + pythonPid, + 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 && + ![ + 'teach', + 'edit', + 'recall', + 'read', + 'empty', + 'boundary', + 'oversize', + 'fail-initial', + 'fail-model', + 'fail-terminal', + ].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 || 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 ( + (body.thread_id !== undefined && + (!uuid.test(body.thread_id) || threads.has(body.thread_id))) || + threads.size >= 100 || + !only(body, ['thread_id', 'if_exists', 'metadata']) || + (body.if_exists !== undefined && body.if_exists !== 'raise') || + (body.metadata !== undefined && !only(body.metadata, [])) + ) { + json(response, {}, 400); + return true; + } + const threadId = body.thread_id ?? randomUUID(); + await operate({ op: 'create', threadId }); + threads.add(threadId); + json(response, { thread_id: threadId }); + 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' || + body.checkpoint.checkpoint_ns !== '' + ) { + 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, + limit: body.limit ?? 10, + }); + 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]; + const nativeHuman = only(message, ['type', 'id', 'content']) && + Object.keys(message).length === 3 && typeof message.id === 'string' && + message.id.trim() && message.id.length <= 256; + const angularHuman = only(message, ['type', 'role', 'content']) && + Object.keys(message).length === 3 && message.role === 'human'; + if ( + !only(body, [ + 'assistant_id', + 'context', + 'config', + 'metadata', + 'input', + 'command', + 'stream_mode', + 'stream_subgraphs', + 'stream_resumable', + 'on_disconnect', + ]) || + !( + body.command === undefined && + only(body.input, ['messages']) && + Array.isArray(body.input.messages) && + body.input.messages?.length === 1 && + (nativeHuman || angularHuman) && + message.type === 'human' && + typeof message.content === 'string' && + Buffer.byteLength(message.content) <= 65536 + ) || + body.assistant_id !== 'da-memory' || + !only(body.context, ['memory_scope']) || + !uuid.test(body.context.memory_scope ?? '') || + (body.config !== undefined && !only(body.config, [])) || + (body.metadata !== undefined && !only(body.metadata, [])) || + !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); + // Canonical Angular's SDK omits id; LangGraph add_messages assigns + // UUIDv4 for missing IDs. Normalize once at this private boundary. + // Captured requests above preserve the original SDK wire unchanged. + const actualMessage = angularHuman + ? { type: 'human', id: randomUUID(), content: message.content } + : message; + const wire = await operate({ + op: 'submit', + threadId, + messages: [actualMessage], + context: body.context, + }); + rawEvents.push({ + threadId, + runId: run.run_id, + events: wire.events, + modelPrompts: wire.modelPrompts, + storeOperations: wire.storeOperations, + ...(wire.failure ? { failure: wire.failure } : {}), + }); + if (response.destroyed) { + run.status = 'interrupted'; + return true; + } + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + '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)); + if (wire.failure) + response.write( + event('error', { message: 'Memory graph operation failed.' }) + ); + run.status = wire.failure ? 'error' : 'success'; + response.end(); + }; + if (options.holdStream) hold(response, finish, run); + else finish(); + return true; + } + json(response, {}, 404); + return true; + } catch { + json(response, { error: 'Memory graph fixture failed.' }, 500); + return true; + } + }; + handle.close = () => + (closing ??= (async () => { + closed = true; + for (const item of held) item.response.destroy(); + held.clear(); + await worker?.close(); + await queue; + })()); + return handle; +} diff --git a/scripts/react-cockpit/deep-agents-memory-fixture.spec.mjs b/scripts/react-cockpit/deep-agents-memory-fixture.spec.mjs new file mode 100644 index 000000000..8c6d8ad9e --- /dev/null +++ b/scripts/react-cockpit/deep-agents-memory-fixture.spec.mjs @@ -0,0 +1,684 @@ +import assert from 'node:assert/strict'; +import { createServer, request as httpRequest } from 'node:http'; +import { once } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@langchain/langgraph-sdk'; +const { createDeepAgentsMemoryFixture } = await import( + './deep-agents-memory-fixture.mjs' +).catch(() => ({})); +const captures = mkdtempSync(join(tmpdir(), 'threadplane-memory-task4a-sdk-')); +console.log('Memory SDK captures: ' + captures); +const FILE = '/memories/AGENTS.md'; +const LITERAL = 'Fleet note\n\n Ω literal whitespace \n'; +const UPDATED = 'Updated fleet note\n\n Ω literal whitespace \n'; +async function fixture(t, options = {}) { + assert.equal( + typeof createDeepAgentsMemoryFixture, + 'function', + 'Missing actual compiled Memory fixture' + ); + const handle = createDeepAgentsMemoryFixture(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', + apiKey: null, + callerOptions: { maxRetries: 0 }, + }); + const control = async (body) => { + const r = await fetch(base + '/__configure', { + method: 'POST', + body: JSON.stringify(body), + }); + assert.equal(r.status, 200); + }; + const diagnostics = async (name) => (await fetch(base + '/__' + name)).json(); + const scope = randomUUID(); + const thread = ( + await client.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ).thread_id; + const stream = ( + target = thread, + memory_scope = scope, + id = randomUUID(), + streamMode = [ + 'values', + 'messages-tuple', + 'updates', + 'custom', + 'checkpoints', + ] + ) => + client.runs.stream(target, 'da-memory', { + input: { messages: [{ type: 'human', id, content: 'Offline dispatch' }] }, + context: { memory_scope }, + streamMode, + streamSubgraphs: true, + }); + const submit = async (...args) => { + const events = []; + for await (const e of stream(...args)) events.push(e); + return events; + }; + return { + handle, + server, + base, + client, + control, + diagnostics, + scope, + thread, + stream, + submit, + }; +} +const observations = (events) => + events.filter((e) => e.event === 'custom').map((e) => e.data.data); +test('a length-shaped object cannot become an authored compiled human array', async t => { + const f = await fixture(t); + const response = await fetch(f.base + '/api/threads/' + f.thread + '/runs/stream', { + method: 'POST', body: JSON.stringify({ assistant_id: 'da-memory', + input: { messages: { 0: { type: 'human', id: randomUUID(), content: 'Malformed nonarray input' }, length: 1 } }, + context: { memory_scope: f.scope }, stream_mode: ['values', 'custom'] }), + }); + await response.text(); + const raw = await f.diagnostics('raw-events'); + writeFileSync(join(captures, 'nonarray-rejection.json'), JSON.stringify({ status: response.status, requests: await f.diagnostics('requests'), raw, proofs: await f.diagnostics('graph-proof') }, null, 2)); + assert.equal(response.status, 400); + assert.deepEqual(raw, []); + assert.equal((await f.diagnostics('graph-proof')).filter(proof => proof.op === 'submit').length, 0); +}); +test('actual canonical Angular SDK human wire gets one fixture-assigned message identity and genuine write', async t => { + const f = await fixture(t); + const human = { type: 'human', role: 'human', content: 'Angular genuine input\n\n Ω ' }; + const events = []; + for await (const event of f.client.runs.stream(f.thread, 'da-memory', { input: { messages: [human] }, context: { memory_scope: f.scope }, streamMode: ['values', 'custom'] })) events.push(event); + assert.deepEqual(observations(events).at(-1).memory_contents, { [FILE]: LITERAL }); + const state = await f.client.threads.getState(f.thread); + const authored = state.values.messages.filter(m => m.type === 'human'); + assert.equal(authored.length, 1); assert.equal(authored[0].content, human.content); + assert.match(authored[0].id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + const captured = (await f.diagnostics('requests')).find(r => r.path.endsWith('/runs/stream')); + assert.deepEqual(captured.body.input.messages, [human]); assert.equal(Object.hasOwn(captured.body.input.messages[0], 'id'), false); + for (const bad of [{ ...human, role: 'assistant' }, { ...human, id: '' }, { ...human, extra: true }, { type: 'human', content: 'missing role' }]) { + const response = await fetch(f.base + '/api/threads/' + f.thread + '/runs/stream', { method: 'POST', body: JSON.stringify({ assistant_id: 'da-memory', input: { messages: [bad] }, context: { memory_scope: f.scope }, stream_mode: ['values'] }) }); + assert.equal(response.status, 400); + } + const raw = await f.diagnostics('raw-events'); assert.equal(raw.length, 1); + assert(raw[0].storeOperations.some(op => op.type === 'PutOp')); + writeFileSync(join(captures, 'angular-sdk-wire.json'), JSON.stringify({ requests: await f.diagnostics('requests'), raw, state, proofs: await f.diagnostics('graph-proof') }, null, 2)); +}); +test( + 'real SDK two threads share actual scoped writes edits and pinned prompt recall; raw exact newest agrees', + { timeout: 30000 }, + async (t) => { + const f = await fixture(t); + const first = await f.submit(); + assert.deepEqual( + observations(first).map((e) => e.phase), + ['initial', 'model', 'model', 'terminal'] + ); + assert.deepEqual(observations(first)[0].memory_contents, {}); + assert.deepEqual(observations(first).at(-1).memory_contents, { + [FILE]: LITERAL, + }); + assert( + first + .filter((e) => e.event === 'values') + .every( + (e) => + !Object.hasOwn(e.data, 'memory_contents') && + !Object.hasOwn(e.data, 'memory_scope_owner') + ) + ); + let saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.memory_contents, { [FILE]: LITERAL }); + assert.equal(saved.values.memory_scope_owner, f.scope); + assert.deepEqual( + first.filter((e) => e.event === 'checkpoints').at(-1).data.values + .memory_contents, + saved.values.memory_contents + ); + assert.deepEqual( + await f.client.threads.getState(f.thread, saved.checkpoint), + saved + ); + assert.deepEqual( + (await f.client.threads.getHistory(f.thread, { limit: 1 }))[0], + saved + ); + assert.equal( + saved.values.messages.filter((m) => m.type === 'tool')[0].status, + 'success' + ); + await f.control({ scenario: 'edit' }); + await f.submit(); + saved = await f.client.threads.getState(f.thread); + assert.deepEqual(saved.values.memory_contents, { [FILE]: UPDATED }); + const second = ( + await f.client.threads.create({ ifExists: 'raise', metadata: {} }) + ).thread_id; + await f.control({ scenario: 'recall' }); + const recall = await f.submit(second); + assert.deepEqual(observations(recall)[0].memory_contents, { + [FILE]: UPDATED, + }); + const third = (await f.client.threads.create()).thread_id; + const otherScope = randomUUID(); + const isolated = await f.submit(third, otherScope); + assert( + observations(isolated).every( + (e) => + Object.keys(e.memory_contents).length === 0 && + e.memory_scope_owner === otherScope + ) + ); + const raw = await f.diagnostics('raw-events'); + assert(raw[2].modelPrompts.every((p) => p.includes('Updated fleet note'))); + assert(raw[3].modelPrompts.every((p) => !p.includes('Updated fleet note'))); + assert(raw[0].storeOperations.some((o) => o.type === 'PutOp')); + const proof = await f.diagnostics('graph-proof'); + assert( + proof.every( + (p) => + p.actualCompiledGraph && + p.networkConnectAttempts === 0 && + p.workerSha256?.length === 64 && + p.sourceSha256['tests/model_support.py'] + ) + ); + writeFileSync( + join(captures, 'scoped-sdk-proof.json'), + JSON.stringify({ first, saved, recall, isolated, raw, proof }, null, 2), + { mode: 0o400 } + ); + } +); +for (const [scenario, content] of [ + ['recall', null], + ['empty', ''], + ['boundary', 'Ω'.repeat(32768)], +]) { + test( + `actual ${scenario} distinguishes missing empty and UTF8 bound`, + { timeout: 30000 }, + async (t) => { + const f = await fixture(t); + await f.control({ scenario }); + const events = await f.submit(); + const expected = content === null ? {} : { [FILE]: content }; + assert.deepEqual(observations(events).at(-1).memory_contents, expected); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values.memory_contents, + expected + ); + } + ); +} +for (const [scenario, phases] of [ + ['fail-initial', []], + ['fail-model', ['initial']], + ['fail-terminal', ['initial', 'model', 'model']], + ['oversize', ['initial', 'model']], +]) { + test( + `actual read failure ${scenario} forwards partial events without terminal confirmation`, + { timeout: 30000 }, + async (t) => { + const f = await fixture(t); + await f.control({ scenario }); + const events = await f.submit(); + assert.deepEqual( + observations(events).map((e) => e.phase), + phases + ); + assert.equal(events.at(-1).event, 'error'); + assert.equal( + events.at(-1).data.message, + 'Memory graph operation failed.' + ); + const raw = await f.diagnostics('raw-events'); + assert(raw[0].failure); + if (scenario === 'fail-terminal') { + const second = (await f.client.threads.create()).thread_id; + await f.control({ scenario: 'recall' }); + assert.deepEqual( + observations(await f.submit(second)).at(-1).memory_contents, + { [FILE]: LITERAL } + ); + } + } + ); +} +test('ownership replay malformed context foreign checkpoints controls and unsupported SDK bodies fail closed', async (t) => { + const f = await fixture(t); + await f.submit(f.thread, f.scope, 'owned-human'); + await assert.rejects(() => f.submit(f.thread, f.scope, 'owned-human')); + await f.control({ scenario: 'recall' }); + const mismatched = await f.submit(f.thread, randomUUID()); + assert.equal(mismatched.at(-1).event, 'error'); + const raw = await f.diagnostics('raw-events'); + assert.deepEqual(raw.at(-1).storeOperations, []); + assert.deepEqual(raw.at(-1).modelPrompts, []); + await assert.rejects(() => f.client.threads.getState(randomUUID())); + const state = await f.client.threads.getState(f.thread); + for (const cp of [ + { ...state.checkpoint, checkpoint_id: randomUUID() }, + { ...state.checkpoint, thread_id: randomUUID() }, + { ...state.checkpoint, checkpoint_ns: 'foreign' }, + ]) + await assert.rejects(() => f.client.threads.getState(f.thread, cp)); + for (const body of [ + { scenario: 'unknown' }, + { filePath: '/etc/passwd' }, + { holdStream: 'true' }, + ]) + assert.equal( + ( + await fetch(f.base + '/__configure', { + method: 'POST', + body: JSON.stringify(body), + }) + ).status, + 400 + ); + const body = { + assistant_id: 'da-memory', + input: { + messages: [{ type: 'human', id: randomUUID(), content: 'offline' }], + }, + context: { memory_scope: f.scope }, + stream_mode: ['values'], + }; + for (const change of [ + { assistant_id: 'foreign' }, + { context: {} }, + { context: { memory_scope: f.scope, extra: true } }, + { command: { resume: true } }, + { input: { messages: body.input.messages, memory_contents: {} } }, + { stream_mode: ['unknown'] }, + ]) + assert.equal( + ( + await fetch(f.base + `/api/threads/${f.thread}/runs/stream`, { + method: 'POST', + body: JSON.stringify({ ...body, ...change }), + }) + ).status, + 400 + ); + assert.equal( + ( + await fetch(f.base + '/__configure', { + method: 'POST', + body: 'x'.repeat(1024 * 1024 + 1), + }) + ).status, + 413 + ); +}); +test('held stream cancellation and awaited worker shutdown preserve actual persistent write', async (t) => { + const f = await fixture(t); + await f.control({ holdStream: true }); + const stream = f.stream(); + const meta = await stream.next(); + await f.client.runs.cancel( + f.thread, + meta.value.data.run_id, + true, + 'interrupt' + ); + assert.equal((await stream.next()).done, true); + assert.deepEqual( + (await f.client.threads.getState(f.thread)).values.memory_contents, + { [FILE]: LITERAL } + ); + const life = await f.diagnostics('lifetime'); + await Promise.all([f.handle.close(), f.handle.close()]); + assert.throws(() => process.kill(life.workerPid, 0), { code: 'ESRCH' }); + assert.throws(() => process.kill(life.pythonPid, 0), { code: 'ESRCH' }); +}); +for (const [name, script, timeout] of [ + [ + 'oversized frame', + 'process.stdin.once("data",()=>process.stdout.write(Buffer.alloc(16*1024*1024+1,0x78)));setInterval(()=>{},1000)', + 6000, + ], + [ + 'malformed frame', + 'process.stdin.once("data",()=>process.stdout.write("invalid\\n"));setInterval(()=>{},1000)', + 6000, + ], + [ + 'unowned frame', + 'process.stdin.once("data",()=>process.stdout.write("{\\"requestId\\":9999}\\n"));setInterval(()=>{},1000)', + 6000, + ], + ['timeout', 'setInterval(()=>{},1000)', 100], +]) + test( + `worker ${name} fails closed and terminates owned process`, + { timeout: 12000 }, + async (t) => { + assert.equal(typeof createDeepAgentsMemoryFixture, 'function'); + let child; + const handle = createDeepAgentsMemoryFixture({ + workerTimeoutMs: timeout, + spawnWorker: () => + (child = spawn(process.execPath, ['-e', script], { + stdio: ['pipe', 'pipe', 'pipe'], + })), + }); + const server = createServer((req, res) => + handle(req, res, new URL(req.url, 'http://localhost').pathname) + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await handle.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(r)); + }); + const sdk = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + const started = performance.now(); + await assert.rejects(() => + sdk.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await handle.close(); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + if (timeout === 6000) assert(performance.now() - started < 3000); + } + ); +import { test } from 'node:test'; +test('actual read_file and mode filtering preserve captured payloads and tool arguments', async (t) => { + const f = await fixture(t); + await f.submit(); + await f.control({ scenario: 'read' }); + const events = await f.submit(f.thread, f.scope, randomUUID(), ['custom']); + assert(events.every((e) => ['metadata', 'custom'].includes(e.event))); + const raw = await f.diagnostics('raw-events'); + assert.deepEqual( + events.filter((e) => e.event === 'custom').map((e) => e.data), + raw + .at(-1) + .events.filter(([ns, kind]) => kind === 'custom' && !ns.length) + .map((e) => e[2]) + ); + const state = await f.client.threads.getState(f.thread); + const calls = state.values.messages.flatMap((m) => m.tool_calls ?? []); + assert.deepEqual( + calls.map((c) => [c.name, c.args]), + [ + ['write_file', { file_path: FILE, content: LITERAL }], + ['read_file', { file_path: FILE }], + ] + ); + const tools = state.values.messages.filter((m) => m.type === 'tool'); + assert(tools.every((m) => m.status === 'success')); + assert(tools.at(-1).content.includes('Fleet note')); +}); +test('close rejects pending graph requests and releases held current and exact reads', async (t) => { + const f = await fixture(t); + await f.submit(); + const state = await f.client.threads.getState(f.thread); + await f.control({ holdState: true, holdCheckpoint: true }); + const current = f.client.threads.getState(f.thread); + const exact = f.client.threads.getState(f.thread, state.checkpoint); + // Observe both actual reads reaching the held delivery boundary. + for (let i = 0; i < 100; i++) { + if ((await f.diagnostics('lifetime')).held === 2) break; + await new Promise((r) => setTimeout(r, 10)); + } + assert.equal((await f.diagnostics('lifetime')).held, 2); + const failures = Promise.allSettled([current, exact]); + await f.handle.close(); + assert((await failures).every((r) => r.status === 'rejected')); +}); +test( + 'close awaits worker exit while an actual request is pending', + { timeout: 10000 }, + async (t) => { + let child; + const handle = createDeepAgentsMemoryFixture({ + workerTimeoutMs: 6000, + spawnWorker: () => + (child = spawn( + process.execPath, + [ + '-e', + 'process.stdin.once("data",()=>process.stderr.write("ready"));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((r) => server.close(r)); + }); + const client = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + const pending = client.threads.create({ + threadId: randomUUID(), + ifExists: 'raise', + }); + const rejected = assert.rejects(() => pending); + for (let i = 0; i < 100 && !child; i++) + await new Promise((r) => setTimeout(r, 10)); + assert(child); + await handle.close(); + await rejected; + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + } +); +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +async function privateWorker(t) { + const child = spawn( + 'uv', + [ + 'run', + '--frozen', + '--python', + '3.12', + '--project', + 'cockpit/deep-agents/memory/python', + 'python', + 'scripts/react-cockpit/deep-agents-memory-wire.py', + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + child.stderr.resume(); + const exit = once(child, 'exit'); + const lines = createInterface({ input: child.stdout }); + const iter = lines[Symbol.asyncIterator](); + t.after(async () => { + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGTERM'); + await exit; + lines.close(); + }); + const send = async (body) => { + child.stdin.write(JSON.stringify(body) + '\n'); + const value = await iter.next(); + assert.equal(value.done, false); + return JSON.parse(value.value); + }; + return { child, exit, send }; +} +test( + 'private compiled worker rejects replayed request IDs unknown fields foreign roots and invalid bounded history', + { timeout: 15000 }, + async (t) => { + const w = await privateWorker(t), + threadId = randomUUID(); + assert.equal( + (await w.send({ requestId: 1, op: 'create', threadId })).proof + .actualCompiledGraph, + true + ); + for (const [requestId, body] of [ + [2, { op: 'history', threadId, limit: 0 }], + [ + 3, + { + op: 'state', + threadId, + checkpoint: { + thread_id: threadId, + checkpoint_id: randomUUID(), + checkpoint_ns: '', + }, + }, + ], + [4, { op: 'configure', scenario: 'recall', unexpected: true }], + [ + 5, + { + op: 'submit', + threadId, + messages: [{ type: 'human', id: 'x', content: 'offline' }], + context: { memory_scope: randomUUID(), extra: true }, + }, + ], + ]) + assert.equal( + (await w.send({ requestId, ...body })).error, + 'Graph operation failed' + ); + w.child.stdin.write( + JSON.stringify({ requestId: 5, op: 'state', threadId }) + '\n' + ); + await w.exit; + assert.throws(() => process.kill(w.child.pid, 0), { code: 'ESRCH' }); + } +); +test( + 'private compiled worker rejects an oversized unterminated request before JSON decode', + { timeout: 15000 }, + async (t) => { + const w = await privateWorker(t); + w.child.stdin.write(Buffer.alloc(1024 * 1024 + 1, 0x78)); + await w.exit; + assert.throws(() => process.kill(w.child.pid, 0), { code: 'ESRCH' }); + } +); +test('fixture rejects spoofed host, methods, paths and controls without graph submission', async (t) => { + const f = await fixture(t); + const initial = (await f.diagnostics('graph-proof')).length; + assert.equal( + await new Promise((resolve, reject) => { + const req = httpRequest( + f.base + '/__requests', + { + headers: { host: 'foreign.example' }, + }, + (res) => { + res.resume(); + resolve(res.statusCode); + } + ); + req.on('error', reject); + req.end(); + }), + 400 + ); + assert.equal( + (await fetch(f.base + '/__configure', { method: 'GET' })).status, + 405 + ); + assert.equal( + (await fetch(f.base + '/__unknown', { method: 'POST', body: '{}' })).status, + 404 + ); + assert.equal( + (await fetch(f.base + '/api/threads', { method: 'PUT', body: '{}' })) + .status, + 404 + ); + assert.equal( + ( + await fetch(f.base + '/__release', { + method: 'POST', + body: '{"extra":true}', + }) + ).status, + 400 + ); + assert.equal((await f.diagnostics('graph-proof')).length, initial); +}); +test( + 'timed out worker that ignores SIGTERM is forcibly terminated without fixture close', + { timeout: 10000 }, + async (t) => { + let child; + const handle = createDeepAgentsMemoryFixture({ + workerTimeoutMs: 100, + spawnWorker: () => + (child = spawn( + process.execPath, + ['-e', 'process.on("SIGTERM",()=>{});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((r) => server.close(r)); + }); + const sdk = new Client({ + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + callerOptions: { maxRetries: 0 }, + }); + await assert.rejects(() => + sdk.threads.create({ threadId: randomUUID(), ifExists: 'raise' }) + ); + await new Promise((r) => setTimeout(r, 1800)); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + } +); +test('Memory fixture exposes a real compiled worker SDK handler', () => { + assert.equal( + typeof createDeepAgentsMemoryFixture, + 'function', + 'Missing actual compiled Memory fixture' + ); +}); diff --git a/scripts/react-cockpit/deep-agents-memory-wire.py b/scripts/react-cockpit/deep-agents-memory-wire.py new file mode 100644 index 000000000..602393fba --- /dev/null +++ b/scripts/react-cockpit/deep-agents-memory-wire.py @@ -0,0 +1,180 @@ +"""Private bounded worker: actual Memory builder, one owned store for its lifetime.""" +import os +os.environ['LANGSMITH_TRACING'] = 'false' +os.environ['LANGCHAIN_TRACING_V2'] = 'false' +import asyncio +from contextlib import redirect_stdout, nullcontext +from copy import deepcopy +from dataclasses import asdict, is_dataclass +import hashlib +import json +from pathlib import Path +import socket +import sys +from unittest.mock import patch +from uuid import UUID + +NETWORK_GUARD = patch.object(socket.socket, 'connect', side_effect=AssertionError('Remote socket forbidden')) +NETWORK = NETWORK_GUARD.start() +ROOT = Path(__file__).resolve().parents[2] +PROJECT = ROOT / 'cockpit/deep-agents/memory/python' +sys.path[:0] = [str(PROJECT), str(PROJECT / 'tests')] +from test_streaming import MemoryModel, SCENARIOS +from model_support import CountingStore, load +from deepagents.backends import StoreBackend +from src.memory_observation import validate_scope + +SOURCES = ('src/graph.py', 'src/memory_observation.py', 'tests/test_streaming.py', 'tests/model_support.py', 'prompts/memory.md', 'uv.lock') +MAX_REQUEST = 1024 * 1024 +MAX_RESPONSE = 16 * 1024 * 1024 +SCENARIO_NAMES = set(SCENARIOS) | {'fail-initial', 'fail-model', 'fail-terminal'} + +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): + fields = ('thread_id', 'checkpoint_id', 'checkpoint_ns', 'checkpoint_map') + return { + 'values': state.values, 'next': list(state.next), + 'checkpoint': {k:v for k,v in state.config['configurable'].items() if k in fields}, + 'parent_checkpoint': {k:v for k,v in state.parent_config['configurable'].items() if k in fields} 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':i.id,'value':i.value} for i in t.interrupts]} for t in state.tasks], + } + +def valid_uuid(value): + try: + return isinstance(value,str) and len(value)==36 and UUID(value).version==4 and str(UUID(value))==value + except ValueError: + return False + +async def main(): + # Block sockets before loading any actual graph modules/model constructors. + with nullcontext(NETWORK) as network: + model, store = MemoryModel(), CountingStore() + with redirect_stdout(sys.stderr): + _, graph = load(model, store=store) + owners, seen, request_ids = set(), set(), set() + known = {} + scenario = 'teach' + provenance = {} + while line := sys.stdin.buffer.readline(MAX_REQUEST + 1): + request = {} + try: + if len(line)>MAX_REQUEST or not line.endswith(b'\n'): + # A broken frame cannot be resynchronized safely. + raise EOFError('Invalid bounded request frame') + request = json.loads(line) + if not isinstance(request,dict): + raise ValueError('Malformed request') + request_id = request.get('requestId') + if type(request_id) is not int or request_id<=0 or request_id in request_ids or len(request_ids)>=2000: + raise EOFError('Invalid/replayed request identity') + request_ids.add(request_id) + op = request.get('op') + fields = {'create':{'threadId'}, 'configure':{'scenario'}, 'state':{'threadId','checkpoint'}, + 'history':{'threadId','limit'}, 'submit':{'threadId','messages','context'}} + if op not in fields or set(request)-({'requestId','op'}|fields[op]): + raise ValueError('Unsupported operation fields') + thread = request.get('threadId') + config = {'configurable': {'thread_id': thread}} + if op=='create': + if not valid_uuid(thread) or thread in owners or len(owners)>=100: + raise ValueError('Invalid/duplicate owner') + owners.add(thread) + known[thread] = set() + elif op!='configure' and thread not in owners: + raise ValueError('Unknown owner') + if 'checkpoint' in request: + cp = request['checkpoint'] + if (not isinstance(cp,dict) or set(cp)-{'thread_id','checkpoint_id','checkpoint_ns','checkpoint_map'} + or cp.get('thread_id')!=thread or cp.get('checkpoint_ns')!='' + or cp.get('checkpoint_id') not in known[thread] + or ('checkpoint_map' in cp and cp['checkpoint_map']!={'':cp['checkpoint_id']})): + raise ValueError('Unknown/foreign root checkpoint') + config['configurable'].update(cp) + with redirect_stdout(sys.stderr): + result = {} + scope = None + if op=='configure': + if request.get('scenario') not in SCENARIO_NAMES: + raise ValueError('Unsupported scenario') + scenario = request['scenario'] + elif op=='state': + result['state'] = checkpoint(await graph.aget_state(config)) + elif op=='history': + limit = request.get('limit',10) + if type(limit) is not int or not 1<=limit<=100: + raise ValueError('Invalid bounded history query') + result['history'] = [checkpoint(s) async for s in graph.aget_state_history(config,limit=limit)] + elif op=='submit': + scope = validate_scope(request.get('context')) + messages = request.get('messages') + if not isinstance(messages,list) or len(messages)!=1: + raise ValueError('Expected one actual human') + human = messages[0] + if (not isinstance(human,dict) or set(human)!={'type','id','content'} or human['type']!='human' + or not isinstance(human['id'],str) or not human['id'].strip() or len(human['id'])>256 + or not isinstance(human['content'],str) or len(human['content'].encode())>65536): + raise ValueError('Invalid actual human') + identity = (thread,human['id']) + if identity in seen: + raise ValueError('Replayed human') + seen.add(identity) + model.calls = SCENARIOS['teach' if scenario=='fail-terminal' else 'recall' if scenario.startswith('fail-') else scenario] + provenance[thread] = {'scenario':scenario,'memoryScope':scope} + events = [] + prompt_start, operation_start = len(model.prompts), len(store.operations) + read_count = 0 + original = StoreBackend.download_files + failure_read = {'fail-initial':1,'fail-model':2,'fail-terminal':4}.get(scenario) + def controlled_failure(backend,paths): + nonlocal read_count + read_count += 1 + if read_count==failure_read: + raise RuntimeError('Controlled offline backend read failure') + return original(backend,paths) + with patch.object(StoreBackend,'download_files',controlled_failure) if failure_read else nullcontext(): + try: + async for namespace,kind,event in graph.astream( + {'messages':messages},config,context=request['context'], + output_keys=graph.output_channels,subgraphs=True, + stream_mode=['messages','values','updates','checkpoints','custom']): + events.append([list(namespace),kind,deepcopy(event)]) + if kind=='checkpoints' and not namespace: + known[thread].add(event['config']['configurable']['checkpoint_id']) + except Exception as failure: + result['failure'] = type(failure).__name__ + state = await graph.aget_state(config) + if state.config['configurable'].get('checkpoint_id'): + known[thread].add(state.config['configurable']['checkpoint_id']) + result.update(events=events,state=checkpoint(state), + modelPrompts=model.prompts[prompt_start:], + storeOperations=[{'type':type(o).__name__,'namespace':getattr(o,'namespace',None),'key':getattr(o,'key',None)} for o in store.operations[operation_start:]]) + network.assert_not_called() + result['proof'] = { + 'actualCompiledGraph': True, 'op':op,'threadId':thread, 'pythonPid':os.getpid(), + **provenance.get(thread,{'scenario':scenario,'memoryScope':None}), + 'sourceSha256':{p:hashlib.sha256((PROJECT/p).read_bytes()).hexdigest() for p in SOURCES}, + 'workerSha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'networkConnectAttempts':network.call_count, + } + result['requestId'] = request_id + wire = json.dumps(result,default=encode,ensure_ascii=False).encode() + if len(wire)>MAX_RESPONSE: + raise EOFError('Oversized response') + sys.stdout.buffer.write(wire+b'\n') + sys.stdout.buffer.flush() + except EOFError: + return + except Exception: + 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 28b129cb9..9def2f962 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -28,10 +28,12 @@ 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 { createDeepAgentsMemoryFixture } from './deep-agents-memory-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 deepAgentsMemoryFixture = configuration.library === 'deep-agents' && configuration.topic === 'memory' ? createDeepAgentsMemoryFixture() : null; 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; @@ -81,7 +83,7 @@ const interruptsFixture = ? createInterruptsFixture() : null; const memoryFixture = - configuration.topic === 'memory' ? createMemoryFixture() : null; + configuration.library !== 'deep-agents' && configuration.topic === 'memory' ? createMemoryFixture() : null; const clientToolsFixture = configuration.topic === 'client-tools' ? createClientToolsFixture() : null; @@ -113,6 +115,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (deepAgentsMemoryFixture && (await deepAgentsMemoryFixture(request, response, pathname))) return; if (deepAgentsSkillsFixture && (await deepAgentsSkillsFixture(request, response, pathname))) return; if (deepAgentsSubagentsFixture && (await deepAgentsSubagentsFixture(request, response, pathname))) return; if (deepAgentsFilesystemFixture && (await deepAgentsFilesystemFixture(request, response, pathname))) return; @@ -347,11 +350,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 || deepAgentsSkillsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (deepAgentsMemoryFixture || deepAgentsSubagentsFixture || deepAgentsSkillsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (deepAgentsSubagentsFixture ?? deepAgentsSkillsFixture ?? deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (deepAgentsMemoryFixture ?? 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 60303657e..cab846afd 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baselineHead": "b654abd3dfed6932f364c27d32e219f585f4143e", + "baselineHead": "54d9a356b42dbf83c3b6ae5ff8b255a46a8f8f9e", "sourceState": { "modified": [], "untracked": [] @@ -2682,7 +2682,7 @@ "id": "doc:apps/website/content/docs/deep-agents/capabilities/memory.mdx", "kind": "doc", "path": "apps/website/content/docs/deep-agents/capabilities/memory.mdx", - "sha256": "62160da63e594cb423645e708fd260043dc01fe50942507cde4296e13a0890b8" + "sha256": "fd2577c6ab090c5fa37c4c7c7bbff4410d1927c49f75699d095184c4038e49c7" }, { "id": "doc:apps/website/content/docs/deep-agents/capabilities/planning.mdx", @@ -2706,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": "b03339b9fff465dce25d55c086c655b6206f31789efcf2795fc08d534e6538c2" + "sha256": "2096bd5210569850b192236ee732e1e04a747a4f7d77478a7e80bea606437a84" }, { "id": "doc:apps/website/content/docs/langgraph/api/fetch-stream-transport.mdx", @@ -13461,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": "94146df9963d3893e36db016aa787c72067ab2c366bfb0b548f5c3e1702cdac6" + "sha256": "60edfd2b2f9ae703f395fd43e466eed663fe6898d725ae4282537e0ee1bc3884" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "eb8710b9ba1d987bade2a95b59c2f31a2697cf291aeaab73cf8ae0c790f750cc" + "sha256": "446be6436f7b8d5b614fa36b7fd0c5b3a8248005ef756d0b292d7902424b4911" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",