Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions fixtures/react-parity/runtime/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -559,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
Expand Down
3 changes: 3 additions & 0 deletions fixtures/react-parity/runtime/angular-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ const submit = (input: string) => {
<output aria-label="Citations" data-testid="citations">{{
view().citations
}}</output>
<output aria-label="Reasoning" data-testid="reasoning">{{
view().reasoning
}}</output>
</div>
</div>
</section>
Expand Down
986 changes: 986 additions & 0 deletions fixtures/react-parity/runtime/evidence.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions fixtures/react-parity/runtime/installed-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import type { FixtureTools } from './scenarios';
/* BINDING_IMPORT */

export function assertSnapshot(snapshot: AgentSnapshot<FixtureTools>) {
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;
Expand Down
3 changes: 3 additions & 0 deletions fixtures/react-parity/runtime/react-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ function App() {
<output aria-label="Citations" data-testid="citations">
{view.citations}
</output>
<output aria-label="Reasoning" data-testid="reasoning">
{view.reasoning}
</output>
</div>
</div>
</section>
Expand Down
13 changes: 10 additions & 3 deletions fixtures/react-parity/runtime/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions libs/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions libs/core/src/contracts/contracts.type-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions libs/core/src/contracts/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 48 additions & 1 deletion libs/langgraph/src/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,9 +44,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
Expand Down
53 changes: 4 additions & 49 deletions libs/langgraph/src/runtime/citations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,53 +31,9 @@ const frame = (citations?: unknown, content = 'Answer'): StreamEvent => ({
});
const cleanups: (() => Promise<void>)[] = [];
function fixture() {
const streams: ReturnType<typeof controlledTransport<StreamEvent>>[] = [];
const starts = Array.from({ length: 4 }, () => deferred<void>());
const acknowledgments: ReturnType<typeof deferred<void>>[] = [];
const stream = vi.fn<AgentTransport['stream']>((_a, _t, _input, signal) => {
const index = streams.length;
const controlled = controlledTransport<StreamEvent>({
signal,
ignoreAbort: true,
});
streams.push(controlled);
starts[index].resolve();
const iterator: AsyncIterableIterator<StreamEvent> = {
[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<NonNullable<AgentTransport['getHistory']>>(
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<void>();
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()));
Expand Down
53 changes: 53 additions & 0 deletions libs/langgraph/src/runtime/history-projection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () =>
Expand Down
2 changes: 2 additions & 0 deletions libs/langgraph/src/runtime/history-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
43 changes: 43 additions & 0 deletions libs/langgraph/src/runtime/message-reducer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
10 changes: 10 additions & 0 deletions libs/langgraph/src/runtime/message-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading