From dac10309cfb1d8d41ea219b24616e2b1d1858e6b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 22 Sep 2026 16:55:33 -0700 Subject: [PATCH] test(runtime): prove application-owned thread session lifetime --- fixtures/react-parity/runtime/README.md | 46 ++- .../react-parity/runtime/angular-threads.ts | 153 ++++++++++ fixtures/react-parity/runtime/evidence.json | 261 +++++++++--------- .../react-parity/runtime/react-threads.tsx | 129 +++++++++ fixtures/react-parity/runtime/thread-owner.ts | 45 +++ libs/angular/src/observe-agent.spec.ts | 29 ++ .../src/runtime/thread-lifetime.spec.ts | 210 ++++++++++++++ libs/react/src/use-agent.spec.tsx | 35 +++ scripts/react-parity/review-runtime.mjs | 3 +- scripts/react-parity/runtime-consumer.mjs | 17 +- scripts/react-parity/thread-lifetime.mjs | 153 ++++++++++ scripts/react-parity/thread-lifetime.spec.mjs | 100 +++++++ 12 files changed, 1046 insertions(+), 135 deletions(-) create mode 100644 fixtures/react-parity/runtime/angular-threads.ts create mode 100644 fixtures/react-parity/runtime/react-threads.tsx create mode 100644 fixtures/react-parity/runtime/thread-owner.ts create mode 100644 libs/langgraph/src/runtime/thread-lifetime.spec.ts create mode 100644 scripts/react-parity/thread-lifetime.mjs create mode 100644 scripts/react-parity/thread-lifetime.spec.mjs diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md index ae1fdcea5..f47445811 100644 --- a/fixtures/react-parity/runtime/README.md +++ b/fixtures/react-parity/runtime/README.md @@ -1,5 +1,37 @@ # Installed native runtime consumers +## Thread lifetime + +Open `/?threads` on either review URL for the separate conversation-selection +workflow. Both apps use a fixed-thread session for each selected conversation. +The application selects a new session and explicitly disposes the outgoing one; +selecting the same thread is a no-op. Returning to an old thread creates a fresh +local session. Selection performs no history read, submission or server thread +creation. Click **Load selected** to observe persisted data. + +React borrows the selected session through `useAgent`. A keyed view also resets +its local command feedback. Angular keys a child component by selection identity +and calls `observeAgent` in that component's injection context once its required +input is available. Destroying the component releases the subscription. The +application owns disposal, independently of view destruction. Binding tests also +cover retaining the old session and reattaching to its completed run. + +Follow the thirteen-action sequence shown in the app. The wire fixture requires +four history POSTs and two run POSTs. Selecting B during A's stream aborts local +observation; returning to A starts empty. A's second history request deliberately +stays pending until selecting B disposes it. Each new session starts with no +observed values or history. Disposal permanently closes this example's owner. +The existing main review sequence remains available at `/` on the same server. + +The selection owner is fixture-only application code, not a public library +manager, cache or router. URL selection can feed an application's selected ID, +but URL synchronization and thread creation/list CRUD remain migration work. +Disposal stops local ownership, not remote execution or already-started side +effects. Runtime tests cover late history, concurrent sessions and a late durable +tool claim settled under its retired thread. Checkpoint execution is separate +from thread selection. No core, runtime or native binding API changes are added +by this proof. + ## Run configuration The private LangGraph session accepts readonly `config`, `context`, `metadata` @@ -218,7 +250,7 @@ are missing. `node scripts/react-parity/review-runtime.mjs --help` prints the prerequisite and review sequence. The runner packs those artifacts, installs and strictly type-checks isolated -React and Angular consumers, builds each app, and runs all fifteen browser +React and Angular consumers, builds each app, and runs all twenty-one browser scenarios on fresh fixture servers. Only after those checks pass does it print two new, untouched loopback URLs. Open each URL manually; no browser opens automatically. The review servers have made no SDK requests at that point. @@ -404,14 +436,18 @@ React uses a Vite production build. Angular uses the existing consumer template' installed Angular CLI application builder and real APF linking, with output in `dist/consumer/browser` and input evidence from `dist/consumer/stats.json`. -Both built apps run the same fifteen browser scenarios in installed Playwright +Both built apps run the same twenty-one browser scenarios in installed Playwright Chromium: inert mount, explicit history load, equal history refresh, empty history replacement, successful text, a real local tool handler and exact two-request result continuation, protected visible server error, held streaming DOM updates and Stop, the full pause batch retained after Stop, an explicit response map and second pause, same-message resume completion, known-run premature EOF, explicit cursor join with exact-run completion, reuse after Stop, -then unmount/dispose/post-disposal commands. +then unmount/dispose/post-disposal commands. The separate thread view adds six +scenario groups covering explicit selection/load, switching during streaming, +same-thread identity, a fresh session on return, switching during history and +permanent owner disposal. Its four history and two run POSTs are counted +separately from the main workflow below. Seven submissions and two resumes through the component controls make exactly ten run POSTs (including one tool continuation) and call the handler once. One explicit reconnect makes one join GET; the original Drop EOF and joined EOF each require @@ -425,8 +461,8 @@ the catalog and actual serialized ToolMessage payload. Values assertions distinguish unobserved from empty state, show loaded application fields, and verify replacement/deletion across root, tool, held and reused runs. Separate native component tests make four history reads to cover a values-only -refresh with unchanged messages and interrupts; installed browser scenarios still -make three. History fixtures contain two separate task payloads and show paused +refresh with unchanged messages and interrupts; the main installed workflow +makes three. History fixtures contain two separate task payloads and show paused delivery. The Pause button sends two separate root controls and renders both payloads; Stop retains them without another request. Resume clears the old batch and observes a new pause, then a second explicit resume completes without adding diff --git a/fixtures/react-parity/runtime/angular-threads.ts b/fixtures/react-parity/runtime/angular-threads.ts new file mode 100644 index 000000000..d06540f84 --- /dev/null +++ b/fixtures/react-parity/runtime/angular-threads.ts @@ -0,0 +1,153 @@ +import { + Component, + inject, + Injector, + input, + runInInjectionContext, + signal, + type OnInit, + type Signal, +} from '@angular/core'; +import { observeAgent } from '@threadplane/angular'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { createFixtureSession } from './runtime-entry.js'; +import { + createThreadOwner, + threadInstructions, + type ThreadSelection, +} from './thread-owner'; +import { display, type FixtureSnapshot } from './scenarios'; + +// The application owns execution outside component initialization/destruction. +const owner = createThreadOwner((id) => createFixtureSession('/api', id)); + +@Component({ + selector: 'thread-view', + template: ` +
+

Selected conversation

+
+ + +
+
+
+

Thread

+ {{ selected().id }} +
+
+

Session generation

+ {{ + selected().generation + }} +
+
+

Status

+ {{ snapshot().status }} +
+
+

History request

+ {{ load() }} +
+
+

Run outcome

+ {{ outcome() }} +
+
+

Transcript

+ {{ view().transcript }} +
+
+

Values

+ {{ view().values }} +
+
+

Checkpoint history

+ {{ view().history }} +
+
+
+ `, +}) +export class ThreadView implements OnInit { + readonly selected = input.required(); + private readonly injector = inject(Injector); + snapshot!: Signal; + readonly load = signal('unobserved'); + readonly outcome = signal(''); + // This component is keyed by selection identity. Its input never changes; + // destruction releases observeAgent through this component's DestroyRef. + ngOnInit() { + this.snapshot = runInInjectionContext(this.injector, () => + observeAgent(this.selected().session) + ); + } + readonly view = () => display(this.snapshot()); + async loadSelected() { + const selected = this.selected(); + this.load.set('loading'); + try { + await selected.session.load?.(); + this.load.set('loaded'); + } catch { + this.load.set('error'); + } + } + async run() { + const selected = this.selected(); + this.outcome.set('running'); + try { + this.outcome.set( + await selected.session.submit( + selected.id === 'thread-a' ? 'Hold A' : 'Send B' + ) + ); + } catch { + this.outcome.set('error'); + } + } +} + +@Component({ + selector: 'app-root', + imports: [ThreadView], + template: ` +
+
+

Installed package review · Angular

+

Thread lifetime

+
+
+

Review sequence

+

{{ instructions }}

+
+
+

Application selection

+
+ + + +
+

Owner

+ {{ state() }} +
+ @for (entry of [selected()]; track entry) { + + } +
+ `, +}) +export class ThreadApp { + readonly instructions = threadInstructions; + readonly selected = signal(owner.selected); + readonly state = signal('active'); + select(id: ThreadSelection['id']) { + this.selected.set(owner.select(id)); + } + async dispose() { + await owner.dispose(); + this.state.set('disposed'); + } +} + +void bootstrapApplication(ThreadApp); diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index 66308ba44..efebd7da7 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -1,14 +1,14 @@ { "schemaVersion": 1, "status": "verified-local", - "increment": "Private LangGraph owned run options (O01/O02/O03)", + "increment": "Application-owned fixed-thread session lifetime (L01/L02/L03)", "observedOn": "2026-09-22", - "recordedAt": "2026-09-22T22:17:23.533Z", + "recordedAt": "2026-09-22T23:55:08.501Z", "source": { - "branch": "codex/langgraph-run-options", - "baseCommit": "56cd7b799ee98c544ff670f868762dd87a8b6787", - "verificationHead": "28a03729acf3cea0ba29586b8a9c0a333deee0da", - "workingTree": "Verified working-tree changes on the reviewed #1126 head; predecessor CI is reported separately. Selected-byte fingerprint identifies actual source, not a future commit. Foundation artifacts were freshly built; tarball hashes identify installed native/core artifacts and private runtime source was separately emitted/bundled for each consumer.", + "branch": "codex/thread-session-lifetime", + "baseCommit": "eabfc71a5bab77eb4ce140b9c143cd6e30241b7c", + "verificationHead": "eabfc71a5bab77eb4ce140b9c143cd6e30241b7c", + "workingTree": "Verified working-tree increment on merged #1127, fast-forwarded to #1128 main. The four webhook integration files do not change runtime/native/fixture bytes and their 68 affected tests passed separately. Selected-byte fingerprint identifies actual source, not a future commit. Foundation artifacts were freshly built and their hashes identify installed bytes. Private runtime source is separately emitted/bundled for each consumer.", "fingerprint": { "algorithm": "SHA-256 of a UTF-8 manifest: one line per selected file, lowercase SHA-256(file bytes), two ASCII spaces, repo-relative path, LF; unique paths sorted by JavaScript default string ordering.", "pathspecs": [ @@ -42,31 +42,26 @@ "excludedPaths": [ "fixtures/react-parity/runtime/evidence.json" ], - "fileCount": 807, - "sha256": "40ed0416736a45b4e259e364bbdc6f5f9cba9fe8f9b1fcf642a247f36bee64a6", + "fileCount": 813, + "sha256": "b0023cc2b7e56a077920edfffb29ff953eff4f579a644746f3bfa24267d11ae8", "selection": "git ls-files -z --cached --others --exclude-standard -- ; existing files minus excludedPaths, including new tests. Local research/planning files are outside these paths. Evidence excludes itself to avoid self-reference.", "reproduce": "node --input-type=module <<'JS'\nimport {createHash} from 'node:crypto';\nimport {execFileSync} from 'node:child_process';\nimport {readFileSync,existsSync} from 'node:fs';\nconst {fingerprint:f}=JSON.parse(readFileSync('fixtures/react-parity/runtime/evidence.json')).source;\nconst sha=value=>createHash('sha256').update(value).digest('hex');\nconst paths=[...new Set(execFileSync('git',['ls-files','-z','--cached','--others','--exclude-standard','--',...f.pathspecs],{encoding:'utf8'}).split('\\0').filter(Boolean))].filter(path=>!f.excludedPaths.includes(path)&&existsSync(path)).sort();\nconst actual=sha(paths.map(path=>sha(readFileSync(path))+' '+path+'\\n').join(''));\nif(paths.length!==f.fileCount||actual!==f.sha256) throw new Error('Source fingerprint mismatch');\nconsole.log(paths.length+' files: '+actual);\nJS" }, "sourceState": { "modified": [ "fixtures/react-parity/runtime/README.md", - "fixtures/react-parity/runtime/angular-app.ts", - "fixtures/react-parity/runtime/react-app.tsx", - "fixtures/react-parity/runtime/runtime-entry.ts", - "fixtures/react-parity/runtime/scenarios.ts", - "libs/langgraph/src/runtime/create-session.ts", - "libs/langgraph/src/runtime/reconnect.spec.ts", - "libs/langgraph/src/runtime/submit-input.type-test.ts", - "libs/langgraph/src/runtime/transport.integration.spec.ts", - "scripts/react-parity/baseline.json", - "scripts/react-parity/dispositions.json", - "scripts/react-parity/runtime-consumer.mjs", - "scripts/react-parity/runtime-consumer.spec.mjs" + "libs/angular/src/observe-agent.spec.ts", + "libs/react/src/use-agent.spec.tsx", + "scripts/react-parity/review-runtime.mjs", + "scripts/react-parity/runtime-consumer.mjs" ], "untracked": [ - "libs/langgraph/src/runtime/run-options.spec.ts", - "libs/langgraph/src/runtime/run-options.ts", - "libs/langgraph/src/runtime/run-options.type-test.ts" + "fixtures/react-parity/runtime/angular-threads.ts", + "fixtures/react-parity/runtime/react-threads.tsx", + "fixtures/react-parity/runtime/thread-owner.ts", + "libs/langgraph/src/runtime/thread-lifetime.spec.ts", + "scripts/react-parity/thread-lifetime.mjs", + "scripts/react-parity/thread-lifetime.spec.mjs" ] } }, @@ -101,91 +96,102 @@ { "command": "NX_DAEMON=false npx nx run-many -t runtime-quality,runtime-type-tests,lint -p langgraph --skip-nx-cache", "exitCode": 0, - "testsPassed": 574, - "testFiles": 24, + "testsPassed": 578, + "testFiles": 25, "lintErrors": 0, "lintWarnings": 68, - "log": "/tmp/ro03-runtime.log", - "logSha256": "a8c8e223921db5d0b0bc097494fec991dc8a78fb536f663674f3ad9d8971d9f3" + "log": "/tmp/tl04-runtime.log", + "logSha256": "615fd839adb6fd4d4e8b8f1d160830141b2a1fb0e729ac1c85cceae45236f6bd" }, { "command": "node --test scripts/react-parity/*.spec.mjs fixtures/react-parity/traces.spec.mjs scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs", "exitCode": 0, - "testsPassed": 439, - "log": "/tmp/ro04-infrastructure.log", - "logSha256": "805e8ad1fd84faceb68a9a43c9c6e8d06253bee3eb378b61f7bf036239c16871" + "testsPassed": 442, + "log": "/tmp/tl04-infrastructure.log", + "logSha256": "dcdb7e5663aad1b0d58f4ec2e8db4d4eef8bdc143d69fe43c6e23b55321a8049" }, { "command": "NX_DAEMON=false npx nx run-many -t lint,test,type-tests -p core,content,angular,react --parallel=2 --skip-nx-cache", "exitCode": 0, - "log": "/tmp/ro03-foundations.log", - "logSha256": "d14ff294d8dcfc9f721b88c51637a2228161a0a97d66a923ce6624e7ae26fe26" + "log": "/tmp/tl03-foundations.log", + "logSha256": "e32e29c86b8334e5ce2a4be4793ed45b7566fbbad85fc7afeca95f30fc79486d" }, { "command": "NX_DAEMON=false npx nx run-many -t build -p core,content,angular,react --parallel=2 --skip-nx-cache", "exitCode": 0, - "log": "/tmp/ro03-build.log", - "logSha256": "45a6495d56a757f6ce8ed862a0b015c854aa8ae7d57c4608abfeb3114082803f" + "log": "/tmp/tl03-build.log", + "logSha256": "887d742aebdd1a9bde1066d89cff3cf1f9a75cbf2990a56287232fec08a9b9ca" }, { "command": "node scripts/react-parity/verify-packages.mjs", "exitCode": 0, - "browserScenariosPassed": 15, + "browserScenariosPassed": 21, "framework": "React", - "log": "/tmp/ro04-packages.log", - "logSha256": "c0fac067ff100a4304a09a9b612749177cd0ce1fa31420ff9d06c3d8aa9f9ff3" + "log": "/tmp/tl04-packages.log", + "logSha256": "f692bcb3686e367379fda63e343972972fbc48966e84887aab8f603db82bbf2c" }, { "command": "node scripts/react-parity/verify-angular-package.mjs", "exitCode": 0, - "browserScenariosPassed": 15, + "browserScenariosPassed": 21, "framework": "Angular", - "log": "/tmp/ro04-angular-package.log", - "logSha256": "93eb306d0990db17d22d5cedf51136d8616dffba1d48d25addefaa9382b2aa66" + "log": "/tmp/tl03-angular-package.log", + "logSha256": "cc4f66f54ca8e79eb7e031e950f362a5a7bc79b255560da354ca607a951a6ec5" }, { "command": "node scripts/react-parity/review-runtime.mjs", - "exitCode": 0, - "browserScenariosPerFramework": 15, - "termination": "SIGTERM, exit 0; untouched review servers created only after both scenario suites passed.", - "log": "/tmp/ro04-cli.log", - "logSha256": "929f273f67dbc732fee5995537363b3b815f4deea6974cc2f06ab6ceaf5d2d04" + "status": "Both framework builds and 21 scenarios per framework passed; untouched review servers remain running on 50688/50689.", + "log": "/tmp/tl03-cli.log", + "logSha256": "c4b1edb26b4abfbcc2fea446930763ca68ee10d9d8a900784f921a8a83357a74" }, { "command": "node scripts/react-parity/verify-boundaries.mjs", "exitCode": 0, - "log": "/tmp/ro03-source-boundaries.log", + "log": "/tmp/tl03-source-boundaries.log", "logSha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" }, { "command": "node scripts/react-parity/verify-boundaries.mjs --built", "exitCode": 0, - "log": "/tmp/ro03-built-boundaries.log", + "log": "/tmp/tl03-built-boundaries.log", "logSha256": "c983a97d17ff7ced7aa9113afa031f464103f566f1bc94eec8160558ec2553f7" }, { "command": "node scripts/react-parity/inventory.mjs --check", "exitCode": 0, - "log": "/tmp/ro03-inventory-check.log", + "log": "/tmp/tl03-inventory-check.log", "logSha256": "20ee033cb189a88e39e8ff73a6f1c984312a2bdcbfa74e3548bbf7090afe2ae1" + }, + { + "command": "NX_DAEMON=false npx nx test website --testFile=apps/website/src/app/api/webhooks/resend/route.spec.ts --reporter=default --skip-nx-cache", + "exitCode": 0, + "testsPassed": 12, + "log": "/tmp/tl04-main-website.log", + "logSha256": "3474ac4c0796aa489a5f1c7c991fb86cd62d25063bcb152a8a8856b737315c97" + }, + { + "command": "NX_DAEMON=false npx nx test growth --testFile=libs/growth/src/lib/webhooks.spec.ts --reporter=default --skip-nx-cache", + "exitCode": 0, + "testsPassed": 56, + "log": "/tmp/tl04-main-growth.log", + "logSha256": "dbfc05b7d7e200477d6e40fecd185fa59d91d663af6cc45b01b540d7a120b297" } ], "manualBrowserReview": { - "performedBy": "Parent using Chrome DevTools MCP and Codex in-app browser. Each framework/surface used a fresh server and all 20 button actions. DOM assertions and screenshots are recorded in the task tool transcript.", + "performedBy": "Parent using Chrome DevTools MCP and Codex in-app browser. Each framework/surface used a fresh server and thirteen thread-selection button actions; DOM assertions and screenshots appear in the task tool transcript.", "runs": [ { "surface": "chrome", "framework": "react", - "historyPosts": 3, - "runPosts": 10, - "joinGets": 1, - "statusGets": 2, - "submissions": 7, - "resumes": 2, - "reconnects": 1, - "handlerCalls": 1, - "buttonActions": 20, - "finalOwnerOutcome": "aborted", + "threadHistoryPosts": 4, + "threadRunPosts": 2, + "buttonActions": 13, + "localStreamAbort": true, + "localHistoryAbort": true, + "finalThread": "thread-b", + "finalGeneration": 4, + "finalOwner": "disposed", + "finalRunOutcome": "aborted", "consoleWarnings": 0, "consoleErrors": 0, "serverErrors": 0 @@ -193,16 +199,15 @@ { "surface": "chrome", "framework": "angular", - "historyPosts": 3, - "runPosts": 10, - "joinGets": 1, - "statusGets": 2, - "submissions": 7, - "resumes": 2, - "reconnects": 1, - "handlerCalls": 1, - "buttonActions": 20, - "finalOwnerOutcome": "aborted", + "threadHistoryPosts": 4, + "threadRunPosts": 2, + "buttonActions": 13, + "localStreamAbort": true, + "localHistoryAbort": true, + "finalThread": "thread-b", + "finalGeneration": 4, + "finalOwner": "disposed", + "finalRunOutcome": "aborted", "consoleWarnings": 0, "consoleErrors": 0, "serverErrors": 0 @@ -210,16 +215,15 @@ { "surface": "iab", "framework": "react", - "historyPosts": 3, - "runPosts": 10, - "joinGets": 1, - "statusGets": 2, - "submissions": 7, - "resumes": 2, - "reconnects": 1, - "handlerCalls": 1, - "buttonActions": 20, - "finalOwnerOutcome": "aborted", + "threadHistoryPosts": 4, + "threadRunPosts": 2, + "buttonActions": 13, + "localStreamAbort": true, + "localHistoryAbort": true, + "finalThread": "thread-b", + "finalGeneration": 4, + "finalOwner": "disposed", + "finalRunOutcome": "aborted", "consoleWarnings": 0, "consoleErrors": 0, "serverErrors": 0 @@ -227,42 +231,40 @@ { "surface": "iab", "framework": "angular", - "historyPosts": 3, - "runPosts": 10, - "joinGets": 1, - "statusGets": 2, - "submissions": 7, - "resumes": 2, - "reconnects": 1, - "handlerCalls": 1, - "buttonActions": 20, - "finalOwnerOutcome": "aborted", + "threadHistoryPosts": 4, + "threadRunPosts": 2, + "buttonActions": 13, + "localStreamAbort": true, + "localHistoryAbort": true, + "finalThread": "thread-b", + "finalGeneration": 4, + "finalOwner": "disposed", + "finalRunOutcome": "aborted", "consoleWarnings": 0, "consoleErrors": 0, "serverErrors": 0 } ], "checks": [ - "exact configuration/context/metadata on Tool, tool continuation, both explicit resumes and Drop", - "execution settings absent from later independent Send and other unconfigured submissions", - "three explicit history loads with equal refresh and empty replacement", - "application state only on initial Tool and Drop, never replayed on continuation", - "separate same-ID child namespaces, protected child failure and no child tool execution", - "held root/child output and local Stop", - "both pause batches, explicit response maps and same-message resume completion", - "retained anonymous root/child identities through exact cursor join without another POST", - "unmount, disposal and three inert post-disposal commands" + "initial idle session with unobserved history and no SDK I/O", + "explicit A history and held incremental stream", + "select B disposes outgoing A observation and starts empty", + "explicit B load and completed run", + "same-thread selection retains generation and transcript", + "returning to A creates a fresh empty session", + "switching away from pending history aborts it and preserves fresh B state", + "permanent owner disposal prevents later selection or run admission" ], - "visual": "React and Angular observation panels inspected in IAB at its default desktop viewport. Both Chrome apps inspected at 390px; document width equaled viewport width for each. Chrome network lists contained only expected local assets and fixture requests.", - "toolingNote": "Contributor fixtures verify observation and execution contracts, not production UI, SSR or compositor/performance budgets.", + "visual": "Both frameworks inspected in IAB at desktop size and Chrome at 390px. Document width equaled viewport width in both mobile views. Chrome network lists contained six local assets and six expected SDK requests per page; pending history was ERR_ABORTED.", + "limits": "Only the new thread workflow was repeated manually in this increment. All 21 main/thread scenarios ran in both installed Playwright consumers and the review CLI. No SSR, live backend or performance-budget claim.", "logs": [ { - "log": "/tmp/ro04-browser-verification.json", - "logSha256": "e4151e8bb4eda847c0f2e29fed8d67a62b8f2d72e66307698f7fde87a27728c2" + "log": "/tmp/tl04-browser-verification.json", + "logSha256": "2a1ee202434276cd1b9a6445635719749ac318acd4582446c2dfcee2e780c0eb" }, { - "log": "/tmp/ro04-browser-requests.json", - "logSha256": "f8c744ddbb3fbca27456e70922257257ffa9411998670288d42e8355440d52a5" + "log": "/tmp/tl04-browser-requests.json", + "logSha256": "3cbbf3dd60358fe26c0d9fd85ae9967b03195f3b84db7b6dba9e48b003e52790" } ] }, @@ -270,34 +272,36 @@ { "framework": "react", "name": "@threadplane/react", - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-51UfI6/react/packed/threadplane-react-0.0.0.tgz", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-KTIhBz/react/packed/threadplane-react-0.0.0.tgz", "sha256": "b71db72347e6ac0777304265d611968232b36e0dffe2171f7fb660f2e8958bc9" }, { "framework": "react", "name": "@threadplane/core", - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-51UfI6/react/packed/threadplane-core-0.0.0.tgz", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-KTIhBz/react/packed/threadplane-core-0.0.0.tgz", "sha256": "651fe687bf2909572eeb543b97b94361a91afbb93c8d74c92ab02cfe092b855c" }, { "framework": "angular", "name": "@threadplane/angular", - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-51UfI6/angular/packed/threadplane-angular-0.0.0.tgz", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-KTIhBz/angular/packed/threadplane-angular-0.0.0.tgz", "sha256": "632956c2279a700c9b8ba4b7762d58a6617b8e9b089776eec5fa8091397e15c5" }, { "framework": "angular", "name": "@threadplane/core", - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-51UfI6/angular/packed/threadplane-core-0.0.0.tgz", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-KTIhBz/angular/packed/threadplane-core-0.0.0.tgz", "sha256": "651fe687bf2909572eeb543b97b94361a91afbb93c8d74c92ab02cfe092b855c" } ], "cleanup": { - "reviewRunnerExitCode": 0, - "temporaryConsumersRemoved": true, - "allSixReviewAndVerificationPortsClosed": true, - "log": "/tmp/ro04-cleanup.log", - "logSha256": "4ef66267335599a4235a18ac035b143c9b6bdf4683b3d31eb48a8f9179f0cb60" + "proofServersExitCode": 0, + "proofPortsClosed": true, + "previousReviewRunnerExitCode": 0, + "previousReviewPortsClosed": true, + "currentReviewServersRetained": true, + "log": "/tmp/tl04-cleanup.log", + "logSha256": "78072ce93894d9e0e3749d722b119299947311cb4246a7950ebbb2736cef4f18" }, "inventory": { "rows": 1461, @@ -305,14 +309,14 @@ "exports": 550, "uniqueExportDefinitions": 514, "assignmentsPreserved": true, - "changes": "All existing dispositions remain equivalent by row; one internal run-options source row added. No public export or whole-task completion." + "changes": "No inventory or disposition changes: new runtime tests and contributor fixtures add no library source/export capability row." }, "scope": [ - "Backend-private readonly per-command configuration, context and metadata on submit/resume", - "Pure supported-field capture, deep ownership and session-owned routing", - "Captured settings retained for tool continuations and reconnect handoff, never graph-input replay", - "Reentrant options getters cannot admit stale commands or displace newer history loads", - "Real SDK wire bodies and installed React/Angular consumers" + "Application-owned fixed-thread session replacement with explicit disposal or retention policy", + "Same-thread no-op and fresh session on return after disposal", + "Native React session replacement and Angular keyed observation scopes", + "Late history, concurrent session isolation, distinct durable tool identities and retired-thread late settlement", + "Actual SDK routing, history/run cancellation and installed native browser proof" ], "runtimePolicy": { "sessionAutomaticReconnect": false, @@ -331,16 +335,21 @@ "childHistoryFabricated": false, "runOptionsInherited": false, "runOptionsOnToolContinuation": true, - "runOptionsInGraphInput": false + "runOptionsInGraphInput": false, + "fixedThreadIdentity": true, + "selectionImplicitIO": false, + "observerOwnsDisposal": false, + "threadOwnerPublicApi": false }, "ci": { "status": "Not yet requested for this working tree; local evidence does not imply CI green." }, "limits": [ - "Run settings have no session defaults or inheritance; callers explicitly supply settings for each independent submit or resume.", - "Checkpoint execution, pagination, thread switching/creation and queue policies remain separate work.", - "Full adapter migration and React component parity remain later milestones; fixture-local declarations and private source composition do not establish a neutral published LangGraph package.", - "Local HTTP fixtures do not certify a live deployment, full SSR or performance budgets." + "Thread creation, list CRUD and actual URL routing integration remain later migration work.", + "Checkpoint execution/pagination, queue policies and rich messages remain separate work.", + "Local disposal does not cancel remote execution or roll back started side effects.", + "Full adapter migration and React component parity remain later milestones; fixture-local declarations/private source composition do not establish a neutral published LangGraph package.", + "Local HTTP fixtures do not certify a live deployment, SSR or performance budgets." ], "warnings": [ "68 existing legacy lint warnings; zero lint errors.", @@ -349,7 +358,7 @@ ], "documentation": { "generatorsRun": [], - "reason": "Only contributor fixture guidance and private runtime sources changed. No public export, narrative, API generator or public agent-context inputs changed." + "reason": "Only contributor fixture guidance, tests and private review infrastructure changed; no public docs/API/context generator inputs changed." }, - "verificationProvenance": "All listed commands were freshly run in this milestone. Native package implementation and public exports are unchanged. Historical foundation evidence remains unchanged. Parent audited source, spec, types, lifecycle and evidence. Independent subagent review was unavailable because thread agent capacity was exhausted. Hosted review output must be inspected separately from job success." + "verificationProvenance": "All listed commands ran in this increment. Runtime and native package production implementation/public exports remain unchanged. Historical foundation evidence remains unchanged. Parent audited spec, source, lifecycle, types, installed behavior and evidence. New independent subagent review was unavailable after earlier thread capacity exhaustion; hosted review must be inspected separately from job success." } diff --git a/fixtures/react-parity/runtime/react-threads.tsx b/fixtures/react-parity/runtime/react-threads.tsx new file mode 100644 index 000000000..cb14860e7 --- /dev/null +++ b/fixtures/react-parity/runtime/react-threads.tsx @@ -0,0 +1,129 @@ +import { StrictMode, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useAgent } from '@threadplane/react'; +import { createFixtureSession } from './runtime-entry.js'; +import { + createThreadOwner, + threadInstructions, + type ThreadSelection, +} from './thread-owner'; +import { display } from './scenarios'; +import './review.css'; + +// The application, outside React/StrictMode, owns execution and selection. +const owner = createThreadOwner((id) => createFixtureSession('/api', id)); + +function ThreadView({ selected }: { selected: ThreadSelection }) { + const snapshot = useAgent(selected.session); + const [load, setLoad] = useState('unobserved'); + const [outcome, setOutcome] = useState(''); + const view = display(snapshot); + const loadSelected = async () => { + setLoad('loading'); + try { + await selected.session.load?.(); + setLoad('loaded'); + } catch { + setLoad('error'); + } + }; + const run = async () => { + setOutcome('running'); + try { + setOutcome( + await selected.session.submit( + selected.id === 'thread-a' ? 'Hold A' : 'Send B' + ) + ); + } catch { + setOutcome('error'); + } + }; + return ( +
+

Selected conversation

+
+ + +
+
+
+

Thread

+ {selected.id} +
+
+

Session generation

+ {selected.generation} +
+
+

Status

+ {snapshot.status} +
+
+

History request

+ {load} +
+
+

Run outcome

+ {outcome} +
+
+

Transcript

+ {view.transcript} +
+
+

Values

+ {view.values} +
+
+

Checkpoint history

+ {view.history} +
+
+
+ ); +} + +export function ThreadApp() { + const [selected, setSelected] = useState(owner.selected); + const [state, setState] = useState('active'); + const dispose = async () => { + await owner.dispose(); + setState('disposed'); + }; + return ( +
+
+

Installed package review · React

+

Thread lifetime

+
+
+

Review sequence

+

{threadInstructions}

+
+
+

Application selection

+
+ + + +
+

Owner

+ {state} +
+ +
+ ); +} + +const container = document.getElementById('root'); +if (!container) throw new Error('Missing fixture root'); +createRoot(container).render( + + + +); diff --git a/fixtures/react-parity/runtime/thread-owner.ts b/fixtures/react-parity/runtime/thread-owner.ts new file mode 100644 index 000000000..88d653ca9 --- /dev/null +++ b/fixtures/react-parity/runtime/thread-owner.ts @@ -0,0 +1,45 @@ +import type { createFixtureSession } from './runtime-entry.js'; + +export type ThreadSelection = Readonly<{ + id: 'thread-a' | 'thread-b'; + generation: number; + session: ReturnType; +}>; + +/** Application policy for this example, not a library switching service. + * Construct outside framework render/setup; observers only borrow its sessions. */ +export function createThreadOwner( + create: (id: string) => ThreadSelection['session'] +) { + let selected: ThreadSelection = Object.freeze({ + id: 'thread-a', + generation: 1, + session: create('thread-a'), + }); + let disposed = false; + return { + get selected() { + return selected; + }, + select(id: ThreadSelection['id']) { + if (disposed || selected.id === id) return selected; + const previous = selected; + selected = Object.freeze({ + id, + generation: previous.generation + 1, + session: create(id), + }); + // Dispose locally before the view changes. This runtime's disposal settles + // ownership without waiting for an uncooperative remote read or tool. + void previous.session.dispose(); + return selected; + }, + dispose() { + disposed = true; + return selected.session.dispose(); + }, + }; +} + +export const threadInstructions = + 'Load selected → Run selected (A stays running) → Select B → Load selected → Run selected → Select B again → Select A → Load selected (stays pending) → Select B → Load selected → Dispose selected → Run selected → Select A. Selection creates a fresh local session, never a server thread or automatic history request. This example disposes outgoing sessions; an application can choose to retain them instead. Restart the review server to repeat.'; diff --git a/libs/angular/src/observe-agent.spec.ts b/libs/angular/src/observe-agent.spec.ts index d73169311..431b6930b 100644 --- a/libs/angular/src/observe-agent.spec.ts +++ b/libs/angular/src/observe-agent.spec.ts @@ -109,6 +109,35 @@ afterEach(async () => { }); describe('observeAgent borrowed session', () => { + it('replaces observation scopes while the application retains the old run', async () => { + const a = fixture(); + const b = fixture(); + const first = observe(a.session); + const run = a.session.submit('A'); + await a.started(); + first.destroy(); + const second = observe(b.session); + expect(second.snapshot()).toBe(b.session.getSnapshot()); + expect(a.session.releases).toBe(a.session.subscriptions); + expect(a.session.stopCalls + a.session.disposeCalls).toBe(0); + expect(b.history.reads).toBe(0); + expect(b.streams).toHaveLength(0); + const selected = second.snapshot(); + a.streams[0].release(finalText('A retained result')); + a.streams[0].finish(); + expect(await run).toBe('success'); + expect(second.snapshot()).toBe(selected); + second.destroy(); + const restored = observe(a.session); + expect(restored.snapshot().messages.at(-1)?.content).toBe( + 'A retained result' + ); + expect(b.session.releases).toBe(b.session.subscriptions); + expect(b.session.disposeCalls).toBe(0); + restored.destroy(); + expect(a.session.releases).toBe(a.session.subscriptions); + }); + it('observes explicit history loads without owning reads, refreshes, or teardown', async () => { const f = fixture(); TestBed.configureTestingModule({ diff --git a/libs/langgraph/src/runtime/thread-lifetime.spec.ts b/libs/langgraph/src/runtime/thread-lifetime.spec.ts new file mode 100644 index 000000000..1e1b872f4 --- /dev/null +++ b/libs/langgraph/src/runtime/thread-lifetime.spec.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ThreadState } from '@langchain/langgraph-sdk'; +import type { ToolExecutionStore } from '@threadplane/core/tools'; +import { setImmediate } from 'node:timers/promises'; +import { createSession } from './create-session'; +import type { AgentTransport, StreamEvent } from './transport.types'; +import { controlledTransport } from './testing/controlled-transport'; +import { deferred } from './testing/deferred'; +import { finalText, weatherCall } from './testing/binding-fixture'; + +describe('application-owned fixed thread lifetime', () => { + it('disposes a pending read without allowing its late result into either session', async () => { + const pending = deferred(); + const started = deferred(); + let signal: AbortSignal | undefined; + const transport: AgentTransport = { + stream: async function* () { + /* no run */ + }, + getHistory: async (thread, abort) => { + expect(thread).toBe('a'); + signal = abort; + started.resolve(); + return pending.promise; + }, + }; + const a = createSession({ assistantId: 'agent', threadId: 'a', transport }); + const b = createSession({ assistantId: 'agent', threadId: 'b', transport }); + const pristineB = b.getSnapshot(); + const reading = a.load?.(); + await started.promise; + await a.dispose(); + await reading; + expect(signal?.aborted).toBe(true); + const finalA = a.getSnapshot(); + pending.resolve([ + { + values: { secret: 'A only', messages: [] }, + next: [], + tasks: [], + metadata: {}, + checkpoint: { + thread_id: 'a', + checkpoint_id: 'old', + checkpoint_ns: '', + checkpoint_map: {}, + }, + parent_checkpoint: null, + created_at: '2026-09-22T00:00:00Z', + }, + ]); + // Let the deliberately noncooperative transport finish its read. + await pending.promise; + await setImmediate(); + expect(a.getSnapshot()).toBe(finalA); + expect(b.getSnapshot()).toBe(pristineB); + expect(await a.submit('stale UI')).toBe('aborted'); + await b.dispose(); + }); + + it('keeps concurrent sessions independent when one is disposed during a stream', async () => { + const streams = new Map< + string, + ReturnType> + >(); + const signals = new Map(); + const started = { a: deferred(), b: deferred() }; + const transport: AgentTransport = { + stream: (_assistant, thread, _payload, signal) => { + const id = thread as 'a' | 'b'; + const stream = controlledTransport({ + signal, + ignoreAbort: true, + }); + streams.set(id, stream); + signals.set(id, signal); + started[id].resolve(); + return stream.stream; + }, + }; + const a = createSession({ assistantId: 'agent', threadId: 'a', transport }); + const b = createSession({ assistantId: 'agent', threadId: 'b', transport }); + const runA = a.submit('A'); + const runB = b.submit('B'); + await Promise.all([started.a.promise, started.b.promise]); + await a.dispose(); + expect(await runA).toBe('aborted'); + expect(signals.get('a')?.aborted).toBe(true); + expect(signals.get('b')?.aborted).toBe(false); + const finalA = a.getSnapshot(); + streams.get('a')?.release(finalText('late A')); + streams.get('b')?.release(finalText('B complete')); + streams.get('b')?.finish(); + expect(await runB).toBe('success'); + expect(a.getSnapshot()).toBe(finalA); + expect( + b.getSnapshot().messages.some((m) => m.content.includes('late A')) + ).toBe(false); + await b.dispose(); + }); + + it('scopes durable claims by fixed thread even when servers reuse a tool call ID', async () => { + const claim = vi.fn(async () => 'claimed'); + const record = vi.fn(async () => undefined); + const handler = vi.fn(({ city }: { city: string }) => ({ city })); + const transport: AgentTransport = { + stream: async function* (_assistant, _thread, payload) { + const input = payload as { messages: { type: string }[] }; + yield input.messages[0].type === 'tool' + ? finalText('finished') + : weatherCall; + }, + }; + const create = (threadId: string) => + createSession({ + assistantId: 'agent', + threadId, + transport, + executionStore: { claim, record }, + tools: { weather: { description: 'Weather', handler } }, + }); + const a = create('a'); + const b = create('b'); + try { + expect(await a.submit('A')).toBe('success'); + expect(await b.submit('B')).toBe('success'); + expect(claim.mock.calls.map((call) => call[0])).toEqual([ + { threadId: 'a', toolCallId: 'weather-call' }, + { threadId: 'b', toolCallId: 'weather-call' }, + ]); + expect(record.mock.calls.map((call) => call[0])).toEqual( + claim.mock.calls.map((call) => call[0]) + ); + expect(handler).toHaveBeenCalledTimes(2); + } finally { + await Promise.all([a.dispose(), b.dispose()]); + } + }); + + it('settles a late durable claim under the retired thread without executing its tool', async () => { + const claiming = deferred(); + const claimed = deferred<'claimed'>(); + const retiredFlushed = deferred(); + const handler = vi.fn(({ city }: { city: string }) => ({ city })); + const record = vi.fn(async () => undefined); + const flush = vi.fn>( + async (thread) => { + expect(thread).toBe('a'); + retiredFlushed.resolve(); + } + ); + const routes: string[] = []; + const transport: AgentTransport = { + stream: async function* (_assistant, thread, payload) { + routes.push(thread ?? 'missing'); + const input = payload as { messages: { type: string }[] }; + yield input.messages[0].type === 'tool' + ? finalText('B finished') + : weatherCall; + }, + updateState: flush, + }; + const store: ToolExecutionStore = { + claim: async (key) => { + if (key.threadId === 'a') { + claiming.resolve(); + return claimed.promise; + } + return 'claimed'; + }, + record, + }; + const create = (threadId: string) => + createSession({ + assistantId: 'agent', + threadId, + transport, + executionStore: store, + tools: { weather: { description: 'Weather', handler } }, + }); + const a = create('a'); + const b = create('b'); + try { + const oldRun = a.submit('A'); + await claiming.promise; + await a.dispose(); + expect(await oldRun).toBe('aborted'); + expect(await b.submit('B')).toBe('success'); + const current = b.getSnapshot(); + claimed.resolve('claimed'); + await retiredFlushed.promise; + expect(handler).toHaveBeenCalledTimes(1); + expect(record.mock.calls).toEqual([ + [ + { threadId: 'b', toolCallId: 'weather-call' }, + { ok: true, value: { city: 'Paris' } }, + ], + [ + { threadId: 'a', toolCallId: 'weather-call' }, + { ok: false, error: expect.stringContaining('cancelled') }, + ], + ]); + expect(routes).toEqual(['a', 'b', 'b']); + expect(b.getSnapshot()).toBe(current); + } finally { + claimed.resolve('claimed'); + await Promise.all([a.dispose(), b.dispose()]); + } + }); +}); diff --git a/libs/react/src/use-agent.spec.tsx b/libs/react/src/use-agent.spec.tsx index 0b06fb1da..cfeea22af 100644 --- a/libs/react/src/use-agent.spec.tsx +++ b/libs/react/src/use-agent.spec.tsx @@ -46,6 +46,41 @@ afterEach(async () => { }); describe('useAgent borrowed session', () => { + it('follows a replaced session while the application retains the old run', async () => { + const a = fixture(); + const b = fixture(); + const view = renderHook(({ session }) => useAgent(session), { + initialProps: { session: a.session }, + reactStrictMode: true, + }); + let run!: ReturnType; + await act(async () => { + run = a.session.submit('A'); + await a.started(); + }); + view.rerender({ session: b.session }); + expect(view.result.current).toBe(b.session.getSnapshot()); + expect(a.session.releases).toBe(a.session.subscriptions); + expect(a.session.stopCalls + a.session.disposeCalls).toBe(0); + expect(b.history.reads).toBe(0); + expect(b.streams).toHaveLength(0); + const selected = view.result.current; + await act(async () => { + a.streams[0].release(finalText('A retained result')); + a.streams[0].finish(); + expect(await run).toBe('success'); + }); + expect(view.result.current).toBe(selected); + view.rerender({ session: a.session }); + expect(view.result.current.messages.at(-1)?.content).toBe( + 'A retained result' + ); + expect(b.session.releases).toBe(b.session.subscriptions); + expect(b.session.disposeCalls).toBe(0); + view.unmount(); + expect(a.session.releases).toBe(a.session.subscriptions); + }); + it('observes explicit history loads without owning reads, refreshes, or teardown', async () => { const f = fixture(); let renders = 0; diff --git a/scripts/react-parity/review-runtime.mjs b/scripts/react-parity/review-runtime.mjs index ba91164d2..98c8b144c 100644 --- a/scripts/react-parity/review-runtime.mjs +++ b/scripts/react-parity/review-runtime.mjs @@ -17,7 +17,7 @@ const script = fileURLToPath(import.meta.url); const buildCommand = 'NX_DAEMON=false npx nx run-many -t build -p core,angular,react --skip-nx-cache'; const manualOrder = - 'Use three Load clicks (saved, equal refresh, empty); Send → Tool → Error → Hold → Stop → Pause → Stop → Resume → Resume → Drop → Reconnect → Send → Unmount → Dispose → Send after dispose → Resume after dispose → Reconnect after dispose. Resume answers both approvals, then the final confirmation. Reconnect joins the dropped run without resubmitting. Only three Load requests and one Drop are available per server; restart this command for a fresh review. Reloading the page does not reset server state.'; + 'Use three Load clicks (saved, equal refresh, empty); Send → Tool → Error → Hold → Stop → Pause → Stop → Resume → Resume → Drop → Reconnect → Send → Unmount → Dispose → Send after dispose → Resume after dispose → Reconnect after dispose. Resume answers both approvals, then the final confirmation. Reconnect joins the dropped run without resubmitting. Open /?threads on either review URL for application-owned conversation selection; follow its separate sequence. Only bounded requests are available per server; restart this command for a fresh review. Reloading the page does not reset server state.'; function prerequisites(root) { const missing = ['core', 'angular', 'react'].filter( @@ -39,6 +39,7 @@ function sourceProvenance(root) { 'libs/langgraph/src/lib/transport', 'fixtures/react-parity/runtime', 'scripts/react-parity/runtime-consumer.mjs', + 'scripts/react-parity/thread-lifetime.mjs', 'scripts/react-parity/review-runtime.mjs', ]; return { diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs index 74ee30865..959532a9f 100644 --- a/scripts/react-parity/runtime-consumer.mjs +++ b/scripts/react-parity/runtime-consumer.mjs @@ -7,6 +7,7 @@ import { once } from 'node:events'; import { build } from 'vite'; import ts from 'typescript'; import { chromium, expect } from '@playwright/test'; +import { createThreadRoutes, runThreadScenarios } from './thread-lifetime.mjs'; export function lockedReactManifest(lock) { const entries = (names) => Object.fromEntries(names.map((name) => { @@ -331,8 +332,14 @@ export async function prepareRuntimeConsumer(root, consumer, kind) { cpSync(join(temporary, 'bundle/runtime-entry.js'), join(destination, 'runtime-entry.js')); cpSync(join(temporary, 'types/fixtures/react-parity/runtime/runtime-entry.d.ts'), join(destination, 'runtime-entry.d.ts')); cpSync(join(fixture, 'scenarios.ts'), join(destination, 'scenarios.ts')); + cpSync(join(fixture, 'thread-owner.ts'), join(destination, 'thread-owner.ts')); + const threadView = `${kind}-threads.${kind === 'react' ? 'tsx' : 'ts'}`; + cpSync(join(fixture, threadView), join(destination, threadView)); cpSync(join(fixture, 'review.css'), join(destination, 'review.css')); - cpSync(join(fixture, `${kind}-app.${kind === 'react' ? 'tsx' : 'ts'}`), join(destination, kind === 'react' ? 'main.tsx' : 'main.ts')); + const app = `${kind}-app.${kind === 'react' ? 'tsx' : 'ts'}`; + cpSync(join(fixture, app), join(destination, app)); + writeFileSync(join(destination, kind === 'react' ? 'main.tsx' : 'main.ts'), + `if (new URLSearchParams(location.search).has('threads')) {\n void import('./${kind}-threads');\n} else {\n void import('./${kind}-app');\n}\n`); if (kind === 'angular') { const configPath = join(consumer, 'angular.json'); const config = JSON.parse(readFileSync(configPath, 'utf8')); @@ -351,6 +358,7 @@ export async function prepareRuntimeConsumer(root, consumer, kind) { /** Bounded fixture server: built files and deterministic history/run routes. */ export async function serveRuntimeConsumer(directory) { + const threads = createThreadRoutes(); const requests = []; const historyRequests = []; const joinRequests = []; @@ -368,6 +376,7 @@ export async function serveRuntimeConsumer(directory) { const url = new URL(request.url, 'http://fixture'); const pathname = url.pathname; if (pathname.startsWith('/api/')) { + if (await threads.handle(request, response, pathname)) return; const runPath = '/api/threads/fixture-thread/runs/drop-run'; if (pathname === runPath || pathname === `${runPath}/stream`) { assert.equal(request.method, 'GET', 'known-run recovery only performs GET'); @@ -438,8 +447,9 @@ export async function serveRuntimeConsumer(directory) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); return { - url: `http://127.0.0.1:${server.address().port}`, requests, historyRequests, joinRequests, statusRequests, errors, holdStarted, holdAborted, + url: `http://127.0.0.1:${server.address().port}`, requests, historyRequests, joinRequests, statusRequests, errors, holdStarted, holdAborted, threads, async close() { + threads.close(); for (const response of held) response.destroy(); const closed = once(server, 'close'); server.close(); @@ -713,10 +723,11 @@ export async function runRuntimeScenarios(directory, kind) { assert.equal(server.statusRequests.length, 2); assert.deepEqual(server.historyRequests, [{ limit: 10 }, { limit: 10 }, { limit: 10 }], 'only explicit loads read history'); completed.push('unmount and explicit disposal'); + completed.push(...await runThreadScenarios(page, server)); assert.deepEqual(server.errors.map(String), []); assert.deepEqual(pageErrors, []); assert.deepEqual(unexpected, []); - console.log(`${kind}: ${completed.length} browser scenarios passed (${completed.join('; ')}); 3 exact history reads, 10 exact run POSTs, 1 cursor join GET, 2 exact-run status GETs, one tool handler, 7 component submissions, 2 explicit resumes, 1 explicit reconnect, no page errors/unexpected requests.`); + console.log(`${kind}: ${completed.length} browser scenarios passed (${completed.join('; ')}); main workflow: 3 exact history reads, 10 exact run POSTs, 1 cursor join GET, 2 exact-run status GETs, one tool handler, 7 component submissions, 2 explicit resumes, 1 explicit reconnect; thread workflow: 4 history POSTs, 2 run POSTs, stream/history abort, no implicit selection I/O; no page errors/unexpected requests.`); return completed; } finally { try { await context?.close(); } diff --git a/scripts/react-parity/thread-lifetime.mjs b/scripts/react-parity/thread-lifetime.mjs new file mode 100644 index 000000000..99eaf9897 --- /dev/null +++ b/scripts/react-parity/thread-lifetime.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; + +const sse = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +const gate = () => { + let resolve; + return { promise: new Promise((done) => { resolve = done; }), resolve: () => resolve() }; +}; + +/** Independent, bounded wire oracle for two fixed thread identities. */ +export function createThreadRoutes() { + const requests = []; + const streamStarted = gate(); + const streamAborted = gate(); + const historyStarted = gate(); + const historyAborted = gate(); + const held = new Set(); + const hold = (response, started, aborted) => { + held.add(response); + response.once('close', () => { held.delete(response); aborted.resolve(); }); + started.resolve(); + }; + return { + requests, + streamStarted: streamStarted.promise, streamAborted: streamAborted.promise, + historyStarted: historyStarted.promise, historyAborted: historyAborted.promise, + async handle(request, response, pathname) { + const match = /^\/api\/threads\/(thread-[ab])\/(history|runs\/stream)$/.exec(pathname); + if (!match) return false; + assert.equal(request.method, 'POST'); + const [, thread, operation] = match; + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString()); + if (operation === 'history') { + assert.deepEqual(body, { limit: 10 }); + const previous = requests.filter((r) => r.thread === thread && r.operation === operation).length; + assert.ok(previous < 2, 'two explicit history loads per thread'); + requests.push({ thread, operation, body }); + if (thread === 'thread-a' && previous === 1) { + hold(response, historyStarted, historyAborted); + return true; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify([{ + values: { selected: thread, messages: [{ id: `${thread}-saved`, type: 'ai', content: thread === 'thread-a' ? 'Saved A' : 'Saved B' }] }, + next: [], tasks: [], metadata: {}, parent_checkpoint: null, + checkpoint: { thread_id: thread, checkpoint_ns: '', checkpoint_id: `${thread}-checkpoint`, checkpoint_map: {} }, + created_at: '2026-09-22T00:00:00Z', + }])); + return true; + } + const message = body.input?.messages?.[0]; + assert.equal(typeof message?.id, 'string'); + assert.ok(message.id.length > 0); + assert.deepEqual(body, { + assistant_id: 'fixture-assistant', + input: { + messages: [{ id: message.id, type: 'human', content: thread === 'thread-a' ? 'Hold A' : 'Send B' }], + client_tools: [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }], + }, + stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], + stream_subgraphs: true, stream_resumable: true, on_disconnect: 'continue', + }); + assert.ok(!requests.some((r) => r.thread === thread && r.operation === operation), 'one run per thread'); + requests.push({ thread, operation, body }); + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + if (thread === 'thread-a') { + hold(response, streamStarted, streamAborted); + response.write(sse('values', { selected: thread, transient: true }) + + sse('messages', [{ type: 'AIMessageChunk', id: 'a-answer', content: 'A partial' }, { langgraph_node: 'assistant' }])); + } else { + response.end(sse('values', { selected: thread, messages: [{ id: 'b-answer', type: 'ai', content: 'B complete' }] })); + } + return true; + }, + close() { for (const response of held) response.destroy(); }, + }; +} + +async function handshake(promise, label) { + let timeout; + try { + await Promise.race([promise, new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out awaiting ${label}`)), 10000); + })]); + } finally { clearTimeout(timeout); } +} + +/** Run on an already monitored page; every selected identity starts inert. */ +export async function runThreadScenarios(page, server) { + await page.goto(`${server.url}/?threads`); + const click = (name) => page.getByRole('button', { name, exact: true }).click(); + const field = (id) => page.getByTestId(`thread-${id}`); + const fresh = async (id, generation) => { + await expect(field('id')).toHaveText(id); + await expect(field('generation')).toHaveText(String(generation)); + await expect(field('status')).toHaveText('idle'); + await expect(field('text')).toHaveText(''); + await expect(field('values')).toHaveText('unobserved'); + await expect(field('history')).toHaveText('unobserved'); + await expect(field('load')).toHaveText('unobserved'); + }; + await fresh('thread-a', 1); + assert.equal(server.threads.requests.length, 0, 'selection/mount performs no I/O'); + await click('Load selected'); + await expect(field('text')).toHaveText('Saved A'); + await expect(field('load')).toHaveText('loaded'); + await click('Run selected'); + await expect(field('status')).toHaveText('running'); + await expect(field('text')).toContainText('A partial'); + await click('Select B'); + await fresh('thread-b', 2); + await handshake(server.threads.streamAborted, 'outgoing stream abort'); + assert.equal(server.threads.requests.length, 2, 'switching performs no load or submit'); + await click('Load selected'); + await expect(field('text')).toHaveText('Saved B'); + await click('Run selected'); + await expect(field('text')).toContainText('B complete'); + await expect(field('status')).toHaveText('idle'); + await expect(field('outcome')).toHaveText('success'); + const before = await field('text').textContent(); + await click('Select B'); + await expect(field('generation')).toHaveText('2'); + await expect(field('text')).toHaveText(before); + assert.equal(server.threads.requests.length, 4, 'same thread is a no-op'); + await click('Select A'); + await fresh('thread-a', 3); + await click('Load selected'); + await expect(field('load')).toHaveText('loading'); + await handshake(server.threads.historyStarted, 'pending history request'); + await click('Select B'); + await fresh('thread-b', 4); + await handshake(server.threads.historyAborted, 'outgoing history abort'); + assert.equal(server.threads.requests.length, 5); + await click('Load selected'); + await expect(field('text')).toHaveText('Saved B'); + await expect(field('load')).toHaveText('loaded'); + await click('Dispose selected'); + await expect(field('owner')).toHaveText('disposed'); + await click('Run selected'); + await expect(field('outcome')).toHaveText('aborted'); + await click('Select A'); + await expect(field('id')).toHaveText('thread-b'); + await expect(field('generation')).toHaveText('4'); + assert.deepEqual(server.threads.requests.map(({ thread, operation }) => [thread, operation]), [ + ['thread-a', 'history'], ['thread-a', 'runs/stream'], + ['thread-b', 'history'], ['thread-b', 'runs/stream'], + ['thread-a', 'history'], ['thread-b', 'history'], + ]); + assert.deepEqual(server.errors, []); + return ['fixed-thread selection and explicit loading', 'switch during streaming', 'same-thread identity', 'fresh session on return', 'switch during history read', 'permanent owner disposal']; +} diff --git a/scripts/react-parity/thread-lifetime.spec.mjs b/scripts/react-parity/thread-lifetime.spec.mjs new file mode 100644 index 000000000..9748affdf --- /dev/null +++ b/scripts/react-parity/thread-lifetime.spec.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; +import { serveRuntimeConsumer } from './runtime-consumer.mjs'; + +const runBody = (message) => ({ + assistant_id: 'fixture-assistant', + input: { + messages: [{ id: 'new-user', type: 'human', content: message }], + client_tools: [ + { name: 'weather', description: 'Current weather' }, + { name: 'count', description: 'Count values' }, + ], + }, + stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], + stream_subgraphs: true, stream_resumable: true, on_disconnect: 'continue', +}); +const post = (server, path, body, signal) => fetch(`${server.url}/api/threads/${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal, +}); + +test('example owner replaces locally, preserves same-thread identity and closes permanently', async () => { + const source = readFileSync(new URL('../../fixtures/react-parity/runtime/thread-owner.ts', import.meta.url), 'utf8'); + const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }); + const { createThreadOwner } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}`); + const created = []; + const disposed = []; + const owner = createThreadOwner((id) => { + created.push(id); + return { dispose: async () => { disposed.push(id); } }; + }); + const a = owner.selected; + assert.equal(Object.isFrozen(a), true); + assert.equal(owner.select('thread-a'), a); + assert.deepEqual(created, ['thread-a']); + assert.deepEqual(disposed, []); + const b = owner.select('thread-b'); + assert.equal(owner.selected, b); + assert.equal(b.generation, 2); + assert.deepEqual(disposed, ['thread-a']); + assert.equal(a.id, 'thread-a'); + const restored = owner.select('thread-a'); + assert.notEqual(restored.session, a.session); + assert.equal(restored.generation, 3); + await owner.dispose(); + assert.equal(owner.select('thread-b'), restored); + assert.deepEqual(created, ['thread-a', 'thread-b', 'thread-a']); + assert.deepEqual(disposed, ['thread-a', 'thread-b', 'thread-a']); +}); + +test('thread review isolates exact history/run routes and observes aborts of pending work', { timeout: 10000 }, async () => { + const server = await serveRuntimeConsumer('/unused'); + try { + const saved = await post(server, 'thread-a/history', { limit: 10 }); + assert.equal(saved.status, 200); + const history = await saved.json(); + assert.equal(history[0].checkpoint.thread_id, 'thread-a'); + assert.deepEqual(history[0].values, { + selected: 'thread-a', messages: [{ id: 'thread-a-saved', type: 'ai', content: 'Saved A' }], + }); + const streamAbort = new AbortController(); + const stream = await post(server, 'thread-a/runs/stream', runBody('Hold A'), streamAbort.signal); + assert.equal(stream.status, 200); + const reader = stream.body.getReader(); + assert.match(new TextDecoder().decode((await reader.read()).value), /A partial/); + streamAbort.abort(); + await server.threads.streamAborted; + const pendingAbort = new AbortController(); + const pending = post(server, 'thread-a/history', { limit: 10 }, pendingAbort.signal).catch(() => undefined); + await server.threads.historyStarted; + pendingAbort.abort(); + await server.threads.historyAborted; + await pending; + const b = await post(server, 'thread-b/history', { limit: 10 }); + assert.equal((await b.json())[0].values.messages[0].content, 'Saved B'); + const run = await post(server, 'thread-b/runs/stream', runBody('Send B')); + assert.match(await run.text(), /B complete/); + assert.deepEqual(server.threads.requests.map(({ thread, operation }) => [thread, operation]), [ + ['thread-a', 'history'], ['thread-a', 'runs/stream'], ['thread-a', 'history'], + ['thread-b', 'history'], ['thread-b', 'runs/stream'], + ]); + assert.deepEqual(server.requests, []); + assert.deepEqual(server.historyRequests, []); + assert.deepEqual(server.errors, []); + } finally { await server.close(); } +}); + +test('thread review rejects extra run fields and misrouted prompts', async () => { + const server = await serveRuntimeConsumer('/unused'); + try { + for (const body of [{ ...runBody('Send B'), config: {} }, runBody('Hold A')]) { + const response = await post(server, 'thread-b/runs/stream', body); + assert.equal(response.status, 500); + await response.text(); + } + assert.equal(server.errors.length, 2); + assert.deepEqual(server.threads.requests, []); + } finally { await server.close(); } +});