React chat tool calls preview
Show the lookups behind an answer without asking readers to infer them from prose. Tool calls have stable identities, so parallel requests to the same function can retain their own arguments and results.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Observe the call the backend already made
ToolObservation displays application-supplied argument and result text. It does not execute tools. The example associates each observation with its requesting assistant and confirms the saved root conversation before another submission. A finalized result means the tool produced data; whether that data represents a successful business outcome remains application-specific.
import { ToolObservation } from '@threadplane/react/chat';
export function RouteLookup({ argumentsText, resultText }: {
argumentsText: string;
resultText?: string;
}) {
return <ToolObservation name="find_routes" argumentsText={argumentsText} resultText={resultText} />;
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Inspect observed calls and results
Choose Run, select Flight UA123, Compare airports, or Find routes, then send the draft. The graph uses a read-only aviation demo dataset through lookup_flight, get_airport_info and find_routes. A prompt suggestion fills the draft without creating a conversation or sending a request.
Each tool observation appears under the assistant message that requested it. Arguments appear after the runtime exposes a complete call. A result appears only when its tool-call identity and name match the later result message. Multiple calls to the same tool remain separate, including parallel airport lookups whose results arrive in a different order.
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useCallback, useEffect, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
ChatInput,
ToolObservation,
MessageList,
Reasoning,
Citations,
} from '@threadplane/react/chat';
import { Markdown } from '@threadplane/react/markdown';
import type { MessageRow } from '@threadplane/content/messages';
import {
createConnectedApplication,
type toolCallsConnection,
} from './connection';
import type { ToolCard } from './projection';
/** Stable row renderer; the application owns Markdown snapshot lifetimes. */
export function renderMessage(
row: MessageRow,
cards: readonly ToolCard[] = []
) {
if (row.role === 'tool') return null;
if (
row.role === 'assistant' &&
!row.message.content &&
!row.reasoning &&
!row.message.citations?.length &&
!cards.length &&
row.message.toolCallIds?.length
)
return null;
const label =
row.role === 'user'
? 'You'
: row.role === 'assistant'
? 'Assistant'
: 'System';
return (
<article
aria-label={label}
className={`tp-chat-message tp-chat-message--${row.role}`}
>
<p className="tp-chat-message__role">{label}</p>
{row.role === 'assistant' && row.reasoning && (
<Reasoning snapshot={row.reasoning} />
)}
<Markdown snapshot={row.markdown} />
{cards.map((card) => (
<div className="tool-observation" key={card.id}>
<ToolObservation
name={card.name}
argumentsText={card.argumentsText}
resultText={card.resultText}
label={
card.resultText === undefined
? 'Observed tool call'
: 'Observed tool result'
}
/>
</div>
))}
{row.role === 'assistant' && row.message.citations && (
<Citations citations={row.message.citations} />
)}
</article>
);
}
export function ToolCallsDemo({
connection,
onReady,
}: {
readonly connection: ReturnType<typeof toolCallsConnection>;
readonly onReady: () => void;
}) {
const [application] = useState(() => createConnectedApplication(connection));
const [draft, setDraft] = useState('');
const snapshot = useAgent(application);
const renderRow = useCallback(
(row: MessageRow) => renderMessage(row, snapshot.observations.get(row.id)),
[snapshot.observations]
);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
const status = snapshot.busy
? snapshot.activity === 'creating'
? 'Creating conversation…'
: snapshot.activity === 'confirming'
? 'Confirming saved conversation…'
: snapshot.activity === 'replacing'
? 'Releasing conversation…'
: 'Receiving response…'
: snapshot.outcome === 'success'
? 'Response saved.'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: 'Ready.';
return (
<ExampleLayout className="tool-calls-demo">
<header>
<div>
<h1>Chat tool calls</h1>
</div>
<button
type="button"
disabled={snapshot.busy}
onClick={() => {
if (application.getSnapshot().busy) return;
setDraft('');
void application.newConversation();
}}
>
New conversation
</button>
</header>
<p>
Explore read-only aviation tools with demo data. Inspect the observed
arguments and results for each assistant request.
</p>
<div className="seed-prompts" aria-label="Example prompts">
{[
{ label: 'Flight UA123', text: "What's the status of UA123?" },
{ label: 'Compare airports', text: 'Compare LAX and JFK airports.' },
{
label: 'Find routes',
text: 'Find routes from LAX to JFK tomorrow.',
},
].map((prompt) => (
<button
key={prompt.label}
type="button"
disabled={!snapshot.canSubmit || snapshot.busy}
onClick={() => {
if (
application.getSnapshot().canSubmit &&
!application.getSnapshot().busy
)
setDraft(prompt.text);
}}
>
{prompt.label}
</button>
))}
</div>
<MessageList
rows={snapshot.rows}
renderMessage={renderRow}
label="Conversation"
/>
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
key={snapshot.viewGeneration}
label="Message"
value={draft}
onValueChange={setDraft}
placeholder="Ask about a flight, airport, or route…"
hint="Enter to send. Shift+Enter adds a new line."
busy={snapshot.busy}
disabled={!snapshot.canSubmit && !snapshot.busy}
onStop={
snapshot.busy && snapshot.activity !== 'replacing'
? () => void application.stop()
: undefined
}
onSubmit={(text) => {
if (!application.getSnapshot().canSubmit) return false;
void application.submit(text);
return true;
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}Arguments and results are literal observed data. A completed observation does not establish that a business operation succeeded: an unknown flight can return an error in the demo data. The example validates the supported argument shapes and withholds malformed calls. ToolObservation renders the supplied text; the application owns association and formatting.
import type { Message, ToolCall } from '@threadplane/core';
import { copyData, validToolArgs } from './authority';
export interface ToolCard {
readonly id: string;
readonly name: string;
readonly argumentsText: string;
readonly resultText?: string;
}
/** Associate literal observations with their one requesting assistant, never a result row. */
export function projectToolObservations(
observedMessages: readonly Message[],
observedTools: readonly ToolCall[]
): ReadonlyMap<string, readonly ToolCard[]> {
const cards = new Map<string, readonly ToolCard[]>();
try {
const messages = copyData(observedMessages) as readonly Message[];
const tools = copyData(observedTools) as readonly ToolCall[];
if (
new Set(messages.map((message) => message.id)).size !== messages.length ||
new Set(tools.map((tool) => tool.id)).size !== tools.length
)
return cards;
for (const tool of tools) {
if (
!tool.id ||
!validToolArgs(tool) ||
!['pending', 'complete'].includes(tool.status)
)
continue;
const owners = messages.flatMap((message, index) =>
message.role === 'assistant'
? (message.toolCallIds ?? [])
.filter((id) => id === tool.id)
.map(() => ({ message, index }))
: []
);
if (owners.length !== 1) continue;
const owner = owners[0];
const results = messages.flatMap((message, index) =>
message.toolCallId === tool.id ? [{ message, index }] : []
);
let resultText: string | undefined;
if (tool.status === 'complete') {
if (results.length !== 1 || typeof tool.result !== 'string') continue;
const result = results[0];
if (
result.index <= owner.index ||
result.message.role !== 'tool' ||
result.message.name !== tool.name ||
result.message.content !== tool.result ||
result.message.toolCallIds?.length ||
messages
.slice(owner.index + 1, result.index)
.some((message) => message.role === 'user')
)
continue;
resultText = tool.result;
} else if (
results.length ||
('result' in tool && tool.result !== undefined) ||
messages
.slice(owner.index + 1)
.some((message) => message.role === 'user')
)
continue;
const card = Object.freeze({
id: tool.id,
name: tool.name,
argumentsText: JSON.stringify(tool.args, null, 2),
...(resultText === undefined ? {} : { resultText }),
});
cards.set(
owner.message.id,
Object.freeze([...(cards.get(owner.message.id) ?? []), card])
);
}
} catch {
return new Map();
}
return cards;
}Confirm the saved conversation
After a successful stream, the application reads saved history once. Follow-up messages remain blocked until the exact observed messages, tool identities, arguments and results agree with a terminal root checkpoint for the current conversation. Every completed tool call must have one later matching result. Text-only answers are also supported.
The owner retains an observed conflict even if a later response restores the earlier content. Changed call arguments, replaced results, failed messages, interrupts, subgraphs and uncertain work require a new local conversation. Partial argument chunks stay private in the runtime and do not count as malformed finalized calls.
import type { AgentSnapshot, Message, ToolCall } from '@threadplane/core';
export interface ToolCallsState extends AgentSnapshot {
readonly interrupts: readonly unknown[];
readonly subgraphs: readonly unknown[];
readonly history:
| readonly {
readonly checkpoint: unknown;
readonly next: readonly string[];
readonly parent_checkpoint?: unknown;
readonly created_at?: unknown;
}[]
| undefined;
}
export interface Authority {
readonly threadId: string;
readonly checkpoint: string;
readonly signature: string;
readonly messages: readonly Message[];
readonly tools: readonly ToolCall[];
}
/** Capture own data without evaluating backend-supplied accessors. */
export function copyData(value: unknown, parents = new Set<object>()): unknown {
if (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'boolean'
)
return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (
typeof value !== 'object' ||
parents.has(value) ||
Object.getOwnPropertySymbols(value).length
)
throw new Error('Unsupported data');
const array = Array.isArray(value),
prototype = Object.getPrototypeOf(value);
if (array && prototype !== Array.prototype)
throw new Error('Unsupported data');
if (!array && prototype !== Object.prototype && prototype !== null)
throw new Error('Unsupported data');
parents.add(value);
const descriptors = Object.getOwnPropertyDescriptors(value);
const result: Record<string, unknown> | unknown[] = array
? []
: Object.create(null);
const keys = array
? Array.from({ length: value.length }, (_, index) => String(index))
: Object.keys(descriptors).sort();
if (
array &&
Object.keys(descriptors).some(
(name) => name !== 'length' && !keys.includes(name)
)
)
throw new Error('Unsupported data');
for (const key of keys) {
const descriptor = descriptors[key];
if (!descriptor || !('value' in descriptor))
throw new Error('Unsupported data');
Object.defineProperty(result, key, {
value: copyData(descriptor.value, parents),
enumerable: true,
});
}
parents.delete(value);
return Object.freeze(result);
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string =>
typeof value === 'string' && !!value.trim();
const key = (value: unknown) => JSON.stringify(copyData(value));
export function messageKey(input: Message): string {
const message = copyData(input) as Message;
return key({
id: message.id,
role: message.role,
content: message.content,
reasoning: message.reasoning,
citations: message.citations,
name: message.name,
toolCallId: message.toolCallId,
toolCallIds: message.toolCallIds,
});
}
export function toolsKey(tools: readonly ToolCall[]): string {
return key(tools);
}
export function validToolArgs(input: Pick<ToolCall, 'name' | 'args'>): boolean {
try {
const tool = copyData(input) as Pick<ToolCall, 'name' | 'args'>;
const args = tool.args;
if (!record(args)) return false;
switch (tool.name) {
case 'lookup_flight':
return text(args['flight_number']);
case 'get_airport_info':
return text(args['airport_code']);
case 'find_routes':
return (
text(args['from_code']) &&
text(args['to_code']) &&
(args['date_offset_days'] === undefined ||
Number.isInteger(args['date_offset_days']))
);
default:
return false;
}
} catch {
return false;
}
}
/** Only exact saved root evidence grants permission for a follow-up turn. */
export function captureTerminal(
input: ToolCallsState,
threadId: string
): Authority | null {
try {
const state = copyData(input) as ToolCallsState;
if (
state.status !== 'idle' ||
state.error ||
state.interrupts.length ||
state.subgraphs.length ||
!state.history?.length
)
return null;
const history = state.history[0],
checkpoint = history.checkpoint;
if (
!record(checkpoint) ||
checkpoint['thread_id'] !== threadId ||
checkpoint['checkpoint_ns'] !== '' ||
!text(checkpoint['checkpoint_id']) ||
!Array.isArray(history.next) ||
history.next.length
)
return null;
const messages = state.messages,
tools = state.toolCalls;
if (
!messages.length ||
messages[0].role !== 'user' ||
new Set(messages.map((m) => m.id)).size !== messages.length ||
new Set(tools.map((tool) => tool.id)).size !== tools.length ||
tools.some(
(tool) =>
!text(tool.id) ||
!validToolArgs(tool) ||
tool.status !== 'complete' ||
typeof tool.result !== 'string'
)
)
return null;
const catalog = new Map(tools.map((tool) => [tool.id, tool]));
const calls = new Set<string>(),
results = new Set<string>(),
pending = new Set<string>();
for (const message of messages) {
if (
!text(message.id) ||
typeof message.content !== 'string' ||
message.delivery.phase !== 'complete' ||
message.delivery.outcome !== 'success' ||
message.delivery.generation !== message.id
)
return null;
if (message.role === 'user') {
if (
pending.size ||
message.toolCallId !== undefined ||
message.toolCallIds?.length
)
return null;
} else if (message.role === 'assistant') {
if (message.toolCallId !== undefined) return null;
for (const id of message.toolCallIds ?? []) {
if (!catalog.has(id) || calls.has(id)) return null;
calls.add(id);
pending.add(id);
}
} else if (message.role === 'tool') {
const id = message.toolCallId,
tool = id ? catalog.get(id) : undefined;
if (
!id ||
!tool ||
!pending.has(id) ||
results.has(id) ||
tool.status !== 'complete' ||
tool.name !== message.name ||
tool.result !== message.content ||
message.toolCallIds?.length
)
return null;
results.add(id);
pending.delete(id);
} else return null;
}
if (
calls.size !== tools.length ||
results.size !== tools.length ||
pending.size ||
messages.at(-1)?.role !== 'assistant' ||
!text(messages.at(-1)?.content)
)
return null;
return Object.freeze({
threadId,
checkpoint: key(checkpoint),
signature: key({ history, messages, tools }),
messages,
tools,
});
} catch {
return null;
}
}These checks cover state observed by this page. They do not prevent another client from changing the same server thread without this page observing it. Each page creates a fresh thread and has no shared-thread picker.
Stop and recover locally
While work is active, Send and New conversation stay blocked and Stop remains available. Stop immediately detaches the owner; late stream or saved-history responses cannot restore its commands. New conversation releases the old local owner and clears the old draft. New text typed during cleanup is preserved. The next server thread is created only when another message is sent.
import type {
AgentSession,
CompleteOutcome,
Message,
ToolCall,
} from '@threadplane/core';
import {
createMessageContent,
type MessageRow,
} from '@threadplane/content/messages';
import {
captureTerminal,
messageKey,
toolsKey,
copyData,
validToolArgs,
type ToolCallsState,
} from './authority';
import { projectToolObservations, type ToolCard } from './projection';
export interface ToolCallsSession extends Omit<AgentSession, 'getSnapshot'> {
getSnapshot(): ToolCallsState;
load(options?: { readonly signal?: AbortSignal }): Promise<void>;
}
export interface ToolCallsSnapshot {
readonly threadId: string | null;
readonly rows: readonly MessageRow[];
readonly activity:
| 'idle'
| 'creating'
| 'running'
| 'confirming'
| 'replacing';
readonly busy: boolean;
readonly canSubmit: boolean;
readonly outcome: CompleteOutcome | null;
readonly observations: ReadonlyMap<string, readonly ToolCard[]>;
readonly error: string | null;
readonly viewGeneration: number;
}
const completed = (message: Message) => message.delivery.phase === 'complete';
const same = (left: Message, right: Message | undefined) =>
!!right && messageKey(left) === messageKey(right);
/** Owns one conversation. Only a matching saved terminal transcript grants a follow-up. */
export function createToolCallsApplication(options: {
readonly createThread: (signal: AbortSignal) => Promise<string>;
readonly sessionFactory: (id: string) => ToolCallsSession;
}) {
type Turn = {
text?: string;
human?: Message;
generation?: string;
base: readonly Message[];
positions: string[];
completed: Map<string, string>;
tools: Map<string, ToolCall>;
expected?: { messages: readonly Message[]; tools: readonly ToolCall[] };
};
type Owner = {
id: string;
session: ToolCallsSession;
content: ReturnType<typeof createMessageContent>;
release?: () => void;
closed: boolean;
unsafe: boolean;
confirmed: readonly Message[];
confirmedTools: readonly ToolCall[];
generations: Set<string>;
turn?: Turn;
};
let owner: Owner | undefined, operation: AbortController | undefined;
let disposed = false,
disposal: Promise<void> | undefined;
const listeners = new Set<() => void>(),
cleanups = new Set<Promise<void>>();
let snapshot: ToolCallsSnapshot = Object.freeze({
threadId: null,
rows: Object.freeze([]),
activity: 'idle',
busy: false,
canSubmit: true,
outcome: null,
observations: new Map(),
error: null,
viewGeneration: 0,
});
function publish(update: Partial<ToolCallsSnapshot>) {
if (disposed) return;
snapshot = Object.freeze({ ...snapshot, ...update });
for (const notify of listeners) notify();
}
function bounded(work: () => void | Promise<void>) {
let timeout: ReturnType<typeof setTimeout>;
return Promise.race([
Promise.resolve()
.then(work)
.catch(() => undefined),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, 2000);
}),
]).finally(() => clearTimeout(timeout));
}
function close(selected: Owner) {
if (selected.closed) return Promise.resolve();
selected.closed = true;
selected.release?.();
selected.content.dispose();
const cleanup = bounded(() => selected.session.dispose());
cleanups.add(cleanup);
void cleanup.then(() => cleanups.delete(cleanup));
return cleanup;
}
function detach() {
const previous = owner;
owner = undefined;
return previous ? close(previous) : Promise.resolve();
}
function blocked() {
publish({
canSubmit: false,
outcome: 'error',
error:
'This conversation could not be confirmed. Start a new conversation to continue.',
});
}
function observe(selected: Owner, state: ToolCallsState) {
state = copyData(state) as ToolCallsState;
if (
!state ||
!Array.isArray(state.messages) ||
!Array.isArray(state.toolCalls) ||
!Array.isArray(state.interrupts) ||
!Array.isArray(state.subgraphs) ||
state.error ||
state.status === 'error' ||
state.subgraphs.length ||
state.interrupts.length ||
new Set(state.messages.map((m) => m.id)).size !== state.messages.length ||
new Set(state.toolCalls.map((t) => t.id)).size !== state.toolCalls.length
)
throw new Error('Unconfirmed state');
for (const message of state.messages) {
if (
!['user', 'assistant', 'tool'].includes(message.role) ||
(message.role !== 'tool' && message.toolCallId !== undefined) ||
(message.role !== 'assistant' && message.toolCallIds?.length) ||
(message.role === 'tool' && !message.toolCallId) ||
!['streaming', 'complete'].includes(message.delivery.phase) ||
(completed(message) && message.delivery.outcome !== 'success')
)
throw new Error('Invalid observed message');
}
for (const tool of selected.confirmedTools) {
const current = state.toolCalls.find(
(candidate) => candidate.id === tool.id
);
if (
!current ||
current.name !== tool.name ||
toolsKey([{ ...tool, args: current.args }]) !== toolsKey([tool]) ||
(tool.status === 'complete' && toolsKey([current]) !== toolsKey([tool]))
)
throw new Error('Changed saved tool');
}
const turn = selected.turn,
prefix = turn?.base ?? selected.confirmed;
for (const tool of state.toolCalls) {
if (
!validToolArgs(tool) ||
!['pending', 'complete'].includes(tool.status)
)
throw new Error('Unsupported tool');
const previous = turn?.tools.get(tool.id);
if (
previous &&
(toolsKey([{ ...previous, name: tool.name, args: tool.args }]) !==
toolsKey([previous]) ||
(previous.status === 'complete' &&
toolsKey([previous]) !== toolsKey([tool])))
)
throw new Error('Changed tool identity');
turn?.tools.set(tool.id, copyData(tool) as ToolCall);
}
if (
turn &&
[...turn.tools.keys()].some(
(id) => !state.toolCalls.some((tool) => tool.id === id)
)
)
throw new Error('Missing observed tool');
if (
state.messages.length < prefix.length ||
prefix.some((message, index) => !same(message, state.messages[index]))
)
throw new Error('Changed prefix');
if (!turn) {
if (state.messages.length !== prefix.length)
throw new Error('Unowned turn');
return;
}
if (turn.expected) {
if (
state.messages.length !== turn.expected.messages.length ||
turn.expected.messages.some((message, index) => {
const current = state.messages[index];
return (
!same(message, current) ||
current.delivery.phase !== 'complete' ||
(current.delivery.generation !== message.delivery.generation &&
current.delivery.generation !== current.id)
);
}) ||
toolsKey(state.toolCalls) !== toolsKey(turn.expected.tools)
)
throw new Error('Changed saved evidence');
return;
}
if (
state.messages.length < turn.positions.length ||
turn.positions.some((id, index) => state.messages[index].id !== id)
)
throw new Error('Changed message identity');
turn.positions = state.messages.map((message) => message.id);
for (const message of state.messages) {
if (completed(message) && message.delivery.outcome !== 'success')
throw new Error('Failed message');
const identity = messageKey(message),
previous = turn.completed.get(message.id);
if (previous !== undefined && previous !== identity)
throw new Error('Changed completed message');
if (completed(message)) turn.completed.set(message.id, identity);
}
const suffix = state.messages.slice(prefix.length);
if (suffix.length) {
turn.generation ??= suffix[0].delivery.generation;
if (
!turn.generation ||
suffix.some(
(message) => message.delivery.generation !== turn.generation
)
)
throw new Error('Foreign run generation');
}
if (turn.text !== undefined && suffix.length) {
const human = suffix[0];
if (
human.role !== 'user' ||
human.content !== turn.text ||
!completed(human) ||
human.delivery.outcome !== 'success' ||
selected.generations.has(human.delivery.generation) ||
(turn.human &&
(!same(turn.human, human) ||
turn.human.delivery.generation !== human.delivery.generation)) ||
suffix.slice(1).some((message) => message.role === 'user')
)
throw new Error('Unowned human turn');
turn.human ??= human;
} else if (
turn.text === undefined &&
suffix.some((message) => message.role === 'user')
)
throw new Error('Unowned resumed turn');
}
function read(selected: Owner, valid: () => boolean) {
if (!valid()) return;
const state = selected.session.getSnapshot();
if (!valid()) return;
try {
observe(selected, state);
} catch {
selected.unsafe = true;
}
if (!valid()) return;
if (Array.isArray(state?.messages) && Array.isArray(state?.toolCalls)) {
let rows: readonly MessageRow[],
observations: ReadonlyMap<string, readonly ToolCard[]>;
try {
const captured = copyData(state) as ToolCallsState;
rows = selected.content.project(captured);
observations = projectToolObservations(
captured.messages,
captured.toolCalls
);
} catch {
selected.unsafe = true;
return state;
}
if (!valid()) return;
publish({
rows,
observations,
...(selected.unsafe
? {
canSubmit: false,
outcome: 'error' as const,
error:
'This conversation could not be confirmed. Start a new conversation to continue.',
}
: {}),
});
}
if (!valid()) return;
if (selected.session.getSnapshot() !== state) selected.unsafe = true;
if (!valid()) return;
return state;
}
async function execute(input: { text: string }) {
if (disposed || snapshot.busy || !snapshot.canSubmit || !input.text.trim())
return;
const admitted = new AbortController();
operation = admitted;
const current = () =>
!disposed && operation === admitted && !admitted.signal.aborted;
publish({
busy: true,
canSubmit: false,
activity: owner ? 'running' : 'creating',
outcome: null,
error: null,
});
try {
if (!current()) return;
if (!owner) {
const id = await options.createThread(admitted.signal);
if (!current()) return;
if (!id || typeof id !== 'string')
throw new Error('Unconfirmed conversation');
const session = options.sessionFactory(id);
const selected: Owner = {
id,
session,
content: createMessageContent(),
closed: false,
unsafe: false,
confirmed: [],
confirmedTools: [],
generations: new Set(),
};
if (!current()) {
void close(selected);
return;
}
owner = selected;
const release = session.subscribe(() =>
read(
selected,
() =>
!disposed &&
owner === selected &&
!selected.closed &&
!operation?.signal.aborted
)
);
selected.release = release;
if (selected.closed) release();
if (!current()) return;
publish({ threadId: id, activity: 'running' });
}
const selected = owner,
valid = () => current() && owner === selected && !selected.closed;
const before = read(selected, valid);
if (!valid()) return;
if (!before || selected.unsafe) {
blocked();
return;
}
selected.turn = {
text: input.text,
base: selected.confirmed,
positions: selected.confirmed.map((message) => message.id),
completed: new Map(
selected.confirmed.map((message) => [message.id, messageKey(message)])
),
tools: new Map(selected.confirmedTools.map((tool) => [tool.id, tool])),
};
// Admission and row subscribers can replace observations; recheck directly before dispatch.
if (!valid()) return;
const dispatch = selected.session.getSnapshot();
if (!valid()) return;
try {
observe(selected, dispatch);
} catch {
selected.unsafe = true;
}
if (!valid()) return;
const currentDispatch = selected.session.getSnapshot();
if (!valid()) return;
if (selected.unsafe || currentDispatch !== dispatch) {
selected.unsafe = true;
blocked();
return;
}
const outcome = await selected.session.submit(input.text, {
signal: admitted.signal,
});
if (!valid()) return;
const live = read(selected, valid),
turn = selected.turn;
if (!valid()) return;
if (
!live ||
selected.unsafe ||
outcome !== 'success' ||
live.status !== 'idle' ||
!turn ||
!turn.human ||
live.messages.length <= turn.base.length ||
live.messages.some((message) => !completed(message))
) {
blocked();
return;
}
turn.expected = {
messages: copyData(live.messages) as readonly Message[],
tools: copyData(live.toolCalls) as readonly ToolCall[],
};
publish({ activity: 'confirming' });
if (!valid()) return;
if (!read(selected, valid) || selected.unsafe) {
if (valid()) blocked();
return;
}
await selected.session.load({ signal: admitted.signal });
if (!valid()) return;
const saved = read(selected, valid);
if (!valid()) return;
const authority = saved && captureTerminal(saved, selected.id);
if (!valid()) return;
const currentSaved = selected.session.getSnapshot();
if (!valid()) return;
if (!authority || selected.unsafe || currentSaved !== saved) {
blocked();
return;
}
selected.confirmed = authority.messages;
selected.confirmedTools = authority.tools;
if (turn.human) selected.generations.add(turn.human.delivery.generation);
selected.turn = undefined;
publish({
outcome,
canSubmit: true,
});
if (!valid()) return;
const latest = read(selected, valid);
if (valid() && (!latest || selected.unsafe)) blocked();
} catch {
if (current())
publish({
canSubmit: false,
outcome: 'error',
error:
'The conversation could not be confirmed. Start a new conversation.',
});
} finally {
if (current()) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
}
async function stop() {
if (disposed || !operation || snapshot.activity === 'replacing') return;
operation.abort();
const admitted = new AbortController();
operation = admitted;
const selected = owner,
cleanup = detach();
publish({
busy: true,
activity: 'replacing',
canSubmit: false,
outcome: 'aborted',
error: null,
});
await Promise.all([cleanup, bounded(() => selected?.session.stop())]);
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
async function newConversation() {
if (disposed || snapshot.busy) return;
const admitted = new AbortController();
operation = admitted;
const cleanup = detach();
publish({
busy: true,
canSubmit: false,
activity: 'replacing',
});
await cleanup;
if (disposed || operation !== admitted || admitted.signal.aborted) return;
operation = undefined;
publish({
threadId: null,
rows: Object.freeze([]),
observations: new Map(),
busy: false,
canSubmit: true,
activity: 'idle',
outcome: null,
error: null,
viewGeneration: snapshot.viewGeneration + 1,
});
}
function dispose(): Promise<void> {
if (disposal) return disposal;
disposed = true;
operation?.abort();
operation = undefined;
void detach();
listeners.clear();
disposal = Promise.all([...cleanups]).then(() => undefined);
return disposal;
}
return {
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit: (text: string) => execute({ text }),
stop,
newConversation,
dispose,
};
}The shared backend runs c-tool-calls. Runtime settings can select a developer LangGraph target; thread creation, streaming and saved history use that same target, with SDK retries disabled. Creation must confirm the exact requested thread identity.
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import {
createToolCallsApplication,
type ToolCallsSession,
} from './application';
export function toolCallsConnection(
configuration: RuntimeBridgeConfiguration
): {
apiUrl: string;
headers: Record<string, string>;
} {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'langsmith'
? configuration.target
: null;
return {
apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
headers: target ? { 'x-api-key': target.apiKey } : {},
};
}
export function createConnectedApplication(
connection: ReturnType<typeof toolCallsConnection>
) {
const client = new Client({
apiUrl: connection.apiUrl,
apiKey: null,
defaultHeaders: connection.headers,
callerOptions: { maxRetries: 0 },
});
return createToolCallsApplication({
async createThread(signal) {
const threadId = crypto.randomUUID();
const created = await client.threads.create({
threadId,
ifExists: 'raise',
signal,
});
if (created.thread_id !== threadId)
throw new Error('Conversation creation was not confirmed.');
return threadId;
},
sessionFactory(threadId) {
const session = createSession({
assistantId: 'c-tool-calls',
threadId,
apiUrl: connection.apiUrl,
clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
});
try {
const state = session.getSnapshot();
if (
!state ||
!Array.isArray(state.messages) ||
!Array.isArray(state.toolCalls) ||
!Array.isArray(state.interrupts) ||
!Array.isArray(state.subgraphs) ||
![
'getSnapshot',
'subscribe',
'submit',
'load',
'stop',
'dispose',
].every(
(method) =>
typeof session[method as keyof typeof session] === 'function'
)
)
throw new Error('Runtime session is unavailable.');
} catch {
try {
void Promise.resolve(session.dispose()).catch(() => undefined);
} catch {
/* A rejected session cannot be installed. */
}
throw new Error('Runtime session is unavailable.');
}
return session as ToolCallsSession;
},
});
}Moving between Code and Run preserves the mounted example and unsent draft. Reloading starts an empty page-local conversation.
Build from source
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-chat-tool-calls-react
npx nx e2e cockpit-chat-tool-calls-reactThe isolated build uses installed private candidate packages. Browser tests use the real SDK and a persistent compiled Python graph with deterministic providers and real saved checkpoints. The local worker and verification routes are excluded from public output. Background title generation updates metadata without entering the answer stream. Docs, Code and Run select this same React implementation; Angular remains the default.