From 6fcf42df31f80036bf8d6b17622c0de11bb027ab Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 24 Sep 2026 12:00:29 -0700 Subject: [PATCH 1/2] feat(runtime): preserve owned reasoning text --- fixtures/react-parity/runtime/README.md | 16 + fixtures/react-parity/runtime/angular-app.ts | 3 + fixtures/react-parity/runtime/evidence.json | 325 ++++++++++++++++++ .../react-parity/runtime/installed-types.ts | 6 + fixtures/react-parity/runtime/react-app.tsx | 3 + fixtures/react-parity/runtime/scenarios.ts | 13 +- libs/core/README.md | 4 + .../core/src/contracts/contracts.type-test.ts | 6 + libs/core/src/contracts/message.ts | 2 + libs/langgraph/src/runtime/README.md | 28 +- libs/langgraph/src/runtime/citations.spec.ts | 53 +-- .../src/runtime/history-projection.ts | 2 + .../src/runtime/message-reducer.spec.ts | 43 +++ libs/langgraph/src/runtime/message-reducer.ts | 10 + libs/langgraph/src/runtime/ownership.ts | 2 + .../langgraph/src/runtime/publication.spec.ts | 40 +++ .../src/runtime/reasoning-projection.spec.ts | 79 +++++ .../src/runtime/reasoning-projection.ts | 34 ++ libs/langgraph/src/runtime/reasoning.spec.ts | 323 +++++++++++++++++ .../src/runtime/stream-projection.ts | 2 + .../src/runtime/testing/controlled-session.ts | 54 +++ scripts/react-parity/baseline.json | 27 +- scripts/react-parity/dispositions.json | 17 +- scripts/react-parity/runtime-consumer.mjs | 10 +- 24 files changed, 1039 insertions(+), 63 deletions(-) create mode 100644 libs/langgraph/src/runtime/reasoning-projection.spec.ts create mode 100644 libs/langgraph/src/runtime/reasoning-projection.ts create mode 100644 libs/langgraph/src/runtime/reasoning.spec.ts create mode 100644 libs/langgraph/src/runtime/testing/controlled-session.ts diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md index 4bb52b20a..81a0acaf7 100644 --- a/fixtures/react-parity/runtime/README.md +++ b/fixtures/react-parity/runtime/README.md @@ -1,5 +1,21 @@ # Installed native runtime consumers +## Reasoning projection + +Both main native views expose plain text from the real snapshot's readonly +`Message.reasoning` field. The same three Load requests show `Saved reasoning`, +retain it through equal history, and clear it with empty history. The first Resume +shows `Approval reasoning`; the second canonically clears it on the same assistant. +Installed type probes reject writes and non-string values with `skipLibCheck:false`. +Main, thread and checkpoint request and handler counts are unchanged. A temporary +production history-assignment omission must fail the saved reasoning assertion in +both consumers, followed by exact-byte restoration and green reruns. Evidence is +recorded additively in `evidence.json` under `ownedReasoningProjection`. + +This verifies owned display strings and these lifecycle paths only. It does not +claim timing/duration parity, rich blocks, a reasoning renderer, live-provider +conformance, performance, public backend cutover or full T09/T15 completion. + ## Citation projection The main view's plain-text Citations field proves the installed readonly core diff --git a/fixtures/react-parity/runtime/angular-app.ts b/fixtures/react-parity/runtime/angular-app.ts index c4577e8a8..3aa03c2fa 100644 --- a/fixtures/react-parity/runtime/angular-app.ts +++ b/fixtures/react-parity/runtime/angular-app.ts @@ -178,6 +178,9 @@ const submit = (input: string) => { {{ view().citations }} + {{ + view().reasoning + }} diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index b3ad83516..a6058e593 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -698,5 +698,330 @@ "Legacy Angular arbitrary-extra semantics remain separate until backend cutover.", "No full T09/T15 parity, rich blocks, reasoning/timing, citation UI, dependency upgrade, release, live-server, SSR or performance claim." ] + }, + "ownedReasoningProjection": { + "observedOn": "2026-09-24", + "scope": "Private owned reasoning display strings in shared immutable messages and both installed native main views; no execution-authority or public-backend change.", + "source": { + "baseCommit": "9ddeb1579323898cb5e470d658cfa53852931830", + "workingTree": "Uncommitted owned-reasoning implementation based on the reviewed citation milestone. These hashes identify tested inputs, not a future commit.", + "algorithm": "SHA-256(file bytes); inputs sorted lexicographically by path using JavaScript default string ordering, then manifest hash is SHA-256 of sha256 + two spaces + path + LF lines.", + "inputs": [ + { + "path": "fixtures/react-parity/runtime/README.md", + "sha256": "eba776bf8928a7976109722220aab64dde508587fad8aec9893da338b1db3b68" + }, + { + "path": "fixtures/react-parity/runtime/angular-app.ts", + "sha256": "5c5dd1cb9c6fb4cb2a9f13345fad3f08ddab175606a4fab22f83d76985ec7cd1" + }, + { + "path": "fixtures/react-parity/runtime/installed-types.ts", + "sha256": "08fbaed23db4f9763707df0b495737f87428d1189de63f63adade369793665f3" + }, + { + "path": "fixtures/react-parity/runtime/react-app.tsx", + "sha256": "5c3020c64d2fd102ba6255856a99b08321d00bb812cc56b2910cb728befc3f0e" + }, + { + "path": "fixtures/react-parity/runtime/runtime-entry.ts", + "sha256": "72509ac95343b1e2d09c825d5017408fcd66891d5efb96e6e742270baf90aebd" + }, + { + "path": "fixtures/react-parity/runtime/scenarios.ts", + "sha256": "5541e93b15cb8ef4c957f4b3cc53a4fb12cfc7f4b23ae40fa38eceff70a150db" + }, + { + "path": "libs/core/README.md", + "sha256": "638c4e0e0ed1bd4ff7e94512f1f5fc9589a3815ac35ad9df3b1aebe8bd5b48e4" + }, + { + "path": "libs/core/src/contracts/contracts.type-test.ts", + "sha256": "aaa8bc7327d346715a1d364063433f75d94aa8e1d7f64b386ce49b600d256bf4" + }, + { + "path": "libs/core/src/contracts/message.ts", + "sha256": "9bfa684f4793ccdfd6b3f566f5d00e1b7ee821fcd51d1ea84995ef5b4caa61f7" + }, + { + "path": "libs/langgraph/src/runtime/README.md", + "sha256": "5a1897fc817d9ece371923f369193dbd2ee5be5f2aaf4da8cacbb05fb752e66c" + }, + { + "path": "libs/langgraph/src/runtime/checkpoint-authority.ts", + "sha256": "f56912f077e4ba891f9a55419e89743d62f573c435c3984fb64ac0803271c3dc" + }, + { + "path": "libs/langgraph/src/runtime/citations.spec.ts", + "sha256": "6d08887785cdc0a40ea0d6ea8f4455d3cf599a16ad0a7cbde9b4f6b42313af35" + }, + { + "path": "libs/langgraph/src/runtime/create-session.ts", + "sha256": "f4c6a1e693d205cf48a7c9ec357ddf037bdcf4ceabe315c6529c6806fbdaefb1" + }, + { + "path": "libs/langgraph/src/runtime/history-projection.ts", + "sha256": "4e5e7d27e2ae9429274ac47ed979d6b94848dde9b1d03018af39ad888f236fd1" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.spec.ts", + "sha256": "a4360839d00a32e02a985dd8dbd7bb5452797398327ee043d53f7825adc0561f" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.ts", + "sha256": "714be5a4c60d442f84f46b08ac12b43ac901fe6005cf2ba5f35dfb8bfa33f249" + }, + { + "path": "libs/langgraph/src/runtime/ownership.ts", + "sha256": "3d9e0cad3c64b2884fd3049c78e6a844115efa6c839fbe997b17f7defc639641" + }, + { + "path": "libs/langgraph/src/runtime/publication.spec.ts", + "sha256": "db037834e2a8de6bbc50042994d4e6e7d51c42a09719d26ee4d5b7436c80662e" + }, + { + "path": "libs/langgraph/src/runtime/publication.ts", + "sha256": "7d290721417d504313f893ee7e90154b85e9eee27d05f6cce816d592495791e5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.spec.ts", + "sha256": "0131f6cfbe863a537d582b0f249dd2cab38ff0e3bc58dc9d4c4b14c034882d44" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.ts", + "sha256": "3f408da1d2ca5a28af466c11d49391a524117cd705d0363a2bf1bca2ad659cc5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning.spec.ts", + "sha256": "91f8d9fbb8ecf71f308800709954d0647a70606bfb4b071f4967596d1af69b53" + }, + { + "path": "libs/langgraph/src/runtime/stream-projection.ts", + "sha256": "0462792cfff288ce4e4345bbcbfc32f1e162db35fe94f26b348a12ab064ccae4" + }, + { + "path": "libs/langgraph/src/runtime/testing/controlled-session.ts", + "sha256": "36a297e2bb35627c49bcf531df46f4c3bfb8aacef85cf8b22bb906c332b82072" + }, + { + "path": "package-lock.json", + "sha256": "530c28574a832ab39c15c9607717de0eb8ccfae2593e09c7ae373bd8936d0b64" + }, + { + "path": "scripts/react-parity/runtime-consumer.mjs", + "sha256": "361524bf67fc6b040ca9e94776c859f361c48f203a5399d6ec12d4f334871406" + } + ], + "manifestSha256": "e42418ae4c8b8b441e33d0b3c635109ec49e8344fb774ba7f22bc08b3776fbee" + }, + "automated": { + "core": { + "targets": [ + "test", + "type-tests", + "lint" + ], + "tests": 2, + "path": "/tmp/owned-reasoning-core-final.log", + "sha256": "ae0cbbf32cbf4c6cea038dac69054d68443ef15c772775be718a81ca3cfdd2d8" + }, + "nativeBuild": { + "projects": [ + "core", + "content", + "react", + "angular" + ], + "targets": [ + "build" + ], + "path": "/tmp/owned-reasoning-build-native.log", + "sha256": "6f85c88b4d17e732b027e18560bda383c841a570fd236f6d542edb91ed719371" + }, + "runtime": { + "targets": [ + "runtime-quality", + "runtime-type-tests" + ], + "tests": 884, + "files": 48, + "path": "/tmp/owned-reasoning-runtime-final.log", + "sha256": "e5b2f55723f5a42fc2b14c73ca592f2c0be4633aa5bf85dc6035cc977134d79f" + }, + "publicLangGraph": { + "targets": [ + "test", + "type-tests", + "build" + ], + "path": "/tmp/owned-reasoning-public-final.log", + "sha256": "96c85b9664610c0e4ee8663963e4983a9075d1eb8b9df22807ce2d3a6104c80f" + }, + "publicLangGraphReportedTests": { + "tests": 481, + "files": 22, + "command": "NX_DAEMON=false NX_TUI=false npx nx run langgraph:test --skip-nx-cache --outputStyle=stream --watch=false --reporter=default", + "note": "The installed Nx executor substitutes only NxReporter when the public config has no reporters, so the explicit default reporter records actual counts. No source/config change.", + "path": "/tmp/owned-reasoning-public-test-reported.log", + "sha256": "d02a59149dc3e2ffe5b42ac56fc5a6f2bcd3ad61bbd6992a8d768f7ed5ee97ed" + }, + "parityScripts": { + "tests": 329, + "path": "/tmp/owned-reasoning-parity-scripts.log", + "sha256": "d3f99536e053637b1b5f278684b793a0b6cd50a6a5f278ea889850a7961b5625" + }, + "react": { + "scenarios": 27, + "path": "/tmp/owned-reasoning-installed-react-restored.log", + "sha256": "4380e1e10d6cd771241cf96312c8e53868d20793d50c17013647500a3d5f14f7" + }, + "angular": { + "scenarios": 27, + "path": "/tmp/owned-reasoning-installed-angular-restored.log", + "sha256": "dc431fa4ad227081e2274830e3cb44455c66ea13ca09e8a461c87158307fb97d" + }, + "boundaries": { + "path": "/tmp/owned-reasoning-boundaries.log", + "sha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" + }, + "versions": { + "path": "/tmp/owned-reasoning-versions.log", + "sha256": "1d9f9440bfffad48cca3d74ddec850cf803b8f449dcee2216776392713bf9374" + }, + "inventory": { + "path": "/tmp/owned-reasoning-inventory-check.log", + "sha256": "5c01ca9da9d098941941f29835953ec7bb56fe6d385cab7bdcbdc3fe87f77db3" + }, + "diff": { + "path": "/tmp/owned-reasoning-diff-check.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + "packedArtifacts": { + "note": "Repacked unchanged built artifacts after final restored installed runs; no native binding implementation changes. Temporary paths are local provenance, not published artifacts. Core contributor README edits are source documentation and were made after the native build.", + "log": { + "path": "/tmp/owned-reasoning-packed-artifacts.log", + "sha256": "8a1770a523b81ab9a3b1c4728b30bed9fd5534457d3e70bbff38df6d95e065d3" + }, + "artifacts": [ + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/owned-reasoning-packed-DFqZN1/threadplane-angular-0.0.0.tgz", + "sha256": "632956c2279a700c9b8ba4b7762d58a6617b8e9b089776eec5fa8091397e15c5" + }, + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/owned-reasoning-packed-DFqZN1/threadplane-content-0.0.0.tgz", + "sha256": "9c6083c293798b1b10aa6883ea94de97103d5ca65726a74884ed8763079c0e8b" + }, + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/owned-reasoning-packed-DFqZN1/threadplane-core-0.0.0.tgz", + "sha256": "636fb54e16d163cdc7f87e6fa1dc89dc32b0f9e1d4aeaa845bafbe8999989435" + }, + { + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/owned-reasoning-packed-DFqZN1/threadplane-react-0.0.0.tgz", + "sha256": "b71db72347e6ac0777304265d611968232b36e0dffe2171f7fb660f2e8958bc9" + } + ] + }, + "redGreen": [ + { + "expectedFailure": "Message.reasoning absent; positive read/type-index probe fails before adding the field.", + "path": "/tmp/owned-reasoning-red-contract.log", + "sha256": "b77f4fc0a487cb59a50c42ed5532bfbb5726bf4dbd70b9a1886dcab40fb8ab0d" + }, + { + "expectedFailure": "Private reasoning-projection module missing before implementation.", + "path": "/tmp/owned-reasoning-red-normalizer.log", + "sha256": "09d649cfa9b04230594543a90f4b3c19e20f7ded8cde3fceddd8eefcc745ec2d" + }, + { + "expectedFailure": "Reasoning is dropped by ownMessage and reasoning-only publication produces zero notifications; 44 predecessor cases remain green.", + "path": "/tmp/owned-reasoning-red-reduction.log", + "sha256": "af569300ab70bb59ed1517e99b1773b263452f6ce178843e2df17e3b13d7f43d" + }, + { + "tests": 58, + "files": 3, + "path": "/tmp/owned-reasoning-green-reduction.log", + "sha256": "dde4b4506055718ea308b7b206451d89eff32be89129c8a866ccf6a4f6d43fb5" + }, + { + "expectedFailure": "Session ingestion assertions observe missing reasoning before history/stream wiring. The history reentrant getter case times out because the missing projection never reads that getter; other cases fail explicit assertions.", + "path": "/tmp/owned-reasoning-red-session.log", + "sha256": "0622247a7b0644c871dfbda0b4455ffdbeb598ba849c50324addcd66d7844e56" + }, + { + "path": "/tmp/owned-reasoning-green-contract.log", + "sha256": "cb01bdc4e043d034b2c550fc77c18e9756aebab8ee536395045cc8d138172a86" + }, + { + "tests": 884, + "files": 48, + "path": "/tmp/owned-reasoning-green-runtime.log", + "sha256": "25afc3ee5221f754c98d7a0e47116b750ad38a1a5ca162f08954768dd797ec7d" + } + ], + "semanticNegativeControl": { + "mutation": "Temporarily omit only reasoning: projectReasoning(message) from production history construction, leaving native fields and wire data in place.", + "react": { + "path": "/tmp/owned-reasoning-installed-react-negative.log", + "sha256": "03f3fd08de30353f53c8522f9400b84ae59ba9d45c7d7dc0eb661d753921b02c" + }, + "angular": { + "path": "/tmp/owned-reasoning-installed-angular-negative.log", + "sha256": "43a2051f4006e9d87c3a2f978c6a1eda66f48dc6672abb35f04a4f3f39ed2e4e" + }, + "expectedFailure": "Both consumers fail getByTestId(reasoning): expected Saved reasoning, received empty text.", + "restoration": { + "path": "/tmp/owned-reasoning-negative-restoration.log", + "sha256": "2176f45c5e702e4def2e0acec3f3f2db77e997a860abc6934a1da7afaeabf5db" + }, + "restoredHistorySha256": "4e5e7d27e2ae9429274ac47ed979d6b94848dde9b1d03018af39ad888f236fd1", + "method": "Original bytes saved and restored in finally, then byte-compared. An outer log-label check expected Received string rather than this Playwright version's Received; inspection and the corrected check confirm both intended assertion failures. Both final restored verifiers passed." + }, + "installedAssertions": [ + "Readonly optional string through core and both native bindings with skipLibCheck:false.", + "Saved reasoning on load/equal refresh, empty history clears; Approval reasoning on first Resume, final canonical omission clears on second Resume.", + "Existing 27 scenarios each retain all main/thread/checkpoint request counts, one main tool handler, cancellation and disposal assertions." + ], + "manualReview": { + "date": "2026-09-24", + "react": "Chrome MCP, owned port 57809", + "angular": "In-app browser, owned port 57810", + "sequence": "Load, Load, Load, Pause, Resume, Resume, Dispose", + "observations": [ + "Saved reasoning appeared on initial history, survived equal history, and cleared with empty history in both native views.", + "Approval reasoning appeared during the first resume and cleared on canonical completion during the second resume.", + "Both views finished three loads, one submission and two resumes with zero tool handlers and a disposed owner.", + "Both browser consoles had zero warnings and errors.", + "Chrome independently recorded exactly six successful POST requests: three histories and three runs. Angular network behavior is supplied by the automated oracle." + ], + "runner": "/tmp/owned-reasoning-manual-runner.log", + "freshAutomatedScenarios": 54, + "parentRuntimeVerification": "/tmp/owned-reasoning-parent-runtime.log (884 runtime tests plus runtime/public types)", + "cleanup": "Only the two owned tabs and runner were closed; ports 57809 and 57810 were confirmed closed. Existing manual sessions were preserved.", + "scope": "Reasoning lifecycle walkthrough only, not another manual review of every main, thread or checkpoint scenario." + }, + "independentReview": { + "compliance": "Approved after 88 focused runtime tests, core/runtime/public types, boundaries/inventory/versions, additive evidence audit and 53 matching hashes.", + "quality": "Approved after fresh 88 focused tests, runtime types, source boundaries and 53 matching hashes. Reconnect documentation and manifest sort-key wording were clarified; production code was unchanged." + }, + "ownershipCoverage": [ + "Top-level and kwargs string precedence including empty; ordered reasoning/thinking block text, fallback and summaries; unsupported sources never serialize or infer answer text.", + "Repeated and prefix-equal delta strings append verbatim; snapshots replace including shorter/empty; same-generation omission retains; canonical/history omissions clear; later interim cannot resurrect; ordered correction replaces; new generation cannot inherit.", + "Equal history identity, reasoning-only queued publication, source/getter mutation capture, terminal candidates, actual load/submit/stop/stale owner, genuine resumed-generation omission, retained reconnect reasoning and canonical clear.", + "Namespaced children stay isolated; exact checkpoint fork/load retains strings with historical tool handlers inert; Date/cyclic checkpoint ingress remains rejected." + ], + "documentation": { + "generatorsRun": [], + "reason": "Inspected apps/website/scripts/generate-api-docs.ts entrypoints (langgraph/chat/render/ag-ui/a2ui/middleware; no core) and generate-agent-context.ts content inputs. Only private core/runtime and contributor fixture docs changed; no public root, narrative or agent-context input changed." + }, + "limits": [ + "No timing/duration, rich/media block preservation, event-only retention, renderer/markdown/sanitization, text alias expansion or provider-version conformance claim.", + "No new clocks, caches, queue/scheduler, transport protocol, generation classification, checkpoint authority or tool admission logic.", + "No performance, live-provider, full T09/T15, public backend cutover, dependency upgrade or release claim. The separate manualReview record states the exact parent walkthrough scope; independent review remains a delivery gate." + ], + "knownToolingWarnings": [ + "Existing NO_COLOR/FORCE_COLOR and browserslist-age warnings; Vite ignores native React use-client directives during bundling. All recorded gates above passed." + ] } } diff --git a/fixtures/react-parity/runtime/installed-types.ts b/fixtures/react-parity/runtime/installed-types.ts index 58da0540f..a4b4a9b7e 100644 --- a/fixtures/react-parity/runtime/installed-types.ts +++ b/fixtures/react-parity/runtime/installed-types.ts @@ -14,6 +14,12 @@ import type { FixtureTools } from './scenarios'; /* BINDING_IMPORT */ export function assertSnapshot(snapshot: AgentSnapshot) { + const reasoning: Message['reasoning'] = snapshot.messages[0]?.reasoning; + // @ts-expect-error Reasoning remains readonly through native snapshots. + snapshot.messages[0].reasoning = 'changed'; + // @ts-expect-error Reasoning is display text, not an SDK object. + const invalidReasoning: Message['reasoning'] = { text: 'reasoning' }; + void [reasoning, invalidReasoning]; /* BACKEND_VALUES */ const citations: readonly Citation[] | undefined = snapshot.messages[0]?.citations; diff --git a/fixtures/react-parity/runtime/react-app.tsx b/fixtures/react-parity/runtime/react-app.tsx index 154b19057..b260e3275 100644 --- a/fixtures/react-parity/runtime/react-app.tsx +++ b/fixtures/react-parity/runtime/react-app.tsx @@ -226,6 +226,9 @@ function App() { {view.citations} + + {view.reasoning} + diff --git a/fixtures/react-parity/runtime/scenarios.ts b/fixtures/react-parity/runtime/scenarios.ts index 8469548a3..2b8aac40a 100644 --- a/fixtures/react-parity/runtime/scenarios.ts +++ b/fixtures/react-parity/runtime/scenarios.ts @@ -138,10 +138,17 @@ export function display(snapshot: FixtureSnapshot) { const delivery = assistant.at(-1)?.delivery; return { text: assistant.map((message) => message.content).join('\n'), + reasoning: assistant + .flatMap((message) => (message.reasoning ? [message.reasoning] : [])) + .join('\n'), transcript: snapshot.messages.map((message) => message.content).join('\n'), - citations: snapshot.messages.flatMap((message) => - (message.citations ?? []).map((citation) => `${citation.id}: ${citation.title ?? ''}`) - ).join('\n'), + citations: snapshot.messages + .flatMap((message) => + (message.citations ?? []).map( + (citation) => `${citation.id}: ${citation.title ?? ''}` + ) + ) + .join('\n'), values: JSON.stringify(snapshot.values) ?? 'unobserved', history: JSON.stringify(snapshot.history) ?? 'unobserved', interrupts: JSON.stringify(snapshot.interrupts), diff --git a/libs/core/README.md b/libs/core/README.md index b85c408b5..ca20ccf0b 100644 --- a/libs/core/README.md +++ b/libs/core/README.md @@ -23,6 +23,10 @@ they do not certify source accuracy or safe HTML/navigation. No URL is fetched o footnote resolved. Date instances, SDK objects and functions are outside this portable contract. +`Message.reasoning` is optional readonly backend-supplied display text. An explicit +empty string is distinct from absent data. It is not inferred from answer text, +an execution signal, a duration measurement, or a rich content block model. + 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` diff --git a/libs/core/src/contracts/contracts.type-test.ts b/libs/core/src/contracts/contracts.type-test.ts index 8ca507b52..d174d4b79 100644 --- a/libs/core/src/contracts/contracts.type-test.ts +++ b/libs/core/src/contracts/contracts.type-test.ts @@ -12,6 +12,12 @@ import type { declare const snapshot: AgentSnapshot; declare const session: AgentSession; declare const citation: Citation; +const reasoning: Message['reasoning'] = snapshot.messages[0].reasoning; +// @ts-expect-error reasoning is readonly display data +snapshot.messages[0].reasoning = 'changed'; +// @ts-expect-error reasoning accepts strings only +const invalidReasoning: Message['reasoning'] = { text: 'hidden' }; +void [reasoning, invalidReasoning]; const citationList: Message['citations'] = [citation]; // @ts-expect-error citation collections are readonly citationList.push(citation); diff --git a/libs/core/src/contracts/message.ts b/libs/core/src/contracts/message.ts index 19ca2857a..b7c7b8b41 100644 --- a/libs/core/src/contracts/message.ts +++ b/libs/core/src/contracts/message.ts @@ -8,6 +8,8 @@ export interface Message { readonly id: string; readonly role: Role; readonly content: string; + /** Backend-supplied reasoning display text. */ + readonly reasoning?: string; readonly citations?: readonly Citation[]; readonly delivery: MessageDelivery; readonly toolCallId?: string; diff --git a/libs/langgraph/src/runtime/README.md b/libs/langgraph/src/runtime/README.md index 17f796e41..b6ecc836d 100644 --- a/libs/langgraph/src/runtime/README.md +++ b/libs/langgraph/src/runtime/README.md @@ -23,9 +23,35 @@ optional fields, and reject instances/cycles in extras at the ownership boundary Exact checkpoint ingress remains stricter: it captures the complete plain state before projection and rejects Date instances even in otherwise optional metadata. Citation changes never authorize tools, change execution positions, or reopen -delivery. Rich blocks, reasoning/timing, event render state and citation components +delivery. Rich blocks, reasoning timing, event render state and citation components remain separate future capabilities. +## Owned reasoning + +History and root/child streams capture backend-supplied reasoning into the optional +readonly `Message.reasoning` string. The private normalizer prefers a top-level +string, then `additional_kwargs.reasoning_content`, then ordered reasoning/thinking +blocks. Each block contributes string text (or thinking's string `thinking` fallback) +followed by string summary text. Explicit empty strings win source precedence; +recognized empty blocks yield an empty string, while unsupported sources fall +through and ordinary answer text never becomes reasoning. + +Within a delivery generation, omitted interim reasoning is retained, deltas append +verbatim, and cumulative snapshots replace even shorter or empty strings. Canonical +messages and full history replace exactly, including clearing omissions. Committed +canonical data bars later interim changes; ordered canonical corrections still +replace it. New independent generations do not inherit reasoning. Reconnecting the +same physical run retains captured reasoning while rebasing its delivery generation. +Equal histories preserve identity, and reasoning-only changes publish through the existing queue. +Captured terminal candidates and published strings cannot change with wire mutation. + +This bounded normalization duplicates part of the legacy Angular extractor until +backend cutover. It uses explicit event modes, not that adapter's prefix heuristics +or clock map. No timing, rich-block, renderer, event-only retention, provider-version +conformance or complete T09/T15 parity is claimed. Strict checkpoint capture still +rejects non-plain/cyclic data before display normalization. Reasoning never changes +tool admission, checkpoint authority, transport requests or lifecycle scheduling. + ## Completed checkpoint forks `session.fork(checkpoint, input, options?)` submits new input in the session's diff --git a/libs/langgraph/src/runtime/citations.spec.ts b/libs/langgraph/src/runtime/citations.spec.ts index af1a95f2e..40cfb2144 100644 --- a/libs/langgraph/src/runtime/citations.spec.ts +++ b/libs/langgraph/src/runtime/citations.spec.ts @@ -6,8 +6,7 @@ import { confirmCheckpoint, } from './checkpoint-authority'; import type { AgentTransport, StreamEvent } from './transport.types'; -import { controlledTransport } from './testing/controlled-transport'; -import { deferred } from './testing/deferred'; +import { controlledSession } from './testing/controlled-session'; import { checkpointEvent, fixture as checkpointFixture, @@ -32,53 +31,9 @@ const frame = (citations?: unknown, content = 'Answer'): StreamEvent => ({ }); const cleanups: (() => Promise)[] = []; function fixture() { - const streams: ReturnType>[] = []; - const starts = Array.from({ length: 4 }, () => deferred()); - const acknowledgments: ReturnType>[] = []; - const stream = vi.fn((_a, _t, _input, signal) => { - const index = streams.length; - const controlled = controlledTransport({ - signal, - ignoreAbort: true, - }); - streams.push(controlled); - starts[index].resolve(); - const iterator: AsyncIterableIterator = { - [Symbol.asyncIterator]() { - return iterator; - }, - next() { - acknowledgments[index]?.resolve(); - return controlled.stream.next(); - }, - return() { - acknowledgments[index]?.resolve(); - return controlled.stream.return(); - }, - }; - return iterator; - }); - const getHistory = vi.fn>( - async () => [] - ); - const session = createSession({ - assistantId: 'agent', - threadId: 'thread', - transport: { stream, getHistory }, - }); - cleanups.push(() => session.dispose()); - return { - session, - stream, - streams, - getHistory, - started: (index = 0) => starts[index].promise, - async emit(event: StreamEvent, index = 0) { - acknowledgments[index] = deferred(); - streams[index].release(event); - await acknowledgments[index].promise; - }, - }; + const f = controlledSession(); + cleanups.push(() => f.session.dispose()); + return f; } afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); diff --git a/libs/langgraph/src/runtime/history-projection.ts b/libs/langgraph/src/runtime/history-projection.ts index ca3b26361..92f0654ec 100644 --- a/libs/langgraph/src/runtime/history-projection.ts +++ b/libs/langgraph/src/runtime/history-projection.ts @@ -18,6 +18,7 @@ import { projectHistoryInterrupts } from './interrupt-projection'; import type { LangGraphInterrupt } from './langgraph-snapshot'; import { observeInvocation, type ToolInvocation } from './tool-invocations'; import { projectCitations } from './citation-projection'; +import { projectReasoning } from './reasoning-projection'; export interface HistoryProjectionOptions { /** Omit for broad wire observation. A supplied catalog exposes only its @@ -164,6 +165,7 @@ export function projectHistory( role, content: textContent(message['content']), citations: projectCitations(message), + reasoning: projectReasoning(message), delivery: staticDelivery(id), ...(typeof message['name'] === 'string' ? { name: message['name'] } : {}), ...(typeof message['tool_call_id'] === 'string' diff --git a/libs/langgraph/src/runtime/message-reducer.spec.ts b/libs/langgraph/src/runtime/message-reducer.spec.ts index 47220a861..45811a2a1 100644 --- a/libs/langgraph/src/runtime/message-reducer.spec.ts +++ b/libs/langgraph/src/runtime/message-reducer.spec.ts @@ -20,6 +20,49 @@ function message( } describe('pure text and tool transitions', () => { + it('uses explicit reasoning modes, preserving omission and appending even repeated or prefix-equal deltas', () => { + let state = initialMessageState(); + const apply = ( + mode: 'delta' | 'snapshot' | 'canonical', + reasoning?: string, + generation = 'run-1' + ) => { + state = reduceMessages(state, { + type: 'message', + mode, + message: { + ...message('m', ''), + reasoning, + delivery: streamingDelivery(generation), + }, + }); + return state.messages[0].reasoning; + }; + expect(apply('delta', 'A')).toBe('A'); + expect(apply('delta', 'A')).toBe('AA'); + expect(apply('delta', 'AA')).toBe('AAAA'); + expect(apply('delta')).toBe('AAAA'); + expect(apply('snapshot')).toBe('AAAA'); + expect(apply('snapshot', 'B')).toBe('B'); + expect(apply('snapshot', '')).toBe(''); + expect(apply('delta', 'Long')).toBe('Long'); + expect(apply('canonical', 'C')).toBe('C'); + expect(apply('delta', 'Late')).toBe('C'); + expect(apply('snapshot', 'Late')).toBe('C'); + expect(apply('canonical', '')).toBe(''); + expect(apply('canonical')).toBeUndefined(); + expect(apply('snapshot', 'Late')).toBeUndefined(); + expect(apply('canonical', 'Corrected')).toBe('Corrected'); + expect(apply('delta', undefined, 'run-2')).toBeUndefined(); + expect(apply('delta', 'New', 'run-2')).toBe('New'); + state = reduceMessages(state, { + type: 'complete', + generation: 'run-2', + outcome: 'success', + }); + expect(apply('delta', 'Late', 'run-2')).toBe('New'); + expect(state.messages[0].delivery.phase).toBe('complete'); + }); it('replaces citation lists, preserves omitted interim metadata and shares equal metadata across text changes', () => { const citations = [{ id: 'c', index: 1, extra: { nested: ['original'] } }]; const first = reduceMessages(initialMessageState(), { diff --git a/libs/langgraph/src/runtime/message-reducer.ts b/libs/langgraph/src/runtime/message-reducer.ts index 60182270a..aa39fc580 100644 --- a/libs/langgraph/src/runtime/message-reducer.ts +++ b/libs/langgraph/src/runtime/message-reducer.ts @@ -194,6 +194,16 @@ export function reduceMessages( ...incoming, id, content, + reasoning: + sameGeneration && event.mode !== 'canonical' + ? canonical + ? previous.reasoning + : incoming.reasoning === undefined + ? previous.reasoning + : event.mode === 'delta' + ? (previous.reasoning ?? '') + incoming.reasoning + : incoming.reasoning + : incoming.reasoning, citations: sameGeneration && event.mode !== 'canonical' ? canonical diff --git a/libs/langgraph/src/runtime/ownership.ts b/libs/langgraph/src/runtime/ownership.ts index b746d01ef..633ee7c93 100644 --- a/libs/langgraph/src/runtime/ownership.ts +++ b/libs/langgraph/src/runtime/ownership.ts @@ -170,6 +170,7 @@ export function sameMessage(a: Message, b: Message): boolean { (a.id === b.id && a.role === b.role && a.content === b.content && + a.reasoning === b.reasoning && sameOwnedValue( a.citations as unknown as PlainValue, b.citations as unknown as PlainValue @@ -195,6 +196,7 @@ export function ownMessage(message: Message, previous?: Message): Message { id: message.id, role: message.role, content: message.content, + reasoning: message.reasoning, citations, delivery: message.delivery.phase === 'complete' diff --git a/libs/langgraph/src/runtime/publication.spec.ts b/libs/langgraph/src/runtime/publication.spec.ts index 659acdedb..da1a83015 100644 --- a/libs/langgraph/src/runtime/publication.spec.ts +++ b/libs/langgraph/src/runtime/publication.spec.ts @@ -33,6 +33,46 @@ function snapshot(content = 'one'): LangGraphSnapshot { } describe('private snapshot publication', () => { + it('publishes reasoning-only changes, owns queued getter strings and suppresses equal values', () => { + const publication = createPublication(snapshot()); + let reasoning = 'Captured'; + const input = (): LangGraphSnapshot => ({ + ...snapshot(), + messages: [ + { + ...snapshot().messages[0], + get reasoning() { + return reasoning; + }, + }, + ], + }); + const seen: LangGraphSnapshot[] = []; + publication.subscribe(() => { + seen.push(publication.getSnapshot()); + if (seen.length === 1) { + publication.publish(input()); + publication.publish(input()); + reasoning = 'Mutated'; + } + }); + publication.publish({ + ...snapshot(), + messages: [{ ...snapshot().messages[0], reasoning: 'First' }], + }); + expect(seen).toHaveLength(2); + expect(seen[0].messages[0].reasoning).toBe('First'); + expect(seen[1].messages[0].reasoning).toBe('Captured'); + const before = publication.getSnapshot(); + publication.publish({ + ...snapshot(), + messages: [{ ...snapshot().messages[0], reasoning: 'Captured' }], + }); + expect(publication.getSnapshot()).toBe(before); + publication.publish(snapshot()); + expect(publication.getSnapshot().messages[0].reasoning).toBeUndefined(); + expect(seen).toHaveLength(3); + }); it('captures citation ingress during synchronous reentrant publication and suppresses an equal queued value', () => { const publication = createPublication(snapshot()); const citations = [ diff --git a/libs/langgraph/src/runtime/reasoning-projection.spec.ts b/libs/langgraph/src/runtime/reasoning-projection.spec.ts new file mode 100644 index 000000000..555a24ae2 --- /dev/null +++ b/libs/langgraph/src/runtime/reasoning-projection.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { projectReasoning } from './reasoning-projection'; + +describe('owned reasoning projection', () => { + it.each(['top', ''])('prefers the top-level string %j', (reasoning) => { + expect( + projectReasoning({ + reasoning, + additional_kwargs: { reasoning_content: 'other' }, + content: [{ type: 'reasoning', text: 'block' }], + }) + ).toBe(reasoning); + }); + it.each(['kwargs', ''])( + 'falls through unsupported top-level data to kwargs %j', + (reasoning_content) => { + expect( + projectReasoning({ + reasoning: {}, + additional_kwargs: { reasoning_content }, + content: [{ type: 'reasoning', text: 'block' }], + }) + ).toBe(reasoning_content); + } + ); + it('concatenates supported block strings and summaries in order without duplicating thinking', () => { + const raw = { + reasoning: 1, + additional_kwargs: { reasoning_content: [] }, + content: [ + { type: 'text', text: 'Answer' }, + { + type: 'reasoning', + text: 'A', + summary: [{ text: 'B' }, { text: 4 }, { text: 'C' }], + }, + { type: 'thinking', thinking: 'D', summary: [{ text: 'E' }] }, + { type: 'thinking', text: '', thinking: 'Ignored' }, + { type: 'thinking', text: 'F', thinking: 'Ignored' }, + ], + }; + expect(projectReasoning(raw)).toBe('ABCDEF'); + expect(Object.isFrozen(raw.content)).toBe(false); + }); + it.each([ + { content: 'Answer' }, + { + content: [ + { type: 'text', text: 'Answer' }, + { type: 'image', text: 'Image' }, + { type: 'tool', text: 'Tool' }, + ], + }, + { metadata: { reasoning: 'Other' } }, + {}, + ])('does not infer reasoning from unsupported data %j', (raw) => { + expect(projectReasoning(raw)).toBeUndefined(); + }); + it.each(['reasoning', 'thinking'])( + 'distinguishes an empty recognized %s block from absence', + (type) => { + expect( + projectReasoning({ + content: [{ type, text: {}, summary: [null, { text: [] }] }], + }) + ).toBe(''); + } + ); + it('captures supported getter strings once', () => { + let reads = 0; + const raw = { + get reasoning() { + return ++reads === 1 ? 'Captured' : 'Changed'; + }, + }; + expect(projectReasoning(raw)).toBe('Captured'); + expect(reads).toBe(1); + }); +}); diff --git a/libs/langgraph/src/runtime/reasoning-projection.ts b/libs/langgraph/src/runtime/reasoning-projection.ts new file mode 100644 index 000000000..756876d3e --- /dev/null +++ b/libs/langgraph/src/runtime/reasoning-projection.ts @@ -0,0 +1,34 @@ +import { record } from './wire-message'; + +/** Supported backend display text only; absence and explicit empty stay distinct. */ +export function projectReasoning( + raw: Record +): string | undefined { + const reasoning = raw['reasoning']; + if (typeof reasoning === 'string') return reasoning; + const extra = record(raw['additional_kwargs']); + const supplied = extra?.['reasoning_content']; + if (typeof supplied === 'string') return supplied; + const content = raw['content']; + if (!Array.isArray(content)) return undefined; + let result: string | undefined; + for (const value of content) { + const block = record(value); + const type = block?.['type']; + if (!block || (type !== 'reasoning' && type !== 'thinking')) continue; + result ??= ''; + const text = block['text']; + if (typeof text === 'string') result += text; + else if (type === 'thinking') { + const thinking = block['thinking']; + if (typeof thinking === 'string') result += thinking; + } + const summary = block['summary']; + if (Array.isArray(summary)) + for (const entry of summary) { + const text = record(entry)?.['text']; + if (typeof text === 'string') result += text; + } + } + return result; +} diff --git a/libs/langgraph/src/runtime/reasoning.spec.ts b/libs/langgraph/src/runtime/reasoning.spec.ts new file mode 100644 index 000000000..462589d40 --- /dev/null +++ b/libs/langgraph/src/runtime/reasoning.spec.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import type { AgentTransport, StreamEvent } from './transport.types'; +import { controlledSession } from './testing/controlled-session'; +import { controlledTransport } from './testing/controlled-transport'; +import { deferred } from './testing/deferred'; +import { + fixture as checkpointFixture, + position, + saved, +} from './testing/checkpoint-fixture'; + +const ai = (reasoning?: unknown, content = 'Answer') => ({ + type: 'ai', + id: 'answer', + content, + reasoning, +}); +const frame = (reasoning?: unknown, content = 'Answer'): StreamEvent => ({ + type: 'values', + data: { messages: [ai(reasoning, content)] }, +}); +const delta = (reasoning?: unknown, content = ''): StreamEvent => ({ + type: 'messages', + messageMetadata: {}, + messages: [{ ...ai(reasoning, content), type: 'AIMessageChunk' }], +}); +const cleanups: (() => Promise)[] = []; +function fixture() { + const f = controlledSession(); + cleanups.push(() => f.session.dispose()); + return f; +} +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +describe('reasoning through actual session ownership', () => { + it('captures history before later getters mutate it, retains equal identity and clears omissions', async () => { + const f = fixture(); + const raw = ai('Saved'); + f.getHistory.mockResolvedValueOnce([ + saved('history', [], { + values: { + messages: [raw], + get application() { + raw.reasoning = 'Mutated'; + return true; + }, + }, + }), + ]); + await f.session.load!(); + const before = f.session.getSnapshot(); + expect(before.messages[0].reasoning).toBe('Saved'); + f.getHistory.mockResolvedValue([ + saved('history', [], { + values: { messages: [ai('Saved')], application: true }, + }), + ]); + await f.session.load!(); + expect(f.session.getSnapshot()).toBe(before); + f.getHistory.mockResolvedValue([saved('history', [ai('Changed')])]); + await f.session.load!(); + expect(f.session.getSnapshot().messages[0].reasoning).toBe('Changed'); + f.getHistory.mockResolvedValue([saved('history', [ai('')])]); + await f.session.load!(); + expect(f.session.getSnapshot().messages[0].reasoning).toBe(''); + f.getHistory.mockResolvedValue([saved('history', [ai()])]); + await f.session.load!(); + expect(f.session.getSnapshot().messages[0].reasoning).toBeUndefined(); + expect(before.messages[0].reasoning).toBe('Saved'); + expect(Object.isFrozen(raw)).toBe(false); + expect(f.stream).not.toHaveBeenCalled(); + }); + + it('appends chunks verbatim, replaces cumulative snapshots and captures canonical correction before source mutation', async () => { + const f = fixture(); + const run = f.session.submit('Go'); + await f.started(); + await f.emit(delta('A', 'A')); + await f.emit(delta('A')); + await f.emit(delta('AA')); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBe('AAAA'); + await f.emit(delta(undefined, 'B')); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBe('AAAA'); + await f.emit(frame('Short', 'AB')); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBe('Short'); + await f.emit(frame('', 'AB')); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBe(''); + const raw = ai('Corrected', 'Final'); + await f.emit({ type: 'values', data: { messages: [raw] } }); + const captured = f.session.getSnapshot().messages.at(-1)!; + raw.reasoning = 'Mutated'; + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(f.session.getSnapshot().messages.at(-1)).toMatchObject({ + reasoning: 'Corrected', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(captured.reasoning).toBe('Corrected'); + }); + + it('canonical omission clears retained interim reasoning on completion', async () => { + const f = fixture(); + const run = f.session.submit('Go'); + await f.started(); + await f.emit(delta('Interim')); + await f.emit(frame(undefined, 'Final')); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBe('Interim'); + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(f.session.getSnapshot().messages.at(-1)?.reasoning).toBeUndefined(); + }); + + it.each(['history', 'stream'] as const)( + 'bars a stale owner reentered through a %s reasoning getter', + async (mode) => { + const f = fixture(); + let nested: Promise | undefined; + let fired = false; + const raw = { + ...ai(), + get reasoning() { + if (!fired) { + fired = true; + nested = f.session.submit('Replacement'); + } + return 'Stale'; + }, + }; + if (mode === 'history') { + f.getHistory.mockResolvedValue([saved('stale', [raw])]); + await f.session.load!(); + await f.started(); + } else { + const run = f.session.submit('Original'); + await f.started(); + await f.emit({ type: 'values', data: { messages: [raw] } }); + await run; + await f.started(1); + } + expect(fired).toBe(true); + expect( + f.session.getSnapshot().messages.some((m) => m.reasoning === 'Stale') + ).toBe(false); + await f.session.stop(); + await nested; + } + ); + + it('retains stopped reasoning while isolating stale attempts and namespaced children', async () => { + const f = fixture(); + const old = f.session.submit('Old'); + await f.started(); + await f.emit(frame('Old')); + await f.session.stop(); + expect(await old).toBe('aborted'); + const stopped = f.session.getSnapshot(); + const run = f.session.submit('New'); + await f.started(1); + f.streams[0].release(frame('Stale')); + await f.emit(frame('Root'), 1); + await f.emit({ ...frame('Child'), namespace: ['child'] }, 1); + await f.emit({ ...frame('Sibling'), namespace: ['sibling'] }, 1); + const retained = f.session.getSnapshot(); + expect(retained.messages.find((m) => m.id === 'answer')?.reasoning).toBe( + 'Root' + ); + expect(retained.subgraphs.map((s) => s.messages[0].reasoning)).toEqual([ + 'Child', + 'Sibling', + ]); + await f.emit({ ...frame('Changed'), namespace: ['child'] }, 1); + expect(f.session.getSnapshot().subgraphs[1]).toBe(retained.subgraphs[1]); + expect(retained.subgraphs[0].messages[0].reasoning).toBe('Child'); + f.streams[1].finish(); + await run; + expect(stopped.messages.at(-1)).toMatchObject({ + reasoning: 'Old', + delivery: { outcome: 'aborted' }, + }); + expect(f.stream).toHaveBeenCalledTimes(2); + expect(f.getHistory).not.toHaveBeenCalled(); + }); + + it('does not inherit paused reasoning when a resumed delta establishes a new generation', async () => { + const f = fixture(); + f.getHistory.mockResolvedValue([ + saved('paused', [ai('Paused')], { + tasks: [ + { + id: 'task', + name: 'approval', + interrupts: [{ id: 'approve', value: 'Continue?' }], + }, + ], + next: ['approval'], + }), + ]); + await f.session.load!(); + const paused = f.session.getSnapshot(); + expect(paused.messages[0].reasoning).toBe('Paused'); + const run = f.session.resume(true); + await f.started(); + await f.emit(delta(undefined, 'Resumed')); + const current = f.session.getSnapshot().messages[0]; + expect(current.delivery.generation).not.toBe( + paused.messages[0].delivery.generation + ); + expect(current.reasoning).toBeUndefined(); + await f.emit(frame('Fresh', 'Done')); + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(f.session.getSnapshot().messages[0].reasoning).toBe('Fresh'); + expect(f.session.getSnapshot().messages).toHaveLength(1); + expect(f.stream).toHaveBeenCalledOnce(); + }); + + it('retains same physical-run reasoning while reconnecting then accepts canonical clearing', async () => { + const stream = vi.fn(async function* ( + _a, + _t, + _i, + _s, + options + ) { + options?.onRunCreated?.({ run_id: 'run', thread_id: 'thread' }); + yield { ...delta('Retained', 'Partial'), sseId: '1' }; + }); + const joined = controlledTransport(); + const started = deferred(); + const joinStream = vi.fn>(() => { + started.resolve(); + return joined.stream; + }); + const getRunStatus = vi.fn>( + async () => 'running' + ); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { stream, joinStream, getRunStatus }, + }); + cleanups.push(() => session.dispose()); + await session.submit('Go'); + const interrupted = session.getSnapshot(); + expect(interrupted.messages.at(-1)?.reasoning).toBe('Retained'); + getRunStatus.mockResolvedValue('success'); + const run = session.reconnect(); + await started.promise; + expect(session.getSnapshot().messages.at(-1)?.reasoning).toBe('Retained'); + joined.release({ ...frame(undefined, 'Final'), sseId: '2' }); + joined.finish(); + expect(await run).toBe('success'); + expect(session.getSnapshot().messages.at(-1)?.reasoning).toBeUndefined(); + expect(interrupted.messages.at(-1)?.reasoning).toBe('Retained'); + expect(stream).toHaveBeenCalledOnce(); + expect(joinStream).toHaveBeenCalledOnce(); + expect( + session.getSnapshot().messages.filter((m) => m.role === 'user') + ).toHaveLength(1); + }); + + it('retains reasoning through exact checkpoint fork and load without executing historical tools', async () => { + const f = checkpointFixture(); + f.source.values = { + messages: [ + { + ...ai('Historical'), + tool_calls: [{ id: 'old', name: 'work', args: {} }], + }, + { type: 'tool', id: 'result', tool_call_id: 'old', content: 'Saved' }, + ], + }; + const handler = vi.fn(() => 'must not execute'); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: f.transport, + tools: { work: { description: 'Work', handler } }, + }); + cleanups.push(() => session.dispose()); + expect(await session.fork(position('a'), 'Continue')).toBe('success'); + expect(session.getSnapshot().messages[0].reasoning).toBe('Historical'); + const branch = f.states.get('result-1')!; + ( + branch.values as { messages: ReturnType[] } + ).messages[0].reasoning = 'Reloaded'; + await session.load!(); + expect(session.getSnapshot().messages[0].reasoning).toBe('Reloaded'); + expect(f.transport.getState).toHaveBeenCalledTimes(3); + expect(vi.mocked(f.transport.getState!).mock.calls[2][1]).toEqual( + position('result-1') + ); + expect(f.transport.stream).toHaveBeenCalledOnce(); + expect(f.transport.getHistory).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + }); + + it.each(['Date', 'cycle'] as const)( + 'keeps strict %s checkpoint ingress rejected before display normalization', + async (kind) => { + const f = checkpointFixture(); + const cycle: Record = {}; + cycle['self'] = cycle; + const raw = kind === 'Date' ? new Date() : cycle; + f.source.values = { messages: [ai(raw)] }; + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: f.transport, + }); + cleanups.push(() => session.dispose()); + const before = session.getSnapshot(); + await expect(session.fork(position('a'), 'Rejected')).rejects.toThrow(); + expect(session.getSnapshot()).toBe(before); + expect(f.transport.getState).toHaveBeenCalledOnce(); + expect(f.transport.stream).not.toHaveBeenCalled(); + expect(Object.isFrozen(raw)).toBe(false); + } + ); +}); diff --git a/libs/langgraph/src/runtime/stream-projection.ts b/libs/langgraph/src/runtime/stream-projection.ts index b2a97611d..79a956d3f 100644 --- a/libs/langgraph/src/runtime/stream-projection.ts +++ b/libs/langgraph/src/runtime/stream-projection.ts @@ -14,6 +14,7 @@ import type { StreamEvent } from './transport.types'; import { ownMessage, ownToolCall } from './ownership'; import { record, roleOf, textContent } from './wire-message'; import { projectCitations } from './citation-projection'; +import { projectReasoning } from './reasoning-projection'; export { record } from './wire-message'; @@ -172,6 +173,7 @@ export function projectStream( role, content, citations: projectCitations(raw), + reasoning: projectReasoning(raw), delivery: !current && previous ? previous.delivery diff --git a/libs/langgraph/src/runtime/testing/controlled-session.ts b/libs/langgraph/src/runtime/testing/controlled-session.ts new file mode 100644 index 000000000..6a8c2034c --- /dev/null +++ b/libs/langgraph/src/runtime/testing/controlled-session.ts @@ -0,0 +1,54 @@ +import { vi } from 'vitest'; +import { createSession } from '../create-session'; +import type { AgentTransport, StreamEvent } from '../transport.types'; +import { controlledTransport } from './controlled-transport'; +import { deferred } from './deferred'; + +export function controlledSession() { + const streams: ReturnType>[] = []; + const starts = Array.from({ length: 4 }, () => deferred()); + const acknowledgments: ReturnType>[] = []; + const stream = vi.fn((_a, _t, _input, signal) => { + const index = streams.length; + const controlled = controlledTransport({ + signal, + ignoreAbort: true, + }); + streams.push(controlled); + starts[index].resolve(); + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return iterator; + }, + next() { + acknowledgments[index]?.resolve(); + return controlled.stream.next(); + }, + return() { + acknowledgments[index]?.resolve(); + return controlled.stream.return(); + }, + }; + return iterator; + }); + const getHistory = vi.fn>( + async () => [] + ); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { stream, getHistory }, + }); + return { + session, + stream, + streams, + getHistory, + started: (index = 0) => starts[index].promise, + async emit(event: StreamEvent, index = 0) { + acknowledgments[index] = deferred(); + streams[index].release(event); + await acknowledgments[index].promise; + }, + }; +} diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 027950de4..574bb250c 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baselineHead": "4d47995333278f9a19491925bc555d30f8490956", + "baselineHead": "9ddeb1579323898cb5e470d658cfa53852931830", "sourceState": { "modified": [ "libs/langgraph/src/runtime/README.md", @@ -10,7 +10,8 @@ "libs/langgraph/src/runtime/stream-projection.ts" ], "untracked": [ - "libs/langgraph/src/runtime/citation-projection.ts" + "libs/langgraph/src/runtime/reasoning-projection.ts", + "libs/langgraph/src/runtime/testing/controlled-session.ts" ] }, "scope": { @@ -600,7 +601,7 @@ "id": "asset:libs/langgraph/src/runtime/README.md", "kind": "asset", "path": "libs/langgraph/src/runtime/README.md", - "sha256": "289b901753126e898d40f9a84c093afeb630a7f78692ef6bf867e955e1c77f48" + "sha256": "5a1897fc817d9ece371923f369193dbd2ee5be5f2aaf4da8cacbb05fb752e66c" }, { "id": "asset:libs/langgraph/test/fixtures/streaming-reasoning-puzzle.json", @@ -12522,7 +12523,7 @@ "id": "source:libs/langgraph/src/runtime/history-projection.ts", "kind": "source", "path": "libs/langgraph/src/runtime/history-projection.ts", - "sha256": "c9b18e186261d16d6b363ddc906793acbf3295e4a5c45e93bbf2d555bd2b2e92" + "sha256": "4e5e7d27e2ae9429274ac47ed979d6b94848dde9b1d03018af39ad888f236fd1" }, { "id": "source:libs/langgraph/src/runtime/interrupt-projection.ts", @@ -12540,7 +12541,7 @@ "id": "source:libs/langgraph/src/runtime/message-reducer.ts", "kind": "source", "path": "libs/langgraph/src/runtime/message-reducer.ts", - "sha256": "5758caaae8c31361a9361b81a7995239c10a48bf670a135a9fe6a327da2e6531" + "sha256": "714be5a4c60d442f84f46b08ac12b43ac901fe6005cf2ba5f35dfb8bfa33f249" }, { "id": "source:libs/langgraph/src/runtime/operation-errors.ts", @@ -12552,7 +12553,7 @@ "id": "source:libs/langgraph/src/runtime/ownership.ts", "kind": "source", "path": "libs/langgraph/src/runtime/ownership.ts", - "sha256": "0cfc4dabfedbc3aa9406b943257ab3df3bae240276ccaec6d50f1e51f11e5c69" + "sha256": "3d9e0cad3c64b2884fd3049c78e6a844115efa6c839fbe997b17f7defc639641" }, { "id": "source:libs/langgraph/src/runtime/publication.ts", @@ -12560,6 +12561,12 @@ "path": "libs/langgraph/src/runtime/publication.ts", "sha256": "7d290721417d504313f893ee7e90154b85e9eee27d05f6cce816d592495791e5" }, + { + "id": "source:libs/langgraph/src/runtime/reasoning-projection.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/reasoning-projection.ts", + "sha256": "3f408da1d2ca5a28af466c11d49391a524117cd705d0363a2bf1bca2ad659cc5" + }, { "id": "source:libs/langgraph/src/runtime/run-options.ts", "kind": "source", @@ -12576,7 +12583,7 @@ "id": "source:libs/langgraph/src/runtime/stream-projection.ts", "kind": "source", "path": "libs/langgraph/src/runtime/stream-projection.ts", - "sha256": "076cf6eb83b633d67a42118d7f17931b3f5010ca9f00b7c15e97bf5214f42240" + "sha256": "0462792cfff288ce4e4345bbcbfc32f1e162db35fe94f26b348a12ab064ccae4" }, { "id": "source:libs/langgraph/src/runtime/subgraph-projection.ts", @@ -12602,6 +12609,12 @@ "path": "libs/langgraph/src/runtime/testing/checkpoint-fixture.ts", "sha256": "3afa6abd58e1ec630f1b66ce7f4ff7da83023c559564f00dd4f9f6df58434fb3" }, + { + "id": "source:libs/langgraph/src/runtime/testing/controlled-session.ts", + "kind": "source", + "path": "libs/langgraph/src/runtime/testing/controlled-session.ts", + "sha256": "36a297e2bb35627c49bcf531df46f4c3bfb8aacef85cf8b22bb906c332b82072" + }, { "id": "source:libs/langgraph/src/runtime/testing/controlled-transport.ts", "kind": "source", diff --git a/scripts/react-parity/dispositions.json b/scripts/react-parity/dispositions.json index bf2c4f188..6be0819ec 100644 --- a/scripts/react-parity/dispositions.json +++ b/scripts/react-parity/dispositions.json @@ -11353,7 +11353,22 @@ "treatment": "internal", "status": "in-progress", "reason": "Private owned citation metadata projection, verified through both installed native bindings; not a public backend cutover.", - "note": "Normalizes existing aliases with absent/empty distinction, finite primitive timestamps and deeply owned plain extras. Rich blocks, reasoning/timing, citation UI and full T09/T15 parity remain open." + "note": "Normalizes existing aliases with absent/empty distinction, finite primitive timestamps and deeply owned plain extras. Rich blocks, reasoning timing, citation UI and full T09/T15 parity remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/reasoning-projection.ts", + "taskIds": ["T09", "T15"], + "treatment": "internal", + "status": "in-progress", + "reason": "Private owned reasoning display-string projection through both installed native bindings; no public backend cutover.", + "note": "Explicit source precedence and delta/snapshot/canonical modes, immutable publication and lifecycle ownership are covered. Timing, rich blocks, event-only retention, UI and full T09/T15 parity remain open." + }, + { + "id": "source:libs/langgraph/src/runtime/testing/controlled-session.ts", + "taskIds": ["T09", "T15"], + "treatment": "internal", + "status": "in-progress", + "reason": "Shared test-only controlled session fixture for owned citation and reasoning regressions; excluded from production imports and public entry points." }, { "id": "source:libs/langgraph/src/runtime/run-options.ts", diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs index 5fe706d36..7e287c824 100644 --- a/scripts/react-parity/runtime-consumer.mjs +++ b/scripts/react-parity/runtime-consumer.mjs @@ -45,7 +45,7 @@ const savedHistory = [{ { id: 'saved-count', name: 'count', args: { values: ['saved'] }, type: 'tool_call' }, ] }, { id: 'saved-result', type: 'tool', tool_call_id: 'saved-weather', content: 'Raw historical weather result' }, - { id: 'saved-final', type: 'ai', content: [{ type: 'text', text: 'Saved final answer' }], additional_kwargs: { sources: [{ refId: 'saved-source', name: 'Saved reference', publishedAt: '2026-09-24', extra: { provider: { labels: ['history'] } } }] } }, + { id: 'saved-final', type: 'ai', reasoning: 'Saved reasoning', content: [{ type: 'text', text: 'Saved final answer' }], additional_kwargs: { sources: [{ refId: 'saved-source', name: 'Saved reference', publishedAt: '2026-09-24', extra: { provider: { labels: ['history'] } } }] } }, ] }, next: ['review', 'confirmation'], tasks: savedInterrupts.map((interrupt, index) => ({ id: `saved-task-${index}`, name: index === 0 ? 'review' : 'confirmation', error: null, checkpoint: null, state: null, interrupts: [interrupt] })), @@ -73,7 +73,7 @@ export function runtimeResponse(body) { assert.deepEqual(body, { assistant_id: 'fixture-assistant', input: null, command: { resume: response }, stream_mode: ['values', 'messages-tuple', 'updates', 'custom'], stream_subgraphs: true, ...resumableRunFields, ...configuredRunFields }, 'exact resume run fields'); if (Object.hasOwn(response ?? {}, 'live-approval')) { assert.deepEqual(response, { 'live-approval': 'yes', 'live-confirmation': false }, 'exact initial response map'); - return sse('values', { stage: 'final-approval', messages: [{ type: 'ai', id: 'resume-answer', content: 'One final approval', additional_kwargs: { citations: [{ id: 'approval-source', title: 'Approval reference', publishedAt: 0 }] } }] }) + return sse('values', { stage: 'final-approval', messages: [{ type: 'ai', id: 'resume-answer', content: 'One final approval', additional_kwargs: { reasoning_content: 'Approval reasoning', citations: [{ id: 'approval-source', title: 'Approval reference', publishedAt: 0 }] } }] }) + sse('values|review:child', { stage: 'child-final-approval', messages: [{ type: 'ai', id: 'child-review', content: 'Child final approval' }] }) + sse('updates|review:child', { __interrupt__: [{ id: 'child-final', value: 'Child confirmation' }] }) + sse('updates', { __interrupt__: [{ id: 'final-approval', value: { question: 'Confirm final action?' } }] }); @@ -530,6 +530,7 @@ export async function runRuntimeScenarios(directory, kind) { assert.deepEqual(await children(), []); await expect(page.getByTestId('history')).toHaveText('unobserved'); await expect(page.getByTestId('citations')).toHaveText(''); + await expect(page.getByTestId('reasoning')).toHaveText(''); assert.equal(server.requests.length, 0, 'mount/observation performs no I/O'); assert.equal(server.historyRequests.length, 0, 'mount/observation performs no history reads'); completed.push('inert mount'); @@ -544,6 +545,7 @@ export async function runRuntimeScenarios(directory, kind) { await expectInterrupts(savedInterrupts); await expect(page.getByTestId('text')).toHaveText('Saved tool request\nSaved final answer'); await expect(page.getByTestId('citations')).toHaveText('saved-source: Saved reference'); + await expect(page.getByTestId('reasoning')).toHaveText('Saved reasoning'); await expect(page.getByTestId('transcript')).toContainText('Saved question'); await expect(page.getByTestId('transcript')).toContainText('Raw historical weather result'); await expect(page.getByTestId('delivery')).toHaveText('complete:paused'); @@ -567,6 +569,7 @@ export async function runRuntimeScenarios(directory, kind) { assert.equal(server.requests.length, 0); completed.push('equal history refresh'); await expect(page.getByTestId('citations')).toHaveText('saved-source: Saved reference'); + await expect(page.getByTestId('reasoning')).toHaveText('Saved reasoning'); await page.getByRole('button', { name: 'Load', exact: true }).click(); await expect(page.getByTestId('loads-finished')).toHaveText('3'); @@ -577,6 +580,7 @@ export async function runRuntimeScenarios(directory, kind) { await expect(page.getByTestId('text')).toHaveText(''); await expect(page.getByTestId('transcript')).toHaveText(''); await expect(page.getByTestId('citations')).toHaveText(''); + await expect(page.getByTestId('reasoning')).toHaveText(''); await expect(page.getByTestId('tool')).toHaveText('[]'); await expect(page.getByTestId('handler-calls')).toHaveText('0'); assert.equal(server.historyRequests.length, 3); @@ -658,6 +662,7 @@ export async function runRuntimeScenarios(directory, kind) { await expect(page.getByTestId('delivery')).toHaveText('complete:paused'); await expect(page.getByTestId('text')).toContainText('One final approval'); await expect(page.getByTestId('citations')).toHaveText('approval-source: Approval reference'); + await expect(page.getByTestId('reasoning')).toHaveText('Approval reasoning'); await expectInterrupts([{ id: 'final-approval', value: { question: 'Confirm final action?' } }]); await expectValues({ stage: 'final-approval' }); await expectChild(['review:child'], 'Child final approval', 'complete:paused', { stage: 'child-final-approval' }, [{ id: 'child-final', value: 'Child confirmation' }]); @@ -674,6 +679,7 @@ export async function runRuntimeScenarios(directory, kind) { await expect(page.getByTestId('text')).toContainText('Approvals complete'); await expect(page.getByTestId('text')).not.toContainText('One final approval'); await expect(page.getByTestId('citations')).toHaveText(''); + await expect(page.getByTestId('reasoning')).toHaveText(''); await expectInterrupts([]); await expectValues({ stage: 'approved' }); await expectChild(['review:child'], 'Child approved', 'complete:success', { stage: 'child-approved' }); From 9584941ada9a977b3f1b890a79073d622ecc01f2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 24 Sep 2026 12:23:50 -0700 Subject: [PATCH 2/2] fix(runtime): preserve supported visible text blocks --- fixtures/react-parity/runtime/README.md | 8 + fixtures/react-parity/runtime/evidence.json | 661 ++++++++++++++++++ libs/langgraph/src/runtime/README.md | 21 + .../src/runtime/history-projection.spec.ts | 53 ++ .../src/runtime/stream-projection.spec.ts | 37 + .../src/runtime/visible-text.spec.ts | 246 +++++++ .../src/runtime/wire-message.spec.ts | 131 ++++ libs/langgraph/src/runtime/wire-message.ts | 9 +- scripts/react-parity/baseline.json | 16 +- scripts/react-parity/runtime-consumer.mjs | 11 +- .../react-parity/runtime-consumer.spec.mjs | 17 +- 11 files changed, 1194 insertions(+), 16 deletions(-) create mode 100644 libs/langgraph/src/runtime/visible-text.spec.ts create mode 100644 libs/langgraph/src/runtime/wire-message.spec.ts diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md index 81a0acaf7..293f3beb5 100644 --- a/fixtures/react-parity/runtime/README.md +++ b/fixtures/react-parity/runtime/README.md @@ -575,6 +575,14 @@ and no run requests or handler calls. Every completed load must leave its visibl error output empty, so retained text cannot conceal a failed equal refresh. Both registered handlers increment the same counter if executed. Request bodies check the catalog and actual serialized ToolMessage payload. +The saved final answer combines a string entry, `output_text`, untyped text and an +empty `text` block. Its visible text remains exactly `Saved final answer` through +the initial and equal history reads. Reasoning, image and tool-shaped blocks with +text fields stay out of the answer; explicit `Saved reasoning` still wins reasoning +precedence, and saved citations remain visible. The Node fixture test checks the +raw response shapes. Reverting only the private helper to typed `text` entries is +the semantic negative control for both installed consumers. This is text projection +coverage, not rich-media rendering, new tool admission or provider conformance. 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 diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index a6058e593..6aac25a09 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -1023,5 +1023,666 @@ "knownToolingWarnings": [ "Existing NO_COLOR/FORCE_COLOR and browserslist-age warnings; Vite ignores native React use-client directives during bundling. All recorded gates above passed." ] + }, + "visibleTextProjection": { + "observedOn": "2026-09-24", + "scope": "Private shared visible-answer string projection: preserve string entries, output_text and untyped text without changing authority, transport, core or native binding code.", + "source": { + "baseCommit": "c16c509e6fbd73ceafa47f9af33873662fc2b030", + "workingTree": "Uncommitted visible-text increment on the reviewed reasoning predecessor. Selected source hashes identify tested bytes, not a future delivery commit. Prior milestone records are preserved.", + "algorithm": "SHA-256(file bytes); unique repo-relative paths sorted by JavaScript default string ordering, then manifest SHA-256 over lowercase sha256 + two ASCII spaces + path + LF for each input in that order. Evidence excludes itself.", + "inputs": [ + { + "path": "fixtures/react-parity/runtime/README.md", + "sha256": "63b6de98a4dabe87e2fdb5f024cf3712afa1ecef586ab2fde04b5d7ec72f1f66" + }, + { + "path": "fixtures/react-parity/runtime/angular-app.ts", + "sha256": "5c5dd1cb9c6fb4cb2a9f13345fad3f08ddab175606a4fab22f83d76985ec7cd1" + }, + { + "path": "fixtures/react-parity/runtime/installed-types.ts", + "sha256": "08fbaed23db4f9763707df0b495737f87428d1189de63f63adade369793665f3" + }, + { + "path": "fixtures/react-parity/runtime/react-app.tsx", + "sha256": "5c3020c64d2fd102ba6255856a99b08321d00bb812cc56b2910cb728befc3f0e" + }, + { + "path": "fixtures/react-parity/runtime/runtime-entry.ts", + "sha256": "72509ac95343b1e2d09c825d5017408fcd66891d5efb96e6e742270baf90aebd" + }, + { + "path": "fixtures/react-parity/runtime/scenarios.ts", + "sha256": "5541e93b15cb8ef4c957f4b3cc53a4fb12cfc7f4b23ae40fa38eceff70a150db" + }, + { + "path": "libs/core/README.md", + "sha256": "638c4e0e0ed1bd4ff7e94512f1f5fc9589a3815ac35ad9df3b1aebe8bd5b48e4" + }, + { + "path": "libs/core/src/contracts/contracts.type-test.ts", + "sha256": "aaa8bc7327d346715a1d364063433f75d94aa8e1d7f64b386ce49b600d256bf4" + }, + { + "path": "libs/core/src/contracts/message.ts", + "sha256": "9bfa684f4793ccdfd6b3f566f5d00e1b7ee821fcd51d1ea84995ef5b4caa61f7" + }, + { + "path": "libs/langgraph/project.json", + "sha256": "4fa0e5f4b0d4b2a7fd8f8de7270dcce1f21b033c86bb031d75480e44667c29d3" + }, + { + "path": "libs/langgraph/src/runtime/README.md", + "sha256": "075fa7e026752a0e2c1caac1ae4bb467f9251f5b81deb6e4378a3bf82e22dda7" + }, + { + "path": "libs/langgraph/src/runtime/checkpoint-authority.ts", + "sha256": "f56912f077e4ba891f9a55419e89743d62f573c435c3984fb64ac0803271c3dc" + }, + { + "path": "libs/langgraph/src/runtime/citations.spec.ts", + "sha256": "6d08887785cdc0a40ea0d6ea8f4455d3cf599a16ad0a7cbde9b4f6b42313af35" + }, + { + "path": "libs/langgraph/src/runtime/create-session.ts", + "sha256": "f4c6a1e693d205cf48a7c9ec357ddf037bdcf4ceabe315c6529c6806fbdaefb1" + }, + { + "path": "libs/langgraph/src/runtime/history-projection.spec.ts", + "sha256": "89cdd63569aee72f8aff0046291d8435b16263ee456677e0bf03480736e4df3f" + }, + { + "path": "libs/langgraph/src/runtime/history-projection.ts", + "sha256": "4e5e7d27e2ae9429274ac47ed979d6b94848dde9b1d03018af39ad888f236fd1" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.spec.ts", + "sha256": "a4360839d00a32e02a985dd8dbd7bb5452797398327ee043d53f7825adc0561f" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.ts", + "sha256": "714be5a4c60d442f84f46b08ac12b43ac901fe6005cf2ba5f35dfb8bfa33f249" + }, + { + "path": "libs/langgraph/src/runtime/ownership.ts", + "sha256": "3d9e0cad3c64b2884fd3049c78e6a844115efa6c839fbe997b17f7defc639641" + }, + { + "path": "libs/langgraph/src/runtime/publication.spec.ts", + "sha256": "db037834e2a8de6bbc50042994d4e6e7d51c42a09719d26ee4d5b7436c80662e" + }, + { + "path": "libs/langgraph/src/runtime/publication.ts", + "sha256": "7d290721417d504313f893ee7e90154b85e9eee27d05f6cce816d592495791e5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.spec.ts", + "sha256": "0131f6cfbe863a537d582b0f249dd2cab38ff0e3bc58dc9d4c4b14c034882d44" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.ts", + "sha256": "3f408da1d2ca5a28af466c11d49391a524117cd705d0363a2bf1bca2ad659cc5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning.spec.ts", + "sha256": "91f8d9fbb8ecf71f308800709954d0647a70606bfb4b071f4967596d1af69b53" + }, + { + "path": "libs/langgraph/src/runtime/stream-projection.spec.ts", + "sha256": "bf23995c7c661b1f6304874ec917a30d0d59f76e341b9536c830dda1aae52c85" + }, + { + "path": "libs/langgraph/src/runtime/stream-projection.ts", + "sha256": "0462792cfff288ce4e4345bbcbfc32f1e162db35fe94f26b348a12ab064ccae4" + }, + { + "path": "libs/langgraph/src/runtime/testing/controlled-session.ts", + "sha256": "36a297e2bb35627c49bcf531df46f4c3bfb8aacef85cf8b22bb906c332b82072" + }, + { + "path": "libs/langgraph/src/runtime/visible-text.spec.ts", + "sha256": "9cf74154480118c69eaf10ec2d5f8ae4cfecf06d1c6f3cbbb87e1573f2fd5441" + }, + { + "path": "libs/langgraph/src/runtime/wire-message.spec.ts", + "sha256": "05042517d8c2984daf94001ce7538c3de7df7c0a1c092ec4cc7bfa35bcaa0c12" + }, + { + "path": "libs/langgraph/src/runtime/wire-message.ts", + "sha256": "28e27109a268fd8cbde6110b156e46b2d8429d896152075c6c7762f4d4af4ef7" + }, + { + "path": "libs/langgraph/tsconfig.runtime-tests.json", + "sha256": "a692d9d309837d9f37f0e18779a22d7a6f3a2fff1c96ce95a93ccbd0b81397dc" + }, + { + "path": "libs/langgraph/vite.runtime.config.mts", + "sha256": "2fb8014d6fb58ab6d7553b392ad57b0f2946260d36c2d6cb7c5ce4f95679341f" + }, + { + "path": "nx.json", + "sha256": "5a2b4d6d2a395d8c34b716ff2a2e3ca889c154f08dace74d694e6d0d09d8e9f4" + }, + { + "path": "package-lock.json", + "sha256": "530c28574a832ab39c15c9607717de0eb8ccfae2593e09c7ae373bd8936d0b64" + }, + { + "path": "package.json", + "sha256": "1454dd65be7cc56cf85c23eaf83c17953bb3d4b0dc98350cb1bdd392fbdade25" + }, + { + "path": "scripts/react-parity/baseline.json", + "sha256": "600615b536234da727d5b6a8939db0574a4671f4eec32a783943e9a2e08c88fd" + }, + { + "path": "scripts/react-parity/runtime-consumer.mjs", + "sha256": "0bf9976de398ecc66d88a5a10b357fde756e22489848afdaa4c05ff2a5607247" + }, + { + "path": "scripts/react-parity/runtime-consumer.spec.mjs", + "sha256": "e4d0e0522c2f103dc2c7fe0c58e7b368ce19a555ce6197c3fa4dbc33ea479c34" + }, + { + "path": "scripts/react-parity/verify-angular-package.mjs", + "sha256": "dbff070a0e72b5fa2ce25337b7d2ec9aebfadf6956e59fd4d634fdde8116d09e" + }, + { + "path": "scripts/react-parity/verify-packages.mjs", + "sha256": "2e04fae38616ba5bf58d9ffe7a75dd94bcd4c3b02c220f756aa06829a9f8e75f" + }, + { + "path": "tsconfig.base.json", + "sha256": "0948b24b699da2cd2e4bc61f35a0340d25dd21300245b9c04aabadaa32c48fdd" + } + ], + "manifestSha256": "cc8c601cd80f9e69efa4fbf1966d90914248ada25987022b09961f9d936efc84", + "manifest": "/tmp/visible-text-source-manifest.txt" + }, + "redGreen": { + "helperRed": { + "path": "/tmp/visible-text-helper-red.log", + "sha256": "806e9abd1bbb006174d9b2c7da11804221e092ca0339c98824e5092539afbbb2", + "tests": 14, + "failed": 5, + "passed": 9, + "expectedFailure": "String entries, output_text and untyped text return empty; mixed accepted forms lose visible text. No missing-module failure." + }, + "helperGreen": { + "path": "/tmp/visible-text-helper-green.log", + "sha256": "3f1495337c34695615306e8dff559671ccc1ec56eeb38f2cf8566936c969e028", + "tests": 14 + }, + "fixtureRed": { + "path": "/tmp/visible-text-fixture-red.log", + "sha256": "db11c25c73a4291c094396994800220e569fb2aecaea3942fde02b36c49713ff", + "expectedFailure": "History response still contains the old typed-text-only array instead of the intended mixed shapes." + }, + "fixtureGreen": { + "path": "/tmp/visible-text-fixture-green.log", + "sha256": "1530bdec9f929ec4875b800e3e2ce38a15b5b2d9f6789779f703b1890358c325", + "tests": 23 + } + }, + "semanticNegativeControl": { + "mutation": "Replace only wire-message.ts with its original narrow helper from the predecessor; keep the mixed saved-final fixture unchanged. No native binding or hardcoded display change.", + "runtime": { + "path": "/tmp/visible-text-runtime-negative.log", + "sha256": "972362d698390dcd8f28c2ae5338a2959b4e6b3d5413ddf257f13ea7f0b035df", + "failed": 14, + "passed": 48, + "files": 4, + "expectedFailure": "Actual dropped text and baseline/resume identity consequences are detected by the helper, history, stream and session regressions." + }, + "react": { + "path": "/tmp/visible-text-react-negative.log", + "sha256": "8913f8a077d2f960f8a24ba4ac32a11f00d36fd47f67d0d7b860449752b5825c" + }, + "angular": { + "path": "/tmp/visible-text-angular-negative.log", + "sha256": "147a76bfb717623e8007c88a51ac3fdde90165d12f3d96782da1102320f11f2f" + }, + "expectedFailure": "Both installed consumers fail getByTestId('text'): expected Saved tool request\\nSaved final answer; actual Saved tool request\\n.", + "orchestration": { + "path": "/tmp/visible-text-negative-orchestration.log", + "sha256": "235a8a14a158f33b19ea407f8091521f98272d33dd4a47dee038bb2aaff4663c" + }, + "restoration": { + "path": "/tmp/visible-text-negative-restoration.json", + "sha256": "a4dc6d624786ef124f2b3bd373622f98f380534f0e54036d39ea487936cb5d18", + "mutation": "Original narrow textContent helper only; mixed history fixture unchanged", + "originalSha256": "28e27109a268fd8cbde6110b156e46b2d8429d896152075c6c7762f4d4af4ef7", + "narrowSha256": "df124134df4288905ba9a46d084efc64653a6b092592e43b178297c22bd241f0", + "restoredSha256": "28e27109a268fd8cbde6110b156e46b2d8429d896152075c6c7762f4d4af4ef7", + "byteEqual": true, + "outcomes": [ + { + "name": "runtime", + "status": 1, + "path": "/tmp/visible-text-runtime-negative.log" + }, + { + "name": "react", + "status": 1, + "path": "/tmp/visible-text-react-negative.log" + }, + { + "name": "angular", + "status": 1, + "path": "/tmp/visible-text-angular-negative.log" + } + ] + }, + "method": "Saved original bytes; finally restores those bytes even if a run fails unexpectedly; exact byte comparison and matching SHA-256 prove restoration. Both complete installed verifiers rerun green afterward." + }, + "verification": { + "nativeBuild": { + "path": "/tmp/visible-text-native-build.log", + "sha256": "fc3198de914f5328be226e82bc83abacbcf195c7506065f8511d5257db3639e2", + "command": "NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,content,react,angular --skip-nx-cache --outputStyle=stream" + }, + "runtime": { + "path": "/tmp/visible-text-runtime-final.log", + "sha256": "b7c8efbaa6241e36c0d9ec65fa20ae1297cae9d23df022dfce105eb766a91ed2", + "tests": 908, + "files": 50, + "targets": [ + "runtime-quality", + "runtime-type-tests", + "type-tests" + ], + "compiler": "node node_modules/typescript/bin/tsc (locked 5.9.3)" + }, + "publicLangGraphTests": { + "path": "/tmp/visible-text-public-tests.log", + "sha256": "c4187c9aff829070f38675855c24666e4b4803163d201134312279965bcdad6d", + "tests": 481, + "files": 22, + "command": "NX_DAEMON=false NX_TUI=false npx nx run langgraph:test --reporter=default --watch=false --skip-nx-cache --outputStyle=stream" + }, + "publicLangGraphBuild": { + "path": "/tmp/visible-text-public-build.log", + "sha256": "d2d71f89e356b6ce75fa39c3795a05076ac5116884796085ab87d76ee12e2979", + "targets": [ + "build" + ], + "note": "Fresh build also rebuilt its 10 dependency tasks; public type-tests are recorded with runtime verification." + }, + "parityScripts": { + "path": "/tmp/visible-text-parity-scripts.log", + "sha256": "3b0b7d486a3b29cb84e367262582530159e78d6baafa95c387c3dc307ce2ca98", + "tests": 329 + }, + "react": { + "path": "/tmp/visible-text-installed-react-restored.log", + "sha256": "345d89a1586bb5d3c4317083ed19e47f40025ab2fa30879edaf6bbc4122a3a64", + "scenarios": 27 + }, + "angular": { + "path": "/tmp/visible-text-installed-angular-restored.log", + "sha256": "ef8c92e15588eccaffccce849d4c03f3dfc49937b952324228547ee84343c064", + "scenarios": 27 + }, + "inventory": { + "path": "/tmp/visible-text-inventory.log", + "sha256": "5c01ca9da9d098941941f29835953ec7bb56fe6d385cab7bdcbdc3fe87f77db3" + }, + "inventoryIdentity": { + "path": "/tmp/visible-text-inventory-identity.log", + "sha256": "26a0f0d62905de5465dbdcb157e90fea158e88403f05e6ba24f662ef0cfb3fb7" + }, + "boundaries": { + "path": "/tmp/visible-text-boundaries.log", + "sha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" + }, + "versions": { + "path": "/tmp/visible-text-versions.log", + "sha256": "1d9f9440bfffad48cca3d74ddec850cf803b8f449dcee2216776392713bf9374" + }, + "diffCheck": { + "path": "/tmp/visible-text-diff-check.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "packedArtifacts": { + "directory": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/visible-text-packed-cwkpsT", + "tarballs": [ + { + "name": "@threadplane/core", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/visible-text-packed-cwkpsT/threadplane-core-0.0.0.tgz", + "sha256": "fe2a215e73e7089b5ddc25b16ab034152915cd9fd41895fa1e57118a3c6e0f2d" + }, + { + "name": "@threadplane/content", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/visible-text-packed-cwkpsT/threadplane-content-0.0.0.tgz", + "sha256": "9c6083c293798b1b10aa6883ea94de97103d5ca65726a74884ed8763079c0e8b" + }, + { + "name": "@threadplane/react", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/visible-text-packed-cwkpsT/threadplane-react-0.0.0.tgz", + "sha256": "b71db72347e6ac0777304265d611968232b36e0dffe2171f7fb660f2e8958bc9" + }, + { + "name": "@threadplane/angular", + "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/visible-text-packed-cwkpsT/threadplane-angular-0.0.0.tgz", + "sha256": "632956c2279a700c9b8ba4b7762d58a6617b8e9b089776eec5fa8091397e15c5" + } + ], + "path": "/tmp/visible-text-packed.log", + "sha256": "3c3a134e0dd7502454b8f6f0697640c0501aa0ffd162c0ce44ad665f76201b67", + "note": "Retained tarballs packed from the freshly built core/content/react/angular outputs. Each verifier independently packs and installs these outputs; private runtime source is separately compiled/bundled into each installed consumer." + } + }, + "coverage": [ + "Top-level strings verbatim; ordered string entries plus text/output_text/untyped string text, including empty and whitespace; null/number/function/nested arrays/non-string text/unknown typed blocks ignored without coercion or input mutation/freezing.", + "History captures text independently of explicit reasoning and citations, retains equal identity, and isolates later wire mutation. Legitimate ToolMessage roles/IDs settle projected receipt text; registered catalogs still do not adopt historical wire strings as authored typed results.", + "Repeated deltas append verbatim, shorter-prefix and empty interim answer snapshots retain prior content; canonical shorter/empty corrections publish through the actual session owner and late events cannot resurrect text.", + "Plain history with mixed stream echo and mixed history with plain stream echo preserve baseline message references, delivery, outcome and handler/request counts. Both representations resume the same pending call once with exactly two stream requests and one handler admission.", + "Ignored tool-like assistant blocks do not create handler calls. Existing installed saved-answer, explicit reasoning precedence, citations, main/thread/checkpoint request-count and disposal assertions remain unchanged across all 27 scenarios per framework." + ], + "documentation": { + "generatorsRun": [], + "reason": "Inspected public API generator entrypoints, narrative docs root and agent-context template inputs. Only private runtime/contributor fixture docs changed; these are not public generator inputs." + }, + "manualReview": { + "status": "Parent completed Chrome MCP React and in-app-browser Angular on 2026-09-24; both owned tabs and servers closed after verification.", + "reactUrl": "http://127.0.0.1:58672/", + "angularUrl": "http://127.0.0.1:58673/", + "sequence": "Load saved, equal reload, empty replacement, Pause, Resume, Resume, Dispose.", + "observed": "Both frameworks showed exact Saved tool request plus Saved final answer, separate Saved reasoning and saved citation; equal reload retained all three; empty history cleared them. Resume showed Approval reasoning separately from One final approval; final Approvals complete cleared reasoning and citation. Three loads, one submission, two resumes, zero handlers; disposal reported disposed. Both consoles had zero warnings/errors.", + "wire": "Chrome independently recorded six successful POSTs: three histories and three runs. Angular wire counts are covered by the automated oracle, not an independent manual network capture.", + "runner": { + "path": "/tmp/visible-text-manual-runner-final.log", + "sha256": "c8099d114f6f5efbcb52bd1c4861704ab9e975a019f715b5e289f73c3c894264", + "automatedScenarios": 54 + }, + "parentRuntime": { + "path": "/tmp/visible-text-parent-runtime.log", + "sha256": "3e61866796cce99757113f0ddeb5001e84e53d167bfbb9a2347ea83ef9d9f91f", + "tests": 908, + "files": 50, + "types": "runtime and public" + }, + "setupRetry": "Initial preparation raced the final public build's dependency rebuild at 19:09:57 UTC: dist/core/package.json disappeared between prerequisite check and npm pack. No browser or server was opened. The build completed at 19:10:11, then the fresh runner passed both full consumers and supplied the manually reviewed pages. Initial failure is retained in /tmp/visible-text-manual-runner.log (SHA256 14dc8acbccf888bc135840bb3b60a3a8598d551b7aad5dfc58697eb2f3be8659).", + "limits": "Manual scope is the selected history/reasoning lifecycle, not every automated thread/checkpoint scenario. User manual sessions were preserved." + }, + "independentReview": { + "status": "Fresh compliance then quality review and parent integration/delivery remain required." + }, + "limits": [ + "No rich/media block preservation, multimodal input, encrypted/private reasoning handling, timing, event-only retention, new renderer or provider/version conformance claim.", + "No change to classifiers, wire IDs, delivery generation, optimistic input, handler admission, checkpoint authority, native bindings or transport; legacy Angular duplication remains until separate backend cutover.", + "No SSR, performance, full parity, public neutral backend cutover, dependency change, release or publication claim." + ], + "knownToolingWarnings": [ + "Existing NO_COLOR/FORCE_COLOR and browserslist-age warnings; Vite ignores native React use-client directives during bundling. Recorded final gates passed." + ], + "reviewCorrection": { + "observedOn": "2026-09-24", + "finding": "Independent combined quality review and parent reproduction found that checking content.text and then reading it again could admit an object from a changing accessor into join, causing coercion; a throwing second read interrupted a real session.", + "change": "After the accepted-type guard, capture text once and apply the string check and return to that captured value. Excluded typed blocks never read their text property. No authority, classifier, transport, core or binding change.", + "source": { + "algorithm": "SHA-256(file bytes); unique repo-relative paths sorted by JavaScript default string ordering, then manifest SHA-256 over lowercase sha256 + two ASCII spaces + path + LF for each input in that order. Evidence excludes itself.", + "inputs": [ + { + "path": "fixtures/react-parity/runtime/README.md", + "sha256": "63b6de98a4dabe87e2fdb5f024cf3712afa1ecef586ab2fde04b5d7ec72f1f66" + }, + { + "path": "fixtures/react-parity/runtime/angular-app.ts", + "sha256": "5c5dd1cb9c6fb4cb2a9f13345fad3f08ddab175606a4fab22f83d76985ec7cd1" + }, + { + "path": "fixtures/react-parity/runtime/installed-types.ts", + "sha256": "08fbaed23db4f9763707df0b495737f87428d1189de63f63adade369793665f3" + }, + { + "path": "fixtures/react-parity/runtime/react-app.tsx", + "sha256": "5c3020c64d2fd102ba6255856a99b08321d00bb812cc56b2910cb728befc3f0e" + }, + { + "path": "fixtures/react-parity/runtime/runtime-entry.ts", + "sha256": "72509ac95343b1e2d09c825d5017408fcd66891d5efb96e6e742270baf90aebd" + }, + { + "path": "fixtures/react-parity/runtime/scenarios.ts", + "sha256": "5541e93b15cb8ef4c957f4b3cc53a4fb12cfc7f4b23ae40fa38eceff70a150db" + }, + { + "path": "libs/core/README.md", + "sha256": "638c4e0e0ed1bd4ff7e94512f1f5fc9589a3815ac35ad9df3b1aebe8bd5b48e4" + }, + { + "path": "libs/core/src/contracts/contracts.type-test.ts", + "sha256": "aaa8bc7327d346715a1d364063433f75d94aa8e1d7f64b386ce49b600d256bf4" + }, + { + "path": "libs/core/src/contracts/message.ts", + "sha256": "9bfa684f4793ccdfd6b3f566f5d00e1b7ee821fcd51d1ea84995ef5b4caa61f7" + }, + { + "path": "libs/langgraph/project.json", + "sha256": "4fa0e5f4b0d4b2a7fd8f8de7270dcce1f21b033c86bb031d75480e44667c29d3" + }, + { + "path": "libs/langgraph/src/runtime/README.md", + "sha256": "075fa7e026752a0e2c1caac1ae4bb467f9251f5b81deb6e4378a3bf82e22dda7" + }, + { + "path": "libs/langgraph/src/runtime/checkpoint-authority.ts", + "sha256": "f56912f077e4ba891f9a55419e89743d62f573c435c3984fb64ac0803271c3dc" + }, + { + "path": "libs/langgraph/src/runtime/citations.spec.ts", + "sha256": "6d08887785cdc0a40ea0d6ea8f4455d3cf599a16ad0a7cbde9b4f6b42313af35" + }, + { + "path": "libs/langgraph/src/runtime/create-session.ts", + "sha256": "f4c6a1e693d205cf48a7c9ec357ddf037bdcf4ceabe315c6529c6806fbdaefb1" + }, + { + "path": "libs/langgraph/src/runtime/history-projection.spec.ts", + "sha256": "89cdd63569aee72f8aff0046291d8435b16263ee456677e0bf03480736e4df3f" + }, + { + "path": "libs/langgraph/src/runtime/history-projection.ts", + "sha256": "4e5e7d27e2ae9429274ac47ed979d6b94848dde9b1d03018af39ad888f236fd1" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.spec.ts", + "sha256": "a4360839d00a32e02a985dd8dbd7bb5452797398327ee043d53f7825adc0561f" + }, + { + "path": "libs/langgraph/src/runtime/message-reducer.ts", + "sha256": "714be5a4c60d442f84f46b08ac12b43ac901fe6005cf2ba5f35dfb8bfa33f249" + }, + { + "path": "libs/langgraph/src/runtime/ownership.ts", + "sha256": "3d9e0cad3c64b2884fd3049c78e6a844115efa6c839fbe997b17f7defc639641" + }, + { + "path": "libs/langgraph/src/runtime/publication.spec.ts", + "sha256": "db037834e2a8de6bbc50042994d4e6e7d51c42a09719d26ee4d5b7436c80662e" + }, + { + "path": "libs/langgraph/src/runtime/publication.ts", + "sha256": "7d290721417d504313f893ee7e90154b85e9eee27d05f6cce816d592495791e5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.spec.ts", + "sha256": "0131f6cfbe863a537d582b0f249dd2cab38ff0e3bc58dc9d4c4b14c034882d44" + }, + { + "path": "libs/langgraph/src/runtime/reasoning-projection.ts", + "sha256": "3f408da1d2ca5a28af466c11d49391a524117cd705d0363a2bf1bca2ad659cc5" + }, + { + "path": "libs/langgraph/src/runtime/reasoning.spec.ts", + "sha256": "91f8d9fbb8ecf71f308800709954d0647a70606bfb4b071f4967596d1af69b53" + }, + { + "path": "libs/langgraph/src/runtime/stream-projection.spec.ts", + "sha256": "bf23995c7c661b1f6304874ec917a30d0d59f76e341b9536c830dda1aae52c85" + }, + { + "path": "libs/langgraph/src/runtime/stream-projection.ts", + "sha256": "0462792cfff288ce4e4345bbcbfc32f1e162db35fe94f26b348a12ab064ccae4" + }, + { + "path": "libs/langgraph/src/runtime/testing/controlled-session.ts", + "sha256": "36a297e2bb35627c49bcf531df46f4c3bfb8aacef85cf8b22bb906c332b82072" + }, + { + "path": "libs/langgraph/src/runtime/visible-text.spec.ts", + "sha256": "5ee0a993fcb1cd6148979123be2249fad243db42a417eea3aa3c66fbfbc27658" + }, + { + "path": "libs/langgraph/src/runtime/wire-message.spec.ts", + "sha256": "005e09eae49ef743c0f6502f6fdbceee8fdd539210d013b13c1a9110dfbafffa" + }, + { + "path": "libs/langgraph/src/runtime/wire-message.ts", + "sha256": "6b407ff8a968e61420c81160cb2b5063ebbe3cc3593e981c8695474cecb25d89" + }, + { + "path": "libs/langgraph/tsconfig.runtime-tests.json", + "sha256": "a692d9d309837d9f37f0e18779a22d7a6f3a2fff1c96ce95a93ccbd0b81397dc" + }, + { + "path": "libs/langgraph/vite.runtime.config.mts", + "sha256": "2fb8014d6fb58ab6d7553b392ad57b0f2946260d36c2d6cb7c5ce4f95679341f" + }, + { + "path": "nx.json", + "sha256": "5a2b4d6d2a395d8c34b716ff2a2e3ca889c154f08dace74d694e6d0d09d8e9f4" + }, + { + "path": "package-lock.json", + "sha256": "530c28574a832ab39c15c9607717de0eb8ccfae2593e09c7ae373bd8936d0b64" + }, + { + "path": "package.json", + "sha256": "1454dd65be7cc56cf85c23eaf83c17953bb3d4b0dc98350cb1bdd392fbdade25" + }, + { + "path": "scripts/react-parity/baseline.json", + "sha256": "56dbdcb97f70de111b4e201a538d89be29aa1ed5256975d7c8fd5a821b9658bc" + }, + { + "path": "scripts/react-parity/runtime-consumer.mjs", + "sha256": "0bf9976de398ecc66d88a5a10b357fde756e22489848afdaa4c05ff2a5607247" + }, + { + "path": "scripts/react-parity/runtime-consumer.spec.mjs", + "sha256": "e4d0e0522c2f103dc2c7fe0c58e7b368ce19a555ce6197c3fa4dbc33ea479c34" + }, + { + "path": "scripts/react-parity/verify-angular-package.mjs", + "sha256": "dbff070a0e72b5fa2ce25337b7d2ec9aebfadf6956e59fd4d634fdde8116d09e" + }, + { + "path": "scripts/react-parity/verify-packages.mjs", + "sha256": "2e04fae38616ba5bf58d9ffe7a75dd94bcd4c3b02c220f756aa06829a9f8e75f" + }, + { + "path": "tsconfig.base.json", + "sha256": "0948b24b699da2cd2e4bc61f35a0340d25dd21300245b9c04aabadaa32c48fdd" + } + ], + "manifestSha256": "cb292efc84f2d0d7a90d84c2de218156628447d2da27ca6883b390c5c1d1e030", + "manifest": "/tmp/visible-text-accessor-source-manifest.txt", + "helperSha256": "6b407ff8a968e61420c81160cb2b5063ebbe3cc3593e981c8695474cecb25d89", + "provenance": "Final corrected working-tree inputs supersede the earlier source record for current verification. Earlier source, negative-control, packed-artifact and manual records remain unchanged as historical evidence." + }, + "red": { + "path": "/tmp/visible-text-accessor-red.log", + "sha256": "fb5e912428ff37317604be0df8fb7c4101652a1ece8617e4c016c95372ae0d9f", + "tests": 35, + "failed": 7, + "passed": 28, + "files": 2, + "expectedFailures": [ + "text/output_text/undefined-type accessors return Coerced instead of Captured when their second read returns an object.", + "All three accepted types throw on a second read.", + "The actual session returns interrupted instead of success when terminal text is read twice." + ], + "controls": "Excluded reasoning/thinking/image/image_url/tool_use/tool_result/unknown/null types have throwing text getters and are never read." + }, + "focusedGreen": { + "path": "/tmp/visible-text-accessor-green.log", + "sha256": "bde82fdcdf05a480b223ef631a6d61d8eda3b99f145192130d168bbb14a9f2a2", + "tests": 35, + "files": 2 + }, + "runtime": { + "path": "/tmp/visible-text-accessor-runtime.log", + "sha256": "0ea7e329e5a23220fae83181d259cb287376850e2734be5f8cad33b71fee9fd4", + "tests": 923, + "files": 50, + "targets": [ + "runtime-quality", + "runtime-type-tests", + "type-tests" + ], + "compiler": "node node_modules/typescript/bin/tsc (locked 5.9.3)" + }, + "installed": { + "react": { + "path": "/tmp/visible-text-accessor-react.log", + "sha256": "7297f8a2e62e52430f10496302e55f328d03118f6a613a60a1a7cb4b5b3a5b29", + "scenarios": 27 + }, + "angular": { + "path": "/tmp/visible-text-accessor-angular.log", + "sha256": "a7ed18ea7b7a5a26e5b80dcf0475f2b13da57c3572bd77d7e2aa8781b8b71a31", + "scenarios": 27 + }, + "scope": "Both complete installed declarations/build/main/thread/checkpoint verifiers rerun with the corrected private runtime. Existing request counts and exact saved-answer expectations remain unchanged.", + "packages": "Native package sources and artifacts are unchanged. Per parent instruction, no native/public package rebuild was performed; installed verifiers freshly compile/bundle the corrected private runtime source. Prior packed artifact provenance still applies to the native packages." + }, + "inventoryWrite": { + "path": "/tmp/visible-text-accessor-inventory-write.log", + "sha256": "f75469df8b77f0bc9d859fd7fcd2079de898165c0dac50c376b986f4acf50ebc" + }, + "inventory": { + "path": "/tmp/visible-text-accessor-inventory.log", + "sha256": "5c01ca9da9d098941941f29835953ec7bb56fe6d385cab7bdcbdc3fe87f77db3" + }, + "boundaries": { + "path": "/tmp/visible-text-accessor-boundaries.log", + "sha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" + }, + "diffCheck": { + "path": "/tmp/visible-text-accessor-diff-check.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "manualReview": { + "status": "Parent completed a fresh corrected-source Chrome MCP React (59108) and in-app-browser Angular (59109) walkthrough. The earlier visibleTextProjection.manualReview remains historical evidence.", + "sequence": "Load saved, equal reload, empty replacement, Pause, Resume, Resume, Dispose.", + "observed": "Both native views retained the exact Saved final answer separately from Saved reasoning/citation; equal reload retained, empty cleared, first resume showed Approval reasoning/citation and final canonical completion cleared them. Both ended disposed, with clean warning/error consoles. Chrome recorded six successful POSTs (three histories, three runs); Angular wire counts remain automated. Only owned tabs/servers were closed; ports verified closed and user resources preserved.", + "runner": { + "path": "/tmp/visible-text-manual-corrected.log", + "sha256": "fe51dc1f1847dd2bcaf2c8b7d460854752746dfddb72eca97225d48d6b15436b", + "automatedScenarios": 54 + }, + "parentRuntime": { + "path": "/tmp/visible-text-parent-corrected-runtime.log", + "sha256": "5d13b8e4e0ca436f12e8f307a150ded59c737e848005d92db1993c0f541fb03a", + "tests": 923, + "files": 50, + "types": "runtime and public" + }, + "limits": "The selected manual lifecycle is narrower than the complete automated main/thread/checkpoint suite; getter behavior is established by deterministic runtime regressions because JSON wire data cannot encode accessors." + }, + "independentReview": { + "compliance": "Approved after fresh 35 focused tests, locked runtime types, 51-hash/41-input final manifest audit and preservation of prior evidence.", + "quality": "Fresh combined reasoning/visible-text review found the double-accessor P2. Parent reproduced it. The corrected-source re-review approved after independently rerunning all 923 runtime tests, runtime/public types and 51-hash evidence audit. Reproduction now returns Accepted with one read and zero coercions. No remaining findings." + }, + "priorEvidence": { + "path": "/tmp/visible-text-accessor-prior-evidence.json", + "sha256": "55018c9ced87726e4ad06d87ed3e76e65543b969e848006de35bd6e53e985f14" + }, + "limits": "No broadened rich-content, provider, SSR, performance or public-backend claim. This correction narrows accepted-block access to one text read and preserves ignored-block behavior." + } } } diff --git a/libs/langgraph/src/runtime/README.md b/libs/langgraph/src/runtime/README.md index b6ecc836d..b0179f6b5 100644 --- a/libs/langgraph/src/runtime/README.md +++ b/libs/langgraph/src/runtime/README.md @@ -4,6 +4,27 @@ This directory stages the framework-independent session owner. It is not the published LangGraph package entry point. Core and framework bindings do not own backend execution positions. +## Visible text + +The shared private wire helper preserves a top-level string verbatim. Arrays join +string entries and string `text` fields from `text`, `output_text` or untyped +objects in order, without separators. Empty and whitespace strings are preserved. +Unknown typed blocks (including reasoning, thinking, images and tool blocks), +non-string text, nested arrays and other values are ignored without coercion. +Inputs are neither mutated nor frozen; only the projected string is published. + +History and canonical final messages replace text exactly, including shorter or +empty corrections. Deltas append verbatim. Interim answer snapshots retain the +existing prefix rule: a shorter prefix or empty snapshot does not truncate the +answer. This differs from reasoning snapshot replacement. Committed canonical text +bars late interim text. Text shape does not establish finality, tool identity or +execution authority; actual ToolMessage roles and IDs still determine settlement. + +This closes text-alias loss shared by the native bindings. It does not retain rich +media/block order for rendering, add multimodal input, or establish provider, +SSR or performance parity. The legacy Angular extractor remains a bounded +duplicate until the separate backend cutover. + ## Owned citations History, root streams and read-only child streams project citations from diff --git a/libs/langgraph/src/runtime/history-projection.spec.ts b/libs/langgraph/src/runtime/history-projection.spec.ts index c0bbe83f3..f87b8bb62 100644 --- a/libs/langgraph/src/runtime/history-projection.spec.ts +++ b/libs/langgraph/src/runtime/history-projection.spec.ts @@ -49,6 +49,59 @@ function checkpoint( const initial = () => initialMessageState(); describe('pure authoritative history projection', () => { + it('settles legitimate ToolMessage IDs with projected receipt text', () => { + const state = projectHistory(initial(), [ + checkpoint([ + ai('a', '', [call('real')]), + { + type: 'ToolMessage', + id: 'receipt', + tool_call_id: 'real', + content: [ + 'Received', + { text: ' result' }, + { type: 'tool_result', text: 'Hidden' }, + ], + }, + ]), + ]); + expect(state.toolCalls).toMatchObject([ + { id: 'real', status: 'complete', result: 'Received result' }, + ]); + expect(state.messages[1]).toMatchObject({ + role: 'tool', + toolCallId: 'real', + content: 'Received result', + }); + }); + + it('owns mixed visible text independently of reasoning and citations', () => { + const text = { type: 'output_text', text: 'final' }; + const input = checkpoint([ + { + ...ai('a'), + content: [ + 'Saved ', + text, + { text: ' answer' }, + { type: 'reasoning', text: 'Hidden' }, + ], + reasoning: 'Separate', + additional_kwargs: { citations: [{ title: 'Source' }] }, + }, + ]); + const first = projectHistory(initial(), [input]); + expect(first.messages[0]).toMatchObject({ + content: 'Saved final answer', + reasoning: 'Separate', + citations: [{ title: 'Source' }], + }); + expect(projectHistory(first, [input])).toBe(first); + text.text = 'Changed'; + expect(first.messages[0].content).toBe('Saved final answer'); + expect(Object.isFrozen(text)).toBe(false); + }); + it('owns citations, shares equal histories and equal metadata across text changes, and clears authoritative omissions', () => { const citations = [{ title: 'Source', extra: { tags: ['original'] } }]; const input = () => diff --git a/libs/langgraph/src/runtime/stream-projection.spec.ts b/libs/langgraph/src/runtime/stream-projection.spec.ts index 5c53f85cf..e57010e0e 100644 --- a/libs/langgraph/src/runtime/stream-projection.spec.ts +++ b/libs/langgraph/src/runtime/stream-projection.spec.ts @@ -32,6 +32,43 @@ function start(messages: unknown[] = [assistant('step', ['c1'])]) { } describe('authoritative tool ownership', () => { + it.each(['Short', ''])( + 'commits mixed canonical text %j exactly and bars late text', + (finalText) => { + let current = start([ + { ...assistant('step'), content: [{ text: 'Long answer' }] }, + ]); + current = projectStream(current.state, current.projection, { + type: 'values', + data: { + messages: [ + { + ...assistant('step'), + content: [ + { type: 'output_text', text: finalText }, + { type: 'image', text: 'Hidden' }, + ], + reasoning: 'Separate', + additional_kwargs: { citations: [{ title: 'Source' }] }, + }, + ], + }, + }); + const final = finalizeProjection(current.state, current.projection); + expect(final.messages.at(-1)).toMatchObject({ + content: finalText, + reasoning: 'Separate', + citations: [{ title: 'Source' }], + }); + const late = projectStream(final, current.projection, { + type: 'messages', + messageMetadata: {}, + messages: [{ type: 'AIMessageChunk', id: 'step', content: ['Late'] }], + }); + expect(late.state.messages.at(-1)?.content).toBe(finalText); + } + ); + it('captures terminal citations before finalization and clears omitted canonical metadata', () => { const citations = [ { id: 'source', title: 'Original', extra: { tags: ['original'] } }, diff --git a/libs/langgraph/src/runtime/visible-text.spec.ts b/libs/langgraph/src/runtime/visible-text.spec.ts new file mode 100644 index 000000000..08508ad6c --- /dev/null +++ b/libs/langgraph/src/runtime/visible-text.spec.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import type { AgentTransport, StreamEvent } from './transport.types'; +import { controlledSession } from './testing/controlled-session'; +import { saved } from './testing/checkpoint-fixture'; + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); +const mixed = (text: string) => [ + text.slice(0, 1), + { type: 'output_text', text: text.slice(1, 3) }, + { text: text.slice(3) }, + { type: 'reasoning', text: 'Hidden' }, + { type: 'tool_use', id: 'fake', name: 'work', text: 'Hidden', input: {} }, +]; +const ai = (content: unknown) => ({ + type: 'ai', + id: 'answer', + content, + reasoning: 'Separate', +}); +const frame = (content: unknown): StreamEvent => ({ + type: 'values', + data: { messages: [ai(content)] }, +}); +const delta = (content: unknown): StreamEvent => ({ + type: 'messages', + messageMetadata: {}, + messages: [{ ...ai(content), type: 'AIMessageChunk' }], +}); + +describe('visible text through actual session ownership', () => { + it('captures an accepted text getter once through terminal session publication', async () => { + const f = controlledSession(); + cleanups.push(() => f.session.dispose()); + const run = f.session.submit('Go'); + await f.started(); + let reads = 0; + await f.emit( + frame([ + { + type: 'output_text', + get text() { + if (++reads > 1) throw new Error('terminal text reread'); + return 'Captured'; + }, + }, + ]) + ); + f.streams[0].finish(); + expect(await run).toBe('success'); + expect(f.session.getSnapshot().messages.at(-1)).toMatchObject({ + content: 'Captured', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(reads).toBe(1); + expect(f.stream).toHaveBeenCalledOnce(); + expect(f.getHistory).not.toHaveBeenCalled(); + }); + + it.each(['Short', ''])( + 'preserves repeated deltas and answer prefix snapshots before canonical %j publication', + async (finalText) => { + const f = controlledSession(); + cleanups.push(() => f.session.dispose()); + const run = f.session.submit('Go'); + await f.started(); + await f.emit(delta(mixed('A'))); + await f.emit(delta(mixed('A'))); + await f.emit(delta(mixed('AA'))); + expect(f.session.getSnapshot().messages.at(-1)?.content).toBe('AAAA'); + await f.emit(frame(mixed('A'))); + expect(f.session.getSnapshot().messages.at(-1)?.content).toBe('AAAA'); + await f.emit(frame(mixed(''))); + expect(f.session.getSnapshot().messages.at(-1)?.content).toBe('AAAA'); + const raw = { type: 'output_text', text: finalText }; + await f.emit(frame([raw])); + raw.text = 'Mutated after ingress'; + f.streams[0].finish(); + expect(await run).toBe('success'); + const final = f.session.getSnapshot(); + expect(final.messages.at(-1)).toMatchObject({ + id: 'answer', + content: finalText, + delivery: { phase: 'complete', outcome: 'success' }, + }); + f.streams[0].release(delta(mixed('Late'))); + await Promise.resolve(); + expect(f.session.getSnapshot()).toBe(final); + expect(f.stream).toHaveBeenCalledOnce(); + expect(f.getHistory).not.toHaveBeenCalled(); + } + ); + + it.each(['plain', 'mixed'] as const)( + 'keeps %s baseline echoes inert and settles real tool receipts by role and ID', + async (shape) => { + const content = shape === 'plain' ? (text: string) => text : mixed; + const echoed = shape === 'plain' ? mixed : (text: string) => text; + const historic = { + ...ai(content('Saved')), + tool_calls: [{ id: 'old', name: 'work', args: {} }], + }; + const receipt = { + type: 'ToolMessage', + id: 'receipt', + tool_call_id: 'old', + content: content('Received'), + reasoning: 'Separate', + }; + const handler = vi.fn(() => 'must not execute'); + const getHistory = vi.fn(async () => [ + saved('history', [historic, receipt]), + ]); + const stream = vi.fn(async function* ( + _a, + _t, + input + ) { + yield { + type: 'values', + data: { + messages: [ + { ...historic, content: echoed('Saved') }, + { ...receipt, content: echoed('Received') }, + ...(input as { messages: unknown[] }).messages, + { ...ai(echoed('Fresh')), id: 'fresh' }, + ], + }, + }; + }); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { stream, getHistory }, + tools: { work: { description: 'Work', handler } }, + }); + cleanups.push(() => session.dispose()); + await session.load!(); + const before = session.getSnapshot(); + expect(before.messages.map((m) => [m.id, m.content])).toEqual([ + ['answer', 'Saved'], + ['receipt', 'Received'], + ]); + // Registered catalogs expose pending calls, never adopt historical string results. + expect(before.toolCalls).toEqual([]); + expect(await session.submit('Go')).toBe('success'); + const after = session.getSnapshot(); + expect(after.messages[0]).toBe(before.messages[0]); + expect(after.messages[1]).toBe(before.messages[1]); + expect(after.messages.at(-1)).toMatchObject({ + id: 'fresh', + content: 'Fresh', + delivery: { phase: 'complete', outcome: 'success' }, + }); + expect(after.toolCalls).toEqual([]); + expect(handler).not.toHaveBeenCalled(); + expect(stream).toHaveBeenCalledOnce(); + expect(getHistory).toHaveBeenCalledOnce(); + } + ); + + it.each(['plain', 'mixed'] as const)( + 'keeps %s resumed pending-call admission and follow-up counts unchanged', + async (shape) => { + const content = shape === 'plain' ? (text: string) => text : mixed; + const echoed = shape === 'plain' ? mixed : (text: string) => text; + const pending = { + ...ai(content('Waiting')), + tool_calls: [{ id: 'pending', name: 'work', args: { amount: 2 } }], + }; + const handler = vi.fn(() => 'Result'); + const getHistory = vi.fn(async () => [ + saved( + 'paused', + [{ type: 'human', id: 'user', content: 'Question' }, pending], + { + next: ['approval'], + tasks: [ + { + id: 'task', + name: 'approval', + interrupts: [{ id: 'approve', value: 'Continue?' }], + }, + ], + } + ), + ]); + const stream = vi.fn(async function* ( + _a, + _t, + input + ) { + yield input === null + ? { + type: 'values', + data: { messages: [{ ...pending, content: echoed('Waiting') }] }, + } + : frame(echoed('Done')); + }); + const session = createSession({ + assistantId: 'agent', + threadId: 'thread', + transport: { stream, getHistory }, + tools: { work: { description: 'Work', handler } }, + }); + cleanups.push(() => session.dispose()); + await session.load!(); + const paused = session.getSnapshot(); + expect(paused.messages.at(-1)?.content).toBe('Waiting'); + expect(await session.resume(true)).toBe('success'); + expect( + session.getSnapshot().messages.filter((m) => m.role === 'assistant') + ).toMatchObject([ + { + id: 'answer', + content: 'Done', + delivery: { phase: 'complete', outcome: 'success' }, + }, + ]); + expect( + session.getSnapshot().messages.at(-1)?.delivery.generation + ).not.toBe(paused.messages.at(-1)?.delivery.generation); + expect( + session.getSnapshot().toolCalls.map((c) => [c.id, c.status]) + ).toEqual([['pending', 'complete']]); + expect(handler).toHaveBeenCalledExactlyOnceWith( + { amount: 2 }, + expect.anything() + ); + expect(stream).toHaveBeenCalledTimes(2); + expect(stream.mock.calls[0][2]).toBeNull(); + expect(stream.mock.calls[0][4]).toMatchObject({ + command: { resume: true }, + }); + expect(stream.mock.calls[1][2]).toMatchObject({ + messages: [ + { type: 'tool', tool_call_id: 'pending', content: 'Result' }, + ], + }); + expect(getHistory).toHaveBeenCalledOnce(); + } + ); +}); diff --git a/libs/langgraph/src/runtime/wire-message.spec.ts b/libs/langgraph/src/runtime/wire-message.spec.ts new file mode 100644 index 000000000..2c63ff4df --- /dev/null +++ b/libs/langgraph/src/runtime/wire-message.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { textContent } from './wire-message'; + +describe('visible wire text', () => { + describe.each(['text', 'output_text', undefined])( + 'accepted type %j', + (type) => { + it('captures a string once without coercing a later object value', () => { + let reads = 0; + let coercions = 0; + const later = { + toString() { + coercions++; + return 'Coerced'; + }, + }; + const block = { + type, + get text() { + return ++reads === 1 ? 'Captured' : later; + }, + }; + expect(textContent([block])).toBe('Captured'); + expect(reads).toBe(1); + expect(coercions).toBe(0); + }); + + it('does not revisit a getter that throws on the second read', () => { + let reads = 0; + const block = { + type, + get text() { + if (++reads > 1) throw new Error('second text read'); + return 'Captured'; + }, + }; + expect(textContent([block])).toBe('Captured'); + expect(reads).toBe(1); + }); + } + ); + + it.each([ + 'reasoning', + 'thinking', + 'image', + 'image_url', + 'tool_use', + 'tool_result', + 'unknown', + null, + ])('never reads text for excluded type %j', (type) => { + expect( + textContent([ + { + type, + get text() { + throw new Error('excluded text read'); + }, + }, + ]) + ).toBe(''); + }); + + it.each(['', ' \n ', 'Answer'])( + 'preserves top-level string %j verbatim', + (text) => { + expect(textContent(text)).toBe(text); + } + ); + + it.each([ + ['string entries', ['Saved ', 'answer']], + ['output text', [{ type: 'output_text', text: 'Saved answer' }]], + ['untyped text', [{ text: 'Saved answer' }]], + ])('projects %s', (_name, content) => { + expect(textContent(content)).toBe('Saved answer'); + }); + + it('joins supported pieces in order without separators or coercion', () => { + const stringify = () => { + throw new Error('must not coerce'); + }; + expect( + textContent([ + 'Saved', + null, + 42, + ['nested'], + stringify, + { type: 'text', text: ' ' }, + { type: 'output_text', text: 'final' }, + { text: '' }, + { type: undefined, text: ' answer' }, + ...[ + 'reasoning', + 'thinking', + 'image', + 'image_url', + 'tool_use', + 'tool_result', + 'unknown', + null, + ].map((type) => ({ type, text: 'hidden' })), + { text: 1 }, + { type: 'text', text: { toString: stringify } }, + ]) + ).toBe('Saved final answer'); + }); + + it.each([undefined, null, 1, true, { text: 'not an array' }, () => 'no'])( + 'ignores unsupported top-level value %j', + (value) => { + expect(textContent(value)).toBe(''); + } + ); + + it('reads frozen and mutable inputs without mutating or freezing either', () => { + const block = { type: 'output_text', text: 'Answer' }; + const input = [block]; + expect(textContent(input)).toBe('Answer'); + expect(input).toEqual([{ type: 'output_text', text: 'Answer' }]); + expect(Object.isFrozen(input)).toBe(false); + expect(Object.isFrozen(block)).toBe(false); + expect( + textContent(Object.freeze([Object.freeze({ text: 'Answer' })])) + ).toBe('Answer'); + block.text = 'Changed'; + expect(textContent(input)).toBe('Changed'); + }); +}); diff --git a/libs/langgraph/src/runtime/wire-message.ts b/libs/langgraph/src/runtime/wire-message.ts index 24c8a2c5a..42ede8fe7 100644 --- a/libs/langgraph/src/runtime/wire-message.ts +++ b/libs/langgraph/src/runtime/wire-message.ts @@ -35,10 +35,13 @@ export function textContent(value: unknown): string { if (!Array.isArray(value)) return ''; return value .flatMap((block) => { + if (typeof block === 'string') return [block]; const content = record(block); - return content?.['type'] === 'text' && typeof content['text'] === 'string' - ? [content['text']] - : []; + const type = content?.['type']; + if (type !== 'text' && type !== 'output_text' && type !== undefined) + return []; + const text = content?.['text']; + return typeof text === 'string' ? [text] : []; }) .join(''); } diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 574bb250c..34d18cdd6 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,18 +1,12 @@ { "schemaVersion": 1, - "baselineHead": "9ddeb1579323898cb5e470d658cfa53852931830", + "baselineHead": "c16c509e6fbd73ceafa47f9af33873662fc2b030", "sourceState": { "modified": [ "libs/langgraph/src/runtime/README.md", - "libs/langgraph/src/runtime/history-projection.ts", - "libs/langgraph/src/runtime/message-reducer.ts", - "libs/langgraph/src/runtime/ownership.ts", - "libs/langgraph/src/runtime/stream-projection.ts" + "libs/langgraph/src/runtime/wire-message.ts" ], - "untracked": [ - "libs/langgraph/src/runtime/reasoning-projection.ts", - "libs/langgraph/src/runtime/testing/controlled-session.ts" - ] + "untracked": [] }, "scope": { "libraries": [ @@ -601,7 +595,7 @@ "id": "asset:libs/langgraph/src/runtime/README.md", "kind": "asset", "path": "libs/langgraph/src/runtime/README.md", - "sha256": "5a1897fc817d9ece371923f369193dbd2ee5be5f2aaf4da8cacbb05fb752e66c" + "sha256": "075fa7e026752a0e2c1caac1ae4bb467f9251f5b81deb6e4378a3bf82e22dda7" }, { "id": "asset:libs/langgraph/test/fixtures/streaming-reasoning-puzzle.json", @@ -12661,7 +12655,7 @@ "id": "source:libs/langgraph/src/runtime/wire-message.ts", "kind": "source", "path": "libs/langgraph/src/runtime/wire-message.ts", - "sha256": "df124134df4288905ba9a46d084efc64653a6b092592e43b178297c22bd241f0" + "sha256": "6b407ff8a968e61420c81160cb2b5063ebbe3cc3593e981c8695474cecb25d89" }, { "id": "source:libs/langgraph/src/test-setup.ts", diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs index 7e287c824..2651a6afd 100644 --- a/scripts/react-parity/runtime-consumer.mjs +++ b/scripts/react-parity/runtime-consumer.mjs @@ -45,7 +45,16 @@ const savedHistory = [{ { id: 'saved-count', name: 'count', args: { values: ['saved'] }, type: 'tool_call' }, ] }, { id: 'saved-result', type: 'tool', tool_call_id: 'saved-weather', content: 'Raw historical weather result' }, - { id: 'saved-final', type: 'ai', reasoning: 'Saved reasoning', content: [{ type: 'text', text: 'Saved final answer' }], additional_kwargs: { sources: [{ refId: 'saved-source', name: 'Saved reference', publishedAt: '2026-09-24', extra: { provider: { labels: ['history'] } } }] } }, + { id: 'saved-final', type: 'ai', reasoning: 'Saved reasoning', content: [ + 'Saved ', + { type: 'output_text', text: 'final' }, + { text: ' answer' }, + { type: 'text', text: '' }, + { type: 'reasoning', text: 'Hidden block reasoning' }, + { type: 'image', text: 'Hidden image caption', url: 'https://example.test/image.png' }, + { type: 'tool_use', id: 'ignored-block-call', name: 'savedTool', input: {}, text: 'Hidden tool request' }, + { type: 'tool_result', tool_use_id: 'ignored-block-call', text: 'Hidden tool result' }, + ], additional_kwargs: { sources: [{ refId: 'saved-source', name: 'Saved reference', publishedAt: '2026-09-24', extra: { provider: { labels: ['history'] } } }] } }, ] }, next: ['review', 'confirmation'], tasks: savedInterrupts.map((interrupt, index) => ({ id: `saved-task-${index}`, name: index === 0 ? 'review' : 'confirmation', error: null, checkpoint: null, state: null, interrupts: [interrupt] })), diff --git a/scripts/react-parity/runtime-consumer.spec.mjs b/scripts/react-parity/runtime-consumer.spec.mjs index c91527f74..3d71df1f3 100644 --- a/scripts/react-parity/runtime-consumer.spec.mjs +++ b/scripts/react-parity/runtime-consumer.spec.mjs @@ -238,7 +238,22 @@ test('history uses the exact SDK body and counts reads separately from runs', as { id: 'saved-approval', value: { question: 'Approve saved request?', choices: ['yes', 'no'] }, namespace: ['review', 'task-1'], when: 'during', resumable: true, ns: ['legacy-review'] }, { id: 'saved-confirmation', value: 0, namespace: [], when: 'during', resumable: false, ns: [] }, ]); - assert.deepEqual(saved[0].values.messages.at(-1).content, [{ type: 'text', text: 'Saved final answer' }]); + const final = saved[0].values.messages.at(-1); + assert.deepEqual(final.content, [ + 'Saved ', + { type: 'output_text', text: 'final' }, + { text: ' answer' }, + { type: 'text', text: '' }, + { type: 'reasoning', text: 'Hidden block reasoning' }, + { type: 'image', text: 'Hidden image caption', url: 'https://example.test/image.png' }, + { type: 'tool_use', id: 'ignored-block-call', name: 'savedTool', input: {}, text: 'Hidden tool request' }, + { type: 'tool_result', tool_use_id: 'ignored-block-call', text: 'Hidden tool result' }, + ]); + assert.equal(final.id, 'saved-final'); + assert.equal(final.type, 'ai'); + assert.equal(final.reasoning, 'Saved reasoning'); + assert.deepEqual(final.additional_kwargs.sources, [{ refId: 'saved-source', name: 'Saved reference', publishedAt: '2026-09-24', extra: { provider: { labels: ['history'] } } }]); + assert.equal(final.tool_calls, undefined); assert.deepEqual(await (await read()).json(), saved); assert.deepEqual(await (await read()).json(), []); assert.deepEqual(server.historyRequests, [{ limit: 10 }, { limit: 10 }, { limit: 10 }]);