diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 489b525d8..4a68932ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] # A merge queue runs each candidate on a gh-readonly-queue/* ref. Every # verification job already falls through to the ci-scope outputs when the # event is not `push`, so they scope themselves here exactly as they do on a @@ -138,9 +137,16 @@ jobs: node scripts/react-parity/verify-boundaries.mjs - name: Build and validate private React foundations run: npx nx run-many -t lint test type-tests build --projects=$FOUNDATIONS --parallel=2 + - name: Verify isolated LangGraph runtime and types + run: | + npx nx run langgraph:runtime-quality + npx nx run langgraph:runtime-type-tests + npx nx run langgraph:type-tests - run: npx nx test langgraph --coverage --maxWorkers=2 --reporter=default - run: npx nx run-many -t test --projects=chat,ag-ui,render,a2ui,telemetry --coverage --parallel=1 --maxWorkers=2 - run: npx nx run-many -t build --projects=$LIBS --configuration=production + - name: Install Chromium for packed runtime consumers + run: npx playwright install --with-deps chromium - name: Verify emitted boundaries and isolated packages run: | node scripts/react-parity/verify-boundaries.mjs --built diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 10024bfe1..17091a659 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1033,7 +1033,7 @@ { "name": "AgentOptions", "kind": "interface", - "description": "", + "description": "Options for creating a LangGraph-backed agent via agent.", "properties": [ { "name": "a2uiClientCapabilities", diff --git a/fixtures/react-parity/README.md b/fixtures/react-parity/README.md index da5461da4..69e96ca0a 100644 --- a/fixtures/react-parity/README.md +++ b/fixtures/react-parity/README.md @@ -1,24 +1,55 @@ # React parity foundations -This directory supports the first migration increment: a reviewed Angular baseline -and four private, empty package scaffolds. It does not provide React components, -state management, or extracted backend runtimes. +This directory records the historical package foundations and the bounded shared +LangGraph runtime proof. Core now exposes framework-free contracts; private Angular +and React packages expose native observation bindings. A fixture-only private +LangGraph session runs through the existing transport and SDK in both bindings. The existing Angular packages and release group remain the production path. +## Bounded runtime slice + +The runtime owns immutable snapshots, request generations, stop/dispose, protected +errors, read-only reconciliation after uncertain failures and fixed function-tool +execution. Angular and +React borrow the app-owned session and observe it through their native lifecycles. +The installed consumers run seven scenarios each: inert mount, text, weather tool +roundtrip, protected error, held partial text and Stop, reuse after Stop, and +unmount followed by explicit disposal and an aborted post-disposal submission. +Five live component submissions plus one tool continuation produce six exact wire +requests, with one handler invocation and zero page errors or unexpected requests. + +A local HTTP/SSE fixture serves production-built apps and writes real held response +bytes. Browser assertions observe incremental DOM text and a native response-close +handshake on Stop. This is not compositor paint or a latency measurement. Production +React StrictMode does not replay development effects; separate native unit tests +exercise that subscription replay. See [runtime/README.md](./runtime/README.md) for +reproduction and [runtime/evidence.json](./runtime/evidence.json) for fresh commands, +counts, source provenance, cleanup assertions and limitations. + +The current inventory has **1,451 records**: the historical 1,438 plus eight private +runtime production sources, three testing helpers, a runtime Vitest config and its +type-test config asset. Public export occurrences remain 550 with 514 distinct local +definitions. Fourteen existing export records changed declaration/import text; +there are no legacy export-name additions or removals. Existing task assignments +are preserved; touched extraction/configuration subsets are in progress, not whole +T03–T16 completion. Core and native package contracts remain outside this legacy +inventory scope and are checked by their own tests and package gates. + ## Reviewed baseline -The current facts were generated from the uncommitted foundation working tree at +The historical foundation facts were generated from the uncommitted working tree at base HEAD `7e80cebd607f5c605ef1ec81f11e5ebe3324f81b`. The baseline records the four reviewed configuration changes: CI foundation projects and Angular consumer gate, the direct semver tooling dependency, workspace lockfile links, and package aliases. No inventoried API or implementation changed. This follows the main integration at `a93977ff1e75796336ddd93a882eab2e40f0ca7b`, including v0.2.0 and its package-version -alignment fix. The inventory contains **1,438 records**: 550 public export +alignment fix. The foundation inventory contained **1,438 records**: 550 public export occurrences (514 distinct local definitions), 102 decorated components in 100 files, 461 non-test source files, 128 package assets, 16 distribution/configuration files, 12 entry points, 41 cockpit topics, and 128 documentation pages. These -counts cover the 16 existing libraries selected for migration; the four new empty -packages are checked separately by the boundary and packaging gates. +counts cover the 16 existing libraries selected for migration; the four new packages +are checked separately by the boundary and packaging gates. The original empty +scaffold evidence remains in [baseline-evidence.json](./baseline-evidence.json). The earlier research snapshot at `b1685838069c6b1ec56d5e97a1a01170b26aa125` had 547 export occurrences and 1,435 records. Integration added `AgentRecovery`, @@ -61,16 +92,17 @@ an Angular fix or dependency update changes the migration baseline. ## Private package boundaries -| Current private scaffold | Intended responsibility | +| Current private package | Intended responsibility | | --- | --- | | `@threadplane/core` | Framework-free data, observation, execution and tool contracts | | `@threadplane/content` | Shared Markdown, JSON, A2UI and rendering data processing | | `@threadplane/angular` | Native Angular binding and presentation | | `@threadplane/react` | Native React binding, rendering and presentation | -All four are private version `0.0.0`, with empty entry points. Core, content and -React use plain ESM packaging; Angular uses Angular Package Format (APF). Their -intended responsibilities are not implemented. The source/declaration verifier follows +All four remain private version `0.0.0`. Core contracts and the native observation +bindings are populated; content and presentation entries remain scaffolds. Core, +content and React use plain ESM packaging; Angular uses Angular Package Format (APF). +The source/declaration verifier follows module edges, including type-only imports, aliases and re-exports. It blocks framework dependencies in neutral layers, UI dependencies in backend layers, backend SDKs in framework layers, Angular/React crossover, and optional/testing @@ -94,8 +126,9 @@ The final package map is a separate destination, not the implemented topology: | `@threadplane/telemetry` | Neutral collector; native Angular providers belong to `@threadplane/angular` | There are no suffixed backend packages or separate React renderer in that map. -The first runtime proof keeps the execution owner and publisher private to the -backend; these foundations do not introduce a general shared store. +The runtime proof keeps the execution owner and publisher private to the backend; +it does not introduce a general shared store. The legacy LangGraph root and tarball +remain Angular. The neutral session is staged only into temporary fixture consumers. The new tool contract deliberately omits a schema DSL, automatic argument validation/transformation and validator-to-JSON-Schema conversion. Callers may supply optional JSON Schema metadata and own any validation in their handlers. @@ -105,7 +138,10 @@ transport decoding remain in scope. ```sh NX_DAEMON=false CI=true npx nx run-many -t lint test type-tests build --projects=core,content,angular,react --parallel=2 --skip-nx-cache +NX_DAEMON=false npx nx run langgraph:runtime-quality --skip-nx-cache +NX_DAEMON=false npx nx run langgraph:runtime-type-tests --skip-nx-cache NX_DAEMON=false CI=true npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,telemetry --configuration=production --parallel=1 --skip-nx-cache +npx playwright install --with-deps chromium node scripts/react-parity/verify-boundaries.mjs --built node scripts/react-parity/verify-packages.mjs node scripts/react-parity/verify-angular-package.mjs @@ -116,11 +152,20 @@ paths, README/license inclusion and production exclusions, then imports and type-checks the tarballs outside workspace aliases with `skipLibCheck: false`. Its core-only consumer checks all three core exports and rejects extra dependencies. The separate Angular check packs the one Angular APF entry and proves CLI -compilation/linking with `skipLibCheck: false`. Both inspect consumer module inputs -for unwanted parsers. Installation footprints include actual installed files; +compilation/linking with `skipLibCheck: false`. Both frameworks now run installed +production browser apps with the seven shared scenarios. Inferred native contract +probes reject invalid tool names/arguments/results and deep mutations. The private +runtime's narrow declaration is compiler-generated against installed core declarations, +never hand-written; the staged SDK bundle is fixture-only. Both inspect consumer +module inputs for unwanted parsers. Installation footprints include actual installed files; lockfile locations also include optional platform packages. The Angular footprint -includes CLI/compiler/build tooling. These measurements prove scaffold packaging -and isolation, not runtime performance, React SSR or shared-runtime correctness. +includes CLI/compiler/build tooling. Footprints, root-import probes and application +bundles measure separate surfaces; they are not runtime performance or SSR evidence. +Test-only imports currently add Nx dependencies on legacy package builds even though +the production graphs remain isolated. CI installs Chromium before the packed checks, +runs the isolated Node runtime targets, and preserves production-build-before-scan +ordering. Scenario-only/config-only changes schedule library verification. Stacked +PRs are supported; pushes and production deployment remain guarded to main. ## Existing Angular regression checks @@ -151,7 +196,7 @@ The existing LangGraph stream-manager/agent tests cover cancellation, delivery generations, queues and staged results. AG-UI interruption, resume-wire and persistence tests cover recovery and request serialization. Client-tool tests cover claim/record, cancellation and completed-result reuse. Later migration tasks must -replay these behaviors through the extracted runtime and both bindings. +extend the bounded runtime/binding proof to the remaining migration capabilities. The parser work test checks linear character processing under duplicate cumulative argument updates. A single test duration is not a calibrated performance budget. @@ -162,24 +207,26 @@ burst streams and repeated agent/thread disposal. ## Maintenance and release -The integrated candidate is `codex/react-support-baseline`. The local maintenance +The foundation candidate is `codex/react-support-baseline`; the runtime candidate is +`codex/shared-runtime-quality`, based on `bdcc22ed31aa94f420077e046e88e1481088d453`. +The local maintenance branch `codex/angular-maintenance-v0.2` points to released tag `v0.2.0` (`8daea78d35bfa27513474bd624d0e9495af3cfab`) and retains its released lockfile. Creating that local branch does not establish an operated release lane: a maintainer must own the backport/publication workflow before it is used. No Angular facade currently depends on a new package. Version/tag enforcement, remote maintenance policy and a tested backport/rollback remain T37 work. The existing release group -is unchanged, and none of the scaffolds is publishable. +is unchanged, and none of the new private packages is publishable. ## Task identifiers The following task index makes the ownership ledger readable independently of local research documents. It is a scope map, not evidence that the tasks are complete. -T01/T02 are this foundation increment. The next G1 proof is deliberately limited +T01/T02 describe the foundation increment. The current G1 proof is deliberately limited to shared LangGraph text streaming and fixed function-tool execution with borrowed native Angular and React bindings: the runtime owns execution while each binding observes it. Renderer reuse and SSR are deferred gates, alongside the broader T01–T39 map. -No runtime proof or parity is claimed by these foundations. +This bounded runtime proof does not establish complete migration parity. | Task | Scope | | --- | --- | diff --git a/fixtures/react-parity/consumers/angular/src/main.ts b/fixtures/react-parity/consumers/angular/src/main.ts index e36902711..49c423965 100644 --- a/fixtures/react-parity/consumers/angular/src/main.ts +++ b/fixtures/react-parity/consumers/angular/src/main.ts @@ -1,15 +1,3 @@ -import { Component } from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; -import * as angular from '@threadplane/angular'; -/* PACKAGE_IMPORTS */ - -@Component({ - selector: 'app-root', - standalone: true, - template: '

Private Angular scaffold: {{ supportedExportCount }} supported exports.

', -}) -class App { - readonly supportedExportCount = Object.keys(angular).length /* PACKAGE_EXPORT_COUNT */; -} - -bootstrapApplication(App).catch(console.error); +// The owned verifier installs the tarballs, then replaces this entry with +// fixtures/react-parity/runtime/angular-app.ts and the prebuilt private backend. +// Keep package resolution and compilation inside that installed consumer. diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md new file mode 100644 index 000000000..708706f0a --- /dev/null +++ b/fixtures/react-parity/runtime/README.md @@ -0,0 +1,84 @@ +# Installed native runtime consumers + +Run the owned commands from the repository root. On a fresh checkout, install +dependencies, build the private artifacts, and install Playwright Chromium: + +```sh +npm ci +npx nx build core +npx nx build content +npx nx build angular +npx nx build react +npx playwright install chromium +``` + +On Linux CI, use `npx playwright install --with-deps chromium` to install the +browser's system dependencies too. Then run: + +```sh +node --test scripts/react-parity/verify-packages.spec.mjs scripts/react-parity/runtime-consumer.spec.mjs scripts/react-parity/verify-angular-package.spec.mjs +node scripts/react-parity/verify-packages.mjs +node scripts/react-parity/verify-angular-package.mjs +``` + +The verifiers pack and install local tarballs in temporary directories. Core +installs alone with zero implementation dependencies. React installs no Angular +or backend SDK; Angular installs no React or backend SDK. Framework/compiler +versions come from the root lockfile. Contract probes compile the installed +public entries with `strict` and `skipLibCheck:false`, standard DOM signals, and +no workspace aliases. Negative probes check names, arguments, results and deep +readonly types directly on each binding's inferred snapshot. + +`runtime-entry.ts` is development-only composition around private `createSession`, +the production `FetchStreamTransport`, and the real LangGraph SDK. A focused +TypeScript check resolves its public core imports against the installed tarball +declarations, then emits its narrow annotated `AgentSession` return +type. Vite bundles the private backend and SDK into temporary ESM, externalizing +`@threadplane/core` and `@threadplane/core/tools`. Only that JavaScript bundle and +entry declaration are copied into each installed consumer. No private TypeScript, +transitive private declarations, workspace aliases, or core/framework source is +copied. This is not a neutral LangGraph tarball or a new public entry; the existing +LangGraph package root remains Angular during this migration. + +The session-owned SDK transport defaults `maxRetries` to `0`, so an ambiguous +failed request is not automatically sent again. A positive +`clientOptions.maxRetries` explicitly opts into SDK retries; a caller-supplied +transport owns its retry policy. Canonical updates may replace or remove pending +tool calls for the same assistant message while retaining other messages' calls +and completed results. + +The native fixtures expose Send, Tool, Error, Hold and Stop buttons plus text, +status, error, tool result, delivery, submission and handler count outputs. A single app-owned +session is created outside component lifetime and outside React's StrictMode +tree; owner buttons perform framework unmount and explicit session disposal. +React uses a Vite production build. Angular uses the existing consumer template's +installed Angular CLI application builder and real APF linking, with output in +`dist/consumer/browser` and input evidence from `dist/consumer/stats.json`. + +Both built apps run the same seven browser scenarios in installed Playwright +Chromium: inert mount, successful text, a real local tool handler and exact +two-request result continuation, protected visible server error, held streaming +DOM updates and Stop, reuse after Stop, then unmount/dispose/post-disposal submission. +Five submissions through the component controls make exactly six run requests +(including one tool continuation) and call the handler once. The separate +post-disposal submit attempt resolves aborted without making a request. +Request bodies check the catalog and actual serialized ToolMessage payload. + +A small in-process HTTP fixture serves only built artifacts and the expected +LangGraph run route on dynamic port 0. The held response writes an actual SSE +assistant chunk and stays open. The test observes partial DOM text and streaming +delivery before pressing Stop, then awaits the server response-close handshake +and aborted delivery. This proves incremental DOM updates and native request abort in +these installed consumers. Whole-response SSE traces for the other scenarios +do not establish intermediate updates; controlled native DOM unit tests separately +cover publication timing and StrictMode subscription replay (production React +does not replay development StrictMode effects). + +These checks make no claims about compositor timing, performance, SSR support, +or complete feature parity. The React development root-import probe, full Vite +app, Angular runtime app, and installation footprint are separate diagnostics. + +Page errors and unexpected requests fail verification. Browser contexts, +browsers, held responses, server connections and temporary artifacts are closed +in `finally`, including on assertion failures. No remote deployment, production +product components, or showcase UI is involved. diff --git a/fixtures/react-parity/runtime/angular-app.ts b/fixtures/react-parity/runtime/angular-app.ts new file mode 100644 index 000000000..0a198d710 --- /dev/null +++ b/fixtures/react-parity/runtime/angular-app.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { observeAgent } from '@threadplane/angular'; +import { createFixtureSession } from './runtime-entry.js'; +import { attachOwner, display } from './scenarios'; + +let handlerCalls = 0; +let submissions = 0; +const session = createFixtureSession('/api', 'fixture-thread', () => { handlerCalls += 1; }); +const submit = (input: string) => { submissions += 1; return session.submit(input); }; + +@Component({ + selector: 'app-root', + standalone: true, + template: ` +
+ + + + + + {{ snapshot().status }} + {{ view().text }} + {{ view().error }} + {{ view().tool }} + {{ view().delivery }} + {{ handlerCalls() }} + {{ submissions() }} +
+ `, +}) +class App { + readonly snapshot = observeAgent(session); + readonly view = () => display(this.snapshot()); + readonly handlerCalls = () => handlerCalls; + readonly submissions = () => submissions; + readonly submit = submit; + readonly stop = () => session.stop(); +} + +void bootstrapApplication(App).then((application) => attachOwner(session, () => application.destroy())); diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json new file mode 100644 index 000000000..ba813d757 --- /dev/null +++ b/fixtures/react-parity/runtime/evidence.json @@ -0,0 +1,425 @@ +{ + "schemaVersion": 1, + "status": "verified-local", + "observedOn": "2026-09-21", + "source": { + "branch": "codex/shared-runtime-quality", + "baseCommit": "bdcc22ed31aa94f420077e046e88e1481088d453", + "verificationHead": "bdcc22ed31aa94f420077e046e88e1481088d453", + "workingTree": "Verified uncommitted runtime, native binding, fixture and CI changes on the foundation HEAD. The fingerprint identifies the actual selected working-tree bytes, including tests and configuration; this is not a future commit SHA.", + "fingerprint": { + "algorithm": "SHA-256 of a UTF-8 manifest: one line per selected file, lowercase SHA-256(file bytes), two ASCII spaces, repo-relative path, LF; unique paths sorted by JavaScript default string ordering.", + "pathspecs": [ + "libs/core", + "libs/content", + "libs/angular", + "libs/react", + "libs/langgraph", + "libs/chat", + "libs/ag-ui", + "libs/render", + "libs/a2ui", + "libs/telemetry", + "scripts/react-parity", + "fixtures/react-parity", + "scripts/ci-scope.mjs", + "scripts/ci-scope.spec.mjs", + "scripts/ci-workflow.spec.mjs", + ".github/workflows/ci.yml", + "package.json", + "package-lock.json", + "nx.json", + "tsconfig.json", + "tsconfig.base.json", + "eslint.config.mjs" + ], + "excludedPaths": [ + "fixtures/react-parity/runtime/evidence.json" + ], + "fileCount": 765, + "sha256": "2385a2cf52c60c6e717c03d4b28f399d6228b068315d46947519848c49496669", + "selection": "git ls-files -z --cached --others --exclude-standard -- ; keep existing files and remove excludedPaths. This includes tracked and non-ignored untracked files. Evidence itself is excluded to avoid self-reference; the six research/planning reports are outside all selected paths.", + "reproduce": "node --input-type=module <<'JS'\nimport {createHash} from 'node:crypto';\nimport {execFileSync} from 'node:child_process';\nimport {readFileSync,existsSync} from 'node:fs';\nconst {fingerprint:f}=JSON.parse(readFileSync('fixtures/react-parity/runtime/evidence.json')).source;\nconst sha=value=>createHash('sha256').update(value).digest('hex');\nconst paths=[...new Set(execFileSync('git',['ls-files','-z','--cached','--others','--exclude-standard','--',...f.pathspecs],{encoding:'utf8'}).split('\\0').filter(Boolean))].filter(path=>!f.excludedPaths.includes(path)&&existsSync(path)).sort();\nconst actual=sha(paths.map(path=>sha(readFileSync(path))+' '+path+'\\n').join(''));\nif(paths.length!==f.fileCount||actual!==f.sha256) throw new Error('Source fingerprint mismatch');\nconsole.log(paths.length+' files: '+actual);\nJS" + }, + "sourceState": { + "modified": [ + ".github/workflows/ci.yml", + "fixtures/react-parity/README.md", + "fixtures/react-parity/consumers/angular/src/main.ts", + "libs/angular/README.md", + "libs/angular/package.json", + "libs/angular/src/public-api.ts", + "libs/angular/tsconfig.spec.json", + "libs/angular/vite.config.mts", + "libs/core/README.md", + "libs/core/package.json", + "libs/core/src/index.ts", + "libs/core/src/tools/index.ts", + "libs/core/tsconfig.json", + "libs/core/vite.config.mts", + "libs/langgraph/eslint.config.mjs", + "libs/langgraph/project.json", + "libs/langgraph/src/lib/agent.types.ts", + "libs/langgraph/src/lib/client/create-langgraph-client.ts", + "libs/langgraph/src/lib/runtime-operation-reporter.ts", + "libs/langgraph/src/lib/transport/fetch-stream.transport.spec.ts", + "libs/langgraph/src/lib/transport/fetch-stream.transport.ts", + "libs/langgraph/src/lib/transport/transport.interface.ts", + "libs/langgraph/tsconfig.lib.json", + "libs/langgraph/tsconfig.lib.prod.json", + "libs/langgraph/vite.config.mts", + "libs/react/README.md", + "libs/react/package.json", + "libs/react/src/index.ts", + "libs/react/tsconfig.spec.json", + "libs/react/vite.config.mts", + "package-lock.json", + "scripts/ci-scope.spec.mjs", + "scripts/ci-workflow.spec.mjs", + "scripts/react-parity/baseline.json", + "scripts/react-parity/dispositions.json", + "scripts/react-parity/package-policy.mjs", + "scripts/react-parity/verify-angular-package.mjs", + "scripts/react-parity/verify-angular-package.spec.mjs", + "scripts/react-parity/verify-boundaries.mjs", + "scripts/react-parity/verify-boundaries.spec.mjs", + "scripts/react-parity/verify-packages.mjs", + "scripts/react-parity/verify-packages.spec.mjs" + ], + "untracked": [ + "fixtures/react-parity/runtime/README.md", + "fixtures/react-parity/runtime/angular-app.ts", + "fixtures/react-parity/runtime/installed-types.ts", + "fixtures/react-parity/runtime/react-app.tsx", + "fixtures/react-parity/runtime/runtime-entry.ts", + "fixtures/react-parity/runtime/scenarios.ts", + "fixtures/react-parity/runtime/vite.config.mts", + "libs/angular/src/observe-agent.spec.ts", + "libs/angular/src/observe-agent.ts", + "libs/angular/src/observe-agent.type-test.ts", + "libs/angular/src/test-setup.ts", + "libs/core/src/contracts/agent-session.ts", + "libs/core/src/contracts/agent-snapshot.ts", + "libs/core/src/contracts/contracts.spec.ts", + "libs/core/src/contracts/contracts.type-test.ts", + "libs/core/src/contracts/delivery.ts", + "libs/core/src/contracts/error.ts", + "libs/core/src/contracts/message.ts", + "libs/core/src/contracts/tool.ts", + "libs/core/src/tools/execution-context.ts", + "libs/core/src/tools/function-tool.ts", + "libs/core/src/tools/function-tool.type-test.ts", + "libs/langgraph/src/runtime/create-session.ts", + "libs/langgraph/src/runtime/function-tools.ts", + "libs/langgraph/src/runtime/function-tools.type-test.ts", + "libs/langgraph/src/runtime/harness.spec.ts", + "libs/langgraph/src/runtime/message-reducer.spec.ts", + "libs/langgraph/src/runtime/message-reducer.ts", + "libs/langgraph/src/runtime/operation-errors.ts", + "libs/langgraph/src/runtime/ownership.ts", + "libs/langgraph/src/runtime/publication.spec.ts", + "libs/langgraph/src/runtime/publication.ts", + "libs/langgraph/src/runtime/recovery.spec.ts", + "libs/langgraph/src/runtime/session-lifecycle.spec.ts", + "libs/langgraph/src/runtime/stream-projection.spec.ts", + "libs/langgraph/src/runtime/stream-projection.ts", + "libs/langgraph/src/runtime/testing/binding-fixture.ts", + "libs/langgraph/src/runtime/testing/controlled-transport.ts", + "libs/langgraph/src/runtime/testing/deferred.ts", + "libs/langgraph/src/runtime/tool-execution.spec.ts", + "libs/langgraph/src/runtime/tool-settlement.spec.ts", + "libs/langgraph/src/runtime/transport.integration.spec.ts", + "libs/langgraph/src/runtime/transport.types.ts", + "libs/langgraph/tsconfig.runtime-tests.json", + "libs/langgraph/vite.runtime.config.mts", + "libs/react/src/use-agent.spec.tsx", + "libs/react/src/use-agent.ts", + "libs/react/src/use-agent.type-test.ts", + "scripts/react-parity/runtime-config.spec.mjs", + "scripts/react-parity/runtime-consumer.mjs", + "scripts/react-parity/runtime-consumer.spec.mjs" + ] + } + }, + "environment": { + "node": "v24.20.0", + "npm": "11.19.0", + "platform": "darwin", + "architecture": "arm64", + "osRelease": "25.5.0", + "lockedVersions": { + "react": "19.2.4", + "react-dom": "19.2.4", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@angular/core": "21.1.6", + "@angular/cli": "21.1.5", + "@angular/build": "21.1.5", + "@angular/compiler-cli": "21.1.6", + "@langchain/langgraph-sdk": "1.10.0", + "@langchain/core": "1.2.9", + "vitest": "4.1.0", + "vite": "7.3.1", + "typescript": "5.9.3", + "@playwright/test": "1.58.2", + "nx": "22.5.1" + }, + "ciNode22ExecutedLocally": false, + "browser": "Google Chrome for Testing 145.0.7632.6 (Playwright Chromium)", + "browserInstallation": "Existing local Playwright browser installation; no local Linux --with-deps installation is claimed. CI installs Chromium with --with-deps." + }, + "acceptanceMatrix": { + "status": "Both production-built installed browser verifiers passed against this source fingerprint.", + "frameworks": [ + "Angular installed APF production app", + "React installed Vite production app" + ], + "scenariosPerFramework": 7, + "scenarios": [ + "inert mount", + "text success", + "weather tool roundtrip", + "protected visible error", + "held partial DOM update and Stop/native response close", + "reuse after Stop", + "unmount/app-owned disposal/post-disposal submit" + ], + "notificationAssertions": { + "source": "libs/langgraph/src/runtime/publication.spec.ts and session-lifecycle.spec.ts", + "inertSubscription": 0, + "duplicateSnapshot": 0, + "changedSnapshot": 1, + "submitThenStop": { + "unsubscribedListener": 1, + "remainingListener": 2 + }, + "limit": "Behavioral counts, not render/paint or performance measurements." + }, + "observedPerFramework": { + "componentSubmissions": 5, + "toolContinuations": 1, + "wireRequests": 6, + "toolHandlerCalls": 1, + "postDisposalSubmissions": 1, + "postDisposalRequests": 0, + "pageErrors": 0, + "unexpectedRequests": 0, + "text": "Hello 🌍.", + "toolResult": { + "city": "Paris", + "temperature": 20 + }, + "toolAnswerContains": "20 degrees", + "heldTextContains": "Held partial", + "stopDelivery": "complete:aborted", + "postDisposalOutcome": "aborted" + } + }, + "cleanup": { + "assertions": "Unmount removes component controls; explicit app disposal resolves; post-disposal submit resolves aborted without extra I/O. Held SSE waits for the native response-close handshake.", + "resources": "Browser context, browser, held responses and server connections close in finally; verifier temporary install/bundle directories are removed in finally, including assertion failures.", + "finalBrowserCleanupVerified": true, + "processes": "Both verifier processes exited 0 after awaited finally cleanup of contexts, browsers and HTTP servers.", + "temporaryDirectories": [ + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-consumer-FdU7ri", + "existsAfterExit": false + }, + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-angular-consumer-XpH0tb", + "existsAfterExit": false + } + ], + "runtimeBundleDirectoriesRemaining": [] + }, + "inventory": { + "historicalRows": 1438, + "currentRows": 1451, + "newProductionRuntimeSources": 8, + "newTestingHelpers": 3, + "newRuntimeVitestConfigSources": 1, + "newRuntimeTypeConfigAssets": 1, + "legacyExports": 550, + "uniqueLocalDefinitions": 514, + "legacyExportRecordsWithReviewedTextChanges": 14, + "legacyExportNameChanges": 0, + "existingAssignmentsPreserved": true, + "scope": "Existing 16-library inventory only. Core/native package public contracts are checked separately. T01-T39 assignments are retained; only touched bounded subsets are in-progress." + }, + "ci": { + "pullRequestBases": "unrestricted, including stacked branches", + "pushBranches": [ + "main" + ], + "deploymentGuards": "Existing main-ref and push-event guards preserved.", + "runtimeEnvironment": "Isolated Node Vitest config, no Angular setup/plugins.", + "chromium": "npx playwright install --with-deps chromium precedes both packed browser calls.", + "builtScan": "Production legacy builds precede emitted boundary scan." + }, + "limits": [ + "Legacy LangGraph root and tarball remain Angular.", + "Private staged runtime is fixture-only; no neutral LangGraph tarball or public runtime entry is claimed.", + "Public core/native contracts compile against installed declarations; the narrow private fixture declaration is compiler-generated against installed core, never hand-written.", + "Native inferred type probes cover heterogeneous tool names, arguments, results and deep readonly behavior.", + "HTTP/SSE fixture proves incremental DOM updates and native request abort, not compositor paint.", + "Production React StrictMode has no development replay; native unit tests cover replay separately.", + "No full product parity, renderer reuse, SSR, calibrated latency, heap retention or performance claim.", + "CI Node22 and other platforms were not executed locally.", + "Test-only imports add Nx dependencies on legacy builds although production package graphs remain isolated.", + "No production backend, publication, release, deployment or remote data was exercised." + ], + "warnings": [ + "Nx reports NO_COLOR/FORCE_COLOR overlap; legacy native builds report stale Browserslist data, ng-packagr export-condition overrides and keepLifecycleScripts notices.", + "Vite reports that use client directives are ignored in the client-only production consumer bundle. No SSR claim is made." + ], + "recordedAt": "2026-09-21T23:00:37.935Z", + "executed": [ + { + "command": "NX_DAEMON=false node --test scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs scripts/react-parity/*.spec.mjs fixtures/react-parity/traces.spec.mjs", + "exitCode": 0, + "testsPassed": 407, + "testsFailed": 0, + "log": "/tmp/r08-final-focused.log", + "logSha256": "c2d520eccb4f0002282c2f043ac3b0f08dbcc756d95a559ac4d5448ef6f94bcf" + }, + { + "command": "NX_DAEMON=false npx nx run-many -t lint test type-tests build --projects=core,content,angular,react --parallel=2 --skip-nx-cache", + "exitCode": 0, + "projects": 4, + "targetConfigurations": 16, + "behaviorTests": { + "core": 2, + "angular": 6, + "react": 6 + }, + "content": "Empty scaffold: test target passes with passWithNoTests; no content behavior test count claimed.", + "log": "/tmp/r08-final-foundations.log", + "logSha256": "0738c2cdf2575fa1a6269d3c96ef95a88663d80450a1042b9ae5dfe689b4bc91" + }, + { + "command": "NX_DAEMON=false npx nx run langgraph:runtime-quality --skip-nx-cache", + "exitCode": 0, + "testFiles": 9, + "testsPassed": 156, + "log": "/tmp/r08-final-runtime-quality.log", + "logSha256": "9675327833ac0e4987c58d423da570da2bc476e487eec58838d61ef613c693f1" + }, + { + "command": "NX_DAEMON=false npx nx run langgraph:runtime-type-tests --skip-nx-cache", + "exitCode": 0, + "log": "/tmp/r08-final-runtime-types.log", + "logSha256": "6e5c64695f3aad8fd1b474649ebb26c81204d54e45d589424d65e2b0843c921d" + }, + { + "command": "NX_DAEMON=false npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,telemetry --configuration=production --parallel=2 --skip-nx-cache", + "exitCode": 0, + "productionProjects": 6, + "log": "/tmp/r08-final-production-builds.log", + "logSha256": "eeb87b96a645703d0511f4c26d7360397ad07cfbb6a02042ca78bb4e6cf427ff" + }, + { + "command": "node scripts/react-parity/verify-boundaries.mjs", + "exitCode": 0, + "log": "/tmp/r08-final-source-boundaries.log", + "logSha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" + }, + { + "command": "node scripts/react-parity/verify-boundaries.mjs --built", + "exitCode": 0, + "order": "Executed after successful foundation and six legacy production builds.", + "log": "/tmp/r08-final-built-boundaries.log", + "logSha256": "c983a97d17ff7ced7aa9113afa031f464103f566f1bc94eec8160558ec2553f7" + }, + { + "command": "node scripts/react-parity/verify-packages.mjs", + "exitCode": 0, + "privatePlainTarballs": 3, + "esmTypeExports": 9, + "isolatedCoreExports": 3, + "browserScenariosPassed": 7, + "log": "/tmp/r08-final-packages.log", + "logSha256": "91125a29c21f67710c85520d48339c49ef3854b0e7691adfb64747ff397ad319" + }, + { + "command": "node scripts/react-parity/verify-angular-package.mjs", + "exitCode": 0, + "angularAPFExports": 1, + "browserScenariosPassed": 7, + "log": "/tmp/r08-final-angular-package.log", + "logSha256": "5d2c29a1435ed80ae0f1981319ef77eba62e56c35ab51d175a9bb0bdaf137c28" + }, + { + "command": "node scripts/react-parity/inventory.mjs --write-baseline; node scripts/react-parity/inventory.mjs --check", + "exitCode": 0, + "inventoryRows": 1451, + "reviewedChange": "Final refresh changed only hashes for create-session.ts, message-reducer.ts and stream-projection.ts; no IDs/counts/assignments changed.", + "log": "/tmp/r08-final-inventory.log", + "logSha256": "5b8f74cc9f500da010e2933303fbeab9e4b06813a3cf5a95c97416a1621acd93" + }, + { + "command": "git diff --check", + "exitCode": 0, + "log": "/tmp/r08-final-diff-check.log", + "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "reusedUnchangedLegacyChecks": [ + { + "command": "NX_DAEMON=false npx nx test langgraph --skip-nx-cache", + "exitCode": 0, + "log": "/tmp/runtime-final-legacy-langgraph.log", + "provenance": "Same-session parent run, with successful output inspected. Final review fixes changed private runtime files only; the Angular suite excludes src/runtime. These unchanged legacy results are reused, not represented as a new post-fix run. No test count inferred.", + "logSha256": "eb2085807b18946b5e3340b37475f8ae706f86471c392635748fe83b04f5b42e" + }, + { + "command": "NX_DAEMON=false npx nx run langgraph:type-tests --skip-nx-cache", + "exitCode": 0, + "log": "/tmp/runtime-final-legacy-types.log", + "provenance": "Same-session parent run, with successful output inspected. Final review fixes changed private runtime files only; the Angular suite excludes src/runtime. These unchanged legacy results are reused, not represented as a new post-fix run. No test count inferred.", + "logSha256": "48a8dac2f4cc97169640bd20382f995f07e2cb40e125fac98b926d8ba39103b8" + } + ], + "runtimePolicy": { + "ownedSDKDefaultMaxRetries": 0, + "explicitOptIn": "Positive clientOptions.maxRetries enables SDK retries. Custom transports own their retry policy; no global no-retry guarantee is claimed.", + "recovery": "Uncertain failures use read-only reconciliation without implicit resubmission.", + "canonicalTools": "Canonical updates remove or replace pending tool references for the same assistant message, retain calls belonging to other messages, and preserve completed results; ownership is rechecked across pending async execution boundaries." + }, + "packageDiagnostics": { + "core": { + "installedPackages": 1, + "fileBytes": 26099, + "lockLocations": 1 + }, + "reactConsumer": { + "installedPackages": 24, + "fileBytes": 51239870, + "lockLocationsIncludingOptionalPlatforms": 74, + "productionAppModulesTransformed": 34, + "productionAppJavaScriptRawReported": "408.65 kB", + "productionAppJavaScriptGzipReported": "123.14 kB", + "developmentRootImportProbe": { + "inputs": 5, + "bytes": 47778, + "contentParserInputs": 0 + } + }, + "angularConsumer": { + "installedPackages": 411, + "fileBytes": 196915823, + "lockLocationsIncludingOptionalPlatforms": 516, + "bundleInputs": 257, + "contentParserInputs": 0, + "productionAppRawReported": "309.75 kB", + "productionAppEstimatedTransferReported": "81.94 kB", + "includes": "Angular CLI/compiler/build tooling" + }, + "limits": "Installed files, optional lock locations, development root-import probes and full production apps are separate diagnostics. App bundles include the staged SDK. These are not performance benchmarks or comparable framework overhead measurements." + }, + "notRun": [ + "CI Node22 or Linux environment", + "Angular20/22 consumer lanes", + "SSR, renderer reuse, full parity or calibrated performance benchmarks", + "Remote backend/deployment smoke, release or publication" + ] +} diff --git a/fixtures/react-parity/runtime/installed-types.ts b/fixtures/react-parity/runtime/installed-types.ts new file mode 100644 index 000000000..22d5c79ec --- /dev/null +++ b/fixtures/react-parity/runtime/installed-types.ts @@ -0,0 +1,60 @@ +import type { AgentSession, AgentSnapshot, ToolCall } from '@threadplane/core'; +import type { FunctionTool } from '@threadplane/core/tools'; +import type { FixtureTools } from './scenarios'; + +/* BINDING_IMPORT */ + +export function assertSnapshot(snapshot: AgentSnapshot) { + for (const call of snapshot.toolCalls) { + if (call.name === 'weather') { + const city: string = call.args.city; + // @ts-expect-error Heterogeneous tool arguments remain distinct. + void call.args.values; + // @ts-expect-error Snapshot arguments are deeply readonly. + call.args.city = city; + if (call.status === 'complete') { + const temperature: number = call.result.temperature; + // @ts-expect-error Result retains its authored object shape. + const count: number = call.result; + // @ts-expect-error Results are readonly too. + call.result.temperature = temperature; + void count; + } + } else { + const values: readonly string[] = call.args.values; + // @ts-expect-error Nested arrays remain readonly. + call.args.values.push('extra'); + // @ts-expect-error Count has no city argument. + void call.args.city; + if (call.status === 'complete') { + const count: number = call.result; + // @ts-expect-error Count result does not widen to any. + void call.result.temperature; + void count; + } + void values; + } + // @ts-expect-error Results require status narrowing. + void call.result; + // @ts-expect-error Names remain a literal union. + const unknownName: 'missing' = call.name; + void unknownName; + } + // @ts-expect-error Aggregate collections are immutable. + snapshot.toolCalls.push({} as ToolCall); +} + +export function assertCore(session: AgentSession, signal: AbortSignal) { + const snapshot: AgentSnapshot = session.getSnapshot(); + assertSnapshot(snapshot); + void session.submit('Hello', { signal }); + const tool: FunctionTool<{ values: readonly string[] }, number> = { + description: 'Count values', + handler: ({ values }, context) => { + const standardSignal: AbortSignal = context.signal; + void standardSignal; + return values.length; + }, + }; + return tool; +} diff --git a/fixtures/react-parity/runtime/react-app.tsx b/fixtures/react-parity/runtime/react-app.tsx new file mode 100644 index 000000000..56f8857c8 --- /dev/null +++ b/fixtures/react-parity/runtime/react-app.tsx @@ -0,0 +1,36 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useAgent } from '@threadplane/react'; +import { createFixtureSession } from './runtime-entry.js'; +import { attachOwner, display } from './scenarios'; + +// Application ownership is outside StrictMode and the component lifetime. +let handlerCalls = 0; +let submissions = 0; +const session = createFixtureSession('/api', 'fixture-thread', () => { handlerCalls += 1; }); +const submit = (input: string) => { submissions += 1; return session.submit(input); }; + +function App() { + const snapshot = useAgent(session); + const view = display(snapshot); + return
+ + + + + + {snapshot.status} + {view.text} + {view.error} + {view.tool} + {view.delivery} + {handlerCalls} + {submissions} +
; +} + +const container = document.getElementById('root'); +if (!container) throw new Error('Missing fixture root'); +const root = createRoot(container); +root.render(); +attachOwner(session, () => root.unmount()); diff --git a/fixtures/react-parity/runtime/runtime-entry.ts b/fixtures/react-parity/runtime/runtime-entry.ts new file mode 100644 index 000000000..e56e8ee7d --- /dev/null +++ b/fixtures/react-parity/runtime/runtime-entry.ts @@ -0,0 +1,31 @@ +import type { AgentSession } from '@threadplane/core'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- This development-only entry composes private source into a temporary fixture bundle, never a package export. +import { createSession } from '../../../libs/langgraph/src/runtime/create-session'; +import type { FixtureTools } from './scenarios'; + +/** Development-only composition, never a package entry or a shipped factory. */ +export function createFixtureSession( + endpoint: string, + threadId: string, + onHandler: () => void = () => undefined +): AgentSession { + return createSession({ + assistantId: 'fixture-assistant', + threadId, + apiUrl: endpoint, + clientOptions: { maxRetries: 0, defaultHeaders: {} }, + tools: { + weather: { + description: 'Current weather', + handler: ({ city }: { city: string }) => { + onHandler(); + return { city, temperature: 20 }; + }, + }, + count: { + description: 'Count values', + handler: ({ values }: { values: readonly string[] }) => values.length, + }, + }, + }); +} diff --git a/fixtures/react-parity/runtime/scenarios.ts b/fixtures/react-parity/runtime/scenarios.ts new file mode 100644 index 000000000..24e1733e7 --- /dev/null +++ b/fixtures/react-parity/runtime/scenarios.ts @@ -0,0 +1,37 @@ +import type { AgentSession, AgentSnapshot } from '@threadplane/core'; + +export interface FixtureTools { + weather: { args: { city: string }; result: { city: string; temperature: number } }; + count: { args: { values: readonly string[] }; result: number }; +} + +export function display(snapshot: AgentSnapshot) { + const assistant = snapshot.messages.filter((message) => message.role === 'assistant'); + const delivery = assistant.at(-1)?.delivery; + return { + text: assistant.map((message) => message.content).join('\n'), + error: snapshot.error?.message ?? '', + tool: JSON.stringify(snapshot.toolCalls), + delivery: delivery?.phase === 'complete' ? `complete:${delivery.outcome}` : delivery?.phase ?? '', + }; +} + +/** Owner controls deliberately survive framework teardown. */ +export function attachOwner(session: AgentSession, unmount: () => void) { + const owner = document.createElement('section'); + owner.setAttribute('aria-label', 'Session owner'); + const status = document.createElement('output'); + status.setAttribute('data-testid', 'owner'); + status.textContent = 'mounted'; + const button = (label: string, action: () => void | Promise) => { + const control = document.createElement('button'); + control.textContent = label; + control.addEventListener('click', () => void action()); + owner.append(control); + }; + button('Unmount', () => { unmount(); status.textContent = 'unmounted'; }); + button('Dispose', async () => { await session.dispose(); status.textContent = 'disposed'; }); + button('Send after dispose', async () => { status.textContent = await session.submit('Send'); }); + owner.append(status); + document.body.append(owner); +} diff --git a/fixtures/react-parity/runtime/vite.config.mts b/fixtures/react-parity/runtime/vite.config.mts new file mode 100644 index 000000000..fe5a4f381 --- /dev/null +++ b/fixtures/react-parity/runtime/vite.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +// Runs inside the installed React consumer: no workspace aliases or plugins. +export default defineConfig({ + esbuild: { jsx: 'automatic' }, + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/libs/angular/README.md b/libs/angular/README.md index 63063389d..f01734ab9 100644 --- a/libs/angular/README.md +++ b/libs/angular/README.md @@ -1,14 +1,34 @@ # @threadplane/angular -Private, unpublished Angular foundation scaffolding. The empty root entry point -provides no supported runtime API or secondary entry points. Angular bindings, -providers, components, and backend integrations are not implemented. - -Build with `npx nx build angular`. The production target uses ng-packagr and -partial Angular compilation to emit the Angular Package Format. Tests currently -contain no cases and pass with `passWithNoTests`; the type-tests target checks -the empty source with TypeScript. - -The declared Angular peer range follows the existing workspace packages. It is -not a compatibility-matrix claim. An installed Angular CLI consumer must verify -package resolution and linking separately before this foundation is complete. +Private, unpublished Angular binding for app-owned `AgentSession` values from +`@threadplane/core`. The root exports `observeAgent(session)`, which returns a +read-only `Signal`. Tool names, arguments, and results retain the +session's declared types. + +Call `observeAgent` in an Angular injection context, such as a component field +initializer or provider factory, with a session supplied by the app: + +```ts +import { observeAgent } from '@threadplane/angular'; +import type { AgentSession } from '@threadplane/core'; + +// Invoke this factory within an Angular injection context. +export function observeStatus(session: AgentSession) { + return observeAgent(session); +} +``` + +The function borrows the session. Reading or subscribing does not start a run. +The injected `DestroyRef` releases only this observer's subscription when its +context is destroyed. Pending work continues, and other observers remain +connected. The app calls `session.submit(text)`, `session.stop()`, and +`session.dispose()` and owns the session's lifetime. + +No backend constructor is exported here; the current real LangGraph runtime is +still a private development composition seam. This API is under development and +does not claim full SSR, hydration, or feature parity. The Angular peer range +follows existing workspace policy; it is not a compatibility-matrix claim. + +Build with `npx nx build angular` (ng-packagr and partial Angular compilation). +Run `npx nx test angular` and `npx nx run angular:type-tests` for real-session +and strict type coverage. The tests use Angular 21.1.6. diff --git a/libs/angular/package.json b/libs/angular/package.json index bd0dbf964..6a67269af 100644 --- a/libs/angular/package.json +++ b/libs/angular/package.json @@ -2,11 +2,12 @@ "name": "@threadplane/angular", "version": "0.0.0", "private": true, - "description": "Private Angular foundation scaffolding. No supported runtime API yet.", + "description": "Private Angular binding for observing app-owned agent sessions.", "license": "MIT", "type": "module", "sideEffects": false, "peerDependencies": { + "@threadplane/core": "0.0.0", "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0" }, "files": [ diff --git a/libs/angular/src/observe-agent.spec.ts b/libs/angular/src/observe-agent.spec.ts new file mode 100644 index 000000000..5bc28f767 --- /dev/null +++ b/libs/angular/src/observe-agent.spec.ts @@ -0,0 +1,290 @@ +import { + Component, + createEnvironmentInjector, + EnvironmentInjector, + inject, + InjectionToken, + runInInjectionContext, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { observeAgent } from './public-api'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Only this integration spec composes the private runtime; production imports core only. +import { + bindingFixture, + changed, + delta, + finalText, + weatherCall, +} from '../../langgraph/src/runtime/testing/binding-fixture'; + +const fixtures: ReturnType[] = []; +const injectors: EnvironmentInjector[] = []; +const SESSION = new InjectionToken< + ReturnType['session'] +>('test session'); + +@Component({ + selector: 'test-agent', + template: ` + + + {{ snapshot().status }} +
{{ messages }}
+
{{ delivery }}
+
{{ tools }}
+
{{ snapshot().error?.message }}
+ `, +}) +class Chat { + readonly session = inject(SESSION); + readonly snapshot = observeAgent(this.session); + run?: ReturnType; + send() { + this.run = this.session.submit('Hello'); + } + stop() { + void this.session.stop(); + } + get messages() { + return this.snapshot() + .messages.map((message) => message.content) + .join('\n'); + } + get delivery() { + return JSON.stringify(this.snapshot().messages.at(-1)?.delivery); + } + get tools() { + return JSON.stringify(this.snapshot().toolCalls); + } +} +function fixture() { + const value = bindingFixture(); + fixtures.push(value); + return value; +} +function observe(session: ReturnType['session']) { + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + injectors.push(injector); + return { + snapshot: runInInjectionContext(injector, () => observeAgent(session)), + destroy: () => injector.destroy(), + }; +} +afterEach(async () => { + injectors.splice(0).forEach((injector) => { + if (!injector.destroyed) injector.destroy(); + }); + await Promise.all(fixtures.splice(0).map((value) => value.cleanup())); + TestBed.resetTestingModule(); +}); + +describe('observeAgent borrowed session', () => { + it('renders streamed text, tool results, errors and stop outcomes through native controls', async () => { + const f = fixture(); + TestBed.configureTestingModule({ + imports: [Chat], + providers: [{ provide: SESSION, useValue: f.session }], + }); + const view = TestBed.createComponent(Chat); + view.detectChanges(); + const element: HTMLElement = view.nativeElement; + const text = (selector: string) => + element.querySelector(selector)?.textContent; + const send = () => element.querySelectorAll('button')[0].click(); + expect(text('[data-testid="status"]')).toBe('idle'); + expect(f.streams).toHaveLength(0); + send(); + await f.started(); + view.detectChanges(); + expect(text('[data-testid="status"]')).toBe('running'); + const partial = changed( + f.session, + (s) => s.messages.at(-1)?.content === 'Visible partial' + ); + f.streams[0].release(delta('Visible partial')); + await partial; + view.detectChanges(); + expect(text('[data-testid="messages"]')).toContain('Visible partial'); + expect(text('[data-testid="delivery"]')).toContain('streaming'); + f.streams[0].release(finalText('Visible final')); + f.streams[0].finish(); + expect(await view.componentInstance.run).toBe('success'); + view.detectChanges(); + expect(text('[data-testid="messages"]')).toContain('Visible final'); + expect(text('[data-testid="status"]')).toBe('idle'); + expect(text('[data-testid="delivery"]')).toContain('success'); + + send(); + await f.started(1); + f.streams[1].release(weatherCall); + f.streams[1].finish(); + await f.entered; + view.detectChanges(); + expect(text('[data-testid="tools"]')).toContain('running'); + f.toolResult.resolve({ temperature: 24 }); + await f.started(2); + f.streams[2].release(finalText('24 degrees')); + f.streams[2].finish(); + expect(await view.componentInstance.run).toBe('success'); + view.detectChanges(); + expect(text('[data-testid="tools"]')).toContain('"temperature":24'); + expect(text('[data-testid="messages"]')).toContain('24 degrees'); + + send(); + await f.started(3); + f.streams[3].release({ type: 'error', data: { message: 'Unavailable' } }); + expect(await view.componentInstance.run).toBe('error'); + view.detectChanges(); + expect(text('[data-testid="status"]')).toBe('error'); + expect(text('[role="alert"]')).toBeTruthy(); + send(); + await f.started(4); + const stopping = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[4].release(delta('Stopping', 'stopped-answer')); + await stopping; + element.querySelectorAll('button')[1].click(); + expect(await view.componentInstance.run).toBe('aborted'); + view.detectChanges(); + expect(text('[data-testid="status"]')).toBe('idle'); + expect(text('[data-testid="delivery"]')).toContain('aborted'); + expect(f.handlerCalls).toBe(1); + expect(f.session.submitCalls).toBe(4); + expect(f.streams).toHaveLength(5); + expect(f.session.stopCalls).toBe(1); + view.destroy(); + expect(f.session.disposeCalls).toBe(0); + expect(f.session.subscriptions).toBe(f.session.releases); + }); + + it('reads inertly and releases its subscription through the injected DestroyRef', () => { + const f = fixture(); + const view = observe(f.session); + expect(view.snapshot()).toBe(f.session.getSnapshot()); + expect(view.snapshot()).toBe(view.snapshot()); + expect(f.session.subscriptions).toBe(1); + view.destroy(); + expect(f.session.releases).toBe(1); + expect( + f.session.submitCalls + f.session.stopCalls + f.session.disposeCalls + ).toBe(0); + expect(f.streams).toHaveLength(0); + }); + + it('requires an injection context before subscribing', () => { + const f = fixture(); + expect(() => observeAgent(f.session)).toThrow(/NG0203/); + expect(f.session.subscriptions).toBe(0); + }); + + it('shares intermediate and final snapshots between two simultaneous observers', async () => { + const f = fixture(); + const first = observe(f.session); + const second = observe(f.session); + const run = f.session.submit('Hello'); + await f.started(); + expect(first.snapshot().status).toBe('running'); + const observed = changed( + f.session, + (s) => s.messages.at(-1)?.content === 'Hel' + ); + f.streams[0].release(delta('Hel')); + await observed; + expect(first.snapshot().messages.at(-1)?.content).toBe('Hel'); + expect(first.snapshot()).toBe(second.snapshot()); + const detached = first.snapshot(); + first.destroy(); + f.streams[0].release(finalText('Hello world')); + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(first.snapshot()).toBe(detached); + expect(second.snapshot()).toBe(f.session.getSnapshot()); + expect(second.snapshot().status).toBe('idle'); + expect(second.snapshot().messages.at(-1)).toMatchObject({ + content: 'Hello world', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(f.session.submitCalls).toBe(1); + expect(f.streams).toHaveLength(1); + expect(f.session.stopCalls + f.session.disposeCalls).toBe(0); + }); + + it('keeps a pending tool alive after destruction and reattaches without executing it again', async () => { + const f = fixture(); + const first = observe(f.session); + const run = f.session.submit('Weather'); + await f.started(); + f.streams[0].release(weatherCall); + f.streams[0].finish(); + await f.entered; + expect(first.snapshot().toolCalls[0]).toMatchObject({ + name: 'weather', + status: 'running', + }); + first.destroy(); + expect(f.handlerSignal?.aborted).toBe(false); + const second = observe(f.session); + expect(second.snapshot().toolCalls[0].status).toBe('running'); + f.toolResult.resolve({ temperature: 24 }); + await f.started(1); + f.streams[1].release(finalText('24 degrees')); + f.streams[1].finish(); + expect(await run).toBe('success'); + expect(second.snapshot().toolCalls[0]).toMatchObject({ + name: 'weather', + args: { city: 'Paris' }, + status: 'complete', + result: { temperature: 24 }, + }); + expect(second.snapshot().messages.at(-1)?.content).toBe('24 degrees'); + expect(f.handlerCalls).toBe(1); + expect(f.session.submitCalls).toBe(1); + expect(f.streams).toHaveLength(2); + expect(f.session.stopCalls + f.session.disposeCalls).toBe(0); + }); + + it('publishes runtime errors and a later app-owned stop', async () => { + const f = fixture(); + const view = observe(f.session); + const failed = f.session.submit('Fail'); + await f.started(); + const partial = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[0].release(delta('Partial')); + await partial; + f.streams[0].release({ type: 'error', data: { message: 'Unavailable' } }); + expect(await failed).toBe('error'); + expect(view.snapshot().status).toBe('error'); + expect(view.snapshot().error).toBeDefined(); + expect(view.snapshot().messages.at(-1)?.delivery).toMatchObject({ + phase: 'complete', + outcome: 'error', + }); + const stopped = f.session.submit('Stop'); + await f.started(1); + const stopping = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[1].release(delta('Stopping', 'stopped-answer')); + await stopping; + await f.session.stop(); + expect(await stopped).toBe('aborted'); + expect(view.snapshot().status).toBe('idle'); + expect(view.snapshot().messages.at(-1)?.delivery).toMatchObject({ + phase: 'complete', + outcome: 'aborted', + }); + expect(f.session.submitCalls).toBe(2); + expect(f.session.stopCalls).toBe(1); + expect(f.session.disposeCalls).toBe(0); + }); +}); diff --git a/libs/angular/src/observe-agent.ts b/libs/angular/src/observe-agent.ts new file mode 100644 index 000000000..e32d7cc0b --- /dev/null +++ b/libs/angular/src/observe-agent.ts @@ -0,0 +1,18 @@ +import { DestroyRef, inject, signal, type Signal } from '@angular/core'; +import type { + AgentSession, + AgentSnapshot, + ToolContract, +} from '@threadplane/core'; + +/** Observe an app-owned session in an injection context. Destroying that + * context releases only this subscription; the app retains session lifetime. */ +export function observeAgent< + TTools extends { [K in keyof TTools]: ToolContract } +>(session: AgentSession): Signal> { + const destroyRef = inject(DestroyRef); + const snapshot = signal(session.getSnapshot()); + const release = session.subscribe(() => snapshot.set(session.getSnapshot())); + destroyRef.onDestroy(release); + return snapshot.asReadonly(); +} diff --git a/libs/angular/src/observe-agent.type-test.ts b/libs/angular/src/observe-agent.type-test.ts new file mode 100644 index 000000000..37303e6d5 --- /dev/null +++ b/libs/angular/src/observe-agent.type-test.ts @@ -0,0 +1,50 @@ +import type { Signal } from '@angular/core'; +import type { AgentSession, AgentSnapshot } from '@threadplane/core'; +import { observeAgent } from './public-api.js'; + +interface Tools { + weather: { args: { city: string }; result: { temperature: number } }; + count: { args: { values: readonly string[] }; result: number }; +} + +export function observeTypedSession(session: AgentSession) { + const signal = observeAgent(session); + const exact: Signal> = signal; + const snapshot = signal(); + for (const call of snapshot.toolCalls) { + if (call.name === 'weather') { + const city: string = call.args.city; + // @ts-expect-error Heterogeneous names retain their own argument shape. + void call.args.values; + // @ts-expect-error Snapshot arguments are immutable. + call.args.city = city; + if (call.status === 'complete') { + const temperature: number = call.result.temperature; + // @ts-expect-error Weather results cannot widen to any or the count result. + const count: number = call.result; + void [temperature, count]; + } + } else { + const values: readonly string[] = call.args.values; + // @ts-expect-error Count arguments cannot widen to any or the weather arguments. + void call.args.city; + if (call.status === 'complete') { + const count: number = call.result; + // @ts-expect-error Count results preserve the primitive type. + void call.result.temperature; + void count; + } + void values; + } + // @ts-expect-error Results are only present on completed calls. + void call.result; + // @ts-expect-error Tool names stay a literal union. + const unknownName: 'missing' = call.name; + void unknownName; + } + // @ts-expect-error Observation is read-only. + signal.set(snapshot); + // @ts-expect-error Commands remain on the borrowed session. + snapshot.submit('Hello'); + return exact; +} diff --git a/libs/angular/src/public-api.ts b/libs/angular/src/public-api.ts index cb0ff5c3b..c6a2253aa 100644 --- a/libs/angular/src/public-api.ts +++ b/libs/angular/src/public-api.ts @@ -1 +1 @@ -export {}; +export { observeAgent } from './observe-agent'; diff --git a/libs/angular/src/test-setup.ts b/libs/angular/src/test-setup.ts new file mode 100644 index 000000000..b9cdf380d --- /dev/null +++ b/libs/angular/src/test-setup.ts @@ -0,0 +1,11 @@ +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } } +); diff --git a/libs/angular/tsconfig.spec.json b/libs/angular/tsconfig.spec.json index 786a68ab2..8a81e49c7 100644 --- a/libs/angular/tsconfig.spec.json +++ b/libs/angular/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.lib.json", "compilerOptions": { + "rootDir": "../..", "declaration": false, "declarationMap": false, "inlineSources": false, diff --git a/libs/angular/vite.config.mts b/libs/angular/vite.config.mts index 382d0ec66..5eda8003a 100644 --- a/libs/angular/vite.config.mts +++ b/libs/angular/vite.config.mts @@ -1,10 +1,16 @@ import { defineConfig } from 'vitest/config'; +import angular from '@analogjs/vite-plugin-angular'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ root: import.meta.dirname, + plugins: [angular(), nxViteTsPaths()], test: { - environment: 'node', + reporters: ['default'], + pool: 'forks', + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], include: ['src/**/*.spec.ts', 'src/**/*.test.ts'], - passWithNoTests: true, + passWithNoTests: false, }, }); diff --git a/libs/core/README.md b/libs/core/README.md index 6edd905a7..e7bbd898d 100644 --- a/libs/core/README.md +++ b/libs/core/README.md @@ -1,14 +1,50 @@ # @threadplane/core -Private, unpublished foundation scaffolding for the React parity work. These empty -entry points reserve planned package boundaries; they provide no supported runtime -API. No React bindings, stores, renderers, or backend adapters are implemented. +Private, unpublished agent contracts for the shared runtime work. The root exports +readonly text messages, delivery outcomes, plain error projections, authored tool +call types, snapshots and the minimal session interface. This package implements +delivery constructors and error projection, not an execution owner or public store. -Reserved exports: `@threadplane/core`, `@threadplane/core/tools`, `@threadplane/core/testing`. +`@threadplane/core/tools` exports optional authored `FunctionTool`, +minimal signal-based execution context, catalog inference, and a structural +claim/record execution-store contract. `@threadplane/core/testing` is reserved. -Core owns dependency-free agent contracts. Schema validation belongs to consumers -and their chosen libraries. +Sessions accept text and an optional standard `AbortSignal`. Read and subscribe are +inert; subscribe notifies changes only. Implementations must publish owned, deeply +readonly plain snapshot data and keep references stable between changes. Raw errors, +causes, controllers, SDK instances and arbitrary extras belong to effect boundaries. +`projectAgentError` copies already classified display fields; it does not classify +transport failures. Tool failures carry a plain error string instead. -Build with `npx nx build core`. Packaging and dependency boundaries are +Tool contracts pair authored TypeScript argument/result types by name, including +ordinary interfaces. Pending calls have decoded, finalized arguments awaiting +execution; partial streamed arguments are not exposed as authored types. `void` +arguments/results appear as `undefined`. Tool values support plain primitives, +objects and arrays. Schema validation belongs to consumers and their chosen libraries; +there is no inference from schemas, runtime argument conversion or execution registry. +Function tools may carry caller-authored JSON Schema in `parameters`; it is metadata +only. Handlers may return promises or void and receive isolated mutable arguments. +The optional guard claims before handlers and records before settlement; tools +marked `idempotent: true` bypass it. A stale executing record fails closed. + +The private LangGraph development session captures a fixed catalog and store at +construction. Its submit attempt owns tool execution and allows at most ten +automatic continuation groups per explicit user turn. `followUp: false` persists +results without another run. Stop/dispose settle local ownership promptly; required +claim cleanup and already-started durable writes may finish without publishing or +continuing. Failed handoffs retain stable tool-result messages for the next explicit +submission. Reading, subscribing and checking history never start tools. + +Typed catalog sessions expose registered client calls. Already server-settled calls +are represented by raw ToolMessages in the transcript unless an authored local or +durable result is available: wire strings cannot recover arbitrary result types. +Sessions without a catalog retain the broad plain-data tool observation contract. + +Core has no implementation dependencies. TypeScript consumers need standard platform +signal declarations (for example `lib: ["ES2022", "DOM"]`); loading those declarations +does not introduce browser imports or runtime effects. + +Verify with `npx nx test core`, `npx nx run core:type-tests` and `npx nx build core`. +Packaging and dependency boundaries are verified by the scripts in `scripts/react-parity`. Optional and testing entry points must remain unreachable from the root runtime and declarations. diff --git a/libs/core/package.json b/libs/core/package.json index 3bc539bb0..93e239256 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -2,9 +2,10 @@ "name": "@threadplane/core", "version": "0.0.0", "private": true, - "description": "Private core foundation scaffolding. No supported runtime API yet.", + "description": "Private dependency-free agent contracts, plain snapshots, and authored function-tool types.", "license": "MIT", "type": "module", + "types": "./src/index.d.ts", "sideEffects": false, "files": [ "src/**/*.js", diff --git a/libs/core/src/contracts/agent-session.ts b/libs/core/src/contracts/agent-session.ts new file mode 100644 index 000000000..e46dec90d --- /dev/null +++ b/libs/core/src/contracts/agent-session.ts @@ -0,0 +1,33 @@ +import type { AgentSnapshot } from './agent-snapshot.js'; +import type { CompleteOutcome } from './delivery.js'; +import type { ToolContract } from './tool.js'; + +/** The initial neutral session supports text submission only. */ +export type AgentSubmitInput = string; + +export interface AgentSubmitOptions { + readonly signal?: AbortSignal; +} + +/** Explicit owner of execution; observing and releasing a subscription are inert. + * The conditional preserves structural widening of authored tool sessions. */ +export type AgentSession< + TTools extends { [K in keyof TTools]: ToolContract } = Record< + string, + ToolContract + > +> = TTools extends unknown + ? { + getSnapshot(): AgentSnapshot; + /** Changes only. Read the initial aggregate through getSnapshot. */ + subscribe(notify: () => void): () => void; + submit( + input: AgentSubmitInput, + options?: AgentSubmitOptions + ): Promise; + stop(): Promise; + /** Read-only reconciliation; never retries an uncertain submission. */ + checkStatus?(): Promise; + dispose(): Promise; + } + : never; diff --git a/libs/core/src/contracts/agent-snapshot.ts b/libs/core/src/contracts/agent-snapshot.ts new file mode 100644 index 000000000..2eb8ced5d --- /dev/null +++ b/libs/core/src/contracts/agent-snapshot.ts @@ -0,0 +1,22 @@ +import type { AgentError } from './error.js'; +import type { Message } from './message.js'; +import type { ToolCall, ToolContract } from './tool.js'; + +export type AgentStatus = 'idle' | 'running' | 'error'; + +/** An immutable aggregate, owned by the session, stable between actual changes. + * The conditional projects concrete shapes so interface-authored tools widen to + * a generic observer without requiring an index signature on their arguments. */ +export type AgentSnapshot< + TTools extends { [K in keyof TTools]: ToolContract } = Record< + string, + ToolContract + > +> = TTools extends unknown + ? { + readonly status: AgentStatus; + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly error?: AgentError; + } + : never; diff --git a/libs/core/src/contracts/contracts.spec.ts b/libs/core/src/contracts/contracts.spec.ts new file mode 100644 index 000000000..c60afeb3d --- /dev/null +++ b/libs/core/src/contracts/contracts.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + completeDelivery, + projectAgentError, + staticDelivery, + streamingDelivery, +} from '../index'; + +describe('plain agent contracts', () => { + it('preserves response generation and the existing terminal outcome vocabulary', () => { + expect(streamingDelivery('run-1')).toEqual({ + generation: 'run-1', + phase: 'streaming', + }); + for (const outcome of [ + 'success', + 'error', + 'aborted', + 'interrupted', + 'paused', + ] as const) { + expect(completeDelivery('run-1', outcome)).toEqual({ + generation: 'run-1', + phase: 'complete', + outcome, + }); + } + expect(staticDelivery('history-1')).toEqual( + completeDelivery('history-1', 'success') + ); + }); + + it('projects only classified error display fields into an owned frozen plain value', () => { + const error = Object.assign( + new Error('Connection dropped', { cause: { mutable: [] } }), + { + kind: 'interrupted' as const, + status: 503, + retryable: false, + recovery: 'check' as const, + detail: 'The request may still have completed.', + } + ); + const projected = projectAgentError(error); + error.message = 'Changed later'; + expect(projected).toEqual({ + kind: 'interrupted', + message: 'Connection dropped', + status: 503, + retryable: false, + recovery: 'check', + detail: 'The request may still have completed.', + }); + expect(Object.getPrototypeOf(projected)).toBe(Object.prototype); + expect(Object.isFrozen(projected)).toBe(true); + expect(projected).not.toHaveProperty('stack'); + expect(projected).not.toHaveProperty('cause'); + }); +}); diff --git a/libs/core/src/contracts/contracts.type-test.ts b/libs/core/src/contracts/contracts.type-test.ts new file mode 100644 index 000000000..2528b37d4 --- /dev/null +++ b/libs/core/src/contracts/contracts.type-test.ts @@ -0,0 +1,169 @@ +import type { + AgentError, + AgentSession, + AgentSnapshot, + Message, + MessageDelivery, + PlainValue, + ToolCall, +} from '../index'; + +declare const snapshot: AgentSnapshot; +declare const session: AgentSession; +// @ts-expect-error snapshot fields are readonly +snapshot.status = 'running'; +// @ts-expect-error snapshot collections are readonly +snapshot.messages.push(snapshot.messages[0]); +// @ts-expect-error tool-call collections are readonly +snapshot.toolCalls.push(snapshot.toolCalls[0]); +// @ts-expect-error message fields are readonly +snapshot.messages[0].content = 'changed'; +// @ts-expect-error nested collections are readonly +snapshot.messages[0].toolCallIds?.push('call-2'); +// @ts-expect-error delivery fields are readonly +snapshot.messages[0].delivery.generation = 'run-2'; +if (snapshot.error) { + // @ts-expect-error errors are readonly plain projections + snapshot.error.message = 'changed'; +} +// @ts-expect-error full message payload submission is outside the text session slice +session.submit({ messages: [] }); +// @ts-expect-error strict null checking must remain enabled for these fixtures +const invalidStatus: AgentSnapshot['status'] = null; + +type Tools = { + weather: { + args: { city: string; coordinates: number[] }; + result: { temperature: number }; + }; + search: { args: { query: string }; result: { hits: string[] } }; +}; +interface WeatherArgs { + city: string; + preferences?: { units: 'c' | 'f' }; +} +interface WeatherResult { + temperature: number; +} +type InterfaceTools = { + weather: { args: WeatherArgs; result: WeatherResult }; + ping: { args: void; result: void }; +}; +const interfaceCall: ToolCall = { + id: 'i', + name: 'weather', + args: { city: 'P' }, + status: 'complete', + result: { temperature: 20 }, +}; +const noInput: ToolCall = { + id: 'p', + name: 'ping', + args: undefined, + status: 'complete', + result: undefined, +}; +const rejected: ToolCall = { + id: 'p', + name: 'ping', + args: undefined, + status: 'error', + error: 'Handler declined', +}; +declare const unionCall: ToolCall; +declare const declaredCall: ToolCall; +if (declaredCall.name === 'search' && declaredCall.status === 'complete') { + // @ts-expect-error authored nested result arrays are readonly + declaredCall.result.hits.push('new'); +} +declare const authoredSession: AgentSession; +const observerSession: AgentSession = authoredSession; +if (unionCall.name === 'weather' && unionCall.status === 'complete') { + const temperature: number = unionCall.result.temperature; + const city: string = unionCall.args.city; + void [temperature, city]; +} +const weather: ToolCall = { + id: 'call-1', + name: 'weather', + status: 'complete', + args: { city: 'Portland', coordinates: [45, -122] }, + result: { temperature: 20 }, +}; +// @ts-expect-error nested authored arguments become readonly +weather.args.coordinates.push(0); +// @ts-expect-error nested authored fields become readonly +weather.result.temperature = 21; +const badArgs: ToolCall = { + id: 'c', + name: 'weather', + status: 'running', + // @ts-expect-error name and arguments remain correlated + args: { query: 'rain' }, +}; +const badResult: ToolCall = { + id: 'c', + name: 'weather', + status: 'complete', + args: { city: 'P', coordinates: [] }, + // @ts-expect-error name and result remain correlated + result: { hits: [] }, +}; +// @ts-expect-error missing result on completed call +const incomplete: ToolCall = { + id: 'c', + name: 'weather', + status: 'complete', + args: { city: 'P', coordinates: [] }, +}; +// @ts-expect-error arbitrary SDK values are not portable data +const sdkData: PlainValue = new Date(); +// @ts-expect-error callable values are not portable data +const functionData: PlainValue = { execute: () => 1 }; +const unsupportedTool: ToolCall<{ + run: { args: { execute: () => void }; result: string }; +}> = { + id: 'x', + name: 'run', + status: 'running', + // @ts-expect-error declared tools cannot introduce executable snapshot data + args: { execute: () => undefined }, +}; +const richMessage: Message = { + id: 'm', + role: 'assistant', + // @ts-expect-error structured content has not been ported in this slice + content: [{ type: 'text', text: 'hi' }], + delivery: { generation: 'g', phase: 'streaming' }, +}; +const premature: MessageDelivery = { + generation: 'g', + phase: 'streaming', + // @ts-expect-error streaming delivery does not have an outcome + outcome: 'success', +}; +const cause: AgentError = { + kind: 'server', + message: 'failed', + retryable: true, + // @ts-expect-error errors cannot expose arbitrary mutable causes + cause: new Error(), +}; + +void [ + weather, + interfaceCall, + noInput, + rejected, + observerSession, + unsupportedTool, + badArgs, + badResult, + incomplete, + sdkData, + functionData, + richMessage, + premature, + cause, + invalidStatus, +]; diff --git a/libs/core/src/contracts/delivery.ts b/libs/core/src/contracts/delivery.ts new file mode 100644 index 000000000..37006b86a --- /dev/null +++ b/libs/core/src/contracts/delivery.ts @@ -0,0 +1,37 @@ +/** Terminal result of one response attempt; paused awaits resumable input. */ +export type CompleteOutcome = + | 'success' + | 'error' + | 'aborted' + | 'interrupted' + | 'paused'; + +export type MessageDelivery = + | { readonly generation: string; readonly phase: 'streaming' } + | { + readonly generation: string; + readonly phase: 'complete'; + readonly outcome: CompleteOutcome; + }; + +export function streamingDelivery(generation: string) { + return Object.freeze({ + generation, + phase: 'streaming', + } as const satisfies MessageDelivery); +} + +export function completeDelivery( + generation: string, + outcome: TOutcome +) { + return Object.freeze({ + generation, + phase: 'complete', + outcome, + } as const satisfies MessageDelivery); +} + +export function staticDelivery(messageId: string) { + return completeDelivery(messageId, 'success'); +} diff --git a/libs/core/src/contracts/error.ts b/libs/core/src/contracts/error.ts new file mode 100644 index 000000000..835e31cdd --- /dev/null +++ b/libs/core/src/contracts/error.ts @@ -0,0 +1,29 @@ +export type AgentErrorKind = + | 'connection' + | 'auth' + | 'server' + | 'interrupted' + | 'aborted'; +export type AgentRecovery = 'retry' | 'check' | 'none'; + +/** Plain display projection. Classification and raw causes remain with the adapter. */ +export interface AgentError { + readonly kind: AgentErrorKind; + readonly message: string; + readonly status?: number; + readonly retryable: boolean; + readonly recovery?: AgentRecovery; + readonly detail?: string; +} + +/** Copies an already classified failure, retaining neither its prototype nor cause. */ +export function projectAgentError(error: AgentError): AgentError { + return Object.freeze({ + kind: error.kind, + message: error.message, + status: error.status, + retryable: error.retryable, + recovery: error.recovery, + detail: error.detail, + }); +} diff --git a/libs/core/src/contracts/message.ts b/libs/core/src/contracts/message.ts new file mode 100644 index 000000000..09d74e2b8 --- /dev/null +++ b/libs/core/src/contracts/message.ts @@ -0,0 +1,14 @@ +import type { MessageDelivery } from './delivery.js'; + +export type Role = 'user' | 'assistant' | 'system' | 'tool'; + +/** Owned text projection; rich content and arbitrary SDK extras are not in this slice. */ +export interface Message { + readonly id: string; + readonly role: Role; + readonly content: string; + readonly delivery: MessageDelivery; + readonly toolCallId?: string; + readonly toolCallIds?: readonly string[]; + readonly name?: string; +} diff --git a/libs/core/src/contracts/tool.ts b/libs/core/src/contracts/tool.ts new file mode 100644 index 000000000..7de07837c --- /dev/null +++ b/libs/core/src/contracts/tool.ts @@ -0,0 +1,63 @@ +/** Data authored for portable snapshots. No schema inference or runtime conversion. */ +export type PlainValue = + | string + | number + | boolean + | null + | undefined + | readonly PlainValue[] + | { readonly [key: string]: PlainValue }; + +export type DeepReadonly = unknown extends T + ? PlainValue + : T extends void + ? undefined + : T extends string | number | boolean | null | undefined + ? T + : T extends (...args: never[]) => unknown + ? never + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : never; + +/** Describes arguments and results only; execution is a separate capability. */ +export interface ToolContract { + readonly args: unknown; + readonly result: unknown; +} + +// Unspecified tools have the broad plain-data view. Authored interfaces retain +// their fields without needing a string index signature. void is represented by +// undefined in a snapshot (for no-input calls and handlers without a result). +type SnapshotValue = unknown extends T + ? PlainValue + : [T] extends [void] + ? undefined + : DeepReadonly; + +export type ToolCallStatus = 'pending' | 'running' | 'complete' | 'error'; + +/** A mapped union preserves each declared name's argument/result relationship. + * pending means decoded, finalized arguments awaiting execution, not partial + * streamed JSON. Adapters keep argument fragments private until finalized. */ +export type ToolCall< + TTools extends { [K in keyof TTools]: ToolContract } = Record< + string, + ToolContract + > +> = { + [K in keyof TTools & string]: TTools[K] extends { + readonly args: infer A; + readonly result: infer R; + } + ? { + readonly id: string; + readonly name: K; + readonly args: SnapshotValue; + } & ( + | { readonly status: 'pending' | 'running' } + | { readonly status: 'complete'; readonly result: SnapshotValue } + | { readonly status: 'error'; readonly error: string } + ) + : never; +}[keyof TTools & string]; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index d045c0857..e40710c22 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,2 +1,26 @@ -// Reserved private entry point. Runtime implementation follows in later work. -export {}; +export type { + AgentSession, + AgentSubmitInput, + AgentSubmitOptions, +} from './contracts/agent-session.js'; +export type { AgentSnapshot, AgentStatus } from './contracts/agent-snapshot.js'; +export type { Message, Role } from './contracts/message.js'; +export { + completeDelivery, + staticDelivery, + streamingDelivery, +} from './contracts/delivery.js'; +export type { CompleteOutcome, MessageDelivery } from './contracts/delivery.js'; +export { projectAgentError } from './contracts/error.js'; +export type { + AgentError, + AgentErrorKind, + AgentRecovery, +} from './contracts/error.js'; +export type { + DeepReadonly, + PlainValue, + ToolCall, + ToolCallStatus, + ToolContract, +} from './contracts/tool.js'; diff --git a/libs/core/src/tools/execution-context.ts b/libs/core/src/tools/execution-context.ts new file mode 100644 index 000000000..8cc4d8581 --- /dev/null +++ b/libs/core/src/tools/execution-context.ts @@ -0,0 +1,28 @@ +import type { PlainValue } from '../contracts/tool.js'; + +/** The owning session cancels this signal on stop, supersession, or disposal. */ +export interface ExecutionContext { + readonly signal: AbortSignal; +} + +export type ToolExecutionResult = + | { readonly ok: true; readonly value: PlainValue } + | { readonly ok: false; readonly error: string }; + +export interface ToolExecutionKey { + readonly threadId: string; + readonly toolCallId: string; +} + +export type ToolExecutionRecord = + | { readonly status: 'executing' } + | { readonly status: 'done'; readonly result: ToolExecutionResult } + | { readonly status: 'failed'; readonly result?: ToolExecutionResult }; + +/** Optional structural durability guard. The session supplies its fixed thread. + * claim must be atomic. Existing executing records fail closed; only a newly + * claimed call may invoke a side effect. record precedes local settlement. */ +export interface ToolExecutionStore { + claim(key: ToolExecutionKey): Promise<'claimed' | ToolExecutionRecord>; + record(key: ToolExecutionKey, result: ToolExecutionResult): Promise; +} diff --git a/libs/core/src/tools/function-tool.ts b/libs/core/src/tools/function-tool.ts new file mode 100644 index 000000000..d39d82323 --- /dev/null +++ b/libs/core/src/tools/function-tool.ts @@ -0,0 +1,63 @@ +import type { PlainValue } from '../contracts/tool.js'; +import type { ExecutionContext } from './execution-context.js'; + +type Portable = T extends string | number | boolean | null | undefined | void + ? T + : T extends (...args: never[]) => unknown + ? never + : T extends object + ? { [K in keyof T]: Portable } + : never; +type IsPortable = 0 extends 1 & T + ? false + : unknown extends T + ? true + : [T] extends [Portable] + ? true + : false; + +/** Authored TypeScript arguments/results, with no inference from JSON metadata, + * validation, conversion, or transforms. Normal interfaces need no index key. + * Handlers receive an isolated mutable copy of their finalized arguments. */ +export type FunctionTool = IsPortable extends true + ? IsPortable extends true + ? { + readonly description: string; + /** Optional caller-authored JSON Schema, sent unchanged as metadata. */ + readonly parameters?: PlainValue; + /** Defaults to true; false persists the result without another run. */ + readonly followUp?: boolean; + /** true bypasses an optional execution store. */ + readonly idempotent?: boolean; + readonly handler: ( + args: A, + context: ExecutionContext + ) => R | Promise; + } + : never + : never; + +/** Constraint for heterogeneous catalogs; never is only the storage boundary, + * never a callable fallback for an untyped handler. */ +export interface FunctionToolDefinition { + readonly description: string; + readonly parameters?: PlainValue; + readonly followUp?: boolean; + readonly idempotent?: boolean; + readonly handler: (args: never, context: ExecutionContext) => unknown; +} + +export type ToolContracts> = { + [K in keyof T]: { + args: Parameters[0]; + result: Awaited>; + }; +}; + +/** Rejects unsupported inferred data without widening to any or unknown. */ +export type CheckedTools> = { + [K in keyof T]: FunctionTool< + Parameters[0], + Awaited> + >; +}; diff --git a/libs/core/src/tools/function-tool.type-test.ts b/libs/core/src/tools/function-tool.type-test.ts new file mode 100644 index 000000000..ebe8cada6 --- /dev/null +++ b/libs/core/src/tools/function-tool.type-test.ts @@ -0,0 +1,118 @@ +/* eslint @typescript-eslint/no-unused-vars: ["warn", { "argsIgnorePattern": "^_" }] */ +import type { AgentSession, ToolCall } from '../index.js'; +import type { ExecutionContext, FunctionTool, ToolContracts } from './index.js'; + +interface Args { + city: string; + flags?: string[]; +} +interface Result { + temperature: number; +} +const weather: FunctionTool = { + description: 'Weather', + handler: async (args, context) => { + const signal: AbortSignal = context.signal; + args.flags?.push('authored mutable args'); + void signal; + return { temperature: args.city.length }; + }, +}; +const ping: FunctionTool = { + description: 'Ping', + handler: (_args, _context: ExecutionContext) => undefined, +}; +const catalog = { weather, ping }; +const maybe: FunctionTool = { + description: 'Maybe', + handler: () => undefined, +}; +const maybeCall: ToolCall> = { + id: 'maybe', + name: 'maybe', + args: undefined, + status: 'complete', + result: undefined, +}; +declare const maybeSession: AgentSession< + ToolContracts<{ maybe: typeof maybe }> +>; +const maybeObserver: AgentSession = maybeSession; +const wrongMaybe: ToolCall> = { + id: 'bad', + name: 'maybe', + args: undefined, + status: 'complete', + // @ts-expect-error a void union still rejects wrong concrete results + result: 2, +}; +void [maybeCall, maybeObserver, wrongMaybe]; +const nestedVoid: ToolCall<{ + nested: { args: void; result: { value: void } }; +}> = { + id: 'nested', + name: 'nested', + args: undefined, + status: 'complete', + result: { value: undefined }, +}; +void nestedVoid; +type Contracts = ToolContracts; +declare const call: ToolCall; +if (call.name === 'weather' && call.status === 'complete') { + const city: string = call.args.city; + const temperature: number = call.result.temperature; + // @ts-expect-error snapshot retains authored types, no any fallback + const wrong: string = call.result.temperature; + // @ts-expect-error immutable snapshot arguments + call.args.flags?.push('wrong'); + void [city, temperature, wrong]; +} +declare const session: AgentSession; +const observer: AgentSession = session; +const wrongArgs: FunctionTool = { + description: 'Wrong', + // @ts-expect-error handler argument must match authored interface + handler: (_args: { query: string }) => ({ temperature: 2 }), +}; +const wrongResult: FunctionTool = { + description: 'Wrong', + // @ts-expect-error handler result must match authored interface + handler: () => ({ temperature: 'wrong' }), +}; +// @ts-expect-error Date is not portable argument data +const date: FunctionTool = { + description: 'No', + handler: () => '', +}; +// @ts-expect-error executable results are not portable data +const executable: FunctionTool = { + description: 'No', + handler: () => ({ + execute() { + return undefined; + }, + }), +}; +const untyped: FunctionTool = { + description: 'No', + handler: () => '', +}; +const wrongName: ToolCall = { + id: 'c', + // @ts-expect-error wrong discriminant + name: 'other', + status: 'pending', + args: { city: 'Paris' }, +}; +void [ + observer, + wrongArgs, + wrongResult, + date, + executable, + untyped, + wrongName, + catalog, + maybe, +]; diff --git a/libs/core/src/tools/index.ts b/libs/core/src/tools/index.ts index d045c0857..d8aa1dc0f 100644 --- a/libs/core/src/tools/index.ts +++ b/libs/core/src/tools/index.ts @@ -1,2 +1,13 @@ -// Reserved private entry point. Runtime implementation follows in later work. -export {}; +export type { + ExecutionContext, + ToolExecutionKey, + ToolExecutionRecord, + ToolExecutionResult, + ToolExecutionStore, +} from './execution-context.js'; +export type { + CheckedTools, + FunctionTool, + FunctionToolDefinition, + ToolContracts, +} from './function-tool.js'; diff --git a/libs/core/tsconfig.json b/libs/core/tsconfig.json index 64d2d4b34..deea138ee 100644 --- a/libs/core/tsconfig.json +++ b/libs/core/tsconfig.json @@ -2,9 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": [], - "lib": [ - "ES2022" - ] + "lib": ["ES2022", "DOM"], + "strict": true }, "files": [], "references": [ diff --git a/libs/core/vite.config.mts b/libs/core/vite.config.mts index f05211ebe..656f2f7eb 100644 --- a/libs/core/vite.config.mts +++ b/libs/core/vite.config.mts @@ -4,7 +4,8 @@ export default defineConfig({ root: import.meta.dirname, test: { environment: 'node', + reporters: ['default'], include: ['src/**/*.spec.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], - passWithNoTests: true, + passWithNoTests: false, }, }); diff --git a/libs/langgraph/eslint.config.mjs b/libs/langgraph/eslint.config.mjs index 223b467d7..c3eafe2ce 100644 --- a/libs/langgraph/eslint.config.mjs +++ b/libs/langgraph/eslint.config.mjs @@ -9,7 +9,15 @@ export default [ '@nx/dependency-checks': [ 'error', { - ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/vite.config.mts', + '{projectRoot}/vite.runtime.config.mts', + // Private runtime staging is not exported by the legacy Angular APF + // package. Its neutral source graph is checked separately; do not + // add unpublished core peers to the legacy production manifest. + '{projectRoot}/src/runtime/**', + ], ignoredDependencies: ['vite', '@nx/vite'], }, ], diff --git a/libs/langgraph/project.json b/libs/langgraph/project.json index 8f1b57655..5a8eb75e9 100644 --- a/libs/langgraph/project.json +++ b/libs/langgraph/project.json @@ -60,6 +60,18 @@ "command": "node ./node_modules/typescript/bin/tsc --noEmit -p libs/langgraph/tsconfig.type-tests.json" } }, + "runtime-quality": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "libs/langgraph/vite.runtime.config.mts" + } + }, + "runtime-type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "node ./node_modules/typescript/bin/tsc --project libs/langgraph/tsconfig.runtime-tests.json" + } + }, "prepare-install": { "executor": "nx:run-commands", "cache": true, diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index fb8f0a6d3..6bf774a3b 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -3,16 +3,11 @@ import type { ResourceStatus as NgResourceStatus } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import type { BagTemplate, - Checkpoint, - Command, - Config, InferBag, Interrupt, - Metadata, ThreadState, ToolProgress, ToolCallWithResult, - StreamMode, } from '@langchain/langgraph-sdk'; import type { MessageMetadata, @@ -28,6 +23,25 @@ import type { ClientToolsCapability, } from '@threadplane/chat'; import type { AgentLifecycle } from './lifecycle'; +import type { + AgentTransport, + LangGraphClientOptions, + LangGraphSubmitOptions, + AgentQueue, +} from '../runtime/transport.types'; + +export type { + StreamEvent, + LangGraphMultitaskStrategy, + LangGraphDurability, + LangGraphOnCompletion, + LangGraphOnDisconnect, + LangGraphSubmitOptions, + AgentQueueEntry, + AgentQueue, + AgentTransport, + LangGraphClientOptions, +} from '../runtime/transport.types'; // Re-export SDK types so consumers don't need to import from langgraph-sdk directly export type { BagTemplate, InferBag, Interrupt, ThreadState, SubmitOptions }; @@ -50,103 +64,6 @@ export type ResourceStatus = NgResourceStatus; // ── Transport interface ────────────────────────────────────────────────────── -/** An event emitted by a LangGraph stream. */ -export interface StreamEvent { - /** Event type identifier (e.g., 'values', 'messages', 'error', 'interrupt'). */ - type: - | 'values' - | `values|${string}` - | 'messages' - | `messages|${string}` - | `messages/${string}` - | `messages/${string}|${string}` - | 'updates' - | `updates|${string}` - | 'tools' - | `tools|${string}` - | 'custom' - | `custom|${string}` - | 'error' - | `error|${string}` - | 'metadata' - | 'checkpoints' - | `checkpoints|${string}` - | 'tasks' - | `tasks|${string}` - | 'debug' - | `debug|${string}` - | 'events' - | `events|${string}` - | 'interrupt' - | 'interrupts'; - namespace?: string[]; - messages?: unknown[]; - messageMetadata?: Record; - [key: string]: unknown; -} - -/** Strategy for handling concurrent LangGraph runs on the same thread. */ -export type LangGraphMultitaskStrategy = 'reject' | 'interrupt' | 'rollback' | 'enqueue'; - -export type LangGraphDurability = 'exit' | 'async' | 'sync'; -export type LangGraphOnCompletion = 'complete' | 'continue'; -export type LangGraphOnDisconnect = 'cancel' | 'continue'; - -/** Options accepted by LangGraph-backed submit calls. */ -export interface LangGraphSubmitOptions { - signal?: AbortSignal; - config?: Config; - context?: unknown; - checkpoint?: Omit | null; - checkpointId?: string; - command?: Command; - metadata?: Metadata; - checkpointDuring?: boolean; - durability?: LangGraphDurability; - interruptBefore?: '*' | string[]; - interruptAfter?: '*' | string[]; - onCompletion?: LangGraphOnCompletion; - webhook?: string; - onDisconnect?: LangGraphOnDisconnect; - afterSeconds?: number; - ifNotExists?: 'create' | 'reject'; - onRunCreated?: (params: { run_id: string; thread_id?: string }) => void; - streamMode?: StreamMode[]; - streamSubgraphs?: boolean; - streamResumable?: boolean; - feedbackKeys?: string[]; - /** Convenience alias normalized to `command.resume` before invoking LangGraph. */ - resume?: unknown; - /** Strategy for handling concurrent runs on the same thread. */ - multitaskStrategy?: LangGraphMultitaskStrategy; -} - -/** A queued server-side LangGraph run. */ -export interface AgentQueueEntry { - /** Server-side run ID. */ - id: string; - /** Thread that owns the queued run. */ - threadId: string; - /** Values submitted for the queued run. */ - values: T | null | undefined; - /** Submit options used when the queued run was created. */ - options?: LangGraphSubmitOptions; - /** Timestamp when the queued run was registered locally. */ - createdAt: Date; -} - -/** Public queue surface for pending server-side LangGraph runs. */ -export interface AgentQueue { - /** Read-only pending queue entries. */ - readonly entries: ReadonlyArray>; - /** Number of pending queue entries. */ - readonly size: number; - /** Cancel a specific pending run by server run ID. */ - cancel: (id: string) => Promise; - /** Cancel all pending runs and clear the queue. */ - clear: () => Promise; -} - /** A checkpoint entry in the experimental branch tree. */ export interface AgentBranchTreeNode { type: 'node'; @@ -174,102 +91,12 @@ export interface CustomStreamEvent { data: unknown; } -/** Transport interface for connecting to a LangGraph agent. */ -export interface AgentTransport { - /** Open a streaming connection to an agent and yield events. */ - stream( - assistantId: string, - threadId: string | null, - payload: unknown, - signal: AbortSignal, - options?: LangGraphSubmitOptions, - ): AsyncIterable; - - /** Optional: join an already-started run without creating a new one. */ - joinStream?( - threadId: string, - runId: string, - lastEventId: string | undefined, - signal: AbortSignal, - ): AsyncIterable; - - /** Optional: create a server-side queued run without joining it immediately. */ - createQueuedRun?( - assistantId: string, - threadId: string, - payload: unknown, - signal: AbortSignal, - options?: LangGraphSubmitOptions, - ): Promise; - - /** Optional: cancel a server-side run. */ - cancelRun?( - threadId: string, - runId: string, - signal: AbortSignal, - ): Promise; - - /** Optional: load persisted checkpoint history for a thread. */ - getHistory?( - threadId: string, - signal: AbortSignal, - ): Promise; - - /** - * Optional: update server-side thread state (e.g. to emit RemoveMessage - * entries for regenerate rollback). Forwards to the LangGraph - * `threads.updateState` API. - * - * `options.asNode` corresponds to LangGraph's `as_node` parameter — the - * server treats the update as if that node had just produced the values, - * which determines what the next pull resumes. `regenerate()` passes - * `asNode: '__start__'` so the next `submit(null)` resumes at the entry - * node and re-runs `generate` against the rolled-back state. - */ - updateState?( - threadId: string, - values: Record, - signal: AbortSignal, - options?: { asNode?: string }, - ): Promise; -} - // ── Options ────────────────────────────────────────────────────────────────── /** Options for creating a LangGraph-backed agent via {@link agent}. */ // The second generic is retained for source compatibility with existing typed // AgentOptions references even though the options shape no longer depends on it. // eslint-disable-next-line @typescript-eslint/no-unused-vars -/** - * Tuning options for the underlying LangGraph SDK `Client` constructed by the - * default {@link FetchStreamTransport}. Ignored when a custom `transport` is - * supplied (the transport owns its own client). - */ -export interface LangGraphClientOptions { - /** - * Headers attached to every request the SDK client makes, including the run - * stream. Use this for a per-user session token that your LangGraph - * deployment's custom `authenticate` handler validates. - * - * There is deliberately no `apiKey` option: a deployment credential passed - * from Angular is bundled into the browser build like any other constant. - * Keep deployment keys on a same-origin proxy or gateway you own, and point - * `apiUrl` at it. The client is constructed with `apiKey: null`, so the SDK - * never attaches a key from the environment either. - */ - defaultHeaders?: Record; - /** - * How many times a failed request — including the initial stream connect — - * is retried with exponential backoff before the error surfaces. Maps to the - * SDK's `callerOptions.maxRetries`. Omitted → the SDK default (currently 4). - * - * Set `0` to fail fast: useful for e2e tests that force a connection failure - * and assert the error surfaces promptly, rather than after the full - * multi-second backoff window. - */ - maxRetries?: number; -} - export interface AgentOptions { /** Base URL of the LangGraph Platform API. Defaults to `provideAgent({ apiUrl })` when omitted. */ apiUrl?: string; diff --git a/libs/langgraph/src/lib/client/create-langgraph-client.ts b/libs/langgraph/src/lib/client/create-langgraph-client.ts index e10982058..e72fe48da 100644 --- a/libs/langgraph/src/lib/client/create-langgraph-client.ts +++ b/libs/langgraph/src/lib/client/create-langgraph-client.ts @@ -1,5 +1,5 @@ import { Client } from '@langchain/langgraph-sdk'; -import type { LangGraphClientOptions } from '../agent.types'; +import type { LangGraphClientOptions } from '../../runtime/transport.types'; /** * Construct a LangGraph SDK Client that accepts both absolute URLs @@ -73,5 +73,6 @@ function constructLangGraphClient( * share the same normalization logic. */ export function toAbsoluteApiUrl(apiUrl: string): string { if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) return apiUrl; - return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl; + const browser = globalThis as typeof globalThis & { window?: { location: { origin: string } } }; + return browser.window !== undefined ? `${browser.window.location.origin}${apiUrl}` : apiUrl; } diff --git a/libs/langgraph/src/lib/runtime-operation-reporter.ts b/libs/langgraph/src/lib/runtime-operation-reporter.ts index c1cf996be..d3b05c836 100644 --- a/libs/langgraph/src/lib/runtime-operation-reporter.ts +++ b/libs/langgraph/src/lib/runtime-operation-reporter.ts @@ -1,146 +1,16 @@ import { InjectionToken } from '@angular/core'; -export type RuntimeOperationFailureReporter = ( - code: 'unauthorized' | 'network_blocked' -) => void; +import type { RuntimeOperationFailureReporter } from '../runtime/operation-errors'; +export { + createLangGraphRuntimeFetch, + projectLangGraphOperationFailure, + sanitizeLangGraphClientOperationFailure, + createSafeRequestError, + type RuntimeOperationFailureReporter, +} from '../runtime/operation-errors'; /** @internal Cockpit-only, generation-bound operation failure reporter. */ export const ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER = new InjectionToken( 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' ); - -const networkFailures = new WeakSet(); -const RESPONSE_STATUS_GETTER = typeof Response === 'undefined' - ? undefined - : Object.getOwnPropertyDescriptor(Response.prototype, 'status')?.get; -const SIGNAL_ABORTED_GETTER = typeof AbortSignal === 'undefined' - ? undefined - : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - -/** @internal SDK fetch seam used only by the default transport. */ -export function createLangGraphRuntimeFetch( - reportOperationFailure: RuntimeOperationFailureReporter | undefined, -): typeof fetch { - return async (input, init) => { - const signalState = inspectRequestSignal(init); - if (signalState === 'invalid') throw createSafeRequestError(); - if (signalState === 'aborted') throw createAbortError(); - - let response: Response; - try { - response = await globalThis.fetch(input, init); - } catch { - const rejectedSignalState = inspectRequestSignal(init); - if (rejectedSignalState === 'invalid') throw createSafeRequestError(); - if (rejectedSignalState === 'aborted') throw createAbortError(); - const failure = new Error('The LangGraph request failed.'); - networkFailures.add(failure); - throw failure; - } - - const status = readResponseStatus(response); - if (status !== null && status >= 200 && status < 300) return response; - if (status === 401 || status === 403) { - safeReport(reportOperationFailure, 'unauthorized'); - } - return sanitizedFailureResponse(status); - }; -} - -/** @internal Projects failures proven to originate at the default SDK boundary. */ -export function projectLangGraphOperationFailure( - error: unknown, - signal: AbortSignal, - reportOperationFailure: RuntimeOperationFailureReporter | undefined, -): never { - const signalState = inspectSignal(signal); - if (signalState === 'aborted') throw createAbortError(); - // Branding happened at the owned fetch boundary. The final SDK catch may - // recognize only that unforgeable brand; it never inspects - // arbitrary status, name, message, body, or header fields. - if (isNetworkFailure(error)) safeReport(reportOperationFailure, 'network_blocked'); - throw createSafeRequestError(); -} - -/** @internal Projects a completed SDK client operation at the owned fetch seam. */ -export function sanitizeLangGraphClientOperationFailure( - error: unknown, - reportOperationFailure: RuntimeOperationFailureReporter | undefined -): Error { - if (isNetworkFailure(error)) { - safeReport(reportOperationFailure, 'network_blocked'); - } - return createSafeRequestError(); -} - -export function createSafeRequestError(): Error { - const safe = new Error('The LangGraph request failed.'); - safe.name = 'LangGraphRequestError'; - return safe; -} - -function readResponseStatus(response: Response): number | null { - try { - if (!RESPONSE_STATUS_GETTER) return null; - const status = RESPONSE_STATUS_GETTER.call(response) as unknown; - return typeof status === 'number' ? status : null; - } catch { - return null; - } -} - -function sanitizedFailureResponse(status: number | null): Response { - const safeStatus = status !== null && status >= 200 && status <= 599 ? status : 500; - return new Response(null, { status: safeStatus }); -} - -type SignalState = 'absent' | 'active' | 'aborted' | 'invalid'; - -function inspectRequestSignal(init: RequestInit | undefined): SignalState { - try { - return inspectSignal(init?.signal); - } catch { - return 'invalid'; - } -} - -function inspectSignal(signal: AbortSignal | null | undefined): SignalState { - if (signal == null) return 'absent'; - if (!SIGNAL_ABORTED_GETTER) return 'invalid'; - try { - const aborted = SIGNAL_ABORTED_GETTER.call(signal) as unknown; - if (typeof aborted !== 'boolean') return 'invalid'; - return aborted ? 'aborted' : 'active'; - } catch { - return 'invalid'; - } -} - -function isNetworkFailure(error: unknown): boolean { - try { - return (typeof error === 'object' && error !== null) || typeof error === 'function' - ? networkFailures.has(error as object) - : false; - } catch { - return false; - } -} - -function createAbortError(): Error { - const error = new Error('AbortError'); - error.name = 'AbortError'; - return error; -} - -function safeReport( - reporter: RuntimeOperationFailureReporter | undefined, - code: 'unauthorized' | 'network_blocked', -): void { - if (!reporter) return; - try { - reporter(code); - } catch { - // Reporting is isolated from request control flow. - } -} diff --git a/libs/langgraph/src/lib/transport/fetch-stream.transport.spec.ts b/libs/langgraph/src/lib/transport/fetch-stream.transport.spec.ts index 86a9713e4..688a3ef59 100644 --- a/libs/langgraph/src/lib/transport/fetch-stream.transport.spec.ts +++ b/libs/langgraph/src/lib/transport/fetch-stream.transport.spec.ts @@ -769,3 +769,23 @@ describe('FetchStreamTransport', () => { expect(result).toBe(history); }); }); + + +describe('SDK iterator cleanup', () => { + it.each([false, true])('preserves the primary error when cleanup fails (protected=%s)', async (protectedErrors) => { + const primary = new Error('primary failure'); + const close = vi.fn().mockRejectedValue(new Error('cleanup failure')); + mocks.runsStream.mockReturnValue({ + [Symbol.asyncIterator]: () => ({ next: async () => { throw primary; }, return: close }), + }); + const transport = new FetchStreamTransport('https://runtime.example', undefined, protectedErrors ? { defaultHeaders: {} } : undefined); + const error = await collect(transport.stream('assistant', 'thread', {}, new AbortController().signal)).catch((reason: unknown) => reason); + expect(close).toHaveBeenCalledTimes(1); + if (protectedErrors) { + expect(error).toMatchObject({ name: 'LangGraphRequestError', message: 'The LangGraph request failed.' }); + expect(error).not.toHaveProperty('cause'); + } else { + expect(error).toBe(primary); + } + }); +}); diff --git a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts index fe487cf5b..a69852e00 100644 --- a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts +++ b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts @@ -1,5 +1,5 @@ import type { Client, StreamMode, ThreadState } from '@langchain/langgraph-sdk'; -import type { AgentQueueEntry, AgentTransport, LangGraphClientOptions, LangGraphSubmitOptions, StreamEvent } from '../agent.types'; +import type { AgentQueueEntry, AgentTransport, LangGraphClientOptions, LangGraphSubmitOptions, StreamEvent } from '../../runtime/transport.types'; import { createLangGraphClient, ɵcreateProtectedLangGraphClient, @@ -8,7 +8,7 @@ import { createLangGraphRuntimeFetch, projectLangGraphOperationFailure, type RuntimeOperationFailureReporter, -} from '../runtime-operation-reporter'; +} from '../../runtime/operation-errors'; /** * Production transport that connects to a LangGraph Platform API via HTTP and SSE. @@ -214,18 +214,37 @@ export class FetchStreamTransport implements AgentTransport { } catch (error) { return this.rethrowOperationError(error, signal); } - while (true) { - let next: IteratorResult<{ event: string; data: unknown }>; - try { - next = await iterator.next(); - } catch (error) { - return this.rethrowOperationError(error, signal); + let completed = false; + let failed = false; + try { + while (true) { + let next: IteratorResult<{ event: string; data: unknown }>; + try { + next = await iterator.next(); + } catch (error) { + return this.rethrowOperationError(error, signal); + } + if (next.done) { + completed = true; + return; + } + try { + yield normalizeSdkEvent(next.value.event as StreamEvent['type'], next.value.data); + } catch (error) { + return this.rethrowLocalError(error, signal); + } } - if (next.done) return; - try { - yield normalizeSdkEvent(next.value.event as StreamEvent['type'], next.value.data); - } catch (error) { - return this.rethrowLocalError(error, signal); + } catch (error) { + failed = true; + throw error; + } finally { + if (!completed) { + try { + await iterator.return?.(); + } catch (error) { + // Closing the SDK iterator must not replace an already projected failure. + if (!failed) this.rethrowOperationError(error, signal); + } } } } diff --git a/libs/langgraph/src/lib/transport/transport.interface.ts b/libs/langgraph/src/lib/transport/transport.interface.ts index bc8794cce..afe1dcd38 100644 --- a/libs/langgraph/src/lib/transport/transport.interface.ts +++ b/libs/langgraph/src/lib/transport/transport.interface.ts @@ -1 +1 @@ -export { AgentTransport, StreamEvent } from '../agent.types'; +export type { AgentTransport, StreamEvent } from '../../runtime/transport.types'; diff --git a/libs/langgraph/src/runtime/create-session.ts b/libs/langgraph/src/runtime/create-session.ts new file mode 100644 index 000000000..3e8ef75fb --- /dev/null +++ b/libs/langgraph/src/runtime/create-session.ts @@ -0,0 +1,596 @@ +import { + completeDelivery, + type AgentError, + type AgentSession, + type CompleteOutcome, + type ToolCall, +} from '@threadplane/core'; +import type { + CheckedTools, + FunctionToolDefinition, + ToolContracts, + ToolExecutionStore, +} from '@threadplane/core/tools'; +import type { ThreadState } from '@langchain/langgraph-sdk'; +import { FetchStreamTransport } from '../lib/transport/fetch-stream.transport'; +import { initialMessageState, reduceMessages } from './message-reducer'; +import { createPublication } from './publication'; +import { + failureProjection, + finalizeProjection, + hasPause, + interruptionError, + projectStream, + record, + type StreamProjection, +} from './stream-projection'; +import type { + AgentTransport, + LangGraphClientOptions, + StreamEvent, +} from './transport.types'; +import { + cancelledResult, + captureTools, + createToolBuffer, + executeTool, + resultCall, +} from './function-tools'; + +export interface SessionOptions { + readonly assistantId: string; + readonly threadId: string; + readonly transport?: AgentTransport; + readonly apiUrl?: string; + /** The owned SDK transport defaults maxRetries to 0: an ambiguous failed + * run-creation POST must not silently create another run. A supplied numeric + * maxRetries explicitly opts into SDK request retries. Custom transports own + * their retry policy; joining a known run is separate from replaying submit. */ + readonly clientOptions?: LangGraphClientOptions; + readonly executionStore?: ToolExecutionStore; +} + +interface Attempt { + readonly controller: AbortController; + readonly generation: string; + readonly result: Promise; + readonly resolve: (outcome: CompleteOutcome) => void; + readonly input: { + messages: { type: 'human'; id: string; content: string }[]; + }; + projection: StreamProjection; + readonly calls: Map; + handoffIds: readonly string[]; + iterator?: AsyncIterator; + unlink?: () => void; + closed?: boolean; +} + +/** Private development composition. Construction/observation perform no I/O. + * stop settles local ownership, not remote side effects. dispose is permanent: + * submits resolve aborted, subscriptions are inert, reads retain the final + * snapshot, stop/dispose are idempotent. checkStatus rejects after disposal or + * during a run; it only reads history and never creates another logical run. */ +export function createSession>( + options: SessionOptions & { readonly tools: T & CheckedTools } +): AgentSession>; +export function createSession( + options: SessionOptions & { readonly tools?: undefined } +): AgentSession; +export function createSession( + options: SessionOptions & { + readonly tools?: Record; + } +): AgentSession { + const { assistantId, threadId } = options; + const { definitions, catalog } = captureTools(options.tools); + const typedTools = options.tools !== undefined; + const store = options.executionStore && { + claim: options.executionStore.claim.bind(options.executionStore), + record: options.executionStore.record.bind(options.executionStore), + }; + const buffer = createToolBuffer(); + const resolvedTools = new Set(); + const transport = + options.transport ?? + new FetchStreamTransport(options.apiUrl ?? '', undefined, { + ...options.clientOptions, + maxRetries: options.clientOptions?.maxRetries ?? 0, + }); + const protectedTransport = + transport instanceof FetchStreamTransport && + transport.protectsOperationErrors; + const canCheck = typeof transport.getHistory === 'function'; + const publication = createPublication({ + status: 'idle', + messages: [], + toolCalls: [], + }); + let state = initialMessageState(); + let owner: Attempt | undefined; + let recoveryAttempt: Attempt | undefined; + let disposed = false; + let revision = 0; + let checkController: AbortController | undefined; + + const owns = (attempt: Attempt) => owner === attempt && !disposed; + function publish(status: 'idle' | 'running' | 'error', error?: AgentError) { + publication.publish({ + status, + messages: state.messages, + toolCalls: typedTools + ? state.toolCalls.filter( + (call) => + definitions.has(call.name) && + (resolvedTools.has(call.id) || + !state.messages.some( + (message) => + message.role === 'tool' && message.toolCallId === call.id + )) + ) + : state.toolCalls, + ...(error ? { error } : {}), + }); + } + function invalidateCheck() { + revision += 1; + const previous = checkController; + checkController = undefined; + return previous; + } + function close(attempt: Attempt, abort = false, final = true) { + const unlink = final ? attempt.unlink : undefined; + if (final) attempt.unlink = undefined; + const iterator = attempt.closed ? undefined : attempt.iterator; + if (iterator) attempt.closed = true; + // Cleanup can synchronously call session commands. Callers must commit all + // ownership/state changes before invoking it, and never overwrite afterward. + if (abort) attempt.controller.abort(); + unlink?.(); + if (!iterator) return; + // return() may wait for a noncooperative next(); local settlement never does. + try { + void Promise.resolve(iterator.return?.()).catch(() => undefined); + } catch { + /* Cleanup cannot revive a settled attempt. */ + } + } + function settle( + attempt: Attempt, + outcome: CompleteOutcome, + error?: AgentError + ) { + if (!owns(attempt)) return; + detach(outcome); + recoveryAttempt = error?.recovery === 'check' ? attempt : undefined; + publish(error ? 'error' : 'idle', error); + // An error can end local consumption before the HTTP body closes. SDK + // iterator return releases its reader; abort also cancels our request. + close(attempt, outcome === 'error'); + } + function detach(outcome: CompleteOutcome): Attempt | undefined { + const attempt = owner; + if (!attempt) return; + owner = undefined; + recoveryAttempt = undefined; + for (const call of attempt.calls.values()) { + const current = state.toolCalls.find((entry) => entry.id === call.id); + if (current?.status !== 'running') continue; + resolvedTools.add(call.id); + state = reduceMessages(state, { + type: 'tool', + toolCall: resultCall(call, cancelledResult(call.id)), + }); + } + state = reduceMessages(state, { + type: 'complete', + generation: attempt.generation, + outcome, + }); + attempt.resolve(outcome); + return attempt; + } + + async function flushTools(signal: AbortSignal) { + const batch = buffer.snapshot(); + if (!batch.messages.length) return; + if (!transport.updateState) + throw new Error( + 'Persisting terminal tool results requires transport.updateState().' + ); + await transport.updateState(threadId, { messages: batch.messages }, signal); + batch.acknowledge(); + } + + async function executeTools(attempt: Attempt, groups: number) { + const calls = state.toolCalls.filter( + (call) => + call.status === 'pending' && + definitions.has(call.name) && + attempt.projection.toolCallIds?.includes(call.id) && + !resolvedTools.has(call.id) + ); + if (!calls.length) return false; + // Capture all calls before publication: observers may synchronously stop. + for (const call of calls) { + attempt.calls.set(call.id, call); + state = reduceMessages(state, { + type: 'tool', + toolCall: { ...call, status: 'running' }, + }); + } + publish('running'); + await Promise.all( + calls.map(async (call) => { + const definition = definitions.get(call.name); + if (!definition) return; + const result = await executeTool( + definition, + call, + attempt.controller.signal, + { threadId, toolCallId: call.id }, + store, + groups >= 10 + ); + resolvedTools.add(call.id); + buffer.stage(call.id, result); + if (owns(attempt)) { + state = reduceMessages(state, { + type: 'tool', + toolCall: resultCall(call, result), + }); + publish('running'); + } + if (!owns(attempt)) { + // Required durable cleanup may finish after stop/dispose. It can only + // persist results; it has no route back to publication or run creation. + void flushTools(new AbortController().signal).catch(() => undefined); + } + }) + ); + return ( + groups < 10 && calls.some((call) => definitions.get(call.name)?.followUp) + ); + } + function stopExecution() { + const checking = invalidateCheck(); + const attempt = detach('aborted'); + if (attempt) publish('idle'); + checking?.abort(); + if (attempt) close(attempt, true); + } + + function reconcile( + attempt: Attempt, + history: ThreadState[] + ): CompleteOutcome | undefined { + const latest = history[0]; + if (!latest) return undefined; + const values = record(latest.values); + const messages = Array.isArray(values?.['messages']) + ? values['messages'] + : []; + // Inert construction gives us no server baseline. Only our unique submitted + // user ID can correlate this checkpoint to this request, including when the + // persisted assistant retains the ID of its earlier streamed partial. + const anchor = messages.findIndex( + (value) => record(value)?.['id'] === attempt.projection.userId + ); + if (anchor < 0) return undefined; + const after = messages.slice(anchor + 1); + const nextUser = after.findIndex((value) => { + const message = record(value); + return message?.['type'] === 'human' || message?.['role'] === 'user'; + }); + const turn = nextUser < 0 ? after : after.slice(0, nextUser); + // A continuation cannot be recovered from the earlier tool-producing step. + // Its exact handoff must be present, with conclusive activity after it. + const handoffs = attempt.handoffIds.map((id) => + turn.findIndex((value) => record(value)?.['id'] === id) + ); + if (handoffs.some((index) => index < 0)) return undefined; + const evidence = handoffs.length + ? turn.slice(Math.max(...handoffs) + 1) + : turn; + const paused = + nextUser < 0 && + (hasPause(values) || + latest.tasks?.some((task) => (task.interrupts?.length ?? 0) > 0)); + const committed = + (nextUser >= 0 || latest.next.length === 0) && + evidence.some((value) => { + const message = record(value); + return ( + message && + (message['type'] === 'ai' || + (attempt.handoffIds.length === 0 && message['type'] === 'tool') || + message['role'] === 'assistant') && + typeof message['id'] === 'string' + ); + }); + if (!paused && !committed) return undefined; + const projected = projectStream(state, attempt.projection, { + type: 'values', + data: { ...values, messages: [messages[anchor], ...turn] }, + }); + attempt.projection = projected.projection; + state = finalizeProjection(projected.state, projected.projection); + return paused ? 'paused' : 'success'; + } + + async function execute(attempt: Attempt): Promise { + if (!owns(attempt)) return; + try { + let groups = 0; + let input: { readonly messages: readonly unknown[] } = attempt.input; + while (owns(attempt)) { + const batch = buffer.snapshot(); + attempt.handoffIds = + groups > 0 ? batch.messages.map((message) => message.id) : []; + const payload = { + messages: [...batch.messages, ...input.messages], + ...(catalog.length ? { client_tools: catalog } : {}), + }; + // Ownership is captured before the first effect. The signal always belongs + // to us, even when the caller also supplied an external AbortSignal. + attempt.iterator = transport + .stream(assistantId, threadId, payload, attempt.controller.signal) + [Symbol.asyncIterator](); + if (!owns(attempt)) { + close(attempt); + return; + } + while (owns(attempt)) { + const next = await attempt.iterator.next(); + if (!owns(attempt)) return; + if (next.done) break; + const event = next.value; + if (event.type === 'error' && !event.namespace?.length) { + const error = failureProjection( + event['data'] ?? event, + protectedTransport, + true, + canCheck + ); + settle(attempt, 'error', error); + return; + } + const projected = projectStream(state, attempt.projection, event); + state = projected.state; + attempt.projection = projected.projection; + publish('running'); + // publish drains observer commands before returning. Never dispatch or + // read on behalf of an attempt a listener just stopped/superseded. + if (!owns(attempt)) return; + } + if (!owns(attempt)) return; + let outcome: CompleteOutcome = attempt.projection.paused + ? 'paused' + : attempt.projection.terminal + ? 'success' + : 'interrupted'; + if (outcome === 'interrupted' && transport.getHistory) { + try { + const history = await transport.getHistory( + threadId, + attempt.controller.signal + ); + if (!owns(attempt)) return; + outcome = reconcile(attempt, history) ?? outcome; + } catch { + if (!owns(attempt)) return; + } + } + if (!owns(attempt)) return; + if (outcome === 'success' || outcome === 'paused') + state = finalizeProjection(state, attempt.projection); + if (outcome === 'success') { + state = reduceMessages(state, { + type: 'complete', + generation: attempt.generation, + outcome: 'success', + }); + batch.acknowledge(); + const followUp = await executeTools(attempt, groups); + if (!owns(attempt)) return; + if (followUp) { + groups += 1; + close(attempt, false, false); + if (!owns(attempt)) return; + attempt.closed = false; + attempt.iterator = undefined; + attempt.projection = { + generation: attempt.generation, + userId: attempt.projection.userId, + baselineIds: state.messages.map((message) => message.id), + sawAssistant: false, + terminal: false, + paused: false, + canonical: [], + }; + input = { messages: [] }; + continue; + } + try { + await flushTools(attempt.controller.signal); + } catch { + if (owns(attempt)) + settle(attempt, 'error', { + kind: 'server', + message: + 'Tool results could not be persisted. They remain staged for the next submission.', + retryable: false, + recovery: 'none', + }); + return; + } + if (!owns(attempt)) return; + } + settle( + attempt, + outcome, + outcome === 'interrupted' ? interruptionError(canCheck) : undefined + ); + return; + } + } catch (raw) { + if (!owns(attempt)) return; + const error = failureProjection(raw, protectedTransport, false, canCheck); + settle( + attempt, + error.kind === 'interrupted' ? 'interrupted' : 'error', + error + ); + } + } + + function submit( + input: string, + submitOptions?: { signal?: AbortSignal } + ): Promise { + let attempt: Attempt | undefined; + const beginning = publication.command(() => { + if (disposed || submitOptions?.signal?.aborted) return; + const checking = invalidateCheck(); + const previous = detach('interrupted'); + const generation = crypto.randomUUID(); + const userId = crypto.randomUUID(); + let resolve!: Attempt['resolve']; + const result = new Promise((done) => { + resolve = done; + }); + attempt = { + controller: new AbortController(), + generation, + result, + resolve, + calls: new Map(), + handoffIds: [], + input: { messages: [{ type: 'human', id: userId, content: input }] }, + projection: { + generation, + userId, + baselineIds: state.messages.map((m) => m.id), + sawAssistant: false, + terminal: false, + paused: false, + canonical: [], + }, + }; + const created = attempt; + owner = created; + recoveryAttempt = undefined; + const external = submitOptions?.signal; + if (external) { + const abort = () => { + void publication.command(() => { + if (owns(created)) { + stopExecution(); + } + }); + }; + external.addEventListener('abort', abort, { once: true }); + created.unlink = () => external.removeEventListener('abort', abort); + } + state = reduceMessages(state, { + type: 'message', + mode: 'snapshot', + message: { + id: userId, + role: 'user', + content: input, + delivery: completeDelivery(generation, 'success'), + }, + }); + publish('running'); + checking?.abort(); + if (previous) close(previous, true); + }); + // Even nested observer commands finish draining before this continuation can + // issue I/O. A stop/dispose from the running publication can prevent it. + return beginning.then(() => { + if (!attempt) return 'aborted'; + void execute(attempt); + return attempt.result; + }); + } + + async function checkStatus() { + let checking: + | { revision: number; attempt: Attempt; controller: AbortController } + | undefined; + await publication.command(() => { + if (disposed) throw new Error('Agent has been disposed'); + if (owner) + throw new Error('Stop the active request before checking status'); + if (!recoveryAttempt) return; + const previous = invalidateCheck(); + checkController = new AbortController(); + checking = { + revision, + attempt: recoveryAttempt, + controller: checkController, + }; + previous?.abort(); + }); + if (!checking || disposed || checking.revision !== revision) return; + const captured = checking; + try { + const history = await transport.getHistory?.( + threadId, + captured.controller.signal + ); + await publication.command(() => { + if (disposed || captured.revision !== revision || !history) return; + const outcome = reconcile(captured.attempt, history); + if (!outcome) return; + recoveryAttempt = undefined; + checkController = undefined; + // Interrupted deliveries are already complete: recovered canonical + // history carries the conclusive success stamp in this aggregate. + state = { + ...state, + messages: state.messages.map((message) => + message.delivery.generation === captured.attempt.generation + ? { + ...message, + delivery: completeDelivery( + captured.attempt.generation, + outcome + ), + } + : message + ), + }; + publish('idle'); + }); + } finally { + if (checkController === captured.controller) checkController = undefined; + } + } + + return { + getSnapshot: publication.getSnapshot, + subscribe: (notify) => + disposed ? () => undefined : publication.subscribe(notify), + submit, + stop: () => + publication.command(() => { + if (disposed) return; + stopExecution(); + }), + ...(canCheck ? { checkStatus } : {}), + dispose: () => + publication.command(() => { + if (disposed) return; + disposed = true; + const checking = invalidateCheck(); + const attempt = detach('aborted'); + recoveryAttempt = undefined; + publish('idle'); + publication.clearListeners(); + checking?.abort(); + if (attempt) close(attempt, true); + }), + }; +} diff --git a/libs/langgraph/src/runtime/function-tools.ts b/libs/langgraph/src/runtime/function-tools.ts new file mode 100644 index 000000000..90d9bd0a5 --- /dev/null +++ b/libs/langgraph/src/runtime/function-tools.ts @@ -0,0 +1,248 @@ +import type { PlainValue, ToolCall } from '@threadplane/core'; +import type { + FunctionToolDefinition, + ToolExecutionKey, + ToolExecutionResult, + ToolExecutionStore, +} from '@threadplane/core/tools'; +import { ownToolCall, ownValue } from './ownership'; + +export interface ToolMessage { + readonly id: string; + readonly role: 'tool'; + readonly type: 'tool'; + readonly tool_call_id: string; + readonly content: string; +} + +export function captureTools( + tools: Readonly> = {} +) { + const definitions = new Map( + Object.entries(tools).map( + ([name, def]) => + [ + name, + { + description: def.description, + parameters: + def.parameters === undefined + ? undefined + : ownValue(def.parameters), + handler: def.handler, + followUp: def.followUp !== false, + idempotent: def.idempotent === true, + }, + ] as const + ) + ); + const catalog = Object.freeze( + [...definitions].map(([name, def]) => + Object.freeze({ + name, + description: def.description, + ...(def.parameters !== undefined ? { parameters: def.parameters } : {}), + }) + ) + ); + return { definitions, catalog }; +} + +export function cancelledResult(id: string): ToolExecutionResult { + return { + ok: false, + error: `Client tool execution cancelled before completion: ${id}`, + }; +} + +function guardFailure(id: string, error: unknown): ToolExecutionResult { + return { + ok: false, + error: `Client tool execution guard failed for ${id}: ${ + error instanceof Error ? error.message : String(error) + }`, + }; +} + +export type ExecutionOutcome = + | { readonly type: 'completed'; readonly value: T } + | { readonly type: 'aborted' }; + +/** Observe both promise branches, including a handler that rejects after stop. */ +async function untilAbort( + promise: Promise, + signal: AbortSignal +): Promise> { + let abort: () => void = () => undefined; + const cancelled = new Promise>((resolve) => { + abort = () => resolve({ type: 'aborted' }); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([ + cancelled, + promise.then( + (value): ExecutionOutcome => + signal.aborted ? { type: 'aborted' } : { type: 'completed', value } + ), + ]); + } finally { + signal.removeEventListener('abort', abort); + } +} + +/** One call, owned by one command. Claim/record may outlive the command: a late + * acquired claim must record cancellation, while a record already in flight + * keeps its original result. The caller controls stale UI and run publication. */ +export async function executeTool( + definition: ReturnType['definitions'] extends Map< + string, + infer D + > + ? D + : never, + call: ToolCall, + signal: AbortSignal, + key: ToolExecutionKey, + store?: ToolExecutionStore, + blocked = false +): Promise { + if (signal.aborted) return cancelledResult(call.id); + const guard = definition.idempotent ? undefined : store; + async function record(result: ToolExecutionResult) { + if (!guard) return result; + const captured = ownResult(result); + try { + await guard.record(key, captured); + return captured; + } catch (error) { + return guardFailure(call.id, error); + } + } + if (guard) { + try { + const claim = await guard.claim(key); + if (signal.aborted) { + if (claim === 'claimed') return record(cancelledResult(call.id)); + // Prior completed facts belong to the durable store, not the cancelled + // attempt. Reuse them without invoking a handler or overwriting a row. + if (claim.status === 'done') return ownResult(claim.result); + if (claim.status === 'failed' && claim.result) + return ownResult(claim.result); + return cancelledResult(call.id); + } + if (claim !== 'claimed') { + if (claim.status === 'done') return ownResult(claim.result); + return record( + claim.status === 'failed' && claim.result + ? ownResult(claim.result) + : { + ok: false, + error: `Client tool execution interrupted before completion: ${call.id}`, + } + ); + } + } catch (error) { + return signal.aborted + ? cancelledResult(call.id) + : guardFailure(call.id, error); + } + } + if (signal.aborted) return record(cancelledResult(call.id)); + if (blocked) + return record({ + ok: false, + error: 'Client tool continuation limit reached.', + }); + // The authored contract is the argument type authority. The protocol boundary + // owns plain data; this mutable copy is intentionally independent of snapshots. + const execution = Promise.resolve().then( + async (): Promise => { + if (signal.aborted) return cancelledResult(call.id); + try { + const value = await definition.handler( + structuredClone(call.args) as never, + { signal } + ); + return { ok: true, value: ownValue(value as PlainValue) }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + ); + const outcome = await untilAbort(execution, signal); + return record( + outcome.type === 'aborted' ? cancelledResult(call.id) : outcome.value + ); +} + +function ownResult(result: ToolExecutionResult): ToolExecutionResult { + return Object.freeze( + result.ok + ? { ok: true, value: ownValue(result.value) } + : { ok: false, error: result.error } + ); +} + +export function resultCall( + call: ToolCall, + result: ToolExecutionResult +): ToolCall { + return ownToolCall( + result.ok + ? { + id: call.id, + name: call.name, + args: call.args, + status: 'complete', + result: result.value, + } + : { + id: call.id, + name: call.name, + args: call.args, + status: 'error', + error: result.error, + } + ); +} + +/** Fixed-thread buffer. Snapshots retain exact entry identity; an old successful + * write can never acknowledge a replacement or a result staged afterward. */ +export function createToolBuffer() { + const entries = new Map(); + return { + stage(id: string, result: ToolExecutionResult) { + entries.set( + id, + Object.freeze({ + id: `client-tool-result-${id}`, + role: 'tool', + type: 'tool', + tool_call_id: id, + content: result.ok + ? result.value === undefined + ? '' + : typeof result.value === 'string' + ? result.value + : JSON.stringify(result.value) + : `Error: ${result.error}`, + }) + ); + }, + snapshot() { + const captured = [...entries]; + return { + messages: captured.map(([, message]) => message), + acknowledge() { + for (const [id, message] of captured) + if (entries.get(id) === message) entries.delete(id); + }, + }; + }, + }; +} diff --git a/libs/langgraph/src/runtime/function-tools.type-test.ts b/libs/langgraph/src/runtime/function-tools.type-test.ts new file mode 100644 index 000000000..3a9fc6b1f --- /dev/null +++ b/libs/langgraph/src/runtime/function-tools.type-test.ts @@ -0,0 +1,63 @@ +/* eslint @typescript-eslint/no-unused-vars: ["warn", { "argsIgnorePattern": "^_" }] */ +import type { AgentSession } from '@threadplane/core'; +import { createSession } from './create-session'; + +interface Args { + city: string; +} +interface Result { + temperature: number; +} +const session = createSession({ + assistantId: 'a', + threadId: 't', + tools: { + weather: { + description: 'Weather', + handler: async (args: Args): Promise => ({ + temperature: args.city.length, + }), + }, + ping: { description: 'Ping', handler: (_args: void): void => undefined }, + }, +}); +const observer: AgentSession = session; +for (const call of session.getSnapshot().toolCalls) { + if (call.name === 'weather' && call.status === 'complete') { + const result: number = call.result.temperature; + const arg: string = call.args.city; + // @ts-expect-error no any result fallback + const wrong: string = call.result.temperature; + // @ts-expect-error no unknown tool-name widening + const wrongName: 'other' = call.name; + void [result, arg, wrong, wrongName]; + } +} +// @ts-expect-error unsupported concrete handler argument data +createSession({ + assistantId: 'a', + threadId: 't', + tools: { invalid: { description: 'No', handler: (_args: Date) => '' } }, +}); +// @ts-expect-error unsupported concrete handler result data +createSession({ + assistantId: 'a', + threadId: 't', + tools: { + invalid: { description: 'No', handler: (_args: string) => new Map() }, + }, +}); +createSession({ + assistantId: 'a', + threadId: 't', + // @ts-expect-error no implicit any handler fallback + tools: { invalid: { description: 'No', handler: (args) => args.missing } }, +}); +void observer; +const unsupported = { + assistantId: 'a', + threadId: 't', + tools: { invalid: { description: 'Invalid', handler: (_args: Date) => '' } }, +}; +// @ts-expect-error a non-fresh options variable cannot select the no-tools overload +createSession(unsupported); diff --git a/libs/langgraph/src/runtime/harness.spec.ts b/libs/langgraph/src/runtime/harness.spec.ts new file mode 100644 index 000000000..3316fa94a --- /dev/null +++ b/libs/langgraph/src/runtime/harness.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { deferred } from './testing/deferred'; +import { controlledTransport } from './testing/controlled-transport'; + +describe('runtime test harness', () => { + it('runs without browser globals or Angular test setup', () => { + expect('window' in globalThis).toBe(false); + expect('document' in globalThis).toBe(false); + expect(new AbortController().signal.aborted).toBe(false); + }); + + it('releases each stream event only when directed', async () => { + const transport = controlledTransport(); + const received: string[] = []; + const first = transport.stream.next().then((result) => { + if (!result.done) received.push(result.value); + return result; + }); + + await Promise.resolve(); + expect(received).toEqual([]); + transport.release('first'); + await expect(first).resolves.toEqual({ value: 'first', done: false }); + const second = transport.stream.next(); + expect(received).toEqual(['first']); + transport.release('second'); + await expect(second).resolves.toEqual({ value: 'second', done: false }); + transport.finish(); + await expect(transport.stream.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + await transport.closed; + }); + + it('buffers released events in order before a reader requests them', async () => { + const transport = controlledTransport(); + transport.release(1); + transport.release(2); + transport.finish(); + + const received: number[] = []; + for await (const event of transport.stream) received.push(event); + expect(received).toEqual([1, 2]); + await transport.closed; + }); + + it('can ignore abort and release a late event until the iterator is closed', async () => { + const controller = new AbortController(); + const transport = controlledTransport({ + signal: controller.signal, + ignoreAbort: true, + }); + const pending = transport.stream.next(); + controller.abort(); + transport.release('late'); + await expect(pending).resolves.toEqual({ value: 'late', done: false }); + + const waiting = transport.stream.next(); + await transport.stream.return(); + await transport.closed; + await expect(waiting).resolves.toEqual({ value: undefined, done: true }); + transport.release('after cleanup'); + await expect(transport.stream.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + }); + + it('closes the iterator when a consumer exits its loop', async () => { + const transport = controlledTransport(); + transport.release('first'); + transport.release('discarded'); + for await (const event of transport.stream) { + expect(event).toBe('first'); + break; + } + + await transport.closed; + await expect(transport.stream.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + }); + + it('rejects a pending read on abort unless configured to ignore it', async () => { + const controller = new AbortController(); + const transport = controlledTransport({ + signal: controller.signal, + }); + const reason = new Error('cancelled'); + const read = expect(transport.stream.next()).rejects.toBe(reason); + controller.abort(reason); + + await read; + await transport.closed; + }); + + it('observes a signal that was already aborted', async () => { + const reason = new Error('already cancelled'); + const transport = controlledTransport({ + signal: AbortSignal.abort(reason), + }); + + await expect(transport.stream.next()).rejects.toBe(reason); + await transport.closed; + }); + + it('controls tool, claim, and record completions independently', async () => { + const tool = deferred(); + const claim = deferred(); + const record = deferred(); + const completed: string[] = []; + const toolResult = tool.promise.then((value) => { + completed.push('tool'); + return value; + }); + const claimResult = claim.promise.then((value) => { + completed.push('claim'); + return value; + }); + const recordResult = record.promise.then(() => completed.push('record')); + + claim.resolve(true); + await expect(claimResult).resolves.toBe(true); + expect(completed).toEqual(['claim']); + record.resolve(); + await recordResult; + expect(completed).toEqual(['claim', 'record']); + tool.resolve('tool result'); + await expect(toolResult).resolves.toBe('tool result'); + expect(completed).toEqual(['claim', 'record', 'tool']); + }); + + it('can reject a deferred completion', async () => { + const completion = deferred(); + const reason = new Error('failed'); + const rejected = expect(completion.promise).rejects.toBe(reason); + completion.reject(reason); + await rejected; + }); +}); diff --git a/libs/langgraph/src/runtime/message-reducer.spec.ts b/libs/langgraph/src/runtime/message-reducer.spec.ts new file mode 100644 index 000000000..c51087376 --- /dev/null +++ b/libs/langgraph/src/runtime/message-reducer.spec.ts @@ -0,0 +1,391 @@ +import { + completeDelivery, + streamingDelivery, + type Message, + type ToolCall, +} from '@threadplane/core'; +import { describe, expect, it } from 'vitest'; +import { + initialMessageState, + reduceMessages, + type MessageEvent, +} from './message-reducer'; + +function message( + id: string, + content: string, + role: Message['role'] = 'assistant' +): Message { + return { id, content, role, delivery: streamingDelivery('run-1') }; +} + +describe('pure text and tool transitions', () => { + it('appends repeated identical deltas and shares untouched messages', () => { + const before = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'snapshot', + message: message('user-1', 'Question', 'user'), + }); + const first = reduceMessages(before, { + type: 'message', + mode: 'delta', + message: message('ai-1', '|'), + }); + const second = reduceMessages(first, { + type: 'message', + mode: 'delta', + message: message('ai-1', '|'), + }); + expect(second.messages[1].content).toBe('||'); + expect(second.messages[0]).toBe(before.messages[0]); + expect(first.messages[1].content).toBe('|'); + expect(before.messages).toHaveLength(1); + }); + + it('treats snapshots as cumulative, ignores duplicate/shorter snapshots and blocks late deltas after canonical content', () => { + const first = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'snapshot', + message: message('ai-1', 'hello'), + }); + expect( + reduceMessages(first, { + type: 'message', + mode: 'snapshot', + message: message('ai-1', 'hello'), + }) + ).toBe(first); + expect( + reduceMessages(first, { + type: 'message', + mode: 'snapshot', + message: message('ai-1', 'hel'), + }) + ).toBe(first); + const final = reduceMessages(first, { + type: 'message', + mode: 'canonical', + message: message('ai-1', 'hello world'), + }); + expect( + reduceMessages(final, { + type: 'message', + mode: 'delta', + message: message('ai-1', '!'), + }) + ).toBe(final); + expect( + reduceMessages(final, { + type: 'message', + mode: 'snapshot', + message: message('ai-1', 'hello'), + }) + ).toBe(final); + expect( + reduceMessages(final, { + type: 'message', + mode: 'canonical', + message: message('ai-1', 'hello world'), + }) + ).toBe(final); + }); + + it('preserves an explicitly correlated optimistic ID across server echoes and subsequent server updates', () => { + const first = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'snapshot', + message: message('local', 'hello', 'user'), + }); + const echo = reduceMessages(first, { + type: 'message', + mode: 'snapshot', + existingId: 'local', + message: message('server', 'hello', 'user'), + }); + const repeated = reduceMessages(echo, { + type: 'message', + mode: 'snapshot', + message: message('server', 'hello', 'user'), + }); + expect(echo.messages).toBe(first.messages); + expect(repeated).toBe(echo); + expect(repeated.messages.map((value) => value.id)).toEqual(['local']); + // Equal text alone must not collapse two legitimate user submissions. + const another = reduceMessages(repeated, { + type: 'message', + mode: 'snapshot', + message: message('local-2', 'hello', 'user'), + }); + expect(another.messages).toHaveLength(2); + }); + + it.each(['hello', ''])( + 'replaces draft text exactly with canonical content %j', + (content) => { + const draft = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'delta', + message: message('ai', 'hello world'), + }); + const terminal = reduceMessages(draft, { + type: 'message', + mode: 'canonical', + message: message('ai', content), + }); + expect(terminal.messages[0].content).toBe(content); + expect(draft.messages[0].content).toBe('hello world'); + } + ); + + it.each(['hello world', 'different content'])( + 'retains terminal canonical text after a late cumulative snapshot %j', + (content) => { + const terminal = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'canonical', + message: message('ai', 'hello'), + }); + expect( + reduceMessages(terminal, { + type: 'message', + mode: 'snapshot', + message: message('ai', content), + }) + ).toBe(terminal); + } + ); + + it('allows later snapshot metadata and delivery completion without replacing terminal text', () => { + const terminal = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'canonical', + message: message('ai', 'hello'), + }); + const enriched = reduceMessages(terminal, { + type: 'message', + mode: 'snapshot', + message: { + ...message('ai', 'stale draft'), + name: 'assistant', + toolCallIds: ['call-1'], + delivery: completeDelivery('run-1', 'success'), + }, + }); + expect(enriched.messages[0]).toMatchObject({ + content: 'hello', + name: 'assistant', + toolCallIds: ['call-1'], + delivery: completeDelivery('run-1', 'success'), + }); + expect(terminal.messages[0].delivery.phase).toBe('streaming'); + expect( + reduceMessages(enriched, { + type: 'message', + mode: 'snapshot', + message: message('ai', 'another draft'), + }) + ).toBe(enriched); + }); + + it('accepts an explicitly ordered canonical correction exactly and preserves duplicate identity', () => { + const original = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'canonical', + message: { + ...message('ai', 'hello world'), + toolCallIds: ['call-1'], + delivery: completeDelivery('run-1', 'success'), + }, + }); + const correction: MessageEvent = { + type: 'message', + mode: 'canonical', + message: message('ai', 'hello'), + }; + const corrected = reduceMessages(original, correction); + expect(corrected.messages[0]).toMatchObject({ + id: 'ai', + content: 'hello', + toolCallIds: ['call-1'], + delivery: completeDelivery('run-1', 'success'), + }); + expect(reduceMessages(corrected, correction)).toBe(corrected); + expect( + reduceMessages(corrected, { + type: 'message', + mode: 'delta', + message: message('ai', '!'), + }) + ).toBe(corrected); + expect(original.messages[0].content).toBe('hello world'); + }); + + it('preserves streamed assistant identity across a canonical server ID change', () => { + const first = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'delta', + message: message('chunk', 'hello'), + }); + const final = reduceMessages(first, { + type: 'message', + mode: 'canonical', + existingId: 'chunk', + message: message('server', 'hello world'), + }); + expect(final.messages[0]).toMatchObject({ + id: 'chunk', + content: 'hello world', + }); + expect( + reduceMessages(final, { + type: 'message', + mode: 'delta', + message: message('server', '!'), + }) + ).toBe(final); + }); + + it.each(['success', 'error', 'aborted', 'interrupted', 'paused'] as const)( + 'finalizes only matching streaming delivery as %s', + (outcome) => { + const old = { + ...message('old', 'already complete'), + delivery: completeDelivery('old-run', 'success'), + }; + let state = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'snapshot', + message: old, + }); + state = reduceMessages(state, { + type: 'message', + mode: 'delta', + message: message('ai', 'partial'), + }); + const final = reduceMessages(state, { + type: 'complete', + generation: 'run-1', + outcome, + }); + expect(final.messages[0]).toBe(state.messages[0]); + expect(final.messages[1].delivery).toEqual( + completeDelivery('run-1', outcome) + ); + expect(state.messages[1].delivery.phase).toBe('streaming'); + expect( + reduceMessages(final, { + type: 'complete', + generation: 'run-1', + outcome, + }) + ).toBe(final); + expect( + reduceMessages(final, { + type: 'message', + mode: 'delta', + message: message('ai', 'late'), + }) + ).toBe(final); + } + ); + + it('owns incoming nested values and upserts tool slices without mutating history', () => { + const ids = ['call-1']; + const input = { ...message('ai', 'searching'), toolCallIds: ids }; + const args = { queries: ['first'] }; + const call: ToolCall = { + id: 'call-1', + name: 'search', + status: 'running', + args, + }; + const first = reduceMessages(initialMessageState(), { + type: 'message', + mode: 'delta', + message: input, + }); + const running = reduceMessages(first, { type: 'tool', toolCall: call }); + ids.push('call-2'); + args.queries.push('second'); + expect(running.messages[0].toolCallIds).toEqual(['call-1']); + expect(running.toolCalls[0].args).toEqual({ queries: ['first'] }); + const result = { hits: ['found'] }; + const complete = reduceMessages(running, { + type: 'tool', + toolCall: { + ...call, + args: { queries: ['first'] }, + status: 'complete', + result, + }, + }); + result.hits.push('later'); + expect(complete.toolCalls[0]).toMatchObject({ + status: 'complete', + result: { hits: ['found'] }, + }); + expect(complete.messages).toBe(running.messages); + expect(running.toolCalls[0].status).toBe('running'); + expect(Object.isFrozen(complete.toolCalls[0])).toBe(true); + expect( + reduceMessages(complete, { + type: 'tool', + toolCall: { + id: 'call-1', + name: 'search', + args: { queries: ['first'] }, + status: 'complete', + result: { hits: ['found'] }, + }, + }) + ).toBe(complete); + }); + + it('replays fixed events deterministically without changing the initial state or events', () => { + const initial = initialMessageState(); + const events: readonly MessageEvent[] = [ + { + type: 'message', + mode: 'snapshot', + message: message('user', 'go', 'user'), + }, + { type: 'message', mode: 'delta', message: message('ai', 'a') }, + { type: 'message', mode: 'delta', message: message('ai', 'a') }, + { type: 'complete', generation: 'run-1', outcome: 'success' }, + ]; + const saved = JSON.stringify(events); + const replay = () => events.reduce(reduceMessages, initial); + expect(replay()).toEqual(replay()); + expect(initial.messages).toEqual([]); + expect(JSON.stringify(events)).toBe(saved); + }); + + it('preserves sibling tool calls when a handler failure is published as plain text', () => { + const first: ToolCall = { + id: 'one', + name: 'search', + args: { query: 'one' }, + status: 'running', + }; + const second: ToolCall = { + id: 'two', + name: 'search', + args: { query: 'two' }, + status: 'running', + }; + let state = reduceMessages(initialMessageState(), { + type: 'tool', + toolCall: first, + }); + state = reduceMessages(state, { type: 'tool', toolCall: second }); + const failed = reduceMessages(state, { + type: 'tool', + toolCall: { ...second, status: 'error', error: 'Handler declined' }, + }); + expect(failed.toolCalls[0]).toBe(state.toolCalls[0]); + expect(failed.toolCalls[1]).toMatchObject({ + status: 'error', + error: 'Handler declined', + }); + }); +}); diff --git a/libs/langgraph/src/runtime/message-reducer.ts b/libs/langgraph/src/runtime/message-reducer.ts new file mode 100644 index 000000000..66966ef9a --- /dev/null +++ b/libs/langgraph/src/runtime/message-reducer.ts @@ -0,0 +1,183 @@ +import { + completeDelivery, + type CompleteOutcome, + type Message, + type ToolCall, +} from '@threadplane/core'; +import { + ownMessage, + ownToolCall, + sameMessage, + sameToolCall, +} from './ownership'; + +export interface MessageState { + readonly messages: readonly Message[]; + readonly toolCalls: readonly ToolCall[]; + readonly canonical: readonly { + readonly id: string; + readonly generation: string; + }[]; + readonly aliases: readonly { + readonly from: string; + readonly to: string; + readonly generation: string; + }[]; +} + +/** snapshot is an interim cumulative value. canonical replaces text exactly, + * including shorter/empty text, and bars later delta/snapshot text updates for + * that message/generation. Snapshot metadata and delivery can still finalize. + * Another explicitly ordered canonical event is an authoritative correction; + * the effect owner must establish its ordering before dispatching it here. */ +export type MessageEvent = + | { + readonly type: 'message'; + readonly mode: 'delta' | 'snapshot' | 'canonical'; + readonly message: Message; + readonly existingId?: string; + } + | { readonly type: 'tool'; readonly toolCall: ToolCall } + | { readonly type: 'remove-pending-tools'; readonly ids: readonly string[] } + | { + readonly type: 'complete'; + readonly generation: string; + readonly outcome: CompleteOutcome; + }; + +export function initialMessageState(): MessageState { + return Object.freeze({ + messages: Object.freeze([]), + toolCalls: Object.freeze([]), + canonical: Object.freeze([]), + aliases: Object.freeze([]), + }); +} + +/** Deterministic text/tool projection. The caller supplies IDs and generations; + * no clock, SDK mutation or effect runs here. Cross-ID echoes require explicit + * correlation: equal user text by itself is not evidence of a duplicate turn. */ +export function reduceMessages( + state: MessageState, + event: MessageEvent +): MessageState { + if (event.type === 'remove-pending-tools') { + // Authoritative assistant corrections can retract unexecuted calls. Keep + // running/settled facts and any call still owned by a distinct assistant. + const toolCalls = state.toolCalls.filter( + (call) => + call.status !== 'pending' || + !event.ids.includes(call.id) || + state.messages.some( + (message) => + message.role === 'assistant' && + message.toolCallIds?.includes(call.id) + ) + ); + return toolCalls.length === state.toolCalls.length + ? state + : Object.freeze({ ...state, toolCalls: Object.freeze(toolCalls) }); + } + if (event.type === 'tool') { + const index = state.toolCalls.findIndex( + (call) => call.id === event.toolCall.id + ); + // Replayed finalized arguments cannot reopen a locally settled/executing call. + if ( + index >= 0 && + event.toolCall.status === 'pending' && + state.toolCalls[index].status !== 'pending' + ) + return state; + const incoming = ownToolCall(event.toolCall); + if (index >= 0 && sameToolCall(state.toolCalls[index], incoming)) + return state; + const toolCalls = [...state.toolCalls]; + if (index < 0) toolCalls.push(incoming); + else toolCalls[index] = incoming; + return Object.freeze({ ...state, toolCalls: Object.freeze(toolCalls) }); + } + if (event.type === 'complete') { + let changed = false; + const messages = state.messages.map((message) => { + if ( + message.delivery.generation !== event.generation || + message.delivery.phase === 'complete' + ) + return message; + changed = true; + return ownMessage({ + ...message, + delivery: completeDelivery(event.generation, event.outcome), + }); + }); + return changed + ? Object.freeze({ ...state, messages: Object.freeze(messages) }) + : state; + } + + const incoming = event.message; + const generation = incoming.delivery.generation; + const alias = state.aliases.find( + (entry) => entry.from === incoming.id && entry.generation === generation + ); + const id = event.existingId ?? alias?.to ?? incoming.id; + const index = state.messages.findIndex((message) => message.id === id); + const previous = state.messages[index]; + const sameGeneration = previous?.delivery.generation === generation; + const canonical = state.canonical.some( + (entry) => entry.id === id && entry.generation === generation + ); + if ( + sameGeneration && + event.mode === 'delta' && + (canonical || previous.delivery.phase === 'complete') + ) + return state; + + let content = incoming.content; + if (sameGeneration) { + if (event.mode === 'delta') content = previous.content + incoming.content; + else if ( + event.mode === 'snapshot' && + (canonical || previous.content.startsWith(incoming.content)) + ) + content = previous.content; + } + const candidate: Message = { + ...incoming, + id, + content, + // A late snapshot cannot reopen a finalized generation. + delivery: + sameGeneration && previous.delivery.phase === 'complete' + ? previous.delivery + : incoming.delivery, + toolCallIds: incoming.toolCallIds ?? previous?.toolCallIds, + toolCallId: incoming.toolCallId ?? previous?.toolCallId, + name: incoming.name ?? previous?.name, + }; + let messages = state.messages; + if (!previous || !sameMessage(previous, candidate)) { + const next = [...messages]; + if (index < 0) next.push(ownMessage(candidate)); + else next[index] = ownMessage(candidate); + messages = Object.freeze(next); + } + const aliases = + incoming.id !== id && !alias && previous + ? Object.freeze([ + ...state.aliases, + Object.freeze({ from: incoming.id, to: id, generation }), + ]) + : state.aliases; + const canonicalIds = + event.mode === 'canonical' && !canonical + ? Object.freeze([...state.canonical, Object.freeze({ id, generation })]) + : state.canonical; + return messages === state.messages && + aliases === state.aliases && + canonicalIds === state.canonical + ? state + : Object.freeze({ ...state, messages, aliases, canonical: canonicalIds }); +} diff --git a/libs/langgraph/src/runtime/operation-errors.ts b/libs/langgraph/src/runtime/operation-errors.ts new file mode 100644 index 000000000..184185ddd --- /dev/null +++ b/libs/langgraph/src/runtime/operation-errors.ts @@ -0,0 +1,143 @@ +export type RuntimeOperationFailureReporter = ( + code: 'unauthorized' | 'network_blocked' +) => void; + +const networkFailures = new WeakSet(); +const RESPONSE_STATUS_GETTER = + typeof Response === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Response.prototype, 'status')?.get; +const SIGNAL_ABORTED_GETTER = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + +/** @internal SDK fetch seam used only by the default transport. */ +export function createLangGraphRuntimeFetch( + reportOperationFailure: RuntimeOperationFailureReporter | undefined +): typeof fetch { + return async (input, init) => { + const signalState = inspectRequestSignal(init); + if (signalState === 'invalid') throw createSafeRequestError(); + if (signalState === 'aborted') throw createAbortError(); + + let response: Response; + try { + response = await globalThis.fetch(input, init); + } catch { + const rejectedSignalState = inspectRequestSignal(init); + if (rejectedSignalState === 'invalid') throw createSafeRequestError(); + if (rejectedSignalState === 'aborted') throw createAbortError(); + const failure = new Error('The LangGraph request failed.'); + networkFailures.add(failure); + throw failure; + } + + const status = readResponseStatus(response); + if (status !== null && status >= 200 && status < 300) return response; + if (status === 401 || status === 403) { + safeReport(reportOperationFailure, 'unauthorized'); + } + return sanitizedFailureResponse(status); + }; +} + +/** @internal Projects failures proven to originate at the default SDK boundary. */ +export function projectLangGraphOperationFailure( + error: unknown, + signal: AbortSignal, + reportOperationFailure: RuntimeOperationFailureReporter | undefined +): never { + const signalState = inspectSignal(signal); + if (signalState === 'aborted') throw createAbortError(); + // Branding happened at the owned fetch boundary. The final SDK catch may + // recognize only that unforgeable brand; it never inspects + // arbitrary status, name, message, body, or header fields. + if (isNetworkFailure(error)) + safeReport(reportOperationFailure, 'network_blocked'); + throw createSafeRequestError(); +} + +/** @internal Projects a completed SDK client operation at the owned fetch seam. */ +export function sanitizeLangGraphClientOperationFailure( + error: unknown, + reportOperationFailure: RuntimeOperationFailureReporter | undefined +): Error { + if (isNetworkFailure(error)) { + safeReport(reportOperationFailure, 'network_blocked'); + } + return createSafeRequestError(); +} + +export function createSafeRequestError(): Error { + const safe = new Error('The LangGraph request failed.'); + safe.name = 'LangGraphRequestError'; + return safe; +} + +function readResponseStatus(response: Response): number | null { + try { + if (!RESPONSE_STATUS_GETTER) return null; + const status = RESPONSE_STATUS_GETTER.call(response) as unknown; + return typeof status === 'number' ? status : null; + } catch { + return null; + } +} + +function sanitizedFailureResponse(status: number | null): Response { + const safeStatus = + status !== null && status >= 200 && status <= 599 ? status : 500; + return new Response(null, { status: safeStatus }); +} + +type SignalState = 'absent' | 'active' | 'aborted' | 'invalid'; + +function inspectRequestSignal(init: RequestInit | undefined): SignalState { + try { + return inspectSignal(init?.signal); + } catch { + return 'invalid'; + } +} + +function inspectSignal(signal: AbortSignal | null | undefined): SignalState { + if (signal == null) return 'absent'; + if (!SIGNAL_ABORTED_GETTER) return 'invalid'; + try { + const aborted = SIGNAL_ABORTED_GETTER.call(signal) as unknown; + if (typeof aborted !== 'boolean') return 'invalid'; + return aborted ? 'aborted' : 'active'; + } catch { + return 'invalid'; + } +} + +function isNetworkFailure(error: unknown): boolean { + try { + return (typeof error === 'object' && error !== null) || + typeof error === 'function' + ? networkFailures.has(error as object) + : false; + } catch { + return false; + } +} + +function createAbortError(): Error { + const error = new Error('AbortError'); + error.name = 'AbortError'; + return error; +} + +function safeReport( + reporter: RuntimeOperationFailureReporter | undefined, + code: 'unauthorized' | 'network_blocked' +): void { + if (!reporter) return; + try { + reporter(code); + } catch { + // Reporting is isolated from request control flow. + } +} diff --git a/libs/langgraph/src/runtime/ownership.ts b/libs/langgraph/src/runtime/ownership.ts new file mode 100644 index 000000000..2e111998c --- /dev/null +++ b/libs/langgraph/src/runtime/ownership.ts @@ -0,0 +1,233 @@ +import { + completeDelivery, + projectAgentError, + streamingDelivery, + type AgentError, + type AgentSnapshot, + type Message, + type PlainValue, + type ToolCall, +} from '@threadplane/core'; + +// Only objects projected here are trusted. Object.isFrozen on external input is +// insufficient: its children may still be mutable. The weak set retains no data. +const owned = new WeakSet(); + +function freeze(value: T): T { + owned.add(value); + return Object.freeze(value); +} + +export function ownValue( + value: PlainValue, + ancestors = new Set() +): PlainValue { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) + return value; + if (typeof value !== 'object') + throw new TypeError('Tool snapshots require plain data.'); + if (owned.has(value)) return value; + // This is an ownership boundary, not argument/schema validation. Unsupported + // SDK instances and cycles must be projected by the effect adapter explicitly. + if ( + ancestors.has(value) || + (!Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + throw new TypeError('Tool snapshots require acyclic plain data.'); + } + ancestors.add(value); + const result = Array.isArray(value) + ? freeze(value.map((item) => ownValue(item, ancestors))) + : freeze( + Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + ownValue(item, ancestors), + ]) + ) + ); + ancestors.delete(value); + return result; +} + +function equalValue(a: PlainValue, b: PlainValue): boolean { + if (Object.is(a, b)) return true; + if ( + a === null || + b === null || + typeof a !== 'object' || + typeof b !== 'object' + ) + return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const left = Object.keys(a); + const right = Object.keys(b); + return ( + left.length === right.length && + left.every( + (key) => + Object.hasOwn(b, key) && + equalValue( + (a as Record)[key], + (b as Record)[key] + ) + ) + ); +} + +function sameError( + a: AgentError | undefined, + b: AgentError | undefined +): boolean { + return ( + a === b || + (!!a && + !!b && + a.kind === b.kind && + a.message === b.message && + a.status === b.status && + a.retryable === b.retryable && + a.recovery === b.recovery && + a.detail === b.detail) + ); +} + +function ownError(error: AgentError): AgentError { + if (owned.has(error)) return error; + const projected = projectAgentError(error); + owned.add(projected); + return projected; +} + +export function sameMessage(a: Message, b: Message): boolean { + return ( + a === b || + (a.id === b.id && + a.role === b.role && + a.content === b.content && + a.name === b.name && + a.toolCallId === b.toolCallId && + equalValue(a.toolCallIds, b.toolCallIds) && + a.delivery.generation === b.delivery.generation && + a.delivery.phase === b.delivery.phase && + (a.delivery.phase !== 'complete' || + (b.delivery.phase === 'complete' && + a.delivery.outcome === b.delivery.outcome))) + ); +} + +export function ownMessage(message: Message): Message { + if (owned.has(message)) return message; + return freeze({ + id: message.id, + role: message.role, + content: message.content, + delivery: + message.delivery.phase === 'complete' + ? completeDelivery( + message.delivery.generation, + message.delivery.outcome + ) + : streamingDelivery(message.delivery.generation), + name: message.name, + toolCallId: message.toolCallId, + toolCallIds: + message.toolCallIds === undefined + ? undefined + : freeze([...message.toolCallIds]), + }); +} + +export function sameToolCall(a: ToolCall, b: ToolCall): boolean { + return ( + a === b || + (a.id === b.id && + a.name === b.name && + a.status === b.status && + equalValue(a.args, b.args) && + (a.status !== 'complete' || + (b.status === 'complete' && equalValue(a.result, b.result))) && + (a.status !== 'error' || (b.status === 'error' && a.error === b.error))) + ); +} + +export function ownToolCall(call: ToolCall): ToolCall { + if (owned.has(call)) return call; + const base = { id: call.id, name: call.name, args: ownValue(call.args) }; + if (call.status === 'complete') + return freeze({ + ...base, + status: call.status, + result: ownValue(call.result), + }); + if (call.status === 'error') + return freeze({ ...base, status: call.status, error: call.error }); + return freeze({ ...base, status: call.status }); +} + +function ownArray( + values: readonly T[], + previous: readonly T[] | undefined, + project: (value: T) => T, + equal: (a: T, b: T) => boolean +): readonly T[] { + if (values === previous) return values; + const alreadyOwned = owned.has(values); + if (alreadyOwned && previous === undefined) return values; + const next = values.map((value, index) => { + // Ownership permits reuse, not a change notification. A distinct owned + // array may still equal the current one (including queued publications). + const projected = alreadyOwned ? value : project(value); + return previous?.[index] !== undefined && equal(projected, previous[index]) + ? previous[index] + : projected; + }); + if ( + previous && + next.length === previous.length && + next.every((item, index) => item === previous[index]) + ) + return previous; + return alreadyOwned && next.every((item, index) => item === values[index]) + ? values + : freeze(next); +} + +export function ownSnapshot( + input: AgentSnapshot, + previous?: AgentSnapshot +): AgentSnapshot { + if (input === previous) return input; + const messages = ownArray( + input.messages, + previous?.messages, + ownMessage, + sameMessage + ); + const toolCalls = ownArray( + input.toolCalls, + previous?.toolCalls, + ownToolCall, + sameToolCall + ); + const error = sameError(input.error, previous?.error) + ? previous?.error + : input.error && ownError(input.error); + if ( + previous && + input.status === previous.status && + messages === previous.messages && + toolCalls === previous.toolCalls && + error === previous.error + ) + return previous; + return freeze({ status: input.status, messages, toolCalls, error }); +} diff --git a/libs/langgraph/src/runtime/publication.spec.ts b/libs/langgraph/src/runtime/publication.spec.ts new file mode 100644 index 000000000..f0483eda8 --- /dev/null +++ b/libs/langgraph/src/runtime/publication.spec.ts @@ -0,0 +1,294 @@ +import { streamingDelivery, type AgentSnapshot } from '@threadplane/core'; +import { describe, expect, it } from 'vitest'; +import { createPublication } from './publication'; +import { deferred } from './testing/deferred'; + +function snapshot(content = 'one'): AgentSnapshot { + return { + status: 'idle', + messages: [ + { + id: 'm', + role: 'assistant', + content, + delivery: streamingDelivery('g'), + toolCallIds: ['t'], + }, + ], + toolCalls: [ + { + id: 't', + name: 'search', + status: 'complete', + args: { words: ['first'] }, + result: { hits: ['found'] }, + }, + ], + }; +} + +describe('private snapshot publication', () => { + it('does not turn unsupported SDK instances into apparently portable tool data', () => { + const input = snapshot(); + const invalid = { + ...input, + toolCalls: [{ ...input.toolCalls[0], args: new Date() }], + }; + expect(() => + createPublication(invalid as unknown as AgentSnapshot) + ).toThrow(TypeError); + const publication = createPublication({ + ...input, + toolCalls: [{ id: 't', name: 'search', status: 'running', args: {} }], + }); + expect(() => + publication.publish({ + ...input, + toolCalls: [ + { id: 't', name: 'search', status: 'running', args: new Date() }, + ], + } as unknown as AgentSnapshot) + ).toThrow(TypeError); + }); + it('caches repeated reads per session and observes only actual changes', () => { + const one = createPublication(snapshot()); + const two = createPublication(snapshot('two')); + let calls = 0; + one.subscribe(() => calls++); + const original = one.getSnapshot(); + expect(calls).toBe(0); + two.publish({ ...two.getSnapshot(), status: 'running' }); + expect(one.getSnapshot()).toBe(original); + one.publish(snapshot()); + expect(one.getSnapshot()).toBe(original); + expect(calls).toBe(0); + one.publish({ ...original, status: 'running' }); + expect(calls).toBe(1); + expect(one.getSnapshot()).not.toBe(original); + expect(one.getSnapshot().messages).toBe(original.messages); + expect(original.status).toBe('idle'); + }); + + it('owns ingress and old snapshots, retaining unchanged siblings on later publication', () => { + const ids = ['t']; + const args = { words: ['first'] }; + const result = { hits: ['found'] }; + const error = Object.assign(new Error('Failed'), { + kind: 'server' as const, + retryable: true, + cause: { value: [] }, + }); + const incoming = { + ...snapshot(), + error, + messages: [ + { + id: 'm', + role: 'assistant' as const, + content: 'one', + delivery: streamingDelivery('g'), + toolCallIds: ids, + }, + ], + toolCalls: [ + { id: 't', name: 'search', status: 'complete' as const, args, result }, + ], + }; + const publication = createPublication(incoming); + const before = publication.getSnapshot(); + ids.push('new'); + args.words.push('second'); + result.hits.push('later'); + error.message = 'Changed'; + incoming.messages[0].content = 'changed'; + expect(before.messages[0].content).toBe('one'); + expect(before.messages[0].toolCallIds).toEqual(['t']); + expect(before.toolCalls[0]).toMatchObject({ + args: { words: ['first'] }, + result: { hits: ['found'] }, + }); + expect(before.error?.message).toBe('Failed'); + expect(before.error).not.toHaveProperty('cause'); + expect(before.error).not.toHaveProperty('stack'); + publication.publish({ + ...before, + messages: [...before.messages, { ...before.messages[0], id: 'm2' }], + }); + expect(publication.getSnapshot().messages[0]).toBe(before.messages[0]); + expect(before.messages).toHaveLength(1); + expect(Object.isFrozen(before.messages)).toBe(true); + expect(Object.isFrozen(before.messages[0].toolCallIds)).toBe(true); + expect(Object.isFrozen(before.toolCalls[0].args)).toBe(true); + expect( + Object.isFrozen( + (before.toolCalls[0].args as { words: readonly string[] }).words + ) + ).toBe(true); + }); + + it('suppresses equal queued publications captured by separate listeners', () => { + const publication = createPublication(snapshot()); + const seen: string[] = []; + for (let index = 0; index < 2; index++) { + publication.subscribe(() => { + if (publication.getSnapshot().status === 'running') { + publication.publish(snapshot('two')); + } + }); + } + publication.subscribe(() => { + const value = publication.getSnapshot(); + seen.push(`${value.status}:${value.messages[0].content}`); + }); + publication.publish({ ...snapshot(), status: 'running' }); + expect(seen).toEqual(['running:one', 'idle:two']); + }); + + it('retains current identity when another publication supplies an equal owned snapshot', () => { + const publication = createPublication(snapshot()); + const other = createPublication(snapshot()); + const before = publication.getSnapshot(); + let calls = 0; + publication.subscribe(() => calls++); + expect(other.getSnapshot().messages).not.toBe(before.messages); + expect(other.getSnapshot().toolCalls).not.toBe(before.toolCalls); + publication.publish(other.getSnapshot()); + expect(publication.getSnapshot()).toBe(before); + expect(calls).toBe(0); + }); + + it('reuses equal current siblings and new owned members from another publication', () => { + const publication = createPublication(snapshot()); + const before = publication.getSnapshot(); + const other = createPublication({ + ...snapshot(), + messages: [ + snapshot().messages[0], + { ...snapshot('two').messages[0], id: 'second' }, + ], + }); + const incoming = other.getSnapshot(); + publication.publish(incoming); + const after = publication.getSnapshot(); + expect(after.messages[0]).toBe(before.messages[0]); + expect(after.messages[1]).toBe(incoming.messages[1]); + expect(after.toolCalls).toBe(before.toolCalls); + expect(before.messages).toHaveLength(1); + }); + + it('commits the aggregate before listeners and runs nested commands after the complete notification pass', async () => { + const publication = createPublication(snapshot()); + const seen: string[] = []; + let nested!: Promise; + publication.subscribe(() => { + const value = publication.getSnapshot(); + seen.push(`first:${value.status}:${value.messages[0].content}`); + if (value.status === 'running') + nested = publication.command(() => { + seen.push('command'); + publication.publish({ ...publication.getSnapshot(), status: 'idle' }); + }); + }); + publication.subscribe(() => { + const value = publication.getSnapshot(); + seen.push(`second:${value.status}:${value.messages[0].content}`); + }); + publication.publish({ ...snapshot('two'), status: 'running' }); + await nested; + expect(seen).toEqual([ + 'first:running:two', + 'second:running:two', + 'command', + 'first:idle:two', + 'second:idle:two', + ]); + }); + + it('captures deferred publication ingress before returning to a listener', () => { + const publication = createPublication(snapshot()); + const seen: string[] = []; + publication.subscribe(() => { + if (publication.getSnapshot().status === 'running') { + const mutable = { ...snapshot('queued'), status: 'error' as const }; + publication.publish(mutable); + mutable.messages = []; + } + }); + publication.subscribe(() => + seen.push( + `${publication.getSnapshot().status}:${ + publication.getSnapshot().messages[0].content + }` + ) + ); + publication.publish({ ...snapshot('two'), status: 'running' }); + expect(seen).toEqual(['running:two', 'error:queued']); + }); + + it('supports idempotent unsubscription and defers newly registered listeners to the next pass', () => { + const publication = createPublication(snapshot()); + const seen: string[] = []; + let removeSecond = () => { + /* replaced before notification */ + }; + let added = false; + publication.subscribe(() => { + seen.push('first'); + removeSecond(); + removeSecond(); + if (!added) { + publication.subscribe(() => seen.push('new')); + added = true; + } + }); + removeSecond = publication.subscribe(() => seen.push('second')); + publication.publish({ ...snapshot(), status: 'running' }); + expect(seen).toEqual(['first']); + publication.publish(snapshot('next')); + expect(seen).toEqual(['first', 'first', 'new']); + }); + + it('isolates throwing listeners and even a throwing error reporter', () => { + const failure = new Error('listener'); + const reported: unknown[] = []; + const publication = createPublication(snapshot(), (error) => { + reported.push(error); + throw new Error('reporter'); + }); + const seen: string[] = []; + publication.subscribe(() => { + throw failure; + }); + publication.subscribe(() => seen.push(publication.getSnapshot().status)); + publication.publish({ ...snapshot(), status: 'running' }); + publication.publish(snapshot()); + expect(reported).toEqual([failure, failure]); + expect(seen).toEqual(['running', 'idle']); + }); + + it('settles independent commands without waiting on a prior asynchronous effect', async () => { + const publication = createPublication(snapshot()); + const effect = deferred(); + const slow = publication.command(() => effect.promise); + const fast = publication.command(() => 'fast'); + await expect(fast).resolves.toBe('fast'); + effect.resolve('slow'); + await expect(slow).resolves.toBe('slow'); + await expect( + publication.command(() => { + throw new Error('command'); + }) + ).rejects.toThrow('command'); + await expect(publication.command(() => 'still works')).resolves.toBe( + 'still works' + ); + }); + + it('makes a command publication visible before that command continues', async () => { + const publication = createPublication(snapshot()); + await publication.command(() => { + publication.publish({ ...publication.getSnapshot(), status: 'running' }); + expect(publication.getSnapshot().status).toBe('running'); + }); + }); +}); diff --git a/libs/langgraph/src/runtime/publication.ts b/libs/langgraph/src/runtime/publication.ts new file mode 100644 index 000000000..8e1fe8349 --- /dev/null +++ b/libs/langgraph/src/runtime/publication.ts @@ -0,0 +1,86 @@ +import type { AgentSnapshot } from '@threadplane/core'; +import { ownSnapshot } from './ownership'; + +/** Backend-private publication. Listener failures are reported once to the + * optional callback and otherwise ignored; reporter failures are contained too. + * Neither kind of failure changes execution state or rejects a command. */ +export function createPublication( + initial: AgentSnapshot, + reportListenerError: (error: unknown) => void = () => undefined +) { + let current = ownSnapshot(initial); + const listeners = new Set<{ notify: () => void }>(); + const pending: (() => void)[] = []; + let flushing = false; + let notifying = false; + + function drain(): void { + if (flushing || notifying) return; + flushing = true; + try { + while (pending.length) pending.shift()?.(); + } finally { + flushing = false; + } + } + + function schedule(operation: () => void): void { + if (notifying) pending.push(operation); + else { + operation(); + drain(); + } + } + + function publish(input: AgentSnapshot): void { + // Capture external ingress now, including when a listener queues a publish. + const captured = ownSnapshot(input, current); + schedule(() => { + const next = ownSnapshot(captured, current); + if (next === current) return; + current = next; + notifying = true; + try { + for (const listener of [...listeners]) { + if (!listeners.has(listener)) continue; + try { + listener.notify(); + } catch (error) { + try { + reportListenerError(error); + } catch { + /* Reporting cannot abort the pass or corrupt publication. */ + } + } + } + } finally { + notifying = false; + } + }); + } + + return { + getSnapshot: () => current, + subscribe(notify: () => void): () => void { + const listener = { notify }; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + publish, + // Session disposal releases observers without discarding queued commands. + clearListeners: () => listeners.clear(), + command(run: () => T | PromiseLike): Promise { + return new Promise((resolve, reject) => + schedule(() => { + try { + resolve(run()); + } catch (error) { + reject(error); + } + }) + ); + }, + }; +} diff --git a/libs/langgraph/src/runtime/recovery.spec.ts b/libs/langgraph/src/runtime/recovery.spec.ts new file mode 100644 index 000000000..687eb2242 --- /dev/null +++ b/libs/langgraph/src/runtime/recovery.spec.ts @@ -0,0 +1,348 @@ +import type { ThreadState } from '@langchain/langgraph-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import { controlledTransport } from './testing/controlled-transport'; +import { deferred } from './testing/deferred'; +import type { AgentTransport, StreamEvent } from './transport.types'; + +function state(messages: unknown[] = [], paused = false): ThreadState { + return { + values: { messages } as ThreadState['values'], + next: paused ? ['ask'] : [], + tasks: paused + ? [ + { + id: 'task', + name: 'ask', + error: null, + interrupts: [{ value: 'continue?' }], + checkpoint: null, + state: null, + result: null, + }, + ] + : [], + checkpoint: { + checkpoint_id: 'after', + thread_id: 't', + checkpoint_ns: '', + checkpoint_map: {}, + }, + metadata: null, + created_at: null, + parent_checkpoint: null, + }; +} +function setup() { + const streams: ReturnType>[] = []; + const starts = Array.from({ length: 3 }, () => deferred()); + const stream = vi.fn((_a, _t, _p, signal) => { + const next = controlledTransport({ signal }); + streams.push(next); + starts[streams.length - 1].resolve(); + return next.stream; + }); + const history = vi.fn>( + async () => [] + ); + const session = createSession({ + assistantId: 'a', + threadId: 't', + transport: { stream, getHistory: history }, + }); + return { + session, + stream, + history, + streams, + started: (index = 0) => starts[index].promise, + turnState: (messages: unknown[] = [], paused = false) => + state( + [ + stream.mock.calls.at(-1)?.[2] && + (stream.mock.calls.at(-1)?.[2] as { messages: unknown[] }) + .messages[0], + ...messages, + ], + paused + ), + }; +} +const committed = { id: 'committed', type: 'ai', content: 'finished remotely' }; + +describe('neutral session read-only recovery', () => { + it('only exposes checkStatus with history and reads/observes/checks without issuing runs', async () => { + const f = setup(); + expect(f.history).not.toHaveBeenCalled(); + expect(f.session.checkStatus).toBeTypeOf('function'); + f.session.getSnapshot(); + const off = f.session.subscribe(() => undefined); + off(); + await f.session.checkStatus?.(); + expect(f.stream).not.toHaveBeenCalled(); + const noHistory = createSession({ + assistantId: 'a', + threadId: 't', + transport: { stream: f.stream }, + }); + expect(noHistory.checkStatus).toBeUndefined(); + await noHistory.dispose(); + await f.session.dispose(); + }); + + it.each(['eof', 'abort', 'network'] as const)( + 'never infers safe retry from absent first bytes: %s', + async (mode) => { + const stream = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + if (mode === 'abort') + throw Object.assign(new Error('reset'), { name: 'AbortError' }); + if (mode === 'network') throw new TypeError('fetch failed'); + yield* []; + }, + })); + const session = createSession({ + assistantId: 'a', + threadId: 't', + transport: { stream }, + }); + expect(await session.submit('hello')).toBe('interrupted'); + expect(session.getSnapshot().error).toMatchObject({ + kind: 'interrupted', + retryable: false, + recovery: 'none', + }); + expect(stream).toHaveBeenCalledTimes(1); + await session.dispose(); + } + ); + + it.each(['committed', 'paused'] as const)( + 'rescues a closed stream from conclusive %s history atomically', + async (mode) => { + const f = setup(); + f.history.mockImplementation(async () => [ + f.turnState(mode === 'committed' ? [committed] : [], mode === 'paused'), + ]); + const snapshots: ReturnType[] = []; + f.session.subscribe(() => snapshots.push(f.session.getSnapshot())); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + expect(await run).toBe(mode === 'committed' ? 'success' : 'paused'); + expect(f.session.getSnapshot()).toMatchObject({ + status: 'idle', + error: undefined, + }); + if (mode === 'committed') + expect(f.session.getSnapshot().messages.at(-1)).toMatchObject({ + content: committed.content, + delivery: { outcome: 'success' }, + }); + expect(snapshots.filter((s) => s.status === 'idle')).toHaveLength(1); + expect(f.stream).toHaveBeenCalledTimes(1); + await f.session.dispose(); + } + ); + + it('retains the original interrupted aggregate on inconclusive or failed checks and recovers on a later check', async () => { + const f = setup(); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + expect(await run).toBe('interrupted'); + const original = f.session.getSnapshot(); + expect(original.error).toMatchObject({ + kind: 'interrupted', + retryable: false, + recovery: 'check', + }); + await f.session.checkStatus?.(); + expect(f.session.getSnapshot()).toBe(original); + f.history.mockRejectedValueOnce(new Error('history unavailable')); + await expect(f.session.checkStatus?.()).rejects.toThrow(); + expect(f.session.getSnapshot()).toBe(original); + f.history.mockResolvedValueOnce([f.turnState([committed])]); + await f.session.checkStatus?.(); + expect(f.session.getSnapshot()).toMatchObject({ + status: 'idle', + error: undefined, + }); + expect(f.stream).toHaveBeenCalledTimes(1); + await f.session.dispose(); + }); + + it('ignores failed close-time history and does not mistake the optimistic echo for completion', async () => { + const f = setup(); + f.history.mockRejectedValueOnce(new Error('history failed')); + const first = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + expect(await first).toBe('interrupted'); + const second = f.session.submit('same'); + await f.started(1); + f.history.mockResolvedValueOnce([ + state( + f.session + .getSnapshot() + .messages.filter((m) => m.role === 'user') + .map((m) => ({ ...m, type: 'human' })) + ), + ]); + f.streams[1].finish(); + expect(await second).toBe('interrupted'); + await f.session.dispose(); + }); + + it.each(['stop', 'dispose', 'submit'] as const)( + 'settles locally during noncooperative close history on %s and ignores late answers', + async (action) => { + const f = setup(); + const read = deferred(); + const started = deferred(); + f.history.mockImplementationOnce(() => { + started.resolve(); + return read.promise; + }); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + await started.promise; + let newer: Promise | undefined; + if (action === 'submit') newer = f.session.submit('new'); + else await f.session[action](); + expect(await run).toBe(action === 'submit' ? 'interrupted' : 'aborted'); + const stable = f.session.getSnapshot(); + read.resolve([f.turnState([committed])]); + await read.promise; + expect(f.session.getSnapshot()).toBe(stable); + if (newer) { + await f.session.stop(); + await newer; + } + await f.session.dispose(); + } + ); + + it.each(['stop', 'dispose', 'submit'] as const)( + 'ignores an explicit check overtaken by %s', + async (action) => { + const f = setup(); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + await run; + const read = deferred(); + const checkingStarted = deferred(); + f.history.mockImplementationOnce(() => { + checkingStarted.resolve(); + return read.promise; + }); + const checking = f.session.checkStatus?.(); + await checkingStarted.promise; + let newer: Promise | undefined; + if (action === 'submit') newer = f.session.submit('new'); + else await f.session[action](); + const stable = f.session.getSnapshot(); + read.resolve([f.turnState([committed])]); + await checking; + expect(f.session.getSnapshot()).toBe(stable); + if (newer) { + await f.session.stop(); + await newer; + } + await f.session.dispose(); + } + ); + + it('rejects checkStatus during execution and after disposal', async () => { + const f = setup(); + const run = f.session.submit('hello'); + await expect(f.session.checkStatus?.()).rejects.toThrow('active'); + await f.session.dispose(); + expect(await run).toBe('aborted'); + await expect(f.session.checkStatus?.()).rejects.toThrow('disposed'); + expect(f.history).not.toHaveBeenCalled(); + }); + + it('does not let a recovered publication clear a run submitted by an observer', async () => { + const f = setup(); + const first = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + await first; + let next: Promise | undefined; + f.session.subscribe(() => { + if (!next && f.session.getSnapshot().status === 'idle') + next = f.session.submit('new'); + }); + f.history.mockResolvedValueOnce([f.turnState([committed])]); + await f.session.checkStatus?.(); + expect(f.session.getSnapshot().status).toBe('running'); + expect(f.stream).toHaveBeenCalledTimes(2); + await f.session.stop(); + await next; + await f.session.dispose(); + }); + + it.each(['old answer', 'old pause'] as const)( + 'does not claim a new request completed from an uncorrelated %s', + async (kind) => { + const f = setup(); + f.history.mockResolvedValue([state([committed], kind === 'old pause')]); + const run = f.session.submit('new request'); + await f.started(); + f.streams[0].finish(); + expect(await run).toBe('interrupted'); + expect(f.session.getSnapshot().messages).toHaveLength(1); + await f.session.checkStatus?.(); + expect(f.session.getSnapshot().status).toBe('error'); + await f.session.dispose(); + } + ); + + it('recovers a persisted final message sharing the streamed partial ID when the submitted turn is correlated', async () => { + const f = setup(); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].release({ + type: 'messages', + messageMetadata: {}, + messages: [ + { + type: 'AIMessageChunk', + id: 'answer', + content: 'draft longer than final', + }, + ], + }); + f.history.mockImplementation(async () => [ + f.turnState([{ type: 'ai', id: 'answer', content: 'final' }]), + ]); + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(f.session.getSnapshot().messages.at(-1)).toMatchObject({ + id: 'answer', + content: 'final', + delivery: { outcome: 'success' }, + }); + await f.session.dispose(); + }); + + it('keeps the recovery action usable after stop on an interrupted session', async () => { + const f = setup(); + const run = f.session.submit('hello'); + await f.started(); + f.streams[0].finish(); + await run; + await f.session.stop(); + f.history.mockResolvedValueOnce([f.turnState([committed])]); + await f.session.checkStatus?.(); + expect(f.session.getSnapshot()).toMatchObject({ + status: 'idle', + error: undefined, + }); + expect(f.stream).toHaveBeenCalledTimes(1); + await f.session.dispose(); + }); +}); diff --git a/libs/langgraph/src/runtime/session-lifecycle.spec.ts b/libs/langgraph/src/runtime/session-lifecycle.spec.ts new file mode 100644 index 000000000..91f154f89 --- /dev/null +++ b/libs/langgraph/src/runtime/session-lifecycle.spec.ts @@ -0,0 +1,892 @@ +import { readFileSync } from 'node:fs'; +import { ReadableStream } from 'node:stream/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AgentSession, AgentSnapshot } from '@threadplane/core'; +import { createSession } from './create-session'; +import { controlledTransport } from './testing/controlled-transport'; +import { deferred } from './testing/deferred'; +import type { AgentTransport, StreamEvent } from './transport.types'; + +function fixture() { + const streams: ReturnType>[] = []; + const starts = Array.from({ length: 3 }, () => deferred()); + const stream = vi.fn((_a, _t, _p, signal) => { + const next = controlledTransport({ + signal, + ignoreAbort: true, + }); + streams.push(next); + starts[streams.length - 1].resolve(); + return next.stream; + }); + return { + streams, + stream, + started: (index = 0) => starts[index].promise, + session: createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { stream }, + }), + }; +} + +function changed( + session: AgentSession, + predicate: (value: AgentSnapshot) => boolean +) { + const result = deferred(); + const unsubscribe = session.subscribe(() => { + if (predicate(session.getSnapshot())) { + unsubscribe(); + result.resolve(); + } + }); + return result.promise; +} + +const delta = (content: string, id = 'answer'): StreamEvent => ({ + type: 'messages', + messages: [{ type: 'AIMessageChunk', id, content }], + messageMetadata: {}, +}); +const canonical = (content: string, id = 'answer'): StreamEvent => ({ + type: 'values', + data: { messages: [{ type: 'ai', id, content }] }, +}); + +describe('neutral session ownership', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('constructs and observes inertly with cached isolated aggregates', async () => { + const a = fixture(); + const b = fixture(); + const initial = a.session.getSnapshot(); + const first = vi.fn(); + const second = vi.fn(); + const unsubscribe = a.session.subscribe(first); + a.session.subscribe(second); + expect(a.session.getSnapshot()).toBe(initial); + expect(initial).toEqual({ status: 'idle', messages: [], toolCalls: [] }); + expect(first).not.toHaveBeenCalled(); + expect(a.stream).not.toHaveBeenCalled(); + const run = a.session.submit('Hello'); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(b.session.getSnapshot().status).toBe('idle'); + expect(b.stream).not.toHaveBeenCalled(); + unsubscribe(); + await a.session.stop(); + expect(await run).toBe('aborted'); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(2); + await a.session.dispose(); + await b.session.dispose(); + }); + + it.each(['Hello', ''])( + 'streams text, preserves IDs and publishes one coherent canonical correction to %j', + async (finalContent) => { + const { session, stream, streams, started } = fixture(); + const snapshots: AgentSnapshot[] = []; + session.subscribe(() => snapshots.push(session.getSnapshot())); + const run = session.submit('Hello'); + await started(); + const optimistic = session.getSnapshot().messages[0]; + expect(stream.mock.calls[0].slice(0, 3)).toEqual([ + 'agent', + 'thread', + { messages: [{ type: 'human', id: optimistic.id, content: 'Hello' }] }, + ]); + const gotText = changed( + session, + (s) => s.messages.at(-1)?.content === 'Hello world' + ); + streams[0].release(delta('Hello ')); + streams[0].release(delta('world')); + await gotText; + const streamedId = session.getSnapshot().messages.at(-1)?.id; + streams[0].release({ + type: 'values', + data: { + messages: [ + { type: 'human', id: optimistic.id, content: 'Hello' }, + { type: 'ai', id: streamedId, content: finalContent }, + ], + }, + }); + streams[0].finish(); + expect(await run).toBe('success'); + const final = session.getSnapshot(); + expect(final.status).toBe('idle'); + expect(final.messages).toHaveLength(2); + expect(final.messages[0].id).toBe(optimistic.id); + expect(final.messages[1]).toMatchObject({ + id: streamedId, + content: finalContent, + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(final).toBe(session.getSnapshot()); + expect(Object.isFrozen(final.messages[1])).toBe(true); + expect(snapshots.filter((s) => s.status === 'idle')).toHaveLength(1); + await session.dispose(); + } + ); + + it.each(['stop', 'dispose', 'supersede'] as const)( + 'settles %s despite a noncooperative read and ignores late success or failure', + async (action) => { + for (const rejection of [false, true]) { + const reads = [ + deferred>(), + deferred>(), + ]; + const starts = [deferred(), deferred()]; + const returned = vi.fn( + () => new Promise>(() => undefined) + ); + let index = 0; + const transport: AgentTransport = { + stream: () => { + const read = reads[index]; + starts[index++].resolve(); + return { + [Symbol.asyncIterator]: () => ({ + next: () => read.promise, + return: returned, + }), + }; + }, + }; + const session = createSession({ + assistantId: 'a', + threadId: 't', + transport, + }); + const old = session.submit('old'); + await starts[0].promise; + let newer: Promise | undefined; + if (action === 'supersede') newer = session.submit('new'); + else await session[action](); + if (newer) await starts[1].promise; + expect(await old).toBe( + action === 'supersede' ? 'interrupted' : 'aborted' + ); + const stable = session.getSnapshot(); + if (rejection) reads[0].reject(new Error('late private failure')); + else reads[0].resolve({ done: false, value: canonical('late') }); + await reads[0].promise.catch(() => undefined); + expect(session.getSnapshot()).toBe(stable); + if (newer) { + expect(session.getSnapshot().status).toBe('running'); + await session.stop(); + await newer; + reads[1].resolve({ done: true, value: undefined }); + } + expect(returned).toHaveBeenCalled(); + await session.dispose(); + } + } + ); + + it('isolates interleaved sessions and permits a fresh explicit run after stop', async () => { + const a = fixture(); + const b = fixture(); + const old = a.session.submit('one'); + const other = b.session.submit('two'); + await a.started(); + await b.started(); + await a.session.stop(); + expect(await old).toBe('aborted'); + const next = a.session.submit('three'); + await a.started(1); + a.streams[1].release(canonical('a')); + a.streams[1].finish(); + b.streams[0].release(canonical('b')); + b.streams[0].finish(); + expect(await next).toBe('success'); + expect(await other).toBe('success'); + expect(a.session.getSnapshot().messages.at(-1)?.content).toBe('a'); + expect(b.session.getSnapshot().messages.at(-1)?.content).toBe('b'); + await a.session.dispose(); + await b.session.dispose(); + }); + + it.each(['abort', 'return'] as const)( + 'preserves reentrant ownership from synchronous %s cleanup', + async (source) => { + for (const action of ['stop', 'dispose', 'supersede', 'error'] as const) { + const streams: ReturnType>[] = + []; + const started = deferred(); + let nested: Promise | undefined; + const stream = vi.fn((_a, _t, _p, signal) => { + const controlled = controlledTransport({ + ignoreAbort: true, + }); + streams.push(controlled); + if (streams.length === 1) { + const reenter = () => { + nested = session.submit('from cleanup'); + }; + if (source === 'abort') + signal.addEventListener('abort', reenter, { once: true }); + else { + const original = controlled.stream.return; + controlled.stream.return = () => { + reenter(); + return original(); + }; + } + started.resolve(); + } + return controlled.stream; + }); + const session = createSession({ + assistantId: 'a', + threadId: 't', + transport: { stream }, + }); + const old = session.submit('old'); + await started.promise; + let superseding: Promise | undefined; + if (action === 'supersede') + superseding = session.submit('outer submit'); + else if (action === 'error') + streams[0].release({ type: 'error', data: { message: 'failure' } }); + else await session[action](); + expect(await old).toBe( + action === 'supersede' + ? 'interrupted' + : action === 'error' + ? 'error' + : 'aborted' + ); + expect(nested).toBeDefined(); + if (action === 'dispose') { + expect(await nested).toBe('aborted'); + expect(session.getSnapshot().status).toBe('idle'); + expect(stream).toHaveBeenCalledTimes(1); + } else { + expect(session.getSnapshot().status).toBe('running'); + expect(session.getSnapshot().messages.at(-1)?.content).toBe( + 'from cleanup' + ); + await session.stop(); + expect(await nested).toBe('aborted'); + } + if (superseding) expect(await superseding).toBe('interrupted'); + await session.dispose(); + } + } + ); + + it.each(['stop', 'supersede', 'dispose'] as const)( + 'handles reentrant %s before dispatch and never clears a newer owner', + async (action) => { + const { session, stream, streams } = fixture(); + let triggered = false; + let nested: Promise | undefined; + session.subscribe(() => { + if (!triggered && session.getSnapshot().status === 'running') { + triggered = true; + nested = + action === 'supersede' ? session.submit('new') : session[action](); + } + }); + const old = session.submit('old'); + expect(await old).toBe( + action === 'supersede' ? 'interrupted' : 'aborted' + ); + expect(stream).toHaveBeenCalledTimes(action === 'supersede' ? 1 : 0); + if (action === 'supersede') { + streams[0].release(canonical('new')); + streams[0].finish(); + expect(await nested).toBe('success'); + } else await nested; + await session.dispose(); + } + ); + + it('links external abort to its own controller and releases listeners on every exit', async () => { + for (const exit of ['external', 'stop', 'success', 'preaborted'] as const) { + const { session, stream, streams, started } = fixture(); + const controller = new AbortController(); + const add = vi.spyOn(controller.signal, 'addEventListener'); + const remove = vi.spyOn(controller.signal, 'removeEventListener'); + if (exit === 'preaborted') controller.abort(); + const run = session.submit('hello', { signal: controller.signal }); + if (exit === 'preaborted') expect(stream).not.toHaveBeenCalled(); + else { + await started(); + const owned = stream.mock.calls[0][3]; + expect(owned).not.toBe(controller.signal); + if (exit === 'external') controller.abort(); + if (exit === 'stop') { + await session.stop(); + expect(controller.signal.aborted).toBe(false); + } + if (exit === 'success') { + streams[0].release(canonical('done')); + streams[0].finish(); + } else expect(owned.aborted).toBe(true); + } + expect(await run).toBe(exit === 'success' ? 'success' : 'aborted'); + expect(remove.mock.calls.length).toBe(add.mock.calls.length); + await session.dispose(); + } + }); + + it('releases subscriptions permanently without dropping queued command promises', async () => { + const { session, stream } = fixture(); + let disposed: Promise | undefined; + let queued: Promise | undefined; + const listener = vi.fn(() => { + if (session.getSnapshot().status === 'running') { + disposed = session.dispose(); + queued = session.submit('queued'); + } + }); + session.subscribe(listener); + expect(await session.submit('first')).toBe('aborted'); + await disposed; + expect(await queued).toBe('aborted'); + const final = session.getSnapshot(); + const after = vi.fn(); + session.subscribe(after); + await session.stop(); + await session.dispose(); + expect(await session.submit('later')).toBe('aborted'); + expect(session.getSnapshot()).toBe(final); + expect(after).not.toHaveBeenCalled(); + expect(stream).not.toHaveBeenCalled(); + }); + + it('requires fresh root terminal evidence and excludes child namespace content', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + streams[0].release(canonical('first')); + streams[0].release(delta('unfinished', 'second')); + streams[0].release({ + type: 'values|child', + data: { + messages: [{ type: 'ai', id: 'child', content: 'private child' }], + }, + }); + streams[0].finish(); + expect(await run).toBe('interrupted'); + expect(session.getSnapshot().messages.map((m) => m.content)).not.toContain( + 'private child' + ); + await session.dispose(); + }); + + it.each([ + [delta('hello'), { type: 'messages/complete' }], + [{ type: 'values', data: { completed: true } }], + [ + { + type: 'messages/partial', + messages: [{ type: 'ai', id: 'a', content: 'Hel' }], + }, + { + type: 'messages/partial', + messages: [{ type: 'ai', id: 'a', content: 'Hello' }], + }, + { type: 'checkpoints' }, + ], + ] satisfies StreamEvent[][])( + 'accepts root terminal proof %#', + async (...events) => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + for (const event of events) streams[0].release(event); + streams[0].finish(); + expect(await run).toBe('success'); + await session.dispose(); + } + ); + + it('keeps finalized tool-only calls but never exposes partial argument fragments', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('weather'); + await started(); + const seen = changed(session, (s) => + s.messages.some((m) => m.id === 'tools') + ); + streams[0].release({ + type: 'messages', + messageMetadata: {}, + messages: [ + { + type: 'AIMessageChunk', + id: 'tools', + content: '', + tool_call_chunks: [{ id: 'c', name: 'weather', args: '{"city":' }], + }, + ], + }); + await seen; + expect(session.getSnapshot().toolCalls).toEqual([]); + streams[0].release({ + type: 'values', + data: { + messages: [ + { + type: 'ai', + id: 'tools', + content: '', + tool_calls: [{ id: 'c', name: 'weather', args: { city: 'Paris' } }], + }, + ], + }, + }); + streams[0].finish(); + expect(await run).toBe('success'); + expect(session.getSnapshot().messages.at(-1)?.content).toBe(''); + expect(session.getSnapshot().toolCalls).toEqual([ + { id: 'c', name: 'weather', args: { city: 'Paris' }, status: 'pending' }, + ]); + await session.dispose(); + }); + + it('treats values as interim until EOF and keeps subsequent same-ID text', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + streams[0].release(canonical('Hello')); + streams[0].release(delta(' world')); + streams[0].release({ type: 'messages/complete' }); + streams[0].finish(); + expect(await run).toBe('success'); + expect(session.getSnapshot().messages.at(-1)?.content).toBe('Hello world'); + await session.dispose(); + }); + + it('preserves distinct assistant steps and does not guess draft aliases from singleton partials', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + streams[0].release(delta('first', 'a')); + streams[0].release({ + type: 'messages/partial', + messages: [{ type: 'ai', id: 'b', content: 'second' }], + }); + streams[0].release(canonical('second', 'b')); + streams[0].release(canonical('third', 'c')); + streams[0].finish(); + expect(await run).toBe('success'); + expect( + session + .getSnapshot() + .messages.filter((m) => m.role === 'assistant') + .map((m) => [m.id, m.content]) + ).toEqual([ + ['a', 'first'], + ['b', 'second'], + ['c', 'third'], + ]); + await session.dispose(); + }); + + it('does not infer assistant identity from a shared user turn', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + const user = session.getSnapshot().messages[0]; + streams[0].release(delta('first step', 'a')); + streams[0].release({ + type: 'values', + data: { + messages: [ + { type: 'human', id: user.id, content: user.content }, + { type: 'ai', id: 'b', content: 'second step' }, + ], + }, + }); + streams[0].finish(); + expect(await run).toBe('success'); + expect( + session + .getSnapshot() + .messages.filter((m) => m.role === 'assistant') + .map((m) => [m.id, m.content]) + ).toEqual([ + ['a', 'first step'], + ['b', 'second step'], + ]); + await session.dispose(); + }); + + it.each(['success', 'interrupted'] as const)( + 'retains earlier canonical text and finalized calls when the next step is %s', + async (outcome) => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + streams[0].release(delta('draft too long', 'a')); + streams[0].release({ + type: 'messages/complete', + messages: [ + { + type: 'ai', + id: 'a', + content: '', + tool_calls: [ + { id: 'call', name: 'lookup', args: { key: 'value' } }, + ], + }, + ], + }); + streams[0].release(delta('next', 'b')); + if (outcome === 'success') + streams[0].release({ + type: 'messages/complete', + messages: [{ type: 'ai', id: 'b', content: 'next' }], + }); + streams[0].finish(); + expect(await run).toBe(outcome); + expect( + session.getSnapshot().messages.find((m) => m.id === 'a') + ).toMatchObject({ + content: '', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect( + session.getSnapshot().messages.find((m) => m.id === 'b') + ).toMatchObject({ + content: 'next', + delivery: { phase: 'complete', outcome }, + }); + expect(session.getSnapshot().toolCalls).toEqual([ + { + id: 'call', + name: 'lookup', + args: { key: 'value' }, + status: 'pending', + }, + ]); + await session.dispose(); + } + ); + + it.each([false, true])( + 'finalizes every earlier assistant in one terminal batch before a later interrupted step (correct draft: %s)', + async (correctDraft) => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + if (correctDraft) streams[0].release(delta('draft too long', 'a')); + streams[0].release({ + type: 'values', + data: { + messages: [ + { type: 'ai', id: 'a', content: correctDraft ? '' : 'first' }, + { type: 'ai', id: 'b', content: 'second' }, + ], + }, + }); + streams[0].release(delta('unfinished', 'c')); + streams[0].finish(); + expect(await run).toBe('interrupted'); + expect( + session.getSnapshot().messages.find((message) => message.id === 'a') + ?.content + ).toBe(correctDraft ? '' : 'first'); + expect( + session + .getSnapshot() + .messages.filter((message) => message.role === 'assistant') + .map((message) => [message.id, message.delivery]) + ).toEqual([ + [ + 'a', + { + generation: expect.any(String), + phase: 'complete', + outcome: 'success', + }, + ], + [ + 'b', + { + generation: expect.any(String), + phase: 'complete', + outcome: 'success', + }, + ], + [ + 'c', + { + generation: expect.any(String), + phase: 'complete', + outcome: 'interrupted', + }, + ], + ]); + await session.dispose(); + } + ); + + it('projects standard text blocks and a final tool-only assistant without content', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + streams[0].release({ + type: 'messages', + messageMetadata: {}, + messages: [ + { + type: 'AIMessageChunk', + id: 'text', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'reasoning', text: 'private' }, + ], + }, + ], + }); + streams[0].release({ + type: 'values', + data: { + messages: [ + { + type: 'ai', + id: 'tool', + tool_calls: [{ id: 'call', name: 'lookup', args: {} }], + }, + ], + }, + }); + streams[0].finish(); + expect(await run).toBe('success'); + expect( + session + .getSnapshot() + .messages.filter((m) => m.role === 'assistant') + .map((m) => m.content) + ).toEqual(['Hello', '']); + expect(session.getSnapshot().toolCalls).toHaveLength(1); + await session.dispose(); + }); + + it('owns terminal candidates before retaining them across await boundaries', async () => { + const { session, streams, started } = fixture(); + const run = session.submit('hello'); + await started(); + const raw = { + type: 'ai', + id: 'owned', + content: 'original', + tool_calls: [{ id: 'call', name: 'lookup', args: { key: 'original' } }], + }; + const received = changed(session, (s) => + s.messages.some((m) => m.id === 'owned') + ); + streams[0].release({ type: 'values', data: { messages: [raw] } }); + await received; + raw.content = 'mutated'; + raw.tool_calls[0].args.key = 'mutated'; + streams[0].finish(); + expect(await run).toBe('success'); + expect(session.getSnapshot().messages.at(-1)?.content).toBe('original'); + expect(session.getSnapshot().toolCalls[0].args).toEqual({ + key: 'original', + }); + await session.dispose(); + }); + + it('uses unique optimistic and generation IDs across instances of the same server thread', async () => { + const a = fixture(); + const b = fixture(); + const first = a.session.submit('same'); + const second = b.session.submit('same'); + expect(a.session.getSnapshot().messages[0].id).not.toBe( + b.session.getSnapshot().messages[0].id + ); + expect(a.session.getSnapshot().messages[0].delivery.generation).not.toBe( + b.session.getSnapshot().messages[0].delivery.generation + ); + await a.session.dispose(); + await b.session.dispose(); + await first; + await second; + }); + + it('captures the fixed thread and assistant before callers mutate their options', async () => { + const stream = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { type: 'values', data: { done: true } }; + }, + })); + const options = { + assistantId: 'original-agent', + threadId: 'original-thread', + transport: { stream }, + }; + const session = createSession(options); + options.assistantId = 'changed'; + options.threadId = 'changed'; + expect(await session.submit('hello')).toBe('success'); + expect(stream.mock.calls[0].slice(0, 2)).toEqual([ + 'original-agent', + 'original-thread', + ]); + await session.dispose(); + }); + + it('keeps an advertised status check usable after a protected SDK network failure without retrying', async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new TypeError('SECRET network failure')); + vi.stubGlobal('fetch', request); + const session = createSession({ + assistantId: 'a', + threadId: 't', + apiUrl: 'https://runtime.example', + clientOptions: { maxRetries: 0, defaultHeaders: {} }, + }); + expect(await session.submit('hello')).toBe('error'); + expect(session.getSnapshot().error).toMatchObject({ + kind: 'server', + message: 'The LangGraph request failed.', + retryable: false, + recovery: 'check', + }); + const input = JSON.parse(String(request.mock.calls[0][1]?.body)).input; + request.mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + values: { + messages: [ + ...input.messages, + { type: 'ai', id: 'answer', content: 'committed' }, + ], + }, + next: [], + tasks: [], + }, + ]), + { headers: { 'content-type': 'application/json' } } + ) + ); + expect(session.checkStatus).toBeTypeOf('function'); + await session.checkStatus?.(); + expect(session.getSnapshot()).toMatchObject({ + status: 'idle', + error: undefined, + }); + expect(request).toHaveBeenCalledTimes(2); + expect(String(request.mock.calls[1][0])).toContain('/history'); + expect( + request.mock.calls.filter(([url]) => String(url).endsWith('/runs/stream')) + ).toHaveLength(1); + await session.dispose(); + }); + + it.each(['happy', 'error', 'eof', 'decode'] as const)( + 'composes real fragmented SDK SSE into snapshots: %s', + async (mode) => { + const happy = readFileSync( + new URL( + '../../../../fixtures/react-parity/traces/langgraph-text-state.sse', + import.meta.url + ), + 'utf8' + ); + const trace = + mode === 'happy' + ? happy + : mode === 'error' + ? 'event: error\ndata: {"message":"SECRET SSE body","status":401}\n\n' + : mode === 'decode' + ? 'event: values\ndata: not-json\n\n' + : 'event: metadata\ndata: {"run_id":"early"}\n\n'; + const bytes = new TextEncoder().encode(trace); + let offset = 0; + const request = vi.fn( + async () => + new Response( + new ReadableStream({ + pull(controller) { + if (offset === bytes.length) return controller.close(); + controller.enqueue(bytes.slice(offset, offset + 3)); + offset = Math.min(offset + 3, bytes.length); + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ) + ); + vi.stubGlobal('fetch', request); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + apiUrl: 'https://runtime.example', + clientOptions: { + maxRetries: 0, + defaultHeaders: { authorization: 'token' }, + }, + }); + // EOF recovery also makes a read-only history request; provide its response separately. + if (mode === 'eof') + request + .mockImplementationOnce( + async () => + new Response(trace, { + headers: { 'content-type': 'text/event-stream' }, + }) + ) + .mockImplementationOnce( + async () => + new Response('[]', { + headers: { 'content-type': 'application/json' }, + }) + ); + const outcome = await session.submit('Hello'); + expect(outcome).toBe( + mode === 'happy' ? 'success' : mode === 'eof' ? 'interrupted' : 'error' + ); + expect( + request.mock.calls.filter(([url]) => + String(url).endsWith('/runs/stream') + ) + ).toHaveLength(1); + expect(request).toHaveBeenCalledTimes(mode === 'eof' ? 2 : 1); + const [url, init] = request.mock.calls[0]; + expect(String(url)).toBe( + 'https://runtime.example/threads/thread/runs/stream' + ); + expect(init?.method).toBe('POST'); + expect(new Headers(init?.headers).get('authorization')).toBe('token'); + expect(JSON.parse(String(init?.body))).toMatchObject({ + assistant_id: 'agent', + input: { messages: [{ type: 'human', content: 'Hello' }] }, + }); + if (mode === 'happy') + expect(session.getSnapshot().messages.at(-1)).toMatchObject({ + content: 'Hello 🌍.', + delivery: { outcome: 'success' }, + }); + else { + expect(session.getSnapshot().status).toBe('error'); + expect(session.getSnapshot().error?.retryable).toBe(false); + expect(JSON.stringify(session.getSnapshot())).not.toContain('SECRET'); + expect(session.getSnapshot().error).not.toBeInstanceOf(Error); + if (mode === 'error') { + expect(session.getSnapshot().error).toMatchObject({ + kind: 'server', + message: 'The LangGraph request failed.', + }); + expect(init?.signal?.aborted).toBe(true); + } + } + await session.dispose(); + } + ); +}); diff --git a/libs/langgraph/src/runtime/stream-projection.spec.ts b/libs/langgraph/src/runtime/stream-projection.spec.ts new file mode 100644 index 000000000..9a91d2039 --- /dev/null +++ b/libs/langgraph/src/runtime/stream-projection.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import type { ToolCall } from '@threadplane/core'; +import { initialMessageState, reduceMessages } from './message-reducer'; +import { + finalizeProjection, + projectStream, + type StreamProjection, +} from './stream-projection'; + +const user = { type: 'human', id: 'user', content: 'Work' }; +const call = (id: string) => ({ id, name: 'work', args: { id } }); +const assistant = (id: string, ids?: string[], content = '') => ({ + type: 'ai', + id, + content, + ...(ids === undefined ? {} : { tool_calls: ids.map(call) }), +}); +const projection = (): StreamProjection => ({ + generation: 'run', + userId: user.id, + baselineIds: [], + sawAssistant: false, + terminal: false, + paused: false, + canonical: [], +}); +function start(messages = [assistant('step', ['c1'])]) { + return projectStream(initialMessageState(), projection(), { + type: 'values', + data: { messages: [user, ...messages] }, + }); +} + +describe('authoritative tool ownership', () => { + it.each([{ ids: [] }, { ids: ['c2'] }])( + 'reconciles one assistant call list to $ids', + ({ ids }) => { + const before = start(); + const next = projectStream(before.state, before.projection, { + type: 'values', + data: { messages: [user, assistant('step', ids, 'Corrected')] }, + }); + expect(next.state.toolCalls.map((entry) => entry.id)).toEqual(ids); + expect(next.projection.toolCallIds).toEqual(ids); + expect( + finalizeProjection(next.state, next.projection).messages[1] + ).toMatchObject({ + content: 'Corrected', + toolCallIds: ids, + }); + expect(before.state.toolCalls.map((entry) => entry.id)).toEqual(['c1']); + } + ); + + it('keeps omitted tool metadata and ignores empty chunk defaults', () => { + const before = start(); + for (const raw of [ + assistant('step'), + { ...assistant('step', []), type: 'AIMessageChunk' }, + ]) { + const next = projectStream(before.state, before.projection, { + type: 'values', + data: { messages: [user, raw] }, + }); + expect(next.state.toolCalls.map((entry) => entry.id)).toEqual(['c1']); + expect(next.projection.toolCallIds).toEqual(['c1']); + expect(next.state.messages[1].toolCallIds).toEqual(['c1']); + } + }); + + it('preserves earlier distinct assistant steps when correcting a later step', () => { + const before = start([ + assistant('earlier', ['c0']), + assistant('later', ['c1']), + ]); + const next = projectStream(before.state, before.projection, { + type: 'values', + data: { messages: [user, assistant('later', ['c2'])] }, + }); + expect(next.state.toolCalls.map((entry) => entry.id)).toEqual(['c0', 'c2']); + expect(next.projection.toolCallIds).toEqual(['c0', 'c2']); + expect( + next.state.messages.find((entry) => entry.id === 'earlier')?.toolCallIds + ).toEqual(['c0']); + }); + + it('does not let partial chunks overwrite finalized call ownership before a correction', () => { + const before = start(); + const chunk = projectStream(before.state, before.projection, { + type: 'messages', + messageMetadata: {}, + messages: [ + { ...assistant('step', ['fragment']), type: 'AIMessageChunk' }, + ], + }); + expect(chunk.projection.toolCallIds).toEqual(['c1']); + expect(chunk.state.messages[1].toolCallIds).toEqual(['c1']); + const corrected = projectStream(chunk.state, chunk.projection, { + type: 'values', + data: { messages: [user, assistant('step', [])] }, + }); + expect(corrected.state.toolCalls).toEqual([]); + expect(corrected.projection.toolCallIds).toEqual([]); + }); + + it.each(['running', 'complete', 'error'] as const)( + 'preserves a %s fact across removal and replay', + (status) => { + const before = start(); + const fact: ToolCall = { + ...before.state.toolCalls[0], + ...(status === 'complete' + ? { status, result: { saved: true } } + : status === 'error' + ? { status, error: 'Saved failure' } + : { status }), + }; + const state = reduceMessages(before.state, { + type: 'tool', + toolCall: fact, + }); + const corrected = projectStream(state, before.projection, { + type: 'values', + data: { messages: [user, assistant('step', [])] }, + }); + expect(corrected.state.toolCalls).toEqual([fact]); + expect(corrected.projection.toolCallIds).toEqual([]); + const replayed = projectStream(corrected.state, corrected.projection, { + type: 'values', + data: { messages: [user, assistant('step', ['c1'])] }, + }); + expect(replayed.state.toolCalls).toEqual([fact]); + } + ); + + it('preserves server settlement evidence even after a corrected assistant in the same batch', () => { + const before = start(); + const next = projectStream(before.state, before.projection, { + type: 'values', + data: { + messages: [ + user, + assistant('step', []), + { type: 'tool', id: 'result', tool_call_id: 'c1', content: 'Saved' }, + ], + }, + }); + expect(next.state.toolCalls).toMatchObject([ + { id: 'c1', status: 'complete', result: 'Saved' }, + ]); + expect(next.projection.toolCallIds).toEqual([]); + }); +}); diff --git a/libs/langgraph/src/runtime/stream-projection.ts b/libs/langgraph/src/runtime/stream-projection.ts new file mode 100644 index 000000000..206e7cf14 --- /dev/null +++ b/libs/langgraph/src/runtime/stream-projection.ts @@ -0,0 +1,363 @@ +import { + completeDelivery, + streamingDelivery, + type AgentError, + type Message, + type PlainValue, +} from '@threadplane/core'; +import { + reduceMessages, + type MessageEvent, + type MessageState, +} from './message-reducer'; +import type { StreamEvent } from './transport.types'; +import { ownMessage, ownToolCall } from './ownership'; + +type CanonicalMessage = Extract; + +export interface StreamProjection { + readonly generation: string; + readonly userId: string; + readonly baselineIds: readonly string[]; + readonly currentAssistantId?: string; + readonly sawAssistant: boolean; + readonly terminal: boolean; + readonly paused: boolean; + readonly canonical: readonly CanonicalMessage[]; + readonly toolAssistantIds?: readonly string[]; + readonly toolCallIds?: readonly string[]; +} + +export function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function roleOf(raw: Record): Message['role'] | undefined { + switch (raw['type'] ?? raw['role']) { + case 'human': + case 'HumanMessage': + case 'user': + return 'user'; + case 'ai': + case 'AIMessage': + case 'AIMessageChunk': + case 'assistant': + return 'assistant'; + case 'system': + case 'SystemMessage': + return 'system'; + case 'tool': + case 'ToolMessage': + return 'tool'; + default: + return undefined; + } +} + +function textContent(value: unknown): string { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return ''; + return value + .flatMap((block) => { + const content = record(block); + return content?.['type'] === 'text' && typeof content['text'] === 'string' + ? [content['text']] + : []; + }) + .join(''); +} + +export function hasPause(value: unknown): boolean { + const data = record(value); + return ( + Array.isArray(data?.['__interrupt__']) && data['__interrupt__'].length > 0 + ); +} + +/** Pure projection of text and finalized tool data from root stream events. + * Values are interim while the stream is open. EOF or a distinct next assistant + * confirms a terminal candidate. Same-ID chunks invalidate only that message's + * candidate, while any new assistant activity invalidates overall completion. */ +export function projectStream( + state: MessageState, + projection: StreamProjection, + event: StreamEvent +) { + if ((event.namespace?.length ?? 0) > 0 || event.type.includes('|')) + return { state, projection }; + const type = event.type; + const data = record(event['data']); + const values = type === 'checkpoints' ? record(data?.['values']) : data; + const messages = event.messages ?? values?.['messages']; + const messageEvent = type === 'messages' || type.startsWith('messages/'); + const terminal = + type === 'values' || type === 'messages/complete' || type === 'checkpoints'; + if (type === 'interrupt' || type === 'interrupts' || hasPause(values)) { + projection = { ...projection, paused: true }; + } + if (!terminal && !messageEvent) return { state, projection }; + const mode = event.messageMetadata ? 'delta' : 'snapshot'; + const incoming = Array.isArray(messages) + ? messages.map(record).filter((m): m is Record => !!m) + : []; + const candidates: CanonicalMessage[] = []; + const removedToolIds: string[] = []; + const anchor = incoming.findIndex( + (message) => message['id'] === projection.userId + ); + const nextUser = incoming.findIndex( + (message, index) => index > anchor && roleOf(message) === 'user' + ); + for (const raw of incoming) { + const role = roleOf(raw); + if (!role) continue; + const wireId = + typeof raw['id'] === 'string' + ? raw['id'] + : `${projection.generation}-${role}`; + // A shared turn/text does not establish message identity. This protocol + // slice preserves wire IDs; cross-ID correlation needs explicit evidence. + const id = wireId; + const previous = state.messages.find((m) => m.id === id); + const baseline = projection.baselineIds.includes(id); + const calls = Array.isArray(raw['tool_calls']) + ? raw['tool_calls'] + .map(record) + .filter((c): c is Record => !!c) + : []; + const finalizedCalls = + terminal && + role === 'assistant' && + raw['type'] !== 'AIMessageChunk' && + Array.isArray(raw['tool_calls']); + const callIds = calls.flatMap((call) => + typeof call['id'] === 'string' ? [call['id']] : [] + ); + if (finalizedCalls) + removedToolIds.push( + ...(previous?.toolCallIds ?? []).filter( + (callId) => !callIds.includes(callId) + ) + ); + const message: Message = { + id: wireId, + role, + content: textContent(raw['content']), + delivery: + baseline && previous + ? previous.delivery + : role === 'assistant' + ? streamingDelivery(projection.generation) + : completeDelivery(projection.generation, 'success'), + ...(typeof raw['name'] === 'string' ? { name: raw['name'] } : {}), + ...(typeof raw['tool_call_id'] === 'string' + ? { toolCallId: raw['tool_call_id'] } + : {}), + ...(finalizedCalls ? { toolCallIds: callIds } : {}), + }; + if ( + role === 'assistant' && + !baseline && + (!previous || id === projection.currentAssistantId) + ) { + if ( + projection.currentAssistantId && + projection.currentAssistantId !== id + ) { + // A terminal batch can introduce several steps at once. Its already + // projected candidates take precedence over candidates from prior events. + const completedStep = + (terminal + ? candidates.find( + (candidate) => + candidate.message.id === projection.currentAssistantId + ) + : undefined) ?? + projection.canonical.find( + (candidate) => + candidate.message.id === projection.currentAssistantId + ); + if (completedStep) + state = reduceMessages(state, { + ...completedStep, + message: { + ...completedStep.message, + delivery: completeDelivery(projection.generation, 'success'), + }, + }); + } + projection = { + ...projection, + currentAssistantId: id, + sawAssistant: true, + ...(!terminal + ? { + terminal: false, + canonical: projection.canonical.filter( + (candidate) => candidate.message.id !== id + ), + } + : {}), + }; + } + state = reduceMessages(state, { + type: 'message', + mode, + message, + }); + candidates.push({ + type: 'message', + mode: 'canonical', + message: ownMessage(message), + }); + // Only full messages from terminal state carry finalized arguments here. + // AIMessageChunk/tool_call_chunks remain private until that final state. + if (finalizedCalls) { + for (const call of calls) { + if (typeof call['id'] !== 'string' || typeof call['name'] !== 'string') + continue; + // Final tool arguments belong to this call, independently of a later + // text step. Execution belongs to successful command closure, never + // projection; finalized primitive strings are already authored data. + state = reduceMessages(state, { + type: 'tool', + toolCall: ownToolCall({ + id: call['id'], + name: call['name'], + args: call['args'] as PlainValue, + status: 'pending', + }), + }); + } + const position = incoming.indexOf(raw); + if ( + !baseline && + (anchor < 0 || position > anchor) && + (nextUser < 0 || position < nextUser) + ) + projection = { + ...projection, + toolAssistantIds: [ + ...new Set([...(projection.toolAssistantIds ?? []), id]), + ], + }; + } + } + // A ToolMessage is conclusive execution evidence, even when it precedes its + // matching AI message in a batch. Its string content remains wire text. + for (const message of state.messages) { + if (message.role !== 'tool' || !message.toolCallId) continue; + const call = state.toolCalls.find( + (entry) => entry.id === message.toolCallId + ); + if (call?.status === 'pending') + state = reduceMessages(state, { + type: 'tool', + toolCall: { ...call, status: 'complete', result: message.content }, + }); + } + // Settle wire evidence before pruning so ToolMessages later in this same + // batch remain conclusive. Only obsolete pending calls lose their projection. + state = reduceMessages(state, { + type: 'remove-pending-tools', + ids: removedToolIds, + }); + if (projection.toolAssistantIds) + projection = { + ...projection, + toolCallIds: [ + ...new Set( + state.messages.flatMap((message) => + message.role === 'assistant' && + projection.toolAssistantIds?.includes(message.id) + ? message.toolCallIds ?? [] + : [] + ) + ), + ], + }; + if (terminal) { + const payload = event['data'] != null || (event.messages?.length ?? 0) > 0; + projection = { + ...projection, + terminal: projection.sawAssistant || payload, + canonical: [ + ...projection.canonical.filter( + (previous) => + !candidates.some( + (candidate) => candidate.message.id === previous.message.id + ) + ), + ...candidates, + ], + }; + } + return { state, projection }; +} + +export function finalizeProjection( + state: MessageState, + projection: StreamProjection +) { + for (const event of projection.canonical) + state = reduceMessages(state, event); + return state; +} + +export function interruptionError(canCheck: boolean): AgentError { + return { + kind: 'interrupted', + retryable: false, + recovery: canCheck ? 'check' : 'none', + message: canCheck + ? 'The connection dropped. The request may still have completed on the server.' + : 'The connection dropped. We could not confirm whether the request completed.', + detail: canCheck + ? 'Checking will tell you whether it did.' + : 'Trying again could repeat it.', + }; +} + +/** Bounded staging duplication of the legacy display vocabulary. This neutral + * slice never copies causes and never infers undispatched work from first-byte + * absence. Protected SDK errors (including raw SSE error payloads) stay generic. */ +export function failureProjection( + raw: unknown, + protectedTransport: boolean, + explicit: boolean, + canCheck: boolean +): AgentError { + if (protectedTransport) + return { + kind: 'server', + message: 'The LangGraph request failed.', + retryable: false, + recovery: canCheck ? 'check' : 'none', + }; + const object = record(raw); + const status = + typeof object?.['status'] === 'number' ? object['status'] : undefined; + if (status === 401 || status === 403) + return { + kind: 'auth', + message: 'Authentication failed. Check your credentials.', + status, + retryable: false, + recovery: 'none', + }; + if (explicit || raw instanceof SyntaxError || status !== undefined) { + return { + kind: 'server', + message: + typeof object?.['message'] === 'string' + ? object['message'] + : 'The LangGraph request failed.', + status, + retryable: false, + recovery: canCheck ? 'check' : 'none', + }; + } + return interruptionError(canCheck); +} diff --git a/libs/langgraph/src/runtime/testing/binding-fixture.ts b/libs/langgraph/src/runtime/testing/binding-fixture.ts new file mode 100644 index 000000000..048b067ed --- /dev/null +++ b/libs/langgraph/src/runtime/testing/binding-fixture.ts @@ -0,0 +1,146 @@ +import type { AgentSession, AgentSnapshot } from '@threadplane/core'; +import { createSession } from '../create-session'; +import type { AgentTransport, StreamEvent } from '../transport.types'; +import { controlledTransport } from './controlled-transport'; +import { deferred } from './deferred'; + +export interface BindingTools { + weather: { args: { city: string }; result: { temperature: number } }; + count: { args: { values: readonly string[] }; result: number }; +} + +/** Test-only composition: both native bindings borrow this actual runtime. + * The receiver-dependent facade also models class-authored public sessions. */ +export function bindingFixture() { + const streams: ReturnType>[] = []; + const starts = Array.from({ length: 8 }, () => deferred()); + const entered = deferred(); + const toolResult = deferred<{ temperature: number }>(); + let handlerCalls = 0; + let handlerSignal: AbortSignal | undefined; + const stream: AgentTransport['stream'] = (_a, _t, _p, signal) => { + const controlled = controlledTransport({ signal }); + streams.push(controlled); + starts[streams.length - 1].resolve(); + return controlled.stream; + }; + const runtime = createSession({ + assistantId: 'binding-agent', + threadId: 'binding-thread', + transport: { stream }, + tools: { + weather: { + description: 'Weather', + handler: (args: { city: string }, context: { signal: AbortSignal }) => { + if (args.city !== 'Paris') throw new Error('Unexpected test city'); + handlerCalls++; + handlerSignal = context.signal; + entered.resolve(); + return toolResult.promise; + }, + }, + count: { + description: 'Count', + handler: (args: { values: readonly string[] }) => args.values.length, + }, + }, + }); + + class BorrowedSession implements AgentSession { + subscriptions = 0; + releases = 0; + submitCalls = 0; + stopCalls = 0; + disposeCalls = 0; + getSnapshot() { + // Accessing a field makes a detached getSnapshot fail even on first read. + void this.subscriptions; + return runtime.getSnapshot(); + } + subscribe(notify: () => void) { + this.subscriptions++; + const release = runtime.subscribe(notify); + let active = true; + return () => { + if (active) this.releases++; + active = false; + release(); + }; + } + submit(input: string) { + this.submitCalls++; + return runtime.submit(input); + } + stop() { + this.stopCalls++; + return runtime.stop(); + } + dispose() { + this.disposeCalls++; + return runtime.dispose(); + } + } + + const session = new BorrowedSession(); + return { + session, + streams, + started: (index = 0) => starts[index].promise, + entered: entered.promise, + toolResult, + get handlerCalls() { + return handlerCalls; + }, + get handlerSignal() { + return handlerSignal; + }, + async cleanup() { + toolResult.resolve({ temperature: 0 }); + await runtime.dispose(); + streams.forEach((controlled) => controlled.finish()); + }, + }; +} + +/** Registers before releasing a transport event, without timers or polling. */ +export function changed( + session: AgentSession, + predicate: (snapshot: AgentSnapshot) => boolean +) { + if (predicate(session.getSnapshot())) return Promise.resolve(); + const result = deferred(); + const release = session.subscribe(() => { + if (predicate(session.getSnapshot())) { + release(); + result.resolve(); + } + }); + return result.promise; +} + +export const delta = (content: string, id = 'answer'): StreamEvent => ({ + type: 'messages', + messages: [{ type: 'AIMessageChunk', id, content }], + messageMetadata: {}, +}); + +export const finalText = (content: string): StreamEvent => ({ + type: 'values', + data: { messages: [{ type: 'ai', id: 'answer', content }] }, +}); + +export const weatherCall: StreamEvent = { + type: 'values', + data: { + messages: [ + { + type: 'ai', + id: 'tool-request', + content: '', + tool_calls: [ + { id: 'weather-call', name: 'weather', args: { city: 'Paris' } }, + ], + }, + ], + }, +}; diff --git a/libs/langgraph/src/runtime/testing/controlled-transport.ts b/libs/langgraph/src/runtime/testing/controlled-transport.ts new file mode 100644 index 000000000..f6bb1c902 --- /dev/null +++ b/libs/langgraph/src/runtime/testing/controlled-transport.ts @@ -0,0 +1,86 @@ +import { deferred, type Deferred } from './deferred'; + +export interface ControlledTransport { + stream: AsyncIterableIterator & { + return(): Promise>; + }; + closed: Promise; + release(value: T): void; + finish(): void; +} + +/** A controllable async stream, independent of any runtime or SDK protocol. */ +export function controlledTransport( + options: { signal?: AbortSignal; ignoreAbort?: boolean } = {} +): ControlledTransport { + const closed = deferred(); + const queued: IteratorYieldResult[] = []; + const readers: Deferred>[] = []; + const done: IteratorReturnResult = { + value: undefined, + done: true, + }; + let state: 'open' | 'finished' | 'aborted' = 'open'; + let abortReason: unknown; + + function cleanup() { + options.signal?.removeEventListener('abort', abort); + closed.resolve(); + } + + function abort() { + if (state !== 'open') return; + state = 'aborted'; + abortReason = options.signal?.reason; + queued.length = 0; + for (const reader of readers.splice(0)) reader.reject(abortReason); + cleanup(); + } + + function finish() { + if (state !== 'open') return; + state = 'finished'; + for (const reader of readers.splice(0)) reader.resolve(done); + cleanup(); + } + + const stream: ControlledTransport['stream'] = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + if (state === 'aborted') return Promise.reject(abortReason); + const value = queued.shift(); + if (value) return Promise.resolve(value); + if (state === 'finished') return Promise.resolve(done); + const reader = deferred>(); + readers.push(reader); + return reader.promise; + }, + return() { + queued.length = 0; + state = 'finished'; + for (const reader of readers.splice(0)) reader.resolve(done); + cleanup(); + return Promise.resolve(done); + }, + }; + + if (!options.ignoreAbort) { + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + } + + return { + stream, + closed: closed.promise, + release(value) { + if (state !== 'open') return; + const result: IteratorYieldResult = { value, done: false }; + const reader = readers.shift(); + if (reader) reader.resolve(result); + else queued.push(result); + }, + finish, + }; +} diff --git a/libs/langgraph/src/runtime/testing/deferred.ts b/libs/langgraph/src/runtime/testing/deferred.ts new file mode 100644 index 000000000..efaf853d6 --- /dev/null +++ b/libs/langgraph/src/runtime/testing/deferred.ts @@ -0,0 +1,15 @@ +export interface Deferred { + promise: Promise; + resolve(value: T | PromiseLike): void; + reject(reason?: unknown): void; +} + +export function deferred(): Deferred { + let resolve!: Deferred['resolve']; + let reject!: Deferred['reject']; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/libs/langgraph/src/runtime/tool-execution.spec.ts b/libs/langgraph/src/runtime/tool-execution.spec.ts new file mode 100644 index 000000000..5e35962cc --- /dev/null +++ b/libs/langgraph/src/runtime/tool-execution.spec.ts @@ -0,0 +1,1025 @@ +/* eslint @typescript-eslint/no-unused-vars: ["warn", { "argsIgnorePattern": "^_" }] */ +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import type { AgentTransport, StreamEvent } from './transport.types'; +import { deferred } from './testing/deferred'; +import type { ToolExecutionStore } from '@threadplane/core/tools'; +import type { ThreadState } from '@langchain/langgraph-sdk'; + +export function toolEvent( + id = 'call-1', + name = 'weather', + args: unknown = { city: 'Paris' } +): StreamEvent { + return { + type: 'values', + data: { + messages: [ + { + type: 'ai', + id: `assistant-${id}`, + content: '', + tool_calls: [{ id, name, args }], + }, + ], + }, + }; +} +export const finalEvent: StreamEvent = { + type: 'values', + data: { messages: [{ type: 'ai', id: 'answer', content: 'Done' }] }, +}; + +export function fixture( + events: StreamEvent[][] = [[toolEvent()], [finalEvent]] +) { + const stream = vi.fn(async function* () { + for (const event of events.shift() ?? [finalEvent]) yield event; + }); + const updateState = vi.fn>( + async () => undefined + ); + return { stream, updateState }; +} + +describe('owned function tools', () => { + it.each(['local', 'durable'] as const)( + 'retains %s settled results through removal and replay echoes', + async (source) => { + const makeMessage = (ids: string[]) => ({ + type: 'ai', + id: 'assistant-call-1', + content: 'Correction', + tool_calls: ids.map((id) => ({ + id, + name: 'weather', + args: { city: 'Paris' }, + })), + }); + const transport = fixture([ + [toolEvent()], + [ + { type: 'values', data: { messages: [makeMessage([])] } }, + { + type: 'values', + data: { + messages: [ + makeMessage(['call-1']), + { + type: 'tool', + id: 'server-echo', + tool_call_id: 'call-1', + content: 'Wire text', + }, + { type: 'ai', id: 'answer', content: 'Done' }, + ], + }, + }, + ], + ]); + const handler = vi.fn((_args: { city: string }) => ({ saved: true })); + const executionStore: ToolExecutionStore = { + claim: vi.fn(async () => + source === 'local' + ? ('claimed' as const) + : { + status: 'done' as const, + result: { ok: true as const, value: { saved: true } }, + } + ), + record: vi.fn(async () => undefined), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { weather: { description: 'Weather', handler } }, + }); + const corrected: unknown[] = []; + const off = session.subscribe(() => { + const snapshot = session.getSnapshot(); + if ( + snapshot.messages.find((message) => message.id === 'assistant-call-1') + ?.toolCallIds?.length === 0 + ) + corrected.push(snapshot.toolCalls); + }); + try { + await expect(session.submit('Work')).resolves.toBe('success'); + expect(corrected.length).toBeGreaterThan(0); + expect(corrected[0]).toMatchObject([ + { id: 'call-1', status: 'complete', result: { saved: true } }, + ]); + expect(session.getSnapshot().toolCalls).toMatchObject([ + { id: 'call-1', status: 'complete', result: { saved: true } }, + ]); + expect(handler).toHaveBeenCalledTimes(source === 'local' ? 1 : 0); + expect(executionStore.claim).toHaveBeenCalledTimes(1); + expect(executionStore.record).toHaveBeenCalledTimes( + source === 'local' ? 1 : 0 + ); + expect(transport.stream).toHaveBeenCalledTimes(2); + } finally { + off(); + await session.dispose(); + } + } + ); + + it.each(['literal argument', '{"unfinished":'] as const)( + 'passes finalized primitive string %s unchanged while keeping chunks private', + async (args) => { + const transport = fixture([ + [ + { + type: 'values', + data: { + messages: [ + { + type: 'AIMessageChunk', + id: 'chunk', + content: '', + tool_calls: [ + { id: 'fragment', name: 'literal', args: '{"partial":' }, + ], + tool_call_chunks: [ + { id: 'fragment', name: 'literal', args: '{"partial":' }, + ], + }, + ], + }, + }, + toolEvent('full', 'literal', args), + ], + [finalEvent], + ]); + const handler = vi.fn((value: string) => value); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { literal: { description: 'Literal', handler } }, + }); + const seen: unknown[] = []; + const off = session.subscribe(() => { + seen.push(...session.getSnapshot().toolCalls); + }); + try { + await session.submit('Go'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toBe(args); + expect(session.getSnapshot().toolCalls).toEqual([ + { + id: 'full', + name: 'literal', + args, + status: 'complete', + result: args, + }, + ]); + expect( + seen.every((call) => (call as { id: string }).id === 'full') + ).toBe(true); + } finally { + off(); + await session.dispose(); + } + } + ); + it('does not claim a later user turn replayed in the active stream history', async () => { + const handler = vi.fn((_args: { city: string }) => 'Never'); + const transport = fixture(); + transport.stream.mockImplementation(async function* ( + _assistant, + _thread, + input + ) { + const user = (input as { messages: unknown[] }).messages[0]; + yield { + type: 'values', + data: { + messages: [ + user, + { type: 'ai', id: 'ours', content: 'Complete' }, + { type: 'human', id: 'another-user', content: 'Other work' }, + { + type: 'ai', + id: 'other-assistant', + content: '', + tool_calls: [ + { id: 'other-call', name: 'weather', args: { city: 'Paris' } }, + ], + }, + ], + }, + }; + }); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('Ours'); + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(1); + }); + it('exposes registered client calls only and never interprets prototype names as handlers', async () => { + const transport = fixture([ + [ + toolEvent('unknown', 'toString'), + toolEvent('backend', 'server'), + toolEvent(), + ], + ]); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => 'Sun', + }, + }, + }); + await session.submit('Hi'); + expect(session.getSnapshot().toolCalls.map((call) => call.name)).toEqual([ + 'weather', + ]); + }); + + it('does not execute earlier historical calls replayed before the submitted-user anchor', async () => { + const handler = vi.fn((_args: { city: string }) => 'Never'); + const transport = fixture(); + transport.stream.mockImplementation(async function* ( + _assistant, + _thread, + input + ) { + const user = (input as { messages: unknown[] }).messages[0]; + yield { + type: 'values', + data: { + messages: [ + { + type: 'ai', + id: 'old-ai', + content: '', + tool_calls: [ + { id: 'old', name: 'weather', args: { city: 'Paris' } }, + ], + }, + user, + { type: 'ai', id: 'new-ai', content: 'No tools now' }, + ], + }, + }; + }); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('Hi'); + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(1); + }); + + it('checkStatus with finalized tools is read-only and never claims, handles, or continues', async () => { + const handler = vi.fn((_args: { city: string }) => 'Never'); + const executionStore = { + claim: vi.fn(async () => 'claimed' as const), + record: vi.fn(async () => undefined), + }; + const transport = fixture(); + transport.stream.mockImplementation(async function* () { + yield { type: 'error', data: { message: 'Unknown status' } }; + }); + const getHistory = vi.fn>( + async () => [ + { + values: { + messages: [ + ...(transport.stream.mock.calls[0][2] as { messages: unknown[] }) + .messages, + { + type: 'ai', + id: 'recovered', + content: '', + tool_calls: [ + { + id: 'recovered-call', + name: 'weather', + args: { city: 'Paris' }, + }, + ], + }, + ], + }, + next: [], + tasks: [], + checkpoint: { + thread_id: 'thread', + checkpoint_ns: '', + checkpoint_id: 'after', + checkpoint_map: {}, + }, + metadata: null, + created_at: null, + parent_checkpoint: null, + } as ThreadState, + ] + ); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { ...transport, getHistory }, + executionStore, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('Hi'); + await session.checkStatus?.(); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + id: 'recovered-call', + status: 'pending', + }); + expect(executionStore.claim).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(1); + }); + it('executes a named authored tool and continues once with the exact catalog and result', async () => { + const transport = fixture(); + const handler = vi.fn((args: { city: string }) => ({ + forecast: args.city, + })); + const parameters = { + type: 'object', + properties: { city: { type: 'string' } }, + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { weather: { description: 'Weather', parameters, handler } }, + }); + await expect(session.submit('Weather?')).resolves.toBe('success'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual({ city: 'Paris' }); + expect(transport.stream).toHaveBeenCalledTimes(2); + expect(transport.stream.mock.calls[0][2]).toMatchObject({ + client_tools: [{ name: 'weather', description: 'Weather', parameters }], + }); + expect(transport.stream.mock.calls[1][2]).toEqual({ + client_tools: [{ name: 'weather', description: 'Weather', parameters }], + messages: [ + { + id: 'client-tool-result-call-1', + role: 'tool', + type: 'tool', + tool_call_id: 'call-1', + content: '{"forecast":"Paris"}', + }, + ], + }); + expect(session.getSnapshot().toolCalls).toEqual([ + { + id: 'call-1', + name: 'weather', + args: { city: 'Paris' }, + status: 'complete', + result: { forecast: 'Paris' }, + }, + ]); + }); + + it('isolates handler arguments and captures caller-owned catalog configuration', async () => { + const transport = fixture(); + const handler = vi.fn((args: { city: string }) => { + args.city = 'Lyon'; + return args.city; + }); + const tools = { + weather: { + description: 'Original', + followUp: true, + parameters: { title: 'Original' }, + handler, + }, + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools, + }); + tools.weather.handler = vi.fn(() => 'Wrong'); + tools.weather.followUp = false; + tools.weather.parameters.title = 'Changed'; + tools.weather.description = 'Changed'; + await session.submit('Hi'); + expect(handler).toHaveBeenCalledTimes(1); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + args: { city: 'Paris' }, + result: 'Lyon', + status: 'complete', + }); + expect(transport.stream.mock.calls[0][2]).toMatchObject({ + client_tools: [ + { description: 'Original', parameters: { title: 'Original' } }, + ], + }); + expect(transport.stream).toHaveBeenCalledTimes(2); + }); + + it('supports no-input context, void results, rejection, and one batched continuation', async () => { + const ping = { + type: 'values' as const, + data: { + messages: [ + { + type: 'ai', + id: 'ping-message', + content: '', + tool_calls: [{ id: 'ping', name: 'ping' }], + }, + ], + }, + }; + const transport = fixture([ + [ping, toolEvent('fail', 'fail', {})], + [finalEvent], + ]); + let signal: AbortSignal | undefined; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + ping: { + description: 'Ping', + handler: (_args: void, context: { signal: AbortSignal }): void => { + signal = context.signal; + }, + }, + fail: { + description: 'Fail', + handler: async (_args: object): Promise => { + throw new Error('declined'); + }, + }, + }, + }); + await session.submit('Go'); + expect(signal).toBeInstanceOf(AbortSignal); + expect(session.getSnapshot().toolCalls[0].args).toBeUndefined(); + expect(transport.stream).toHaveBeenCalledTimes(2); + expect(transport.stream.mock.calls[1][2]).toMatchObject({ + messages: [ + { tool_call_id: 'ping', content: '' }, + { tool_call_id: 'fail', content: 'Error: declined' }, + ], + }); + expect(session.getSnapshot().toolCalls).toMatchObject([ + { status: 'complete', result: undefined }, + { status: 'error', error: 'declined' }, + ]); + }); + + it('never reexecutes server-settled or duplicate finalized calls', async () => { + const event = toolEvent(); + const transport = fixture([ + [ + event, + { + type: 'values', + data: { + messages: [ + { + type: 'tool', + id: 'server-result', + tool_call_id: 'call-1', + content: 'Server', + }, + ], + }, + }, + ], + [event], + ]); + const handler = vi.fn((_args: { city: string }) => 'Local'); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('First'); + await session.submit('Second'); + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(2); + expect(session.getSnapshot().toolCalls).toEqual([]); + expect(session.getSnapshot().messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: 'Server' }) + ); + }); + + it('stops promptly when the handler ignores abort and discards its late result', async () => { + const entered = deferred(); + const result = deferred(); + const transport = fixture(); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => { + entered.resolve(); + return result.promise; + }, + }, + }, + }); + try { + const run = session.submit('Go'); + await entered.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + const snapshot = session.getSnapshot(); + result.resolve('Late'); + await result.promise; + expect(session.getSnapshot()).toBe(snapshot); + expect(transport.stream).toHaveBeenCalledTimes(1); + } finally { + result.resolve('Cleanup'); + await session.dispose(); + } + }); + + it.each(['resolve', 'reject'] as const)( + 'records cancellation before a noncooperative handler later %s, without stale publication', + async (ending) => { + const entered = deferred(); + const result = deferred(); + const flushed = deferred(); + const transport = fixture(); + transport.updateState.mockImplementation(async () => { + flushed.resolve(); + }); + const executionStore = { + claim: vi.fn(async () => 'claimed' as const), + record: vi.fn(async () => undefined), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => { + entered.resolve(); + return result.promise; + }, + }, + }, + }); + try { + const run = session.submit('Go'); + await entered.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + await flushed.promise; + const snapshot = session.getSnapshot(); + expect(executionStore.record).toHaveBeenCalledWith( + { threadId: 'thread', toolCallId: 'call-1' }, + { ok: false, error: expect.stringContaining('cancelled') } + ); + if (ending === 'resolve') result.resolve('Late'); + else result.reject(new Error('Late rejection')); + await result.promise.catch(() => undefined); + expect(session.getSnapshot()).toBe(snapshot); + expect(executionStore.record).toHaveBeenCalledTimes(1); + expect(transport.stream).toHaveBeenCalledTimes(1); + } finally { + result.resolve('cleanup'); + await session.dispose(); + } + } + ); + + it('keeps external abort linked across the entire continuation chain', async () => { + const entered = deferred(); + const handlerResult = deferred(); + const transport = fixture([[toolEvent('first')], [toolEvent('second')]]); + let count = 0; + const controller = new AbortController(); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => { + if (++count === 1) return 'First'; + entered.resolve(); + return handlerResult.promise; + }, + }, + }, + }); + try { + const run = session.submit('Go', { signal: controller.signal }); + await entered.promise; + controller.abort(); + await expect(run).resolves.toBe('aborted'); + expect(transport.stream).toHaveBeenCalledTimes(2); + expect(session.getSnapshot().toolCalls[1]).toMatchObject({ + status: 'error', + error: expect.stringContaining('cancelled'), + }); + } finally { + handlerResult.resolve('cleanup'); + await session.dispose(); + } + }); + + it.each(['stop', 'dispose', 'submit'] as const)( + 'survives handler abort callbacks that synchronously %s', + async (command) => { + const entered = deferred(); + const result = deferred(); + const transport = fixture(); + let replacement: Promise | undefined; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + weather: { + description: 'Weather', + handler: ( + _args: { city: string }, + context: { signal: AbortSignal } + ) => { + context.signal.addEventListener( + 'abort', + () => { + replacement = + command === 'submit' + ? session.submit('Replacement') + : session[command](); + }, + { once: true } + ); + entered.resolve(); + return result.promise; + }, + }, + }, + }); + try { + const run = session.submit('First'); + await entered.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + await replacement; + expect(transport.stream).toHaveBeenCalledTimes( + command === 'submit' ? 2 : 1 + ); + if (command === 'dispose') + await expect(session.submit('Never')).resolves.toBe('aborted'); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + status: 'error', + }); + } finally { + result.resolve('cleanup'); + await session.dispose(); + } + } + ); +}); + +describe('function tool execution guard', () => { + it('retains a durable done fact discovered after stop instead of replacing it with cancellation', async () => { + const claiming = deferred(); + const claim = deferred<{ + status: 'done'; + result: { ok: true; value: string }; + }>(); + const flushed = deferred(); + const transport = fixture(); + transport.updateState.mockImplementation(async () => { + flushed.resolve(); + }); + const handler = vi.fn((_args: { city: string }) => 'Never'); + const executionStore = { + claim: () => { + claiming.resolve(); + return claim.promise; + }, + record: vi.fn(async () => undefined), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { weather: { description: 'Weather', handler } }, + }); + try { + const run = session.submit('Go'); + await claiming.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + claim.resolve({ + status: 'done', + result: { ok: true, value: 'Previously completed' }, + }); + await flushed.promise; + expect(transport.updateState.mock.calls[0][1]).toMatchObject({ + messages: [{ content: 'Previously completed' }], + }); + expect(executionStore.record).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(1); + } finally { + claim.resolve({ status: 'done', result: { ok: true, value: 'cleanup' } }); + await session.dispose(); + } + }); + it('retains captured store methods and exposes record rejection as a guard failure', async () => { + const transport = fixture(); + const claim = vi.fn(async () => 'claimed' as const); + const record = vi.fn(async () => { + throw new Error('write failed'); + }); + const executionStore = { claim, record }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => 'Result', + }, + }, + }); + executionStore.claim = vi.fn(async () => 'claimed' as const); + executionStore.record = vi.fn(async () => { + throw new Error('different'); + }); + await session.submit('Go'); + expect(claim).toHaveBeenCalledTimes(1); + expect(record).toHaveBeenCalledTimes(1); + expect(executionStore.claim).not.toHaveBeenCalled(); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + status: 'error', + error: expect.stringContaining('write failed'), + }); + }); + it('claims before handling, records before settlement, and idempotent tools skip the guard', async () => { + const recorded = deferred(); + const recording = deferred(); + const order: string[] = []; + const transport = fixture(); + const executionStore = { + claim: vi.fn(async () => { + order.push('claim'); + return 'claimed' as const; + }), + record: vi.fn(async () => { + order.push('record'); + recording.resolve(); + await recorded.promise; + }), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => { + order.push('handler'); + return 'Sun'; + }, + }, + }, + }); + try { + const run = session.submit('Go'); + await recording.promise; + expect(order).toEqual(['claim', 'handler', 'record']); + expect(executionStore.claim).toHaveBeenCalledWith({ + threadId: 'thread', + toolCallId: 'call-1', + }); + expect(session.getSnapshot().toolCalls[0].status).toBe('running'); + expect(transport.stream).toHaveBeenCalledTimes(1); + recorded.resolve(); + await run; + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + status: 'complete', + result: 'Sun', + }); + } finally { + recorded.resolve(); + await session.dispose(); + } + const bypass = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: fixture(), + executionStore, + tools: { + weather: { + description: 'Weather', + idempotent: true, + handler: (_args: { city: string }) => 'Sun', + }, + }, + }); + await bypass.submit('Go'); + expect(executionStore.claim).toHaveBeenCalledTimes(1); + expect(executionStore.record).toHaveBeenCalledTimes(1); + }); + + it.each(['done', 'executing', 'failed', 'reject'] as const)( + 'fails closed/reuses prior %s records', + async (status) => { + const handler = vi.fn((_args: { city: string }) => 'New'); + const executionStore = { + claim: vi.fn(async () => { + if (status === 'reject') throw new Error('offline'); + return status === 'done' + ? { status, result: { ok: true as const, value: 'Saved' } } + : { status }; + }), + record: vi.fn(async () => undefined), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: fixture(), + executionStore, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('Go'); + expect(handler).not.toHaveBeenCalled(); + expect(session.getSnapshot().toolCalls[0]).toMatchObject( + status === 'done' + ? { status: 'complete', result: 'Saved' } + : { status: 'error' } + ); + } + ); + + it.each(['resolve', 'reject'] as const)( + 'settles stop while claim is pending; late claim %s cannot execute', + async (ending) => { + const claimed = deferred<'claimed'>(); + const claiming = deferred(); + const flushed = deferred(); + const handler = vi.fn((_args: { city: string }) => 'Bad'); + const transport = fixture(); + transport.updateState.mockImplementation(async () => { + flushed.resolve(); + }); + const executionStore = { + claim: vi.fn(() => { + claiming.resolve(); + return claimed.promise; + }), + record: vi.fn(async () => undefined), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { weather: { description: 'Weather', handler } }, + }); + try { + const run = session.submit('Go'); + await claiming.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + const snapshot = session.getSnapshot(); + if (ending === 'resolve') claimed.resolve('claimed'); + else claimed.reject(new Error('late')); + await flushed.promise; + expect(handler).not.toHaveBeenCalled(); + expect(transport.stream).toHaveBeenCalledTimes(1); + expect(session.getSnapshot()).toBe(snapshot); + expect(executionStore.record).toHaveBeenCalledTimes( + ending === 'resolve' ? 1 : 0 + ); + if (ending === 'resolve') + expect(executionStore.record.mock.calls[0]).toEqual([ + { threadId: 'thread', toolCallId: 'call-1' }, + { ok: false, error: expect.stringContaining('cancelled') }, + ]); + } finally { + claimed.resolve('claimed'); + await session.dispose(); + } + } + ); + + it.each(['resolve', 'reject'] as const)( + 'preserves the durable fact when stop races a pending record %s', + async (ending) => { + const recorded = deferred(); + const recording = deferred(); + const flushed = deferred(); + const transport = fixture(); + transport.updateState.mockImplementation(async () => { + flushed.resolve(); + }); + const executionStore = { + claim: vi.fn(async () => 'claimed' as const), + record: vi.fn(() => { + recording.resolve(); + return recorded.promise; + }), + }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + executionStore, + tools: { + weather: { + description: 'Weather', + handler: (_args: { city: string }) => 'Recorded success', + }, + }, + }); + try { + const run = session.submit('Go'); + await recording.promise; + await session.stop(); + await expect(run).resolves.toBe('aborted'); + const snapshot = session.getSnapshot(); + if (ending === 'resolve') recorded.resolve(); + else recorded.reject(new Error('durability failed')); + await flushed.promise; + expect(executionStore.record).toHaveBeenCalledTimes(1); + expect(executionStore.record.mock.calls[0][1]).toEqual({ + ok: true, + value: 'Recorded success', + }); + expect(transport.updateState.mock.calls[0][1]).toMatchObject({ + messages: [ + { + content: + ending === 'resolve' + ? 'Recorded success' + : expect.stringContaining('guard failed'), + }, + ], + }); + expect(transport.stream).toHaveBeenCalledTimes(1); + expect(session.getSnapshot()).toBe(snapshot); + } finally { + recorded.resolve(); + await session.dispose(); + } + } + ); + + it('limits automatic continuation to ten groups and resets on a new user turn', async () => { + let calls = 0; + const transport = fixture(); + transport.stream.mockImplementation(async function* () { + yield toolEvent(`call-${++calls}`); + }); + const handler = vi.fn((_args: { city: string }) => 'Result'); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { weather: { description: 'Weather', handler } }, + }); + await session.submit('Go'); + expect(transport.stream).toHaveBeenCalledTimes(11); + expect(handler).toHaveBeenCalledTimes(10); + expect(session.getSnapshot().toolCalls[10]).toMatchObject({ + status: 'error', + error: expect.stringContaining('limit'), + }); + await session.submit('Again'); + expect(handler).toHaveBeenCalledTimes(20); + }); +}); diff --git a/libs/langgraph/src/runtime/tool-settlement.spec.ts b/libs/langgraph/src/runtime/tool-settlement.spec.ts new file mode 100644 index 000000000..cc41c164c --- /dev/null +++ b/libs/langgraph/src/runtime/tool-settlement.spec.ts @@ -0,0 +1,252 @@ +/* eslint @typescript-eslint/no-unused-vars: ["warn", { "argsIgnorePattern": "^_" }] */ +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import { createToolBuffer } from './function-tools'; +import { deferred } from './testing/deferred'; +import type { AgentTransport, StreamEvent } from './transport.types'; +import type { ThreadState } from '@langchain/langgraph-sdk'; + +const call = { + type: 'ai', + id: 'assistant', + content: '', + tool_calls: [{ id: 'call', name: 'work', args: {} }], +}; +const final: StreamEvent = { + type: 'values', + data: { messages: [{ type: 'ai', id: 'answer', content: 'Done' }] }, +}; +function options(transport: AgentTransport, followUp = false) { + return { + assistantId: 'agent', + threadId: 'thread', + transport, + tools: { + work: { + description: 'Work', + followUp, + handler: (_args: object) => 'Result', + }, + }, + }; +} + +describe('tool result handoff', () => { + it('does not acknowledge an empty continuation from the earlier tool-producing checkpoint', async () => { + let count = 0; + const stream = vi.fn(async function* () { + if (++count === 1) yield { type: 'values', data: { messages: [call] } }; + else if (count > 2) yield final; + }); + const getHistory = vi.fn>( + async () => [ + { + values: { + messages: [ + ...(stream.mock.calls[0][2] as { messages: unknown[] }).messages, + call, + ], + }, + next: [], + tasks: [], + checkpoint: { + thread_id: 'thread', + checkpoint_id: 'old', + checkpoint_ns: '', + checkpoint_map: {}, + }, + metadata: null, + parent_checkpoint: null, + created_at: null, + } as ThreadState, + ] + ); + const session = createSession(options({ stream, getHistory }, true)); + await expect(session.submit('First')).resolves.toBe('interrupted'); + await session.checkStatus?.(); + expect(session.getSnapshot().status).toBe('error'); + await session.submit('Second'); + expect(stream.mock.calls[2][2]).toMatchObject({ + messages: [ + { tool_call_id: 'call', content: 'Result' }, + { type: 'human', content: 'Second' }, + ], + }); + }); + it.each(['stop', 'dispose'] as const)( + 'persists without continuation when a completed-result observer calls %s', + async (command) => { + const flushed = deferred(); + const stream = vi.fn(async function* () { + yield { type: 'values', data: { messages: [call] } }; + }); + const updateState = vi.fn>( + async () => { + flushed.resolve(); + } + ); + const session = createSession(options({ stream, updateState }, true)); + const off = session.subscribe(() => { + if (session.getSnapshot().toolCalls[0]?.status === 'complete') + void session[command](); + }); + try { + await expect(session.submit('First')).resolves.toBe('aborted'); + await flushed.promise; + expect(stream).toHaveBeenCalledTimes(1); + expect(updateState.mock.calls[0][1]).toMatchObject({ + messages: [{ content: 'Result' }], + }); + } finally { + off(); + await session.dispose(); + } + } + ); + it.each(['missing', 'reject'] as const)( + 'retains terminal results when updateState is %s, for the next explicit handoff', + async (mode) => { + let runCount = 0; + const stream = vi.fn(async function* () { + yield ++runCount === 1 + ? { type: 'values', data: { messages: [call] } } + : final; + }); + const updateState = vi.fn>( + async () => { + throw new Error('offline'); + } + ); + const session = createSession( + options({ stream, ...(mode === 'reject' ? { updateState } : {}) }) + ); + await expect(session.submit('First')).resolves.toBe('error'); + expect(session.getSnapshot().error).toMatchObject({ + kind: 'server', + recovery: 'none', + }); + expect(stream).toHaveBeenCalledTimes(1); + await expect(session.submit('Second')).resolves.toBe('success'); + expect(stream.mock.calls[1][2]).toMatchObject({ + messages: [ + { id: 'client-tool-result-call', content: 'Result' }, + { type: 'human', content: 'Second' }, + ], + }); + await session.submit('Third'); + expect(stream.mock.calls[2][2]).toMatchObject({ + messages: [{ type: 'human', content: 'Third' }], + }); + expect( + (stream.mock.calls[2][2] as { messages: unknown[] }).messages + ).toHaveLength(1); + } + ); + + it.each(['empty', 'error'] as const)( + 'retains continuation results after %s closure, without inheriting prior completion', + async (mode) => { + let count = 0; + const stream = vi.fn(async function* () { + count += 1; + if (count === 1) yield { type: 'values', data: { messages: [call] } }; + else if (count === 2 && mode === 'error') + yield { type: 'error', data: { message: 'failed' } }; + else if (count > 2) yield final; + }); + const session = createSession(options({ stream }, true)); + await expect(session.submit('First')).resolves.toBe( + mode === 'empty' ? 'interrupted' : 'error' + ); + expect(stream).toHaveBeenCalledTimes(2); + expect(session.getSnapshot().toolCalls[0]).toMatchObject({ + status: 'complete', + result: 'Result', + }); + expect( + session + .getSnapshot() + .messages.find((message) => message.id === 'assistant')?.delivery + ).toMatchObject({ phase: 'complete', outcome: 'success' }); + await session.submit('Next'); + expect(stream.mock.calls[2][2]).toMatchObject({ + messages: [ + { tool_call_id: 'call', content: 'Result' }, + { type: 'human' }, + ], + }); + } + ); + + it('stop settles promptly while terminal persistence is pending and a late ACK preserves newer entries', async () => { + const persisted = deferred(); + const persisting = deferred(); + let count = 0; + const stream = vi.fn(async function* () { + yield { + type: 'values', + data: { + messages: [ + { + ...call, + id: `assistant-${++count}`, + tool_calls: [{ id: `call-${count}`, name: 'work', args: {} }], + }, + ], + }, + }; + }); + const updateState = vi.fn>( + () => { + if (updateState.mock.calls.length === 1) { + persisting.resolve(); + return persisted.promise; + } + return Promise.reject(new Error('keep new result staged')); + } + ); + const session = createSession(options({ stream, updateState })); + try { + const first = session.submit('First'); + await persisting.promise; + await session.stop(); + await expect(first).resolves.toBe('aborted'); + await session.submit('Second'); + persisted.resolve(); + await persisted.promise; + // Third stream input captures remaining second-call result, never loses it + // when the first update finally acknowledges its earlier snapshot. + await session.submit('Third'); + expect(stream.mock.calls[2][2]).toMatchObject({ + messages: [ + { tool_call_id: 'call-2' }, + { type: 'human', content: 'Third' }, + ], + }); + } finally { + persisted.resolve(); + await session.dispose(); + } + }); + + it('buffer ACK removes only exact captured entries and handles void/string/JSON/error deterministically', () => { + const buffer = createToolBuffer(); + buffer.stage('a', { ok: true, value: undefined }); + const first = buffer.snapshot(); + expect(first.messages[0].content).toBe(''); + buffer.stage('a', { ok: true, value: '123' }); + buffer.stage('b', { ok: true, value: { x: 2 } }); + buffer.stage('c', { ok: false, error: 'Failed' }); + first.acknowledge(); + expect( + buffer.snapshot().messages.map((message) => message.content) + ).toEqual(['123', '{"x":2}', 'Error: Failed']); + const second = buffer.snapshot(); + buffer.stage('d', { ok: true, value: null }); + second.acknowledge(); + second.acknowledge(); + expect( + buffer.snapshot().messages.map((message) => message.tool_call_id) + ).toEqual(['d']); + }); +}); diff --git a/libs/langgraph/src/runtime/transport.integration.spec.ts b/libs/langgraph/src/runtime/transport.integration.spec.ts new file mode 100644 index 000000000..109873f09 --- /dev/null +++ b/libs/langgraph/src/runtime/transport.integration.spec.ts @@ -0,0 +1,598 @@ +import { readFileSync } from 'node:fs'; +import { ReadableStream, ReadableStreamDefaultReader } from 'node:stream/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FetchStreamTransport } from '../lib/transport/fetch-stream.transport'; +import type { StreamEvent } from './transport.types'; +import { createSession, type SessionOptions } from './create-session'; +import type { ToolExecutionStore } from '@threadplane/core/tools'; + +const textTrace = readFileSync( + new URL( + '../../../../fixtures/react-parity/traces/langgraph-text-state.sse', + import.meta.url + ), + 'utf8' +); +const toolCall = { + type: 'ai', + id: 'assistant-tool', + content: '', + tool_calls: [ + { + id: 'call-weather', + name: 'weather', + args: { city: 'Paris' }, + type: 'tool_call', + }, + ], +}; +const toolResult = { + type: 'tool', + id: 'tool-result', + tool_call_id: 'call-weather', + name: 'weather', + content: 'Sunny', +}; +const toolTrace = `event: messages\ndata: ${JSON.stringify([ + toolCall, + { langgraph_node: 'assistant' }, +])}\n\nevent: values\ndata: ${JSON.stringify({ + messages: [toolCall, toolResult], +})}\n\n`; + +function fragmentedResponse(trace: string): Response { + const bytes = new TextEncoder().encode(trace); + let offset = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (offset === bytes.length) return controller.close(); + controller.enqueue(bytes.slice(offset, offset + 3)); + offset = Math.min(offset + 3, bytes.length); + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ); +} + +async function collect( + events: AsyncIterable +): Promise { + const result: StreamEvent[] = []; + for await (const event of events) result.push(event); + return result; +} + +describe('neutral real SDK transport', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([ + ['missing options', undefined, 1], + ['empty options', {}, 1], + ['headers only', { defaultHeaders: { authorization: 'session-token' } }, 1], + ['undefined retries', { maxRetries: undefined }, 1], + [ + 'explicit retry opt-in', + { maxRetries: 1, defaultHeaders: { authorization: 'session-token' } }, + 2, + ], + ] as [string, SessionOptions['clientOptions'], number][])( + 'does not replay an ambiguous run-creation POST unless opted in: %s', + async (_label, clientOptions, expectedPosts) => { + vi.useFakeTimers(); + const posts: RequestInit[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url, init) => { + if (!String(url).endsWith('/runs/stream')) + return new Response('[]', { + headers: { 'content-type': 'application/json' }, + }); + posts.push(init ?? {}); + // The server may already have accepted this POST. No known run ID is + // available to join: replaying it can create a second logical run. + if (posts.length === 1) + throw new Error('NetworkError after accepted POST'); + return fragmentedResponse(textTrace); + }) + ); + const session = createSession({ + assistantId: 'assistant-1', + threadId: 'thread-1', + apiUrl: 'https://runtime.example/api', + clientOptions, + }); + try { + const submitted = session.submit('One logical submission'); + await vi.runAllTimersAsync(); + const outcome = await submitted; + expect(posts).toHaveLength(expectedPosts); + expect(outcome).toBe( + expectedPosts === 2 + ? 'success' + : clientOptions?.defaultHeaders + ? 'error' + : 'interrupted' + ); + expect(posts.every((post) => post.method === 'POST')).toBe(true); + for (const post of posts) + expect(new Headers(post.headers).get('authorization')).toBe( + clientOptions?.defaultHeaders?.['authorization'] ?? null + ); + if (expectedPosts === 2) expect(posts[1].body).toBe(posts[0].body); + } finally { + await session.dispose(); + } + } + ); + + it.each([ + { ids: [], followUp: false }, + { ids: [], followUp: true }, + { ids: ['c2'], followUp: false }, + ])( + 'honors a real SSE correction to tool calls $ids before any effects (followUp=$followUp)', + async ({ ids, followUp }) => { + const requests: { url: string; body: Record }[] = []; + const handler = vi.fn((args: { id: string }) => args.id); + const store: ToolExecutionStore = { + claim: vi.fn(async () => 'claimed' as const), + record: vi.fn(async () => undefined), + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url, init) => { + const body = JSON.parse(String(init?.body)) as Record< + string, + unknown + >; + requests.push({ url: String(url), body }); + if (!String(url).endsWith('/runs/stream')) + return new Response('{}', { + headers: { 'content-type': 'application/json' }, + }); + const user = (body['input'] as { messages: unknown[] }).messages[0]; + const values = [['c1'], ids].map((calls, index) => ({ + messages: [ + user, + { + type: 'ai', + id: 'same-assistant', + content: index ? 'Corrected' : '', + tool_calls: calls.map((id) => ({ + id, + name: 'work', + args: { id }, + })), + }, + ], + })); + return fragmentedResponse( + values + .map( + (value) => `event: values\ndata: ${JSON.stringify(value)}\n\n` + ) + .join('') + ); + }) + ); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + apiUrl: 'https://runtime.example', + executionStore: store, + tools: { work: { description: 'Work', followUp, handler } }, + }); + const pending: string[][] = []; + const off = session.subscribe(() => { + const snapshot = session.getSnapshot(); + if ( + snapshot.messages.some((message) => message.content === 'Corrected') + ) + pending.push( + snapshot.toolCalls + .filter((call) => call.status === 'pending') + .map((call) => call.id) + ); + }); + try { + await expect(session.submit('Work')).resolves.toBe('success'); + expect(pending[0]).toEqual(ids); + expect( + session + .getSnapshot() + .messages.find((message) => message.id === 'same-assistant') + ?.toolCallIds + ).toEqual(ids); + expect(session.getSnapshot().toolCalls.map((call) => call.id)).toEqual( + ids + ); + expect(handler.mock.calls.map(([args]) => args.id)).toEqual(ids); + expect(store.claim).toHaveBeenCalledTimes(ids.length); + expect(store.record).toHaveBeenCalledTimes(ids.length); + if (ids.length) + expect(store.claim).toHaveBeenCalledWith({ + threadId: 'thread', + toolCallId: 'c2', + }); + expect( + requests.filter((request) => request.url.endsWith('/runs/stream')) + ).toHaveLength(1); + const persisted = requests.filter((request) => + request.url.endsWith('/state') + ); + expect(persisted).toHaveLength(ids.length); + if (ids.length) + expect(persisted[0].body).toMatchObject({ + values: { messages: [{ tool_call_id: 'c2', content: 'c2' }] }, + }); + } finally { + off(); + await session.dispose(); + } + } + ); + + it('roundtrips an owned function tool through the real SDK and preserves authored results across server echoes', async () => { + const requests: { url: string; body: Record }[] = []; + const parameters = { + type: 'object', + properties: { city: { type: 'string', enum: ['London'] } }, + additionalProperties: false, + }; + const handler = vi.fn((args: { city: string }) => ({ + city: args.city, + temperature: 20, + })); + vi.stubGlobal( + 'fetch', + vi.fn(async (url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + requests.push({ url: String(url), body }); + const input = body['input'] as { messages: unknown[] }; + const messages = + requests.length === 1 + ? [...input.messages, toolCall] + : [ + toolCall, + ...input.messages, + { type: 'ai', id: 'answer', content: '20 degrees' }, + ]; + return fragmentedResponse( + `event: values\ndata: ${JSON.stringify({ messages })}\n\n` + ); + }) + ); + const session = createSession({ + assistantId: 'assistant-1', + threadId: 'thread-1', + apiUrl: 'https://runtime.example/api', + clientOptions: { maxRetries: 0 }, + tools: { + weather: { description: 'Current weather', parameters, handler }, + }, + }); + try { + await expect(session.submit('Weather?')).resolves.toBe('success'); + expect(requests).toHaveLength(2); + expect(handler).toHaveBeenCalledTimes(1); + // Paris deliberately disagrees with the optional metadata enum: metadata + // does not validate or transform caller-authored arguments. + expect(handler.mock.calls[0][0]).toEqual({ city: 'Paris' }); + const catalog = [ + { name: 'weather', description: 'Current weather', parameters }, + ]; + expect(requests[0]).toEqual({ + url: 'https://runtime.example/api/threads/thread-1/runs/stream', + body: { + assistant_id: 'assistant-1', + input: { + messages: [ + { id: expect.any(String), type: 'human', content: 'Weather?' }, + ], + client_tools: catalog, + }, + stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], + stream_subgraphs: true, + }, + }); + expect(requests[1]).toEqual({ + url: 'https://runtime.example/api/threads/thread-1/runs/stream', + body: { + assistant_id: 'assistant-1', + input: { + messages: [ + { + id: 'client-tool-result-call-weather', + type: 'tool', + role: 'tool', + tool_call_id: 'call-weather', + content: '{"city":"Paris","temperature":20}', + }, + ], + client_tools: catalog, + }, + stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], + stream_subgraphs: true, + }, + }); + expect(session.getSnapshot().toolCalls).toEqual([ + { + id: 'call-weather', + name: 'weather', + args: { city: 'Paris' }, + status: 'complete', + result: { city: 'Paris', temperature: 20 }, + }, + ]); + } finally { + await session.dispose(); + } + }); + + it('decodes fragmented UTF-8 and forwards exact run input, catalog and options', async () => { + const request = vi.fn(async () => + fragmentedResponse(textTrace) + ); + vi.stubGlobal('fetch', request); + const transport = new FetchStreamTransport( + 'https://runtime.example/api', + undefined, + { maxRetries: 0, defaultHeaders: { authorization: 'session-token' } } + ); + const catalog = [ + { + name: 'weather', + description: 'Current weather', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + ]; + const input = { + messages: [{ type: 'human', content: 'Hello' }], + client_tools: catalog, + }; + const events = await collect( + transport.stream( + 'assistant-1', + 'thread-1', + input, + new AbortController().signal, + { + config: { configurable: { locale: 'fr' } }, + metadata: { source: 'test' }, + context: { tenant: 'local' }, + command: { resume: 'approved' }, + streamMode: ['messages-tuple', 'values'], + streamSubgraphs: false, + multitaskStrategy: 'reject', + onDisconnect: 'cancel', + durability: 'sync', + } + ) + ); + expect(request).toHaveBeenCalledTimes(1); + const [url, init] = request.mock.calls[0]; + expect(String(url)).toBe( + 'https://runtime.example/api/threads/thread-1/runs/stream' + ); + expect(init?.method).toBe('POST'); + expect(new Headers(init?.headers).get('authorization')).toBe( + 'session-token' + ); + expect(new Headers(init?.headers).has('x-api-key')).toBe(false); + expect(JSON.parse(String(init?.body))).toEqual({ + assistant_id: 'assistant-1', + input, + config: { configurable: { locale: 'fr' } }, + metadata: { source: 'test' }, + context: { tenant: 'local' }, + command: { resume: 'approved' }, + stream_mode: ['messages-tuple', 'values'], + stream_subgraphs: false, + multitask_strategy: 'reject', + on_disconnect: 'cancel', + durability: 'sync', + }); + expect(events).toEqual([ + { + type: 'metadata', + run_id: 'run-parity', + attempt: 1, + data: { run_id: 'run-parity', attempt: 1 }, + }, + ...['Hello ', '🌍.'].map((content) => { + const message = { + type: 'AIMessageChunk', + id: 'message-parity', + content, + }; + const metadata = { langgraph_node: 'assistant' }; + return { + type: 'messages', + messages: [message], + messageMetadata: metadata, + data: [message, metadata], + }; + }), + { + type: 'values', + messages: [{ type: 'ai', id: 'message-parity', content: 'Hello 🌍.' }], + stage: 'complete', + data: { + messages: [ + { type: 'ai', id: 'message-parity', content: 'Hello 🌍.' }, + ], + stage: 'complete', + }, + }, + ]); + }); + + it('routes real tool call and tool result data through the messages and values streams', async () => { + const request = vi.fn(async () => + fragmentedResponse(toolTrace) + ); + vi.stubGlobal('fetch', request); + const transport = new FetchStreamTransport( + 'https://runtime.example', + undefined, + { maxRetries: 0 } + ); + const events = await collect( + transport.stream( + 'assistant-1', + 'thread-1', + null, + new AbortController().signal + ) + ); + expect(request).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual({ + assistant_id: 'assistant-1', + input: null, + stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], + stream_subgraphs: true, + }); + expect(events).toEqual([ + { + type: 'messages', + messages: [toolCall], + messageMetadata: { langgraph_node: 'assistant' }, + data: [toolCall, { langgraph_node: 'assistant' }], + }, + { + type: 'values', + messages: [toolCall, toolResult], + data: { messages: [toolCall, toolResult] }, + }, + ]); + }); + + it('closes the SDK iterator and releases its reader on early consumer return', async () => { + const release = vi.spyOn( + ReadableStreamDefaultReader.prototype, + 'releaseLock' + ); + const cancel = vi.fn(); + let closeBody: () => void = () => undefined; + const body = new ReadableStream({ + start(controller) { + closeBody = () => controller.close(); + controller.enqueue( + new TextEncoder().encode( + 'event: metadata\ndata: {"run_id":"early"}\n\n' + ) + ); + }, + cancel, + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(body, { + headers: { 'content-type': 'text/event-stream' }, + }) + ) + ); + const transport = new FetchStreamTransport( + 'https://runtime.example', + undefined, + { maxRetries: 0 } + ); + try { + for await (const event of transport.stream( + 'assistant-1', + 'thread-1', + {}, + new AbortController().signal + )) { + expect(event.type).toBe('metadata'); + break; + } + expect(release).toHaveBeenCalledTimes(1); + // SDK iterator return releases its decoded reader; it does not cancel fetch. + expect(cancel).not.toHaveBeenCalled(); + } finally { + closeBody(); + } + }); + + it('aborts an outstanding real SDK read via the owned request signal and releases the reader', async () => { + const release = vi.spyOn( + ReadableStreamDefaultReader.prototype, + 'releaseLock' + ); + const read = vi.spyOn(ReadableStreamDefaultReader.prototype, 'read'); + const aborted = vi.fn(); + const cancel = vi.fn(); + let closeBody: () => void = () => undefined; + const request = vi.fn(async (_url, init) => { + const body = new ReadableStream({ + start(controller) { + closeBody = () => controller.error(new Error('Fixture cleanup')); + init?.signal?.addEventListener( + 'abort', + () => { + aborted(); + controller.error(init.signal?.reason); + }, + { once: true } + ); + controller.enqueue( + new TextEncoder().encode( + 'event: metadata\ndata: {"run_id":"cancel"}\n\n' + ) + ); + }, + cancel, + }); + return new Response(body, { + headers: { 'content-type': 'text/event-stream' }, + }); + }); + vi.stubGlobal('fetch', request); + const transport = new FetchStreamTransport( + 'https://runtime.example', + undefined, + { maxRetries: 0, defaultHeaders: {} } + ); + const controller = new AbortController(); + const iterator = transport + .stream('assistant-1', 'thread-1', {}, controller.signal) + [Symbol.asyncIterator](); + let rejection: Promise | undefined; + try { + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'metadata' }, + done: false, + }); + rejection = expect(iterator.next()).rejects.toMatchObject({ + name: 'AbortError', + }); + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + controller.abort(); + await rejection; + expect(request).toHaveBeenCalledTimes(1); + expect(aborted).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(cancel).not.toHaveBeenCalled(); + } finally { + // Close the fixture even if signal forwarding or an earlier assertion fails. + void rejection?.catch(() => undefined); + controller.abort(); + closeBody(); + await rejection?.catch(() => undefined); + // Do not block a failed test on a noncooperative iterator's cleanup. + void iterator.return?.().catch(() => undefined); + } + }); +}); diff --git a/libs/langgraph/src/runtime/transport.types.ts b/libs/langgraph/src/runtime/transport.types.ts new file mode 100644 index 000000000..d52471049 --- /dev/null +++ b/libs/langgraph/src/runtime/transport.types.ts @@ -0,0 +1,196 @@ +import type { + Checkpoint, + Command, + Config, + Metadata, + StreamMode, + ThreadState, +} from '@langchain/langgraph-sdk'; + +/** An event emitted by a LangGraph stream. */ +export interface StreamEvent { + /** Event type identifier (e.g., 'values', 'messages', 'error', 'interrupt'). */ + type: + | 'values' + | `values|${string}` + | 'messages' + | `messages|${string}` + | `messages/${string}` + | `messages/${string}|${string}` + | 'updates' + | `updates|${string}` + | 'tools' + | `tools|${string}` + | 'custom' + | `custom|${string}` + | 'error' + | `error|${string}` + | 'metadata' + | 'checkpoints' + | `checkpoints|${string}` + | 'tasks' + | `tasks|${string}` + | 'debug' + | `debug|${string}` + | 'events' + | `events|${string}` + | 'interrupt' + | 'interrupts'; + namespace?: string[]; + messages?: unknown[]; + messageMetadata?: Record; + [key: string]: unknown; +} + +/** Strategy for handling concurrent LangGraph runs on the same thread. */ +export type LangGraphMultitaskStrategy = + | 'reject' + | 'interrupt' + | 'rollback' + | 'enqueue'; + +export type LangGraphDurability = 'exit' | 'async' | 'sync'; +export type LangGraphOnCompletion = 'complete' | 'continue'; +export type LangGraphOnDisconnect = 'cancel' | 'continue'; + +/** Options accepted by LangGraph-backed submit calls. */ +export interface LangGraphSubmitOptions { + signal?: AbortSignal; + config?: Config; + context?: unknown; + checkpoint?: Omit | null; + checkpointId?: string; + command?: Command; + metadata?: Metadata; + checkpointDuring?: boolean; + durability?: LangGraphDurability; + interruptBefore?: '*' | string[]; + interruptAfter?: '*' | string[]; + onCompletion?: LangGraphOnCompletion; + webhook?: string; + onDisconnect?: LangGraphOnDisconnect; + afterSeconds?: number; + ifNotExists?: 'create' | 'reject'; + onRunCreated?: (params: { run_id: string; thread_id?: string }) => void; + streamMode?: StreamMode[]; + streamSubgraphs?: boolean; + streamResumable?: boolean; + feedbackKeys?: string[]; + /** Convenience alias normalized to `command.resume` before invoking LangGraph. */ + resume?: unknown; + /** Strategy for handling concurrent runs on the same thread. */ + multitaskStrategy?: LangGraphMultitaskStrategy; +} + +/** A queued server-side LangGraph run. */ +export interface AgentQueueEntry { + /** Server-side run ID. */ + id: string; + /** Thread that owns the queued run. */ + threadId: string; + /** Values submitted for the queued run. */ + values: T | null | undefined; + /** Submit options used when the queued run was created. */ + options?: LangGraphSubmitOptions; + /** Timestamp when the queued run was registered locally. */ + createdAt: Date; +} + +/** Public queue surface for pending server-side LangGraph runs. */ +export interface AgentQueue { + /** Read-only pending queue entries. */ + readonly entries: ReadonlyArray>; + /** Number of pending queue entries. */ + readonly size: number; + /** Cancel a specific pending run by server run ID. */ + cancel: (id: string) => Promise; + /** Cancel all pending runs and clear the queue. */ + clear: () => Promise; +} + +/** Transport interface for connecting to a LangGraph agent. */ +export interface AgentTransport { + /** Open a streaming connection to an agent and yield events. */ + stream( + assistantId: string, + threadId: string | null, + payload: unknown, + signal: AbortSignal, + options?: LangGraphSubmitOptions + ): AsyncIterable; + + /** Optional: join an already-started run without creating a new one. */ + joinStream?( + threadId: string, + runId: string, + lastEventId: string | undefined, + signal: AbortSignal + ): AsyncIterable; + + /** Optional: create a server-side queued run without joining it immediately. */ + createQueuedRun?( + assistantId: string, + threadId: string, + payload: unknown, + signal: AbortSignal, + options?: LangGraphSubmitOptions + ): Promise; + + /** Optional: cancel a server-side run. */ + cancelRun?( + threadId: string, + runId: string, + signal: AbortSignal + ): Promise; + + /** Optional: load persisted checkpoint history for a thread. */ + getHistory?(threadId: string, signal: AbortSignal): Promise; + + /** + * Optional: update server-side thread state (e.g. to emit RemoveMessage + * entries for regenerate rollback). Forwards to the LangGraph + * `threads.updateState` API. + * + * `options.asNode` corresponds to LangGraph's `as_node` parameter — the + * server treats the update as if that node had just produced the values, + * which determines what the next pull resumes. `regenerate()` passes + * `asNode: '__start__'` so the next `submit(null)` resumes at the entry + * node and re-runs `generate` against the rolled-back state. + */ + updateState?( + threadId: string, + values: Record, + signal: AbortSignal, + options?: { asNode?: string } + ): Promise; +} + +/** + * Tuning options for the underlying LangGraph SDK `Client` constructed by the + * default {@link FetchStreamTransport}. Ignored when a custom `transport` is + * supplied (the transport owns its own client). + */ +export interface LangGraphClientOptions { + /** + * Headers attached to every request the SDK client makes, including the run + * stream. Use this for a per-user session token that your LangGraph + * deployment's custom `authenticate` handler validates. + * + * There is deliberately no `apiKey` option: a deployment credential passed + * from Angular is bundled into the browser build like any other constant. + * Keep deployment keys on a same-origin proxy or gateway you own, and point + * `apiUrl` at it. The client is constructed with `apiKey: null`, so the SDK + * never attaches a key from the environment either. + */ + defaultHeaders?: Record; + /** + * How many times a failed request — including the initial stream connect — + * is retried with exponential backoff before the error surfaces. Maps to the + * SDK's `callerOptions.maxRetries`. Omitted → the SDK default (currently 4). + * + * Set `0` to fail fast: useful for e2e tests that force a connection failure + * and assert the error surfaces promptly, rather than after the full + * multi-second backoff window. + */ + maxRetries?: number; +} diff --git a/libs/langgraph/tsconfig.lib.json b/libs/langgraph/tsconfig.lib.json index afcadee07..372ffd77f 100644 --- a/libs/langgraph/tsconfig.lib.json +++ b/libs/langgraph/tsconfig.lib.json @@ -9,5 +9,10 @@ "types": [] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/runtime/testing/**", + "src/runtime/**/*.type-test.ts" + ] } diff --git a/libs/langgraph/tsconfig.lib.prod.json b/libs/langgraph/tsconfig.lib.prod.json index 2a2faa884..0762301f7 100644 --- a/libs/langgraph/tsconfig.lib.prod.json +++ b/libs/langgraph/tsconfig.lib.prod.json @@ -1,4 +1,5 @@ { + // Inherit the library config's runtime test/helper exclusions. "extends": "./tsconfig.lib.json", "compilerOptions": { "declarationMap": false diff --git a/libs/langgraph/tsconfig.runtime-tests.json b/libs/langgraph/tsconfig.runtime-tests.json new file mode 100644 index 000000000..ee7470e3a --- /dev/null +++ b/libs/langgraph/tsconfig.runtime-tests.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true, + "emitDeclarationOnly": false, + "declarationMap": false, + "composite": false, + "incremental": false, + "strict": true, + "lib": ["es2022"], + "types": ["node"], + "paths": { + "@threadplane/core": ["libs/core/src/index.ts"], + "@threadplane/core/tools": ["libs/core/src/tools/index.ts"] + } + }, + "include": ["src/runtime/**/*.ts"], + "exclude": [], + "references": [] +} diff --git a/libs/langgraph/vite.config.mts b/libs/langgraph/vite.config.mts index 323376297..7ecceb8b0 100644 --- a/libs/langgraph/vite.config.mts +++ b/libs/langgraph/vite.config.mts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, configDefaults } from 'vitest/config'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ @@ -7,6 +7,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', include: ['src/**/*.spec.ts'], + exclude: [...configDefaults.exclude, 'src/runtime/**'], setupFiles: ['src/test-setup.ts'], }, }); diff --git a/libs/langgraph/vite.runtime.config.mts b/libs/langgraph/vite.runtime.config.mts new file mode 100644 index 000000000..9625ba58b --- /dev/null +++ b/libs/langgraph/vite.runtime.config.mts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: import.meta.dirname, + resolve: { + alias: [ + ['@threadplane/core', '../core/src/index.ts'], + ['@threadplane/core/tools', '../core/src/tools/index.ts'], + ].map(([name, path]) => ({ + find: new RegExp(`^${name}$`), + replacement: fileURLToPath(new URL(path, import.meta.url)), + })), + }, + test: { + environment: 'node', + reporters: ['default'], + include: ['src/runtime/**/*.spec.ts'], + passWithNoTests: false, + }, +}); diff --git a/libs/react/README.md b/libs/react/README.md index 8da3a8372..f85f533bc 100644 --- a/libs/react/README.md +++ b/libs/react/README.md @@ -1,15 +1,32 @@ # @threadplane/react -Private, unpublished foundation scaffolding for the React parity work. The empty -root entry point provides no supported runtime API. No React bindings, stores, -renderers, or backend adapters are implemented. +Private, unpublished React binding for app-owned `AgentSession` values from +`@threadplane/core`. The root exports `useAgent(session)`, which returns the +current `AgentSnapshot` through React's `useSyncExternalStore`. Tool names, +arguments, and results retain the session's declared types. -The only current export is `@threadplane/react`. React rendering and feature -bindings belong to this package; feature subpaths will be added with their -implementations. +```tsx +import { useAgent } from '@threadplane/react'; +import type { AgentSession } from '@threadplane/core'; -Build with `npx nx build react`. Packaging and dependency boundaries are -verified by the scripts in `scripts/react-parity`. Feature entry points must -remain unreachable from the root runtime and declarations. +export function Status({ session }: { session: AgentSession }) { + const snapshot = useAgent(session); + return

{snapshot.status}

; +} +``` -The root entry point retains `use client`. +The hook borrows the session. Rendering, subscribing, StrictMode remounts, and +unmounting do not start runs. Unmounting releases that observer's subscription; +it does not stop pending work or dispose the session. Multiple components can +observe the same session. The app calls `session.submit(text)`, `session.stop()`, +and `session.dispose()` and owns the session's lifetime. Replacing the session +prop transfers the subscription without disposing the previous session. + +The root retains `use client`. Server rendering and hydration are not supported +by this binding. No backend constructor is exported here; the current real +LangGraph runtime is still a private development composition seam. This API is +under development and is not a published compatibility or feature-parity claim. + +Build with `npx nx build react`; run `npx nx test react` and +`npx nx run react:type-tests` for real-session and strict type coverage. React +19.2.4 is the tested runtime; React DOM is used only by the test host. diff --git a/libs/react/package.json b/libs/react/package.json index d55ec30d9..52f1736c9 100644 --- a/libs/react/package.json +++ b/libs/react/package.json @@ -2,10 +2,20 @@ "name": "@threadplane/react", "version": "0.0.0", "private": true, - "description": "Private react foundation scaffolding. No supported runtime API yet.", + "description": "Private React binding for observing app-owned agent sessions.", "license": "MIT", "type": "module", "sideEffects": false, + "peerDependencies": { + "@threadplane/core": "0.0.0", + "react": "^19.2.4" + }, + "devDependencies": { + "@testing-library/react": "16.3.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react-dom": "19.2.4" + }, "files": [ "src/**/*.js", "src/**/*.d.ts", diff --git a/libs/react/src/index.ts b/libs/react/src/index.ts index 127e42d26..f1135c695 100644 --- a/libs/react/src/index.ts +++ b/libs/react/src/index.ts @@ -1,4 +1,3 @@ 'use client'; -// Reserved private entry point. Runtime implementation follows in later work. -export {}; +export { useAgent } from './use-agent.js'; diff --git a/libs/react/src/use-agent.spec.tsx b/libs/react/src/use-agent.spec.tsx new file mode 100644 index 000000000..7805efae2 --- /dev/null +++ b/libs/react/src/use-agent.spec.tsx @@ -0,0 +1,303 @@ +import { + act, + cleanup, + fireEvent, + render, + renderHook, +} from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useAgent } from './index'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Only this integration spec composes the private runtime; production imports core only. +import { + bindingFixture, + changed, + delta, + finalText, + weatherCall, +} from '../../langgraph/src/runtime/testing/binding-fixture'; + +const fixtures: ReturnType[] = []; +function fixture() { + const value = bindingFixture(); + fixtures.push(value); + return value; +} +afterEach(async () => { + cleanup(); + await Promise.all(fixtures.splice(0).map((value) => value.cleanup())); +}); + +describe('useAgent borrowed session', () => { + it('renders streamed text, tool results, errors and stop outcomes through native controls', async () => { + const f = fixture(); + let run: ReturnType | undefined; + function Chat() { + const snapshot = useAgent(f.session); + return ( + <> + + + {snapshot.status} +
+ {snapshot.messages.map((message) => message.content).join('\n')} +
+
+ {JSON.stringify(snapshot.messages.at(-1)?.delivery)} +
+
{JSON.stringify(snapshot.toolCalls)}
+
{snapshot.error?.message}
+ + ); + } + const view = render(, { reactStrictMode: true }); + expect(view.getByTestId('status').textContent).toBe('idle'); + expect(f.streams).toHaveLength(0); + await act(async () => { + fireEvent.click(view.getByText('Send')); + await f.started(); + }); + expect(view.getByTestId('status').textContent).toBe('running'); + await act(async () => { + const observed = changed( + f.session, + (s) => s.messages.at(-1)?.content === 'Visible partial' + ); + f.streams[0].release(delta('Visible partial')); + await observed; + }); + expect(view.getByTestId('messages').textContent).toContain( + 'Visible partial' + ); + expect(view.getByTestId('delivery').textContent).toContain('streaming'); + await act(async () => { + f.streams[0].release(finalText('Visible final')); + f.streams[0].finish(); + expect(await run).toBe('success'); + }); + expect(view.getByTestId('messages').textContent).toContain('Visible final'); + expect(view.getByTestId('status').textContent).toBe('idle'); + expect(view.getByTestId('delivery').textContent).toContain('success'); + + await act(async () => { + fireEvent.click(view.getByText('Send')); + await f.started(1); + f.streams[1].release(weatherCall); + f.streams[1].finish(); + await f.entered; + }); + expect(view.getByTestId('tools').textContent).toContain('running'); + await act(async () => { + f.toolResult.resolve({ temperature: 24 }); + await f.started(2); + f.streams[2].release(finalText('24 degrees')); + f.streams[2].finish(); + expect(await run).toBe('success'); + }); + expect(view.getByTestId('tools').textContent).toContain('"temperature":24'); + expect(view.getByTestId('messages').textContent).toContain('24 degrees'); + + await act(async () => { + fireEvent.click(view.getByText('Send')); + await f.started(3); + f.streams[3].release({ type: 'error', data: { message: 'Unavailable' } }); + expect(await run).toBe('error'); + }); + expect(view.getByTestId('status').textContent).toBe('error'); + expect(view.getByRole('alert').textContent).toBeTruthy(); + await act(async () => { + fireEvent.click(view.getByText('Send')); + await f.started(4); + const observed = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[4].release(delta('Stopping', 'stopped-answer')); + await observed; + }); + await act(async () => { + fireEvent.click(view.getByText('Stop')); + expect(await run).toBe('aborted'); + }); + expect(view.getByTestId('status').textContent).toBe('idle'); + expect(view.getByTestId('delivery').textContent).toContain('aborted'); + expect(f.handlerCalls).toBe(1); + expect(f.session.submitCalls).toBe(4); + expect(f.streams).toHaveLength(5); + expect(f.session.stopCalls).toBe(1); + view.unmount(); + expect(f.session.disposeCalls).toBe(0); + expect(f.session.subscriptions).toBe(f.session.releases); + }); + + it('reads inertly through a receiver-dependent session and survives StrictMode setup-cleanup-setup', () => { + const f = fixture(); + const view = renderHook(() => useAgent(f.session), { + reactStrictMode: true, + }); + expect(view.result.current).toBe(f.session.getSnapshot()); + expect(f.session.subscriptions).toBe(2); + expect(f.session.releases).toBe(1); + view.rerender(); + expect(f.session.subscriptions).toBe(2); + view.unmount(); + expect(f.session.releases).toBe(2); + expect(f.session.submitCalls).toBe(0); + expect(f.session.stopCalls).toBe(0); + expect(f.session.disposeCalls).toBe(0); + expect(f.streams).toHaveLength(0); + }); + + it('shares intermediate and final snapshots between two simultaneous observers', async () => { + const f = fixture(); + const first = renderHook(() => useAgent(f.session)); + const second = renderHook(() => useAgent(f.session)); + let run!: ReturnType; + await act(async () => { + run = f.session.submit('Hello'); + await f.started(); + }); + expect(first.result.current.status).toBe('running'); + await act(async () => { + const observed = changed( + f.session, + (s) => s.messages.at(-1)?.content === 'Hel' + ); + f.streams[0].release(delta('Hel')); + await observed; + }); + expect(first.result.current.messages.at(-1)?.content).toBe('Hel'); + expect(first.result.current).toBe(second.result.current); + first.unmount(); + await act(async () => { + f.streams[0].release(finalText('Hello world')); + f.streams[0].finish(); + expect(await run).toBe('success'); + }); + expect(second.result.current).toBe(f.session.getSnapshot()); + expect(second.result.current.status).toBe('idle'); + expect(second.result.current.messages.at(-1)).toMatchObject({ + content: 'Hello world', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(f.session.submitCalls).toBe(1); + expect(f.streams).toHaveLength(1); + expect(f.session.stopCalls + f.session.disposeCalls).toBe(0); + }); + + it('keeps a pending tool alive after unmount and reattaches without executing it again', async () => { + const f = fixture(); + const first = renderHook(() => useAgent(f.session)); + let run!: ReturnType; + await act(async () => { + run = f.session.submit('Weather'); + await f.started(); + f.streams[0].release(weatherCall); + f.streams[0].finish(); + await f.entered; + }); + expect(first.result.current.toolCalls[0]).toMatchObject({ + name: 'weather', + status: 'running', + }); + first.unmount(); + expect(f.handlerSignal?.aborted).toBe(false); + const second = renderHook(() => useAgent(f.session)); + expect(second.result.current.toolCalls[0].status).toBe('running'); + await act(async () => { + f.toolResult.resolve({ temperature: 24 }); + await f.started(1); + f.streams[1].release(finalText('24 degrees')); + f.streams[1].finish(); + expect(await run).toBe('success'); + }); + expect(second.result.current.toolCalls[0]).toMatchObject({ + name: 'weather', + args: { city: 'Paris' }, + status: 'complete', + result: { temperature: 24 }, + }); + expect(second.result.current.messages.at(-1)?.content).toBe('24 degrees'); + expect(f.handlerCalls).toBe(1); + expect(f.session.submitCalls).toBe(1); + expect(f.streams).toHaveLength(2); + expect(f.session.stopCalls + f.session.disposeCalls).toBe(0); + }); + + it('publishes runtime errors and a later app-owned stop', async () => { + const f = fixture(); + const view = renderHook(() => useAgent(f.session)); + await act(async () => { + const run = f.session.submit('Fail'); + await f.started(); + const partial = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[0].release(delta('Partial')); + await partial; + f.streams[0].release({ type: 'error', data: { message: 'Unavailable' } }); + expect(await run).toBe('error'); + }); + expect(view.result.current.status).toBe('error'); + expect(view.result.current.error).toBeDefined(); + expect(view.result.current.messages.at(-1)?.delivery).toMatchObject({ + phase: 'complete', + outcome: 'error', + }); + await act(async () => { + const run = f.session.submit('Stop'); + await f.started(1); + const partial = changed( + f.session, + (s) => s.messages.at(-1)?.delivery.phase === 'streaming' + ); + f.streams[1].release(delta('Stopping', 'stopped-answer')); + await partial; + await f.session.stop(); + expect(await run).toBe('aborted'); + }); + expect(view.result.current.status).toBe('idle'); + expect(view.result.current.messages.at(-1)?.delivery).toMatchObject({ + phase: 'complete', + outcome: 'aborted', + }); + expect(f.session.submitCalls).toBe(2); + expect(f.session.stopCalls).toBe(1); + expect(f.session.disposeCalls).toBe(0); + }); + + it('transfers its subscription when the supplied session changes', async () => { + const a = fixture(); + const b = fixture(); + const view = renderHook(({ session }) => useAgent(session), { + initialProps: { session: a.session }, + }); + view.rerender({ session: b.session }); + expect(a.session.releases).toBe(1); + expect(b.session.subscriptions).toBe(1); + expect(view.result.current).toBe(b.session.getSnapshot()); + await act(async () => { + const run = a.session.submit('Detached'); + await a.started(); + a.streams[0].release(finalText('Still alive')); + a.streams[0].finish(); + expect(await run).toBe('success'); + }); + expect(view.result.current).toBe(b.session.getSnapshot()); + expect(a.session.stopCalls + a.session.disposeCalls).toBe(0); + view.unmount(); + expect(b.session.releases).toBe(1); + }); +}); diff --git a/libs/react/src/use-agent.ts b/libs/react/src/use-agent.ts new file mode 100644 index 000000000..b2c909df4 --- /dev/null +++ b/libs/react/src/use-agent.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback, useSyncExternalStore } from 'react'; +import type { + AgentSession, + AgentSnapshot, + ToolContract, +} from '@threadplane/core'; + +/** Observe an app-owned session. Unmount releases only this subscription. */ +export function useAgent( + session: AgentSession +): AgentSnapshot { + const getSnapshot = useCallback(() => session.getSnapshot(), [session]); + const subscribe = useCallback( + (notify: () => void) => session.subscribe(notify), + [session] + ); + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/libs/react/src/use-agent.type-test.ts b/libs/react/src/use-agent.type-test.ts new file mode 100644 index 000000000..829c00ab2 --- /dev/null +++ b/libs/react/src/use-agent.type-test.ts @@ -0,0 +1,46 @@ +import type { AgentSession, AgentSnapshot } from '@threadplane/core'; +import { useAgent } from './index.js'; + +interface Tools { + weather: { args: { city: string }; result: { temperature: number } }; + count: { args: { values: readonly string[] }; result: number }; +} + +export function useTypedSession(session: AgentSession) { + const snapshot = useAgent(session); + const exact: AgentSnapshot = snapshot; + for (const call of snapshot.toolCalls) { + if (call.name === 'weather') { + const city: string = call.args.city; + // @ts-expect-error Heterogeneous names retain their own argument shape. + void call.args.values; + // @ts-expect-error Snapshot arguments are immutable. + call.args.city = city; + if (call.status === 'complete') { + const temperature: number = call.result.temperature; + // @ts-expect-error Weather results cannot widen to any or the count result. + const count: number = call.result; + void [temperature, count]; + } + } else { + const values: readonly string[] = call.args.values; + // @ts-expect-error Count arguments cannot widen to any or the weather arguments. + void call.args.city; + if (call.status === 'complete') { + const count: number = call.result; + // @ts-expect-error Count results preserve the primitive type. + void call.result.temperature; + void count; + } + void values; + } + // @ts-expect-error Results are only present on completed calls. + void call.result; + // @ts-expect-error Tool names stay a literal union. + const unknownName: 'missing' = call.name; + void unknownName; + } + // @ts-expect-error The binding exposes a snapshot, not session commands. + snapshot.submit('Hello'); + return exact; +} diff --git a/libs/react/tsconfig.spec.json b/libs/react/tsconfig.spec.json index 86fbbd4e0..aea3ecd0c 100644 --- a/libs/react/tsconfig.spec.json +++ b/libs/react/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.lib.json", "compilerOptions": { + "rootDir": "../..", "types": [ "vitest/globals" ], diff --git a/libs/react/vite.config.mts b/libs/react/vite.config.mts index f05211ebe..cb2b05672 100644 --- a/libs/react/vite.config.mts +++ b/libs/react/vite.config.mts @@ -1,10 +1,13 @@ import { defineConfig } from 'vitest/config'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ root: import.meta.dirname, + plugins: [nxViteTsPaths()], test: { - environment: 'node', + reporters: ['default'], + environment: 'jsdom', include: ['src/**/*.spec.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], - passWithNoTests: true, + passWithNoTests: false, }, }); diff --git a/package-lock.json b/package-lock.json index 0d53d8609..df0153899 100644 --- a/package-lock.json +++ b/package-lock.json @@ -424,7 +424,8 @@ "version": "0.0.0", "license": "MIT", "peerDependencies": { - "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0" + "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0", + "@threadplane/core": "0.0.0" } }, "libs/chat": { @@ -632,7 +633,17 @@ "libs/react": { "name": "@threadplane/react", "version": "0.0.0", - "license": "MIT" + "license": "MIT", + "devDependencies": { + "@testing-library/react": "16.3.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react-dom": "19.2.4" + }, + "peerDependencies": { + "@threadplane/core": "0.0.0", + "react": "^19.2.4" + } }, "libs/render": { "name": "@threadplane/render", diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 3675037ae..e51ed5915 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -43,6 +43,11 @@ describe('React migration baseline scope', () => { for (const file of [ 'scripts/react-parity/inventory.mjs', 'fixtures/react-parity/traces/ag-ui-text-state.sse', + 'fixtures/react-parity/runtime/scenarios.ts', + 'fixtures/react-parity/runtime/vite.config.mts', + 'libs/langgraph/vite.runtime.config.mts', + 'libs/langgraph/tsconfig.runtime-tests.json', + 'libs/langgraph/src/runtime/testing/controlled-transport.ts', 'libs/core/src/index.ts', 'libs/angular/src/public-api.ts', 'libs/react/src/index.ts', diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index d9d55b144..3b7754b56 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -79,6 +79,32 @@ function readNamedStep(job, name) { } describe('CI workflow', () => { + it('verifies pull requests against any base while push and deployment stay main-only', async () => { + const workflow = withoutYamlCommentLines(await readFile('.github/workflows/ci.yml', 'utf8')); + const triggers = workflow.slice(workflow.indexOf('on:'), workflow.indexOf('\nconcurrency:')); + assert.match(triggers, /push:\s*\n {4}branches: \[main\]/); + const pullRequest = triggers.match(/^ {2}pull_request:[\s\S]*?(?=^ {2}\w|$(?![\s\S]))/m)?.[0]; + assert.ok(pullRequest, 'pull_request trigger must exist'); + assert.doesNotMatch(pullRequest, /branches(?:-ignore)?:/); + for (const name of ['deploy', 'demo-deploy', 'ag-ui-demo-deploy', 'production-smoke']) { + const guard = readJobFieldBlock(readJobBlock(workflow, name), 'if'); + assert.match(guard, /github\.ref == 'refs\/heads\/main'/); + assert.match(guard, /github\.event_name == 'push'/); + } + }); + + it('runs the isolated Node runtime gates and installs Chromium before packed browser checks', async () => { + const job = readJobBlock(await readFile('.github/workflows/ci.yml', 'utf8'), 'library'); + assert.match(job, /npx nx run langgraph:runtime-quality/); + assert.match(job, /npx nx run langgraph:runtime-type-tests/); + assert.match(job, /npx nx run langgraph:type-tests/); + const install = job.indexOf('npx playwright install --with-deps chromium'); + assert.ok(install >= 0, 'library browser checks need Chromium and OS dependencies'); + for (const script of ['verify-packages.mjs', 'verify-angular-package.mjs']) { + assert.ok(install < job.indexOf(`node scripts/react-parity/${script}`)); + } + }); + it('enforces React migration source, build, and packaged-consumer gates', async () => { const job = readJobBlock(await readFile('.github/workflows/ci.yml', 'utf8'), 'library'); const source = readNamedStep(job, 'React migration baseline and boundaries'); diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 9d25d26b8..88fac05b5 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,14 +1,36 @@ { "schemaVersion": 1, - "baselineHead": "7e80cebd607f5c605ef1ec81f11e5ebe3324f81b", + "baselineHead": "bdcc22ed31aa94f420077e046e88e1481088d453", "sourceState": { "modified": [ ".github/workflows/ci.yml", - "package-lock.json", - "package.json", - "tsconfig.base.json" + "libs/langgraph/eslint.config.mjs", + "libs/langgraph/project.json", + "libs/langgraph/src/lib/agent.types.ts", + "libs/langgraph/src/lib/client/create-langgraph-client.ts", + "libs/langgraph/src/lib/runtime-operation-reporter.ts", + "libs/langgraph/src/lib/transport/fetch-stream.transport.ts", + "libs/langgraph/src/lib/transport/transport.interface.ts", + "libs/langgraph/tsconfig.lib.json", + "libs/langgraph/tsconfig.lib.prod.json", + "libs/langgraph/vite.config.mts", + "package-lock.json" ], - "untracked": [] + "untracked": [ + "libs/langgraph/src/runtime/create-session.ts", + "libs/langgraph/src/runtime/function-tools.ts", + "libs/langgraph/src/runtime/message-reducer.ts", + "libs/langgraph/src/runtime/operation-errors.ts", + "libs/langgraph/src/runtime/ownership.ts", + "libs/langgraph/src/runtime/publication.ts", + "libs/langgraph/src/runtime/stream-projection.ts", + "libs/langgraph/src/runtime/testing/binding-fixture.ts", + "libs/langgraph/src/runtime/testing/controlled-transport.ts", + "libs/langgraph/src/runtime/testing/deferred.ts", + "libs/langgraph/src/runtime/transport.types.ts", + "libs/langgraph/tsconfig.runtime-tests.json", + "libs/langgraph/vite.runtime.config.mts" + ] }, "scope": { "libraries": [ @@ -591,7 +613,7 @@ "id": "asset:libs/langgraph/project.json", "kind": "asset", "path": "libs/langgraph/project.json", - "sha256": "ec15f5414827294ea51ce5bf56755f576c28cd112961b97adbffbfa379b2fd73" + "sha256": "4fa0e5f4b0d4b2a7fd8f8de7270dcce1f21b033c86bb031d75480e44667c29d3" }, { "id": "asset:libs/langgraph/test/fixtures/streaming-reasoning-puzzle.json", @@ -609,13 +631,19 @@ "id": "asset:libs/langgraph/tsconfig.lib.json", "kind": "asset", "path": "libs/langgraph/tsconfig.lib.json", - "sha256": "2d56829b5aa728927dca322cff6d7521ff9a65a8d8f258bee0f3a4d146826219" + "sha256": "02011ca1da2b468d842b6ff653c0d17079f651d86e0896f66c6d6d0b507cde58" }, { "id": "asset:libs/langgraph/tsconfig.lib.prod.json", "kind": "asset", "path": "libs/langgraph/tsconfig.lib.prod.json", - "sha256": "4815c5ece87bc626f79d81495b3b60bce7bca2dc355106a9b77bea8d11855a8b" + "sha256": "bab73af0fdbb0dc29ce6316b1c5dd9a5fc6ba46e59036ccb3f7587148e354ff6" + }, + { + "id": "asset:libs/langgraph/tsconfig.runtime-tests.json", + "kind": "asset", + "path": "libs/langgraph/tsconfig.runtime-tests.json", + "sha256": "a692d9d309837d9f37f0e18779a22d7a6f3a2fff1c96ce95a93ccbd0b81397dc" }, { "id": "asset:libs/langgraph/tsconfig.type-tests.json", @@ -1857,7 +1885,7 @@ "id": "config:.github/workflows/ci.yml", "kind": "config", "path": ".github/workflows/ci.yml", - "sha256": "7b4b5143058dc90229686fe8e541312ca6ea7276f428bd90fcd7c95e143fb9b6" + "sha256": "f6f53967664c94ce5001308d4f8847ffe48ca6081b61c4f10f404a74b8260c42" }, { "id": "config:.github/workflows/publish-middleware-npm.yml", @@ -1911,7 +1939,7 @@ "id": "config:package-lock.json", "kind": "config", "path": "package-lock.json", - "sha256": "b16eed343e4be6e7d677ffccd7ed004191682eaf248287b1ed4e0fd3bd9f4b5e" + "sha256": "530c28574a832ab39c15c9607717de0eb8ccfae2593e09c7ae373bd8936d0b64" }, { "id": "config:package.json", @@ -8268,7 +8296,7 @@ "symbol": "AgentQueue", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "AgentQueue", "syntaxKind": "InterfaceDeclaration", "signature": "export interface AgentQueue {\n readonly entries: ReadonlyArray>;\n readonly size: number;\n cancel: (id: string) => Promise;\n clear: () => Promise;\n}" @@ -8282,7 +8310,7 @@ "symbol": "AgentQueueEntry", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "AgentQueueEntry", "syntaxKind": "InterfaceDeclaration", "signature": "export interface AgentQueueEntry {\n id: string;\n threadId: string;\n values: T | null | undefined;\n options?: LangGraphSubmitOptions;\n createdAt: Date;\n}" @@ -8296,7 +8324,7 @@ "symbol": "AgentTransport", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "AgentTransport", "syntaxKind": "InterfaceDeclaration", "signature": "export interface AgentTransport {\n stream(assistantId: string, threadId: string | null, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): AsyncIterable;\n joinStream?(threadId: string, runId: string, lastEventId: string | undefined, signal: AbortSignal): AsyncIterable;\n createQueuedRun?(assistantId: string, threadId: string, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): Promise;\n cancelRun?(threadId: string, runId: string, signal: AbortSignal): Promise;\n getHistory?(threadId: string, signal: AbortSignal): Promise;\n updateState?(threadId: string, values: Record, signal: AbortSignal, options?: {\n asNode?: string;\n }): Promise;\n}" @@ -8313,7 +8341,7 @@ "path": "libs/langgraph/src/lib/agent.types.ts", "symbol": "BagTemplate", "syntaxKind": "ImportSpecifier", - "signature": "import type { BagTemplate, Checkpoint, Command, Config, InferBag, Interrupt, Metadata, ThreadState, ToolProgress, ToolCallWithResult, StreamMode, } from '@langchain/langgraph-sdk';" + "signature": "import type { BagTemplate, InferBag, Interrupt, ThreadState, ToolProgress, ToolCallWithResult, } from '@langchain/langgraph-sdk';" } ] }, @@ -8355,7 +8383,7 @@ "path": "libs/langgraph/src/lib/transport/fetch-stream.transport.ts", "symbol": "FetchStreamTransport", "syntaxKind": "ClassDeclaration", - "signature": "export class FetchStreamTransport implements AgentTransport {\n private client: Client;\n private onThreadId?: (id: string) => void;\n private readonly protectErrors: boolean;\n private readonly reportOperationFailure?: RuntimeOperationFailureReporter;\n readonly protectsOperationErrors: boolean;\n constructor(apiUrl: string, onThreadId?: (id: string) => void, clientOptions?: LangGraphClientOptions, reportOperationFailure?: RuntimeOperationFailureReporter) {\n this.protectErrors = clientOptions?.defaultHeaders !== undefined || reportOperationFailure !== undefined;\n this.protectsOperationErrors = this.protectErrors;\n this.reportOperationFailure = reportOperationFailure;\n this.client = this.protectErrors\n ? ɵcreateProtectedLangGraphClient(apiUrl, clientOptions, createLangGraphRuntimeFetch(reportOperationFailure))\n : createLangGraphClient(apiUrl, clientOptions);\n this.onThreadId = onThreadId;\n }\n async *stream(assistantId: string, threadId: string | null, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): AsyncIterable {\n let thread = threadId;\n if (!thread) {\n try {\n const t = await this.client.threads.create();\n thread = t.thread_id;\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n try {\n this.onThreadId?.(thread);\n }\n catch (error) {\n this.rethrowLocalError(error, signal);\n }\n }\n let runPayload: ReturnType;\n try {\n runPayload = buildRunPayload(payload, signal, options);\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n let run: ReturnType;\n try {\n run = this.client.runs.stream(thread, assistantId, runPayload);\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n yield* this.iterateSdkRun(run, signal);\n }\n async *joinStream(threadId: string, runId: string, lastEventId: string | undefined, signal: AbortSignal): AsyncIterable {\n let run: ReturnType;\n try {\n run = this.client.runs.joinStream(threadId, runId, {\n signal,\n ...(lastEventId !== undefined ? { lastEventId } : {}),\n });\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n yield* this.iterateSdkRun(run, signal);\n }\n async createQueuedRun(assistantId: string, threadId: string, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): Promise {\n let runPayload: ReturnType & {\n multitaskStrategy: 'enqueue';\n };\n try {\n runPayload = {\n ...buildRunPayload(payload, signal, options),\n multitaskStrategy: 'enqueue',\n };\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n let run: Awaited>;\n try {\n run = await this.client.runs.create(threadId, assistantId, runPayload);\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n try {\n return {\n id: run.run_id,\n threadId: run.thread_id ?? threadId,\n values: payload,\n options: { multitaskStrategy: 'enqueue', signal },\n createdAt: run.created_at ? new Date(run.created_at) : new Date(),\n };\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n }\n async cancelRun(threadId: string, runId: string, signal: AbortSignal): Promise {\n try {\n await this.client.runs.cancel(threadId, runId, false, 'interrupt', { signal });\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n }\n async getHistory(threadId: string, signal: AbortSignal): Promise {\n try {\n return await this.client.threads.getHistory(threadId, { signal });\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n }\n async updateState(threadId: string, values: Record, _signal: AbortSignal, options?: {\n asNode?: string;\n }): Promise {\n const body: {\n values: Record;\n asNode?: string;\n } = { values };\n if (options?.asNode !== undefined) {\n body.asNode = options.asNode;\n }\n try {\n await this.client.threads.updateState(threadId, body);\n }\n catch (error) {\n this.rethrowOperationError(error, _signal);\n }\n }\n private rethrowOperationError(error: unknown, signal: AbortSignal): never {\n if (!this.protectErrors)\n throw error;\n return projectLangGraphOperationFailure(error, signal, this.reportOperationFailure);\n }\n private rethrowLocalError(error: unknown, signal: AbortSignal): never {\n if (!this.protectErrors)\n throw error;\n return projectLangGraphOperationFailure(error, signal, undefined);\n }\n private async *iterateSdkRun(run: ReturnType | ReturnType, signal: AbortSignal): AsyncIterable {\n let iterator: AsyncIterator<{\n event: string;\n data: unknown;\n }>;\n try {\n iterator = run[Symbol.asyncIterator]();\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n while (true) {\n let next: IteratorResult<{\n event: string;\n data: unknown;\n }>;\n try {\n next = await iterator.next();\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n if (next.done)\n return;\n try {\n yield normalizeSdkEvent(next.value.event as StreamEvent['type'], next.value.data);\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n }\n }\n}" + "signature": "export class FetchStreamTransport implements AgentTransport {\n private client: Client;\n private onThreadId?: (id: string) => void;\n private readonly protectErrors: boolean;\n private readonly reportOperationFailure?: RuntimeOperationFailureReporter;\n readonly protectsOperationErrors: boolean;\n constructor(apiUrl: string, onThreadId?: (id: string) => void, clientOptions?: LangGraphClientOptions, reportOperationFailure?: RuntimeOperationFailureReporter) {\n this.protectErrors = clientOptions?.defaultHeaders !== undefined || reportOperationFailure !== undefined;\n this.protectsOperationErrors = this.protectErrors;\n this.reportOperationFailure = reportOperationFailure;\n this.client = this.protectErrors\n ? ɵcreateProtectedLangGraphClient(apiUrl, clientOptions, createLangGraphRuntimeFetch(reportOperationFailure))\n : createLangGraphClient(apiUrl, clientOptions);\n this.onThreadId = onThreadId;\n }\n async *stream(assistantId: string, threadId: string | null, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): AsyncIterable {\n let thread = threadId;\n if (!thread) {\n try {\n const t = await this.client.threads.create();\n thread = t.thread_id;\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n try {\n this.onThreadId?.(thread);\n }\n catch (error) {\n this.rethrowLocalError(error, signal);\n }\n }\n let runPayload: ReturnType;\n try {\n runPayload = buildRunPayload(payload, signal, options);\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n let run: ReturnType;\n try {\n run = this.client.runs.stream(thread, assistantId, runPayload);\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n yield* this.iterateSdkRun(run, signal);\n }\n async *joinStream(threadId: string, runId: string, lastEventId: string | undefined, signal: AbortSignal): AsyncIterable {\n let run: ReturnType;\n try {\n run = this.client.runs.joinStream(threadId, runId, {\n signal,\n ...(lastEventId !== undefined ? { lastEventId } : {}),\n });\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n yield* this.iterateSdkRun(run, signal);\n }\n async createQueuedRun(assistantId: string, threadId: string, payload: unknown, signal: AbortSignal, options?: LangGraphSubmitOptions): Promise {\n let runPayload: ReturnType & {\n multitaskStrategy: 'enqueue';\n };\n try {\n runPayload = {\n ...buildRunPayload(payload, signal, options),\n multitaskStrategy: 'enqueue',\n };\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n let run: Awaited>;\n try {\n run = await this.client.runs.create(threadId, assistantId, runPayload);\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n try {\n return {\n id: run.run_id,\n threadId: run.thread_id ?? threadId,\n values: payload,\n options: { multitaskStrategy: 'enqueue', signal },\n createdAt: run.created_at ? new Date(run.created_at) : new Date(),\n };\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n }\n async cancelRun(threadId: string, runId: string, signal: AbortSignal): Promise {\n try {\n await this.client.runs.cancel(threadId, runId, false, 'interrupt', { signal });\n }\n catch (error) {\n this.rethrowOperationError(error, signal);\n }\n }\n async getHistory(threadId: string, signal: AbortSignal): Promise {\n try {\n return await this.client.threads.getHistory(threadId, { signal });\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n }\n async updateState(threadId: string, values: Record, _signal: AbortSignal, options?: {\n asNode?: string;\n }): Promise {\n const body: {\n values: Record;\n asNode?: string;\n } = { values };\n if (options?.asNode !== undefined) {\n body.asNode = options.asNode;\n }\n try {\n await this.client.threads.updateState(threadId, body);\n }\n catch (error) {\n this.rethrowOperationError(error, _signal);\n }\n }\n private rethrowOperationError(error: unknown, signal: AbortSignal): never {\n if (!this.protectErrors)\n throw error;\n return projectLangGraphOperationFailure(error, signal, this.reportOperationFailure);\n }\n private rethrowLocalError(error: unknown, signal: AbortSignal): never {\n if (!this.protectErrors)\n throw error;\n return projectLangGraphOperationFailure(error, signal, undefined);\n }\n private async *iterateSdkRun(run: ReturnType | ReturnType, signal: AbortSignal): AsyncIterable {\n let iterator: AsyncIterator<{\n event: string;\n data: unknown;\n }>;\n try {\n iterator = run[Symbol.asyncIterator]();\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n let completed = false;\n let failed = false;\n try {\n while (true) {\n let next: IteratorResult<{\n event: string;\n data: unknown;\n }>;\n try {\n next = await iterator.next();\n }\n catch (error) {\n return this.rethrowOperationError(error, signal);\n }\n if (next.done) {\n completed = true;\n return;\n }\n try {\n yield normalizeSdkEvent(next.value.event as StreamEvent['type'], next.value.data);\n }\n catch (error) {\n return this.rethrowLocalError(error, signal);\n }\n }\n }\n catch (error) {\n failed = true;\n throw error;\n }\n finally {\n if (!completed) {\n try {\n await iterator.return?.();\n }\n catch (error) {\n if (!failed)\n this.rethrowOperationError(error, signal);\n }\n }\n }\n }\n}" } ] }, @@ -8369,7 +8397,7 @@ "path": "libs/langgraph/src/lib/agent.types.ts", "symbol": "InferBag", "syntaxKind": "ImportSpecifier", - "signature": "import type { BagTemplate, Checkpoint, Command, Config, InferBag, Interrupt, Metadata, ThreadState, ToolProgress, ToolCallWithResult, StreamMode, } from '@langchain/langgraph-sdk';" + "signature": "import type { BagTemplate, InferBag, Interrupt, ThreadState, ToolProgress, ToolCallWithResult, } from '@langchain/langgraph-sdk';" } ] }, @@ -8383,7 +8411,7 @@ "path": "libs/langgraph/src/lib/agent.types.ts", "symbol": "Interrupt", "syntaxKind": "ImportSpecifier", - "signature": "import type { BagTemplate, Checkpoint, Command, Config, InferBag, Interrupt, Metadata, ThreadState, ToolProgress, ToolCallWithResult, StreamMode, } from '@langchain/langgraph-sdk';" + "signature": "import type { BagTemplate, InferBag, Interrupt, ThreadState, ToolProgress, ToolCallWithResult, } from '@langchain/langgraph-sdk';" } ] }, @@ -8450,7 +8478,7 @@ "symbol": "LangGraphClientOptions", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "LangGraphClientOptions", "syntaxKind": "InterfaceDeclaration", "signature": "export interface LangGraphClientOptions {\n defaultHeaders?: Record;\n maxRetries?: number;\n}" @@ -8464,7 +8492,7 @@ "symbol": "LangGraphMultitaskStrategy", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "LangGraphMultitaskStrategy", "syntaxKind": "TypeAliasDeclaration", "signature": "export type LangGraphMultitaskStrategy = 'reject' | 'interrupt' | 'rollback' | 'enqueue';" @@ -8478,7 +8506,7 @@ "symbol": "LangGraphSubmitOptions", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "LangGraphSubmitOptions", "syntaxKind": "InterfaceDeclaration", "signature": "export interface LangGraphSubmitOptions {\n signal?: AbortSignal;\n config?: Config;\n context?: unknown;\n checkpoint?: Omit | null;\n checkpointId?: string;\n command?: Command;\n metadata?: Metadata;\n checkpointDuring?: boolean;\n durability?: LangGraphDurability;\n interruptBefore?: '*' | string[];\n interruptAfter?: '*' | string[];\n onCompletion?: LangGraphOnCompletion;\n webhook?: string;\n onDisconnect?: LangGraphOnDisconnect;\n afterSeconds?: number;\n ifNotExists?: 'create' | 'reject';\n onRunCreated?: (params: {\n run_id: string;\n thread_id?: string;\n }) => void;\n streamMode?: StreamMode[];\n streamSubgraphs?: boolean;\n streamResumable?: boolean;\n feedbackKeys?: string[];\n resume?: unknown;\n multitaskStrategy?: LangGraphMultitaskStrategy;\n}" @@ -8568,7 +8596,7 @@ "symbol": "StreamEvent", "declarations": [ { - "path": "libs/langgraph/src/lib/agent.types.ts", + "path": "libs/langgraph/src/runtime/transport.types.ts", "symbol": "StreamEvent", "syntaxKind": "InterfaceDeclaration", "signature": "export interface StreamEvent {\n type: 'values' | `values|${string}` | 'messages' | `messages|${string}` | `messages/${string}` | `messages/${string}|${string}` | 'updates' | `updates|${string}` | 'tools' | `tools|${string}` | 'custom' | `custom|${string}` | 'error' | `error|${string}` | 'metadata' | 'checkpoints' | `checkpoints|${string}` | 'tasks' | `tasks|${string}` | 'debug' | `debug|${string}` | 'events' | `events|${string}` | 'interrupt' | 'interrupts';\n namespace?: string[];\n messages?: unknown[];\n messageMetadata?: Record;\n [key: string]: unknown;\n}" @@ -8613,7 +8641,7 @@ "path": "libs/langgraph/src/lib/agent.types.ts", "symbol": "ThreadState", "syntaxKind": "ImportSpecifier", - "signature": "import type { BagTemplate, Checkpoint, Command, Config, InferBag, Interrupt, Metadata, ThreadState, ToolProgress, ToolCallWithResult, StreamMode, } from '@langchain/langgraph-sdk';" + "signature": "import type { BagTemplate, InferBag, Interrupt, ThreadState, ToolProgress, ToolCallWithResult, } from '@langchain/langgraph-sdk';" } ] }, @@ -8763,7 +8791,7 @@ "path": "libs/langgraph/src/lib/client/create-langgraph-client.ts", "symbol": "toAbsoluteApiUrl", "syntaxKind": "FunctionDeclaration", - "signature": "export function toAbsoluteApiUrl(apiUrl: string): string {\n if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))\n return apiUrl;\n return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl;\n}" + "signature": "export function toAbsoluteApiUrl(apiUrl: string): string {\n if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))\n return apiUrl;\n const browser = globalThis as typeof globalThis & {\n window?: {\n location: {\n origin: string;\n };\n };\n };\n return browser.window !== undefined ? `${browser.window.location.origin}${apiUrl}` : apiUrl;\n}" } ] }, @@ -8788,7 +8816,7 @@ "symbol": "ɵLangGraphRuntimeOperationFailureReporter", "declarations": [ { - "path": "libs/langgraph/src/lib/runtime-operation-reporter.ts", + "path": "libs/langgraph/src/runtime/operation-errors.ts", "symbol": "RuntimeOperationFailureReporter", "syntaxKind": "TypeAliasDeclaration", "signature": "export type RuntimeOperationFailureReporter = (code: 'unauthorized' | 'network_blocked') => void;" @@ -12401,7 +12429,7 @@ "id": "source:libs/langgraph/eslint.config.mjs", "kind": "source", "path": "libs/langgraph/eslint.config.mjs", - "sha256": "d1924a267c02bcc6777cdc9fc8d3d48bda67f1c3c7798e9710b8c8291b835ca6" + "sha256": "69a076a2b6c4c4472f79233f0829874cd30f744b005cfa32c83ba15d72946c2d" }, { "id": "source:libs/langgraph/install/postinstall.cjs", @@ -12431,7 +12459,7 @@ "id": "source:libs/langgraph/src/lib/agent.types.ts", "kind": "source", "path": "libs/langgraph/src/lib/agent.types.ts", - "sha256": "6df8e889912898c0f3a11b6a4a8651bfe02ac08f107fae83d1a940793deda2de" + "sha256": "a98150c0946b53e2c235826ab2ff3f7674ed427909e980ddae2c2a9c35ca76a6" }, { "id": "source:libs/langgraph/src/lib/client-tools.ts", @@ -12449,7 +12477,7 @@ "id": "source:libs/langgraph/src/lib/client/create-langgraph-client.ts", "kind": "source", "path": "libs/langgraph/src/lib/client/create-langgraph-client.ts", - "sha256": "46795d05f61c696dd1bb2910c12129684907c7b006ac35015effd3a53d0164ea" + "sha256": "70cbbc7b8054860323ef6808c251ffa0981656634053752601ea7df5d24827ab" }, { "id": "source:libs/langgraph/src/lib/inject-agent.ts", @@ -12497,7 +12525,7 @@ "id": "source:libs/langgraph/src/lib/runtime-operation-reporter.ts", "kind": "source", "path": "libs/langgraph/src/lib/runtime-operation-reporter.ts", - "sha256": "9bdd7e56d28f9f7cb03ba59ab38d36ab15c8c2f47a444d90ee15816c276c1d73" + "sha256": "22a39169b48510ef6b22dfdc887b42f92f2a969465cc0c01c1b42657ee24f9cf" }, { "id": "source:libs/langgraph/src/lib/testing/fake-stream.transport.ts", @@ -12533,7 +12561,7 @@ "id": "source:libs/langgraph/src/lib/transport/fetch-stream.transport.ts", "kind": "source", "path": "libs/langgraph/src/lib/transport/fetch-stream.transport.ts", - "sha256": "7eb00a24edd3f7d316b874e02b07169bc024782fb5894ece782537ef1fcafae2" + "sha256": "78e712be228f3354bd63f30b13093695701b730c2e8415322d126f2a84e04876" }, { "id": "source:libs/langgraph/src/lib/transport/mock-stream.transport.ts", @@ -12545,7 +12573,7 @@ "id": "source:libs/langgraph/src/lib/transport/transport.interface.ts", "kind": "source", "path": "libs/langgraph/src/lib/transport/transport.interface.ts", - "sha256": "6e4bd1d1896711a4921c481fa54472597304b41b021c4fd1e44427b0d6191147" + "sha256": "cc54bc555e000a53a3dd5e9dc4d3258e3a8f04d2825f486f06eb9a6c466450a1" }, { "id": "source:libs/langgraph/src/public-api.ts", @@ -12553,6 +12581,72 @@ "path": "libs/langgraph/src/public-api.ts", "sha256": "3effe8f90bcd9967bb99b9902b3bb6ffbe2769c8c5b46824d2742e2906f6536b" }, + { + "id": "source:libs/langgraph/src/runtime/create-session.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/create-session.ts", + "sha256": "4867cf1b7c6901174ba965265f6593b0b84113b28dcc75f5de9d68088a4b572e" + }, + { + "id": "source:libs/langgraph/src/runtime/function-tools.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/function-tools.ts", + "sha256": "873ce2341b883ab6bcc17b7abf2d3eb0b61a3d5a9a7128449b8d4b90f5ae06fd" + }, + { + "id": "source:libs/langgraph/src/runtime/message-reducer.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/message-reducer.ts", + "sha256": "eeea886260ca5969733215fe58498ecebbe74f71cfc4461724c8cea089f1a44c" + }, + { + "id": "source:libs/langgraph/src/runtime/operation-errors.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/operation-errors.ts", + "sha256": "d0273c12017c9170260f339b4fe9038fc682a34325fe3e5df88fe1bfb31c8a48" + }, + { + "id": "source:libs/langgraph/src/runtime/ownership.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/ownership.ts", + "sha256": "5cbf266756c5558997bd187944ccba4b84b9c291653f441e8a0ae6e195dcc22f" + }, + { + "id": "source:libs/langgraph/src/runtime/publication.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/publication.ts", + "sha256": "00c5f784139690c001475bd7ef63a5446a4e5d77a99f57353a03d22fbc3569f3" + }, + { + "id": "source:libs/langgraph/src/runtime/stream-projection.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/stream-projection.ts", + "sha256": "da722b46e0d9e72ef79e6e762a2571786bdba5b2fea96cb8dbd6cc30aac60c97" + }, + { + "id": "source:libs/langgraph/src/runtime/testing/binding-fixture.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/testing/binding-fixture.ts", + "sha256": "e5f140179b43c203cca0222464a77a8447658c36747121893da498d3bb25385e" + }, + { + "id": "source:libs/langgraph/src/runtime/testing/controlled-transport.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/testing/controlled-transport.ts", + "sha256": "45fb5ab200314cb4a8f897e08d3c2786a50f20714e6556506cacfbc7680df8fb" + }, + { + "id": "source:libs/langgraph/src/runtime/testing/deferred.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/testing/deferred.ts", + "sha256": "1e3db2740d991eca3822ad08afc2510c8f714bc7763cc1d75ea623f875f72ce0" + }, + { + "id": "source:libs/langgraph/src/runtime/transport.types.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/transport.types.ts", + "sha256": "ca5d20d673bec0ca60d3af59b50b948e0dbd93b0cc26efe59d962f666a6c12cb" + }, { "id": "source:libs/langgraph/src/test-setup.ts", "kind": "source", @@ -12575,7 +12669,13 @@ "id": "source:libs/langgraph/vite.config.mts", "kind": "source", "path": "libs/langgraph/vite.config.mts", - "sha256": "0539aa4a1cdbeefe419f64fd3867d6266ffbef589f9aff2c81f1a198b06379d8" + "sha256": "33c8ce08545e9de615e5597f8df48e732611cdbc8e7358582d9dbf360cfc0cc0" + }, + { + "id": "source:libs/langgraph/vite.runtime.config.mts", + "kind": "source", + "path": "libs/langgraph/vite.runtime.config.mts", + "sha256": "2fb8014d6fb58ab6d7553b392ad57b0f2946260d36c2d6cb7c5ce4f95679341f" }, { "id": "source:libs/middleware/eslint.config.mjs", diff --git a/scripts/react-parity/dispositions.json b/scripts/react-parity/dispositions.json index d9a04cd40..eb74bfbbf 100644 --- a/scripts/react-parity/dispositions.json +++ b/scripts/react-parity/dispositions.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "purpose": "Planning ownership for T01. Status is planned until task-specific implementation and verification are recorded; inventory validation is not React parity.", + "purpose": "Planning ownership for the reviewed migration scope. Existing task assignments are preserved; in-progress records identify bounded runtime/configuration subsets, not whole-task completion. Inventory validation is not React parity.", "rows": [ { "id": "asset:libs/a2ui/LICENSE.md", @@ -847,7 +847,8 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." }, { "id": "asset:libs/langgraph/test/fixtures/streaming-reasoning-puzzle.json", @@ -876,7 +877,8 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." }, { "id": "asset:libs/langgraph/tsconfig.lib.prod.json", @@ -886,7 +888,17 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." + }, + { + "id": "asset:libs/langgraph/tsconfig.runtime-tests.json", + "taskIds": [ + "T36" + ], + "treatment": "infrastructure", + "status": "in-progress", + "note": "Isolated Node runtime verification only; the wider CI/release migration remains open." }, { "id": "asset:libs/langgraph/tsconfig.type-tests.json", @@ -2273,7 +2285,8 @@ "T36" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." }, { "id": "config:.github/workflows/publish-middleware-npm.yml", @@ -2351,7 +2364,8 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." }, { "id": "config:package.json", @@ -7125,7 +7139,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#AgentQueueEntry", @@ -7133,7 +7148,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#AgentTransport", @@ -7141,7 +7157,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#BagTemplate", @@ -7149,7 +7166,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#CustomStreamEvent", @@ -7173,7 +7191,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#InferBag", @@ -7181,7 +7200,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#Interrupt", @@ -7189,7 +7209,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#LANGGRAPH_CLIENT", @@ -7229,7 +7250,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#LangGraphMultitaskStrategy", @@ -7237,7 +7259,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#LangGraphSubmitOptions", @@ -7245,7 +7268,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#LangGraphThreadsAdapter", @@ -7293,7 +7317,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#SubagentStreamRef", @@ -7317,7 +7342,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#createLangGraphClient", @@ -7391,7 +7417,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "planned", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/langgraph/src/public-api.ts#\u0275LANGGRAPH_RUNTIME_OPERATION_REPORTER", @@ -7409,7 +7436,8 @@ ], "treatment": "internal", "status": "planned", - "reason": "Existing \u0275-prefixed implementation seam; retain required behavior without promising a new public React API." + "reason": "Existing \u0275-prefixed implementation seam; retain required behavior without promising a new public React API.", + "note": "Reviewed declaration/import text drift from private runtime extraction; legacy export name and Angular root remain unchanged. This is not completion of the assigned migration task." }, { "id": "export:libs/middleware/src/langgraph/index.ts#BaseMessage", @@ -11136,7 +11164,8 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." }, { "id": "source:libs/langgraph/install/postinstall.cjs", @@ -11178,7 +11207,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "in-progress", + "note": "Only framework-free transport/error dependencies were extracted for the private runtime proof; existing Angular behavior and the remaining assigned migration work are retained." }, { "id": "source:libs/langgraph/src/lib/client-tools.ts", @@ -11202,7 +11232,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "in-progress", + "note": "Only framework-free transport/error dependencies were extracted for the private runtime proof; existing Angular behavior and the remaining assigned migration work are retained." }, { "id": "source:libs/langgraph/src/lib/inject-agent.ts", @@ -11273,7 +11304,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "in-progress", + "note": "Only framework-free transport/error dependencies were extracted for the private runtime proof; existing Angular behavior and the remaining assigned migration work are retained." }, { "id": "source:libs/langgraph/src/lib/testing/fake-stream.transport.ts", @@ -11322,7 +11354,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "in-progress", + "note": "Only framework-free transport/error dependencies were extracted for the private runtime proof; existing Angular behavior and the remaining assigned migration work are retained." }, { "id": "source:libs/langgraph/src/lib/transport/mock-stream.transport.ts", @@ -11338,7 +11371,8 @@ "T08" ], "treatment": "shared", - "status": "planned" + "status": "in-progress", + "note": "Only framework-free transport/error dependencies were extracted for the private runtime proof; existing Angular behavior and the remaining assigned migration work are retained." }, { "id": "source:libs/langgraph/src/public-api.ts", @@ -11348,6 +11382,125 @@ "treatment": "shared", "status": "planned" }, + { + "id": "source:libs/langgraph/src/runtime/create-session.ts", + "taskIds": [ + "T04", + "T08", + "T10", + "T15", + "T16" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/function-tools.ts", + "taskIds": [ + "T15", + "T16" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/message-reducer.ts", + "taskIds": [ + "T09" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/operation-errors.ts", + "taskIds": [ + "T08", + "T10" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/ownership.ts", + "taskIds": [ + "T04" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/publication.ts", + "taskIds": [ + "T04" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/stream-projection.ts", + "taskIds": [ + "T09" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/testing/binding-fixture.ts", + "taskIds": [ + "T05", + "T06" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private controlled runtime/binding test helper; excluded from public exports and production packages.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/testing/controlled-transport.ts", + "taskIds": [ + "T05", + "T06" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private controlled runtime/binding test helper; excluded from public exports and production packages.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/testing/deferred.ts", + "taskIds": [ + "T05", + "T06" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private controlled runtime/binding test helper; excluded from public exports and production packages.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/transport.types.ts", + "taskIds": [ + "T08" + ], + "treatment": "internal", + "status": "in-progress", + "reason": "Private staged LangGraph runtime subset; fixture-only and not a neutral public LangGraph root or tarball.", + "note": "Bounded runtime proof only; broader task capabilities and final public package cutover remain open." + }, { "id": "source:libs/langgraph/src/test-setup.ts", "taskIds": [ @@ -11382,7 +11535,17 @@ "T37" ], "treatment": "infrastructure", - "status": "planned" + "status": "in-progress", + "note": "Bounded runtime verification/configuration subset updated; broader assigned migration and release work remain open." + }, + { + "id": "source:libs/langgraph/vite.runtime.config.mts", + "taskIds": [ + "T36" + ], + "treatment": "infrastructure", + "status": "in-progress", + "note": "Isolated Node runtime verification only; the wider CI/release migration remains open." }, { "id": "source:libs/middleware/eslint.config.mjs", diff --git a/scripts/react-parity/package-policy.mjs b/scripts/react-parity/package-policy.mjs index 31a479766..98bd4daab 100644 --- a/scripts/react-parity/package-policy.mjs +++ b/scripts/react-parity/package-policy.mjs @@ -14,6 +14,8 @@ export const finalPackageDependencies = { export const privateScaffoldProjects = ['core', 'content', 'angular', 'react']; export const angularTransitionProjects = ['chat', 'langgraph', 'ag-ui', 'render']; export const scanProjects = [...new Set([...privateScaffoldProjects, ...angularTransitionProjects, 'a2ui', 'telemetry'])]; +// Source-only staging boundary; this does not declare a published entry point. +export const neutralLangGraphRoots = ['src/lib/transport/fetch-stream.transport.ts', 'src/lib/client/create-langgraph-client.ts']; const retiredProjects = ['chat', 'langgraph-core', 'ag-ui-core', 'react-render']; export function packageOf(specifier) { @@ -24,9 +26,14 @@ export function sourceEntry(project, angularTransitions = angularTransitionProje return project === 'angular' || angularTransitions.includes(project) ? 'src/public-api.ts' : 'src/index.ts'; } -export function forbiddenDependency(project, specifier, { angularTransitions = [], browserTransition = false } = {}) { +export function forbiddenDependency(project, specifier, { angularTransitions = [], browserTransition = false, neutralRuntime = false } = {}) { const pkg = packageOf(specifier); const internal = pkg.startsWith('@threadplane/') ? pkg.slice('@threadplane/'.length) : undefined; + if (neutralRuntime) { + if (internal) return !['langgraph', 'core'].includes(internal); + if (specifier.startsWith('.')) return false; + return specifier !== '@langchain/langgraph-sdk'; + } const angular = pkg.startsWith('@angular/'); const react = ['react', 'react-dom', '@types/react', '@types/react-dom'].includes(pkg) || ['react', 'react-render', 'ui-react', 'workspace-react'].includes(internal); // These are intentionally the only whole-project exceptions. Their current diff --git a/scripts/react-parity/runtime-config.spec.mjs b/scripts/react-parity/runtime-config.spec.mjs new file mode 100644 index 000000000..39e6a26d2 --- /dev/null +++ b/scripts/react-parity/runtime-config.spec.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { loadConfigFromFile } from 'vite'; + +test('runtime-quality loads an isolated Node config without Angular setup or plugins', async () => { + const project = JSON.parse(await readFile('libs/langgraph/project.json', 'utf8')); + const target = project.targets['runtime-quality']; + assert.equal(target.executor, '@nx/vitest:test'); + assert.equal(target.options.configFile, 'libs/langgraph/vite.runtime.config.mts'); + const loaded = await loadConfigFromFile({ command: 'serve', mode: 'test' }, target.options.configFile); + assert.ok(loaded, 'runtime config must load'); + assert.equal(loaded.config.test.environment, 'node'); + assert.deepEqual(loaded.config.test.include, ['src/runtime/**/*.spec.ts']); + assert.deepEqual(loaded.config.test.setupFiles ?? [], []); + assert.deepEqual(loaded.config.plugins ?? [], []); + assert.equal(loaded.config.test.passWithNoTests, false); + assert.ok(!loaded.dependencies.some((path) => /(?:test-setup\.[cm]?[jt]s|vite\.config\.mts)$/.test(path)), 'must not inherit the Angular config or test setup'); +}); diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs new file mode 100644 index 000000000..6ee3413b5 --- /dev/null +++ b/scripts/react-parity/runtime-consumer.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { extname, join, resolve, sep } from 'node:path'; +import { once } from 'node:events'; +import { build } from 'vite'; +import ts from 'typescript'; +import { chromium, expect } from '@playwright/test'; + +export function lockedReactManifest(lock) { + const entries = (names) => Object.fromEntries(names.map((name) => { + const version = lock.packages?.[`node_modules/${name}`]?.version; + if (!version) throw new Error(`Missing root lock version for ${name}`); + return [name, version]; + })); + return { + private: true, type: 'module', + dependencies: entries(['react', 'react-dom']), + devDependencies: entries(['@types/react', '@types/react-dom', 'vite', 'typescript']), + }; +} + +const catalog = [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }]; +const toolCall = { type: 'ai', id: 'assistant-tool', content: '', tool_calls: [{ id: 'call-weather', name: 'weather', args: { city: 'Paris' }, type: 'tool_call' }] }; +const sse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +const textTrace = readFileSync(new URL('../../fixtures/react-parity/traces/langgraph-text-state.sse', import.meta.url), 'utf8'); + +/** A strict wire fixture: malformed/extra operations fail the browser run. */ +export function runtimeResponse(body) { + assert.deepEqual(body.input?.client_tools, catalog, 'exact client tool catalog'); + assert.equal(body.assistant_id, 'fixture-assistant'); + assert.deepEqual(body.stream_mode, ['values', 'messages-tuple', 'updates', 'custom']); + assert.equal(body.stream_subgraphs, true); + assert.equal(body.input.messages.length, 1); + const message = body.input.messages[0]; + assert.deepEqual(body, { assistant_id: 'fixture-assistant', input: { messages: [message], client_tools: catalog }, stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], stream_subgraphs: true }, 'unexpected run fields'); + if (message.type === 'tool') { + assert.deepEqual(message, { id: 'client-tool-result-call-weather', type: 'tool', role: 'tool', tool_call_id: 'call-weather', content: '{"city":"Paris","temperature":20}' }, 'actual handler result continuation'); + return sse('values', { messages: [toolCall, message, { type: 'ai', id: 'answer', content: '20 degrees' }] }); + } + assert.equal(message.type, 'human'); + assert.equal(typeof message.id, 'string'); + assert.deepEqual(message, { id: message.id, type: 'human', content: message.content }, 'exact user message fields'); + if (message.content === 'Send') return textTrace.replaceAll('message-parity', `answer-${message.id}`); + if (message.content === 'Tool') return sse('values', { messages: [message, toolCall] }); + if (message.content === 'Error') return sse('error', { error: 'FixtureFailure', message: 'PRIVATE backend diagnostic' }); + if (message.content === 'Hold') return null; + throw new Error(`Unexpected input ${message.content}`); +} + +export function installedTypeSource(template, kind) { + if (kind === 'core') return template; + const binding = kind === 'react' ? 'useAgent' : 'observeAgent'; + return template.replace('/* BINDING_IMPORT */', `import { ${binding} } from '@threadplane/${kind}';`) + .replace('export function assertSnapshot(snapshot: AgentSnapshot) {', `export function assertSnapshot(session: AgentSession) {\n const snapshot = ${binding}(session)${kind === 'angular' ? '()' : ''};\n const exact: AgentSnapshot = snapshot;\n void exact;`) + .replace(' assertSnapshot(snapshot);', ' void snapshot;'); +} + +export function prepareInstalledTypes(root, consumer, kind) { + const fixture = join(root, 'fixtures/react-parity/runtime'); + cpSync(join(fixture, 'scenarios.ts'), join(consumer, 'scenarios.ts')); + writeFileSync(join(consumer, 'installed-types.ts'), installedTypeSource(readFileSync(join(fixture, 'installed-types.ts'), 'utf8'), kind)); + writeFileSync(join(consumer, 'tsconfig.contracts.json'), JSON.stringify({ compilerOptions: { + target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2022', 'DOM'], types: [], strict: true, skipLibCheck: false, noEmit: true, + }, files: ['installed-types.ts'] }, null, 2)); +} + +/** Typecheck the private source against the *installed* public core declarations, + * emit to temporary storage, and copy only the narrow entry declaration. This + * resolution override is confined to checking private source; neither installed + * consumer config has paths/aliases, and no core implementation is bundled. */ +function emitRuntimeDeclaration(root, consumer, output) { + const options = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, lib: ['lib.es2022.d.ts', 'lib.esnext.disposable.d.ts', 'lib.dom.d.ts'], types: [], strict: true, skipLibCheck: false, declaration: true, emitDeclarationOnly: true, rootDir: root, outDir: output }; + const host = ts.createCompilerHost(options); + host.resolveModuleNames = (names, containingFile) => names.map((name) => ts.resolveModuleName(name, + name === '@threadplane/core' || name === '@threadplane/core/tools' ? join(consumer, 'installed-types.ts') : containingFile, options, ts.sys).resolvedModule); + const program = ts.createProgram([join(root, 'fixtures/react-parity/runtime/runtime-entry.ts')], options, host); + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { getCurrentDirectory: () => root, getCanonicalFileName: (name) => name, getNewLine: () => '\n' })); + const result = program.emit(); + assert.equal(result.emitSkipped, false, 'runtime fixture declaration emitted'); +} + +export async function prepareRuntimeConsumer(root, consumer, kind) { + const fixture = join(root, 'fixtures/react-parity/runtime'); + const temporary = mkdtempSync(join(tmpdir(), 'threadplane-runtime-bundle-')); + try { + emitRuntimeDeclaration(root, consumer, join(temporary, 'types')); + const result = await build({ configFile: false, root, logLevel: 'warn', build: { + outDir: join(temporary, 'bundle'), emptyOutDir: true, minify: false, + lib: { entry: join(fixture, 'runtime-entry.ts'), formats: ['es'], fileName: () => 'runtime-entry.js' }, + rollupOptions: { external: ['@threadplane/core', '@threadplane/core/tools'] }, + } }); + const chunks = (Array.isArray(result) ? result : [result]).flatMap((entry) => entry.output); + const modules = chunks.flatMap((chunk) => Object.keys(chunk.modules ?? {})); + assert.ok(modules.some((path) => path.includes('/@langchain/langgraph-sdk/')), 'real SDK must be in private fixture bundle'); + assert.ok(modules.some((path) => path.endsWith('/transport/fetch-stream.transport.ts')), 'production transport must be in private fixture bundle'); + assert.ok(!modules.some((path) => /\/libs\/(?:core|react|angular)\//.test(path) || /\/node_modules\/(?:@angular|react|react-dom)\//.test(path)), 'fixture must externalize public core and exclude framework implementations'); + const destination = kind === 'angular' ? join(consumer, 'src') : consumer; + cpSync(join(temporary, 'bundle/runtime-entry.js'), join(destination, 'runtime-entry.js')); + cpSync(join(temporary, 'types/fixtures/react-parity/runtime/runtime-entry.d.ts'), join(destination, 'runtime-entry.d.ts')); + cpSync(join(fixture, 'scenarios.ts'), join(destination, 'scenarios.ts')); + cpSync(join(fixture, `${kind}-app.${kind === 'react' ? 'tsx' : 'ts'}`), join(destination, kind === 'react' ? 'main.tsx' : 'main.ts')); + if (kind === 'react') { + cpSync(join(fixture, 'vite.config.mts'), join(consumer, 'vite.config.mts')); + writeFileSync(join(consumer, 'index.html'), 'React installed consumer
'); + writeFileSync(join(consumer, 'tsconfig.app.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2022', 'DOM'], types: [], strict: true, skipLibCheck: false, jsx: 'react-jsx', noEmit: true }, files: ['main.tsx'] })); + } + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +/** Bounded fixture server: built files and exactly one deterministic run route. */ +export async function serveRuntimeConsumer(directory) { + const requests = []; + const errors = []; + const held = new Set(); + let notifyHeld; + let notifyAborted; + const holdStarted = new Promise((resolve) => { notifyHeld = resolve; }); + const holdAborted = new Promise((resolve) => { notifyAborted = resolve; }); + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url, 'http://fixture').pathname; + if (pathname.startsWith('/api/')) { + assert.equal(request.method, 'POST'); + assert.equal(pathname, '/api/threads/fixture-thread/runs/stream', 'only expected run endpoint'); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString()); + requests.push(body); + const trace = runtimeResponse(body); + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + if (trace !== null) return response.end(trace); + held.add(response); + response.once('close', () => { held.delete(response); notifyAborted(); }); + // Actual incremental network bytes, not a whole-response route.fulfill. + response.write(sse('messages', [{ type: 'AIMessageChunk', id: 'held-answer', content: 'Held partial' }, { langgraph_node: 'assistant' }])); + notifyHeld(); + return; + } + assert.equal(request.method, 'GET'); + if (pathname === '/favicon.ico') { response.writeHead(204); return response.end(); } + const file = resolve(directory, `.${pathname === '/' ? '/index.html' : pathname}`); + assert.ok(file.startsWith(resolve(directory) + sep) && existsSync(file), `Unexpected request ${pathname}`); + const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ico': 'image/x-icon' }[extname(file)] ?? 'application/octet-stream'; + response.writeHead(200, { 'content-type': type }); + response.end(readFileSync(file)); + } catch (error) { + errors.push(error); + response.writeHead(500); + response.end('Fixture request rejected'); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { + url: `http://127.0.0.1:${server.address().port}`, requests, errors, holdStarted, holdAborted, + async close() { + for (const response of held) response.destroy(); + const closed = once(server, 'close'); + server.close(); + server.closeAllConnections(); + await closed; + }, + }; +} + +function handshake(promise, label) { + let timeout; + return Promise.race([promise, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(`Timed out awaiting ${label}`)), 10_000); })]).finally(() => clearTimeout(timeout)); +} + +export async function runRuntimeScenarios(directory, kind) { + const server = await serveRuntimeConsumer(directory); + let browser; + let context; + const pageErrors = []; + const unexpected = []; + const completed = []; + try { + browser = await chromium.launch({ headless: true }); + context = await browser.newContext(); + const page = await context.newPage(); + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('request', (request) => { + if (!request.url().startsWith(`${server.url}/`)) unexpected.push(request.url()); + }); + await page.goto(server.url); + await expect(page.getByTestId('status')).toHaveText('idle'); + await expect(page.getByTestId('owner')).toHaveText('mounted'); + await expect(page.getByTestId('handler-calls')).toHaveText('0'); + await expect(page.getByTestId('submissions')).toHaveText('0'); + assert.equal(server.requests.length, 0, 'mount/observation performs no I/O'); + completed.push('inert mount'); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByTestId('text')).toHaveText('Hello 🌍.'); + await expect(page.getByTestId('delivery')).toHaveText('complete:success'); + await expect(page.getByTestId('status')).toHaveText('idle'); + assert.equal(server.requests.length, 1); + completed.push('text success'); + + const beforeTool = server.requests.length; + await page.getByRole('button', { name: 'Tool', exact: true }).click(); + await expect(page.getByTestId('text')).toContainText('20 degrees'); + await expect(page.getByTestId('handler-calls')).toHaveText('1'); + await expect(page.getByTestId('status')).toHaveText('idle'); + assert.deepEqual(JSON.parse(await page.getByTestId('tool').innerText()), [{ id: 'call-weather', name: 'weather', args: { city: 'Paris' }, status: 'complete', result: { city: 'Paris', temperature: 20 } }]); + assert.equal(server.requests.length - beforeTool, 2, 'tool has exactly one run and one result continuation'); + completed.push('tool roundtrip'); + + await page.getByRole('button', { name: 'Error', exact: true }).click(); + await expect(page.getByTestId('status')).toHaveText('error'); + await expect(page.getByTestId('error')).not.toHaveText(''); + await expect(page.getByTestId('error')).not.toContainText('PRIVATE'); + assert.equal(server.requests.length, 4, 'failed run is not retried'); + completed.push('visible protected error'); + + await page.getByRole('button', { name: 'Hold', exact: true }).click(); + await handshake(server.holdStarted, 'held request'); + await expect(page.getByTestId('text')).toContainText('Held partial'); + await expect(page.getByTestId('delivery')).toHaveText('streaming'); + await expect(page.getByTestId('status')).toHaveText('running'); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + await handshake(server.holdAborted, 'native request abort'); + await expect(page.getByTestId('status')).toHaveText('idle'); + await expect(page.getByTestId('delivery')).toHaveText('complete:aborted'); + assert.equal(server.requests.length, 5); + completed.push('incremental DOM update and stop abort'); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByTestId('delivery')).toHaveText('complete:success'); + await expect(page.getByTestId('status')).toHaveText('idle'); + await expect(page.getByTestId('text')).toContainText('Hello 🌍.'); + await expect(page.getByTestId('handler-calls')).toHaveText('1'); + assert.equal(server.requests.length, 6); + await expect(page.getByTestId('submissions')).toHaveText('5'); + completed.push('reuse after stop'); + + await page.getByRole('button', { name: 'Unmount', exact: true }).click(); + await expect(page.getByTestId('owner')).toHaveText('unmounted'); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Dispose', exact: true }).click(); + await expect(page.getByTestId('owner')).toHaveText('disposed'); + await page.getByRole('button', { name: 'Send after dispose', exact: true }).click(); + await expect(page.getByTestId('owner')).toHaveText('aborted'); + assert.equal(server.requests.length, 6, 'cleanup/disposal/post-disposal submit creates no extra runs'); + completed.push('unmount and explicit disposal'); + assert.deepEqual(server.errors.map(String), []); + assert.deepEqual(pageErrors, []); + assert.deepEqual(unexpected, []); + console.log(`${kind}: ${completed.length} browser scenarios passed (${completed.join('; ')}); 6 exact requests, one tool handler, no page errors/unexpected requests.`); + return completed; + } finally { + try { await context?.close(); } + finally { + try { await browser?.close(); } + finally { await server.close(); } + } + } +} diff --git a/scripts/react-parity/runtime-consumer.spec.mjs b/scripts/react-parity/runtime-consumer.spec.mjs new file mode 100644 index 000000000..b11fbea67 --- /dev/null +++ b/scripts/react-parity/runtime-consumer.spec.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import * as runtime from './runtime-consumer.mjs'; + +const versions = { react: '19.2.4', 'react-dom': '19.2.4', '@types/react': '19.2.14', '@types/react-dom': '19.2.3', vite: '7.3.1', typescript: '5.9.3' }; +test('React consumer pins framework and compiler tooling from the actual lock', () => { + assert.equal(typeof runtime.lockedReactManifest, 'function'); + const lock = { packages: Object.fromEntries(Object.entries(versions).map(([name, version]) => [`node_modules/${name}`, { version }])) }; + const manifest = runtime.lockedReactManifest(lock); + assert.deepEqual({ ...manifest.dependencies, ...manifest.devDependencies }, versions); + delete lock.packages['node_modules/react-dom']; + assert.throws(() => runtime.lockedReactManifest(lock), /react-dom/); +}); +test('tool trace requires a real serialized handler result before returning the final answer', () => { + assert.equal(typeof runtime.runtimeResponse, 'function'); + const catalog = [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }]; + const submit = { assistant_id: 'fixture-assistant', input: { messages: [{ id: 'user', type: 'human', content: 'Tool' }], client_tools: catalog }, stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], stream_subgraphs: true }; + assert.match(runtime.runtimeResponse(submit), /call-weather/); + const continuation = { ...submit, input: { ...submit.input, messages: [{ id: 'client-tool-result-call-weather', type: 'tool', role: 'tool', tool_call_id: 'call-weather', content: '{"city":"Paris","temperature":20}' }] } }; + assert.match(runtime.runtimeResponse(continuation), /20 degrees/); + assert.throws(() => runtime.runtimeResponse({ ...continuation, input: { ...continuation.input, messages: [{ ...continuation.input.messages[0], content: 'fake result' }] } }), /result/); + assert.throws(() => runtime.runtimeResponse({ ...submit, input: { ...submit.input, client_tools: [] } }), /catalog/); + assert.throws(() => runtime.runtimeResponse({ ...submit, command: { resume: 'unexpected' } }), /unexpected run fields/); + assert.throws(() => runtime.runtimeResponse({ ...submit, input: { ...submit.input, messages: [{ id: 'user', type: 'human', content: 'Unexpected' }] } }), /Unexpected/); +}); + +test('successive submitted turns receive distinct server message IDs', () => { + const body = (id) => ({ assistant_id: 'fixture-assistant', input: { messages: [{ id, type: 'human', content: 'Send' }], client_tools: [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }] }, stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], stream_subgraphs: true }); + const first = runtime.runtimeResponse(body('first')); + const second = runtime.runtimeResponse(body('second')); + assert.notEqual(first, second); + assert.match(first, /Hello/); + assert.match(second, /Hello/); +}); + +const heldBody = { assistant_id: 'fixture-assistant', input: { messages: [{ id: 'held-user', type: 'human', content: 'Hold' }], client_tools: [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }] }, stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], stream_subgraphs: true }; +for (const ending of ['native abort', 'fixture cleanup']) { + test(`held SSE delivers real bytes and closes on ${ending}`, { timeout: 10_000 }, async () => { + const directory = mkdtempSync(join(tmpdir(), 'r07-server-test-')); + writeFileSync(join(directory, 'index.html'), '

Built fixture

'); + const server = await runtime.serveRuntimeConsumer(directory); + let closed = false; + const controller = new AbortController(); + try { + assert.match(await (await fetch(server.url)).text(), /Built fixture/); + const response = await fetch(`${server.url}/api/threads/fixture-thread/runs/stream`, { method: 'POST', body: JSON.stringify(heldBody), signal: controller.signal }); + await server.holdStarted; + const reader = response.body.getReader(); + const first = await reader.read(); + assert.match(new TextDecoder().decode(first.value), /Held partial/); + const rejected = assert.rejects(reader.read()); + if (ending === 'native abort') controller.abort(); + else { await server.close(); closed = true; } + await rejected; + await server.holdAborted; + assert.equal(server.requests.length, 1); + assert.deepEqual(server.errors, []); + } finally { + controller.abort(); + if (!closed) await server.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); +} + +test('unexpected fixture HTTP operations are recorded instead of silently served', async () => { + const server = await runtime.serveRuntimeConsumer(tmpdir()); + try { + const response = await fetch(`${server.url}/api/threads/fixture-thread/state`, { method: 'POST', body: '{}' }); + assert.equal(response.status, 500); + assert.equal(server.errors.length, 1); + assert.equal(server.requests.length, 0); + } finally { await server.close(); } +}); diff --git a/scripts/react-parity/verify-angular-package.mjs b/scripts/react-parity/verify-angular-package.mjs index 2c427f101..0281dc2ed 100644 --- a/scripts/react-parity/verify-angular-package.mjs +++ b/scripts/react-parity/verify-angular-package.mjs @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertParserFreeInputs, consumerSpecifiers, installConsumer, packLocalArtifacts, runConsumer } from './verify-packages.mjs'; +import { prepareInstalledTypes, prepareRuntimeConsumer, runRuntimeScenarios } from './runtime-consumer.mjs'; export function lockedAngularManifest(template, lock) { const dependencies = ['@angular/core', '@angular/common', '@angular/compiler', '@angular/platform-browser', 'rxjs', 'tslib']; @@ -25,13 +26,7 @@ export function angularBuildCommand(consumer) { return [join(consumer, 'node_modules/@angular/cli/bin/ng.js'), 'build', '--configuration=production', '--stats-json']; } -export function angularConsumerSource(template, specifiers) { - const extra = specifiers.filter((specifier) => specifier !== '@threadplane/angular'); - return template.replace('/* PACKAGE_IMPORTS */', extra.map((specifier, index) => `import * as entry${index} from ${JSON.stringify(specifier)};`).join('\n')) - .replace('/* PACKAGE_EXPORT_COUNT */', extra.map((_, index) => `+ Object.keys(entry${index}).length`).join(' ')); -} - -export function verifyAngularPackage(root = process.cwd()) { +export async function verifyAngularPackage(root = process.cwd()) { root = resolve(root); const temporary = mkdtempSync(join(tmpdir(), 'threadplane-angular-consumer-')); try { @@ -44,15 +39,19 @@ export function verifyAngularPackage(root = process.cwd()) { installConsumer(consumer, manifest, tarballs, 'angular'); const installed = JSON.parse(readFileSync(join(consumer, 'node_modules/@threadplane/angular/package.json'), 'utf8')); const specifiers = consumerSpecifiers(installed); - const main = join(consumer, 'src/main.ts'); - writeFileSync(main, angularConsumerSource(readFileSync(main, 'utf8'), specifiers)); + prepareInstalledTypes(root, consumer, 'angular'); + const contracts = join(consumer, 'installed-types.ts'); + writeFileSync(contracts, readFileSync(contracts, 'utf8') + '\n' + specifiers.map((specifier, index) => `import type * as entry${index} from ${JSON.stringify(specifier)};\nexport type Entry${index} = typeof entry${index};`).join('\n')); + runConsumer(process.execPath, [join(consumer, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.contracts.json'], consumer); + await prepareRuntimeConsumer(root, consumer, 'angular'); console.log(runConsumer(process.execPath, angularBuildCommand(consumer), consumer)); const stats = JSON.parse(readFileSync(join(consumer, 'dist/consumer/stats.json'), 'utf8')); assertParserFreeInputs(stats.inputs); if (!Object.keys(stats.inputs).some((path) => path.includes('node_modules/@threadplane/angular/'))) throw new Error('Angular stats did not include the installed APF artifact'); - console.log(`Angular root bundle: ${Object.keys(stats.inputs).length} inputs, no content parsers. Threadplane inputs: ${Object.keys(stats.inputs).filter((path) => path.includes('node_modules/@threadplane/')).join(', ')}.`); - console.log(`Verified ${specifiers.length} Angular APF exports through CLI compilation/linking with skipLibCheck:false. Empty scaffold only; no runtime behavior is claimed.`); + console.log(`Angular runtime consumer bundle (app, binding and staged SDK): ${Object.keys(stats.inputs).length} inputs, no content parsers. Threadplane inputs: ${Object.keys(stats.inputs).filter((path) => path.includes('node_modules/@threadplane/')).join(', ')}.`); + await runRuntimeScenarios(join(consumer, 'dist/consumer/browser'), 'Angular'); + console.log(`Verified ${specifiers.length} Angular APF exports through CLI compilation/linking with skipLibCheck:false, precise heterogeneous tool contracts, and a production-built installed Angular consumer.`); } finally { rmSync(temporary, { recursive: true, force: true }); } } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) verifyAngularPackage(); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await verifyAngularPackage(); diff --git a/scripts/react-parity/verify-angular-package.spec.mjs b/scripts/react-parity/verify-angular-package.spec.mjs index 5fe46d860..fb4054842 100644 --- a/scripts/react-parity/verify-angular-package.spec.mjs +++ b/scripts/react-parity/verify-angular-package.spec.mjs @@ -28,14 +28,6 @@ test('Angular consumer uses installed CLI build with actual bundle stats, never assert.equal(typeof angularVerifier.angularBuildCommand, 'function'); assert.deepEqual(angularVerifier.angularBuildCommand('/tmp/consumer'), ['/tmp/consumer/node_modules/@angular/cli/bin/ng.js', 'build', '--configuration=production', '--stats-json']); }); -test('Angular app imports every supported executable entry and counts namespace exports', () => { - assert.equal(typeof angularVerifier.angularConsumerSource, 'function'); - const template = "import * as angular from '@threadplane/angular';\n/* PACKAGE_IMPORTS */\nconst count = Object.keys(angular).length /* PACKAGE_EXPORT_COUNT */;"; - const source = angularVerifier.angularConsumerSource(template, ['@threadplane/angular', '@threadplane/angular/tools']); - assert.match(source, /from '@threadplane\/angular'/); - assert.match(source, /from "@threadplane\/angular\/tools"/); - assert.match(source, /Object.keys\(entry0\).length/); -}); test('Angular fixture uses strict declarations and an application builder', () => { const base = 'fixtures/react-parity/consumers/angular/'; const config = JSON.parse(readFileSync(`${base}tsconfig.json`, 'utf8')); diff --git a/scripts/react-parity/verify-boundaries.mjs b/scripts/react-parity/verify-boundaries.mjs index b4a025725..018cb4fdf 100644 --- a/scripts/react-parity/verify-boundaries.mjs +++ b/scripts/react-parity/verify-boundaries.mjs @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import { angularTransitionProjects, assertFinalRelease, emittedEntries, forbiddenDependency, manifestViolations, packageOf, privateScaffoldProjects, scanProjects, sourceEntry } from './package-policy.mjs'; +import { angularTransitionProjects, assertFinalRelease, emittedEntries, forbiddenDependency, manifestViolations, neutralLangGraphRoots, packageOf, privateScaffoldProjects, scanProjects, sourceEntry } from './package-policy.mjs'; export const foundationProjects = privateScaffoldProjects; const optional = /(?:^|\/)(?:testing|zod|math)(?:\/|$)/; @@ -88,8 +88,8 @@ export function verifyBoundaries({ root = process.cwd(), mode = 'source', projec const browserEntries = mode === 'built' ? emittedEntries(manifest, './browser').map((value) => join(directory, value)) : []; const browserPath = (path) => project === 'telemetry' && telemetryBrowserTransition && (path.startsWith(join(directory, mode === 'source' ? 'src/browser' : 'browser') + '/') || browserEntries.includes(path)); const visited = new Set(); - function visit(path, rootRuntime, ancestry = [], browserTransition = false) { - const key = `${path}:${rootRuntime}:${browserTransition}`; + function visit(path, rootRuntime, ancestry = [], browserTransition = false, neutralRuntime = false) { + const key = `${path}:${rootRuntime}:${browserTransition}:${neutralRuntime}`; if (visited.has(key)) return; visited.add(key); if (!existsSync(path)) { errors.add(`${project}: unresolved ${relative(root, path)}`); return; } @@ -100,19 +100,32 @@ export function verifyBoundaries({ root = process.cwd(), mode = 'source', projec const targetProject = target && projectOf(target); const normalized = targetProject ? `@threadplane/${targetProject}` : specifier; const trail = [...ancestry, relative(root, path), specifier].join(' -> '); - const policy = { angularTransitions, rootRuntime, browserTransition: browserTransition && browserPath(path) }; - if (forbiddenDependency(project, specifier, policy) || forbiddenDependency(project, normalized, policy)) errors.add(`${project}: forbidden dependency ${trail}`); + const policy = { angularTransitions, rootRuntime, neutralRuntime, browserTransition: browserTransition && browserPath(path) }; + const label = neutralRuntime ? 'neutral runtime forbidden dependency' : 'forbidden dependency'; + if (forbiddenDependency(project, specifier, policy) || forbiddenDependency(project, normalized, policy)) errors.add(`${project}: ${label} ${trail}`); + if (neutralRuntime && ( + optional.test(specifier) || (target && optional.test(relative(directory, target))) || + (specifier.startsWith('@threadplane/core/') && specifier !== '@threadplane/core/tools') || + (targetProject === 'core' && projectOf(path) !== 'core' && !['@threadplane/core', '@threadplane/core/tools'].includes(specifier)) + )) errors.add(`${project}: neutral runtime private/testing dependency ${trail}`); // Every core entry is dependency-free. Explicitly // review any future external dependency instead of allowing a wrapper // package to hide a framework/parser dependency behind its own imports. if (project === 'core' && (!target || target.includes('/node_modules/')) && !specifier.startsWith('.')) errors.add(`${project}: unreviewed dependency ${trail}`); if (rootRuntime && (optional.test(specifier) || ['zod', 'katex'].includes(packageOf(specifier)) || (target && optional.test(relative(directory, target))))) errors.add(`${project}: optional/testing dependency reachable from root: ${trail}`); if (project === 'react' && rootRuntime && ((specifier.startsWith('@threadplane/react/') && reactFeature.test(specifier.slice('@threadplane/react/'.length))) || (targetProject === 'react' && reactFeature.test(relative(join(directory, 'src'), target))))) errors.add(`${project}: feature dependency reachable from root: ${trail}`); - if (target && !target.includes('/node_modules/')) visit(target, rootRuntime, [...ancestry, relative(root, path)], browserTransition && browserPath(target)); + if (target && !target.includes('/node_modules/')) visit(target, rootRuntime, [...ancestry, relative(root, path)], browserTransition && browserPath(target), neutralRuntime); else if (!target && (specifier.startsWith('.') || specifier.startsWith('@threadplane/'))) errors.add(`${project}: unresolved dependency ${trail}`); } } for (const path of allFiles) visit(path, false, [], browserPath(path)); + if (mode === 'source' && project === 'langgraph') { + const neutralRoots = [ + ...filesIn(join(directory, 'src/runtime')).filter((path) => !optional.test(relative(directory, path))), + ...neutralLangGraphRoots.map((path) => join(directory, path)).filter(existsSync), + ]; + for (const path of neutralRoots) visit(path, false, [], false, true); + } if (!angularTransitions.includes(project)) { if (!roots.length) errors.add(`${project}: missing root exports`); for (const path of roots) visit(path, true); diff --git a/scripts/react-parity/verify-boundaries.spec.mjs b/scripts/react-parity/verify-boundaries.spec.mjs index 135b56e0b..8d930f263 100644 --- a/scripts/react-parity/verify-boundaries.spec.mjs +++ b/scripts/react-parity/verify-boundaries.spec.mjs @@ -322,3 +322,41 @@ for (const extension of ['mjs', 'd.ts']) { assert.ok(verifyBoundaries({ root, mode: 'built', projects: ['chat'] }).some((error) => error.includes('forbidden dependency') && error.includes('react'))); }); } + +for (const dependency of ['@angular/core', '@threadplane/chat', '@threadplane/telemetry', '@threadplane/render', 'react']) { + test(`neutral runtime rejects transitive ${dependency} despite legacy visits`, (t) => { + const root = fixture(t, { + 'libs/langgraph/src/public-api.ts': "export * from './lib/shared.js';", + 'libs/langgraph/src/lib/shared.ts': `export type { X } from '${dependency}';`, + 'libs/langgraph/src/runtime/transport.ts': "export type { X } from '../lib/shared.js';", + }); + assert.ok(verifyBoundaries({ root, projects: ['langgraph'] }).some((error) => error.includes('neutral runtime') && error.includes(dependency))); + }); +} + +test('neutral runtime permits public core and SDK but excludes private core and testing edges', (t) => { + const root = fixture(t, { + 'tsconfig.base.json': JSON.stringify({ compilerOptions: { paths: { '@threadplane/core': ['./libs/core/src/index.ts'] } } }), + 'libs/langgraph/src/public-api.ts': "export type { Signal } from '@angular/core';", + 'libs/langgraph/src/runtime/transport.ts': "export type { X } from '@threadplane/core'; export type { Client } from '@langchain/langgraph-sdk';", + 'libs/langgraph/src/runtime/testing/controlled.ts': 'export type T = string;', + 'libs/langgraph/src/runtime/transport.spec.ts': "import '@angular/core'; import './testing/controlled';", + 'libs/core/src/index.ts': 'export type X = string;', + 'libs/core/src/private.ts': 'export type X = string;', + }); + assert.deepEqual(verifyBoundaries({ root, projects: ['langgraph'] }), []); + for (const edge of ['../../../core/src/private.js', './testing/controlled.js', '@threadplane/core/private']) { + writeFileSync(join(root, 'libs/langgraph/src/runtime/transport.ts'), `export type { X } from '${edge}';`); + assert.ok(verifyBoundaries({ root, projects: ['langgraph'] }).some((error) => error.includes('neutral runtime') && error.includes(edge))); + } +}); + +for (const dependency of ['@langchain/langgraph-sdk/react', '@langchain/langgraph-sdk/react-ui', '@langchain/langgraph-sdk/react-ui/server']) { + test(`strict transport source root rejects ${dependency}`, (t) => { + const root = fixture(t, { + 'libs/langgraph/src/public-api.ts': "export * from './lib/transport/fetch-stream.transport.js';", + 'libs/langgraph/src/lib/transport/fetch-stream.transport.ts': `export type { X } from '${dependency}';`, + }); + assert.ok(verifyBoundaries({ root, projects: ['langgraph'] }).some((error) => error.includes('neutral runtime') && error.includes(dependency))); + }); +} diff --git a/scripts/react-parity/verify-packages.mjs b/scripts/react-parity/verify-packages.mjs index a4b93c56d..8a0c2db14 100644 --- a/scripts/react-parity/verify-packages.mjs +++ b/scripts/react-parity/verify-packages.mjs @@ -7,6 +7,7 @@ import ts from 'typescript'; import { buildSync } from 'esbuild'; import { satisfies } from 'semver'; import { angularTransitionProjects, emittedEntries, manifestViolations, privateScaffoldProjects, scanProjects } from './package-policy.mjs'; +import { lockedReactManifest, prepareInstalledTypes, prepareRuntimeConsumer, runRuntimeScenarios } from './runtime-consumer.mjs'; function filesIn(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? filesIn(join(directory, entry.name)) : [join(directory, entry.name)]); @@ -166,15 +167,20 @@ export function assertParserFreeInputs(inputs) { function verifyPlainExports(root, consumer, projects) { const specifiers = projects.flatMap((project) => consumerSpecifiers(JSON.parse(readFileSync(join(consumer, 'node_modules/@threadplane', project, 'package.json'), 'utf8')))); - writeFileSync(join(consumer, 'index.mjs'), specifiers.map((specifier) => `await import(${JSON.stringify(specifier)});`).join('\n')); + writeFileSync(join(consumer, 'index.mjs'), `${assertSupportedExports.toString()}\n` + specifiers.map((specifier) => `assertSupportedExports(${JSON.stringify(specifier.slice('@threadplane/'.length))}, await import(${JSON.stringify(specifier)}));`).join('\n')); writeFileSync(join(consumer, 'index.ts'), specifiers.map((specifier, index) => `import * as entry${index} from ${JSON.stringify(specifier)};\nexport type Entry${index} = typeof entry${index};`).join('\n')); - writeFileSync(join(consumer, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', lib: ['ES2022'], types: [], strict: true, skipLibCheck: false, noEmit: true }, files: ['index.ts'] })); + writeFileSync(join(consumer, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', lib: ['ES2022', 'DOM'], types: [], strict: true, skipLibCheck: false, noEmit: true }, files: ['index.ts'] })); runConsumer(process.execPath, ['index.mjs'], consumer); runConsumer(process.execPath, [join(root, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.json'], consumer); return specifiers.length; } -export function verifyPackedConsumers(root = process.cwd()) { +export function assertSupportedExports(project, entry) { + const expected = { core: ['completeDelivery', 'streamingDelivery', 'staticDelivery', 'projectAgentError'], react: ['useAgent'] }[project] ?? []; + for (const name of expected) if (typeof entry[name] !== 'function') throw new Error(`${project} missing supported contract ${name}`); +} + +export async function verifyPackedConsumers(root = process.cwd()) { root = resolve(root); const temporary = mkdtempSync(join(tmpdir(), 'threadplane-consumer-')); try { @@ -184,15 +190,22 @@ export function verifyPackedConsumers(root = process.cwd()) { mkdirSync(core); installConsumer(core, { private: true, type: 'module' }, { '@threadplane/core': tarballs['@threadplane/core'] }, 'core'); const coreCount = verifyPlainExports(root, core, ['core']); + prepareInstalledTypes(root, core, 'core'); + runConsumer(process.execPath, [join(root, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.contracts.json'], core); const plain = join(temporary, 'plain-consumer'); mkdirSync(plain); - installConsumer(plain, { private: true, type: 'module' }, tarballs, 'plain'); + installConsumer(plain, lockedReactManifest(JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'))), tarballs, 'plain'); const count = verifyPlainExports(root, plain, Object.keys(tarballs).map((name) => name.slice('@threadplane/'.length))); writeFileSync(join(plain, 'react-root.mjs'), "import * as react from '@threadplane/react';\nconsole.log(Object.keys(react));\n"); const bundle = buildSync({ absWorkingDir: plain, entryPoints: [join(plain, 'react-root.mjs')], bundle: true, platform: 'browser', format: 'esm', write: false, metafile: true }); assertParserFreeInputs(bundle.metafile.inputs); - console.log(`React root bundle: ${Object.keys(bundle.metafile.inputs).length} inputs, ${bundle.outputFiles[0].contents.length} bytes, no content parsers. Inputs: ${Object.keys(bundle.metafile.inputs).join(', ')}.`); - console.log(`Verified ${Object.keys(tarballs).length} private plain tarballs, ${count} ESM/type exports, and ${coreCount} isolated core exports with skipLibCheck:false. These are empty scaffolds; no framework runtime behavior is claimed.`); + console.log(`React development root-import probe (unminified, separate from the production app): ${Object.keys(bundle.metafile.inputs).length} inputs, ${bundle.outputFiles[0].contents.length} bytes, no content parsers. Inputs: ${Object.keys(bundle.metafile.inputs).join(', ')}.`); + prepareInstalledTypes(root, plain, 'react'); + await prepareRuntimeConsumer(root, plain, 'react'); + for (const config of ['tsconfig.contracts.json', 'tsconfig.app.json']) runConsumer(process.execPath, [join(plain, 'node_modules/typescript/bin/tsc'), '-p', config], plain); + console.log(runConsumer(process.execPath, [join(plain, 'node_modules/vite/bin/vite.js'), 'build'], plain)); + await runRuntimeScenarios(join(plain, 'dist'), 'React'); + console.log(`Verified ${Object.keys(tarballs).length} private plain tarballs, ${count} ESM/type exports, and ${coreCount} isolated core exports with skipLibCheck:false, precise heterogeneous tool contracts, and a production-built installed React consumer.`); } finally { rmSync(temporary, { recursive: true, force: true }); } } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) verifyPackedConsumers(); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await verifyPackedConsumers(); diff --git a/scripts/react-parity/verify-packages.spec.mjs b/scripts/react-parity/verify-packages.spec.mjs index 718602871..99f99d133 100644 --- a/scripts/react-parity/verify-packages.spec.mjs +++ b/scripts/react-parity/verify-packages.spec.mjs @@ -6,6 +6,13 @@ import test from 'node:test'; import * as packageVerifier from './verify-packages.mjs'; const { validatePackage } = packageVerifier; +test('installed native contracts reject the former empty scaffold exports', () => { + assert.equal(typeof packageVerifier.assertSupportedExports, 'function'); + assert.throws(() => packageVerifier.assertSupportedExports('core', {}), /completeDelivery/); + assert.throws(() => packageVerifier.assertSupportedExports('react', {}), /useAgent/); + assert.doesNotThrow(() => packageVerifier.assertSupportedExports('react', { useAgent: () => undefined })); +}); + function fixture(t, change = {}) { const directory = mkdtempSync(join(tmpdir(), 'threadplane-package-')); t.after(() => rmSync(directory, { recursive: true, force: true }));