[] = [];
+const injectors: EnvironmentInjector[] = [];
+const SESSION = new InjectionToken<
+ ReturnType['session']
+>('test session');
+
+@Component({
+ selector: 'test-agent',
+ template: `
+ Send
+ Stop
+ {{ 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