diff --git a/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts b/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts
index 51f5c1701..70d11cf27 100644
--- a/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts
+++ b/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts
@@ -33,4 +33,11 @@ test('client-tools: ask tool collects user confirmation and resumes the run', as
await expect(confirm).toContainText('Table for two at 7pm');
await confirm.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Your booking is confirmed')).toBeVisible({ timeout: 30000 });
+
+ // The confirm card freezes once resolved: the adapter writes the emitted
+ // result back onto the local tool call, so the component re-renders into its
+ // frozen state — the interactive buttons disappear and a confirmed line shows.
+ await expect(confirm).toContainText('Booking confirmed');
+ await expect(confirm.getByRole('button', { name: 'Confirm' })).toHaveCount(0);
+ await expect(confirm.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
});
diff --git a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts
index 375aad156..94ab9e96f 100644
--- a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts
+++ b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts
@@ -7,23 +7,40 @@ import { injectRenderHost } from '@threadplane/render';
* The model fills `summary`; the user confirms or cancels; the chosen value is
* announced via `injectRenderHost().result(...)` and becomes the tool result
* that resumes the run.
+ *
+ * Once the ask resolves, the adapter writes the emitted `{ confirmed }` back
+ * onto the local tool call, so this component re-renders with `confirmed` as a
+ * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When
+ * `confirmed()` is defined we render a FROZEN line with no buttons; the live
+ * interactive card only shows while `confirmed()` is still undefined.
*/
@Component({
selector: 'app-confirm-booking',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-
-
{{ summary() }}
-
-
-
+ @if (confirmed() === undefined) {
+
+
{{ summary() }}
+
+
+
+
-
+ } @else if (confirmed() === true) {
+
+
Booking confirmed ✓
+
+ } @else {
+
+
Booking cancelled
+
+ }
`,
styles: [`
.cb { border: 1px solid var(--ngaf-chat-separator, #e5e7eb); border-radius: 12px; padding: 16px; max-width: 360px; }
.cb__summary { margin: 0 0 12px; }
+ .cb--resolved .cb__summary { margin: 0; opacity: 0.85; }
.cb__actions { display: flex; gap: 8px; }
.cb__btn { padding: 6px 14px; border-radius: 8px; border: 1px solid var(--ngaf-chat-separator, #e5e7eb); background: transparent; color: inherit; cursor: pointer; }
.cb__btn--primary { background: var(--ngaf-chat-accent, #2563eb); color: #fff; border-color: transparent; }
@@ -31,6 +48,8 @@ import { injectRenderHost } from '@threadplane/render';
})
export class ConfirmBookingComponent {
readonly summary = input();
+ /** Spread back onto props after the ask resolves (undefined while interactive). */
+ readonly confirmed = input(undefined);
private readonly host = injectRenderHost();
protected respond(confirmed: boolean): void {
this.host.result({ confirmed });
diff --git a/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts b/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts
index 51f5c1701..70d11cf27 100644
--- a/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts
+++ b/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts
@@ -33,4 +33,11 @@ test('client-tools: ask tool collects user confirmation and resumes the run', as
await expect(confirm).toContainText('Table for two at 7pm');
await confirm.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Your booking is confirmed')).toBeVisible({ timeout: 30000 });
+
+ // The confirm card freezes once resolved: the adapter writes the emitted
+ // result back onto the local tool call, so the component re-renders into its
+ // frozen state — the interactive buttons disappear and a confirmed line shows.
+ await expect(confirm).toContainText('Booking confirmed');
+ await expect(confirm.getByRole('button', { name: 'Confirm' })).toHaveCount(0);
+ await expect(confirm.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
});
diff --git a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts
index 375aad156..94ab9e96f 100644
--- a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts
+++ b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts
@@ -7,23 +7,40 @@ import { injectRenderHost } from '@threadplane/render';
* The model fills `summary`; the user confirms or cancels; the chosen value is
* announced via `injectRenderHost().result(...)` and becomes the tool result
* that resumes the run.
+ *
+ * Once the ask resolves, the adapter writes the emitted `{ confirmed }` back
+ * onto the local tool call, so this component re-renders with `confirmed` as a
+ * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When
+ * `confirmed()` is defined we render a FROZEN line with no buttons; the live
+ * interactive card only shows while `confirmed()` is still undefined.
*/
@Component({
selector: 'app-confirm-booking',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-
-
{{ summary() }}
-
-
-
+ @if (confirmed() === undefined) {
+
+
{{ summary() }}
+
+
+
+
-
+ } @else if (confirmed() === true) {
+
+
Booking confirmed ✓
+
+ } @else {
+
+
Booking cancelled
+
+ }
`,
styles: [`
.cb { border: 1px solid var(--ngaf-chat-separator, #e5e7eb); border-radius: 12px; padding: 16px; max-width: 360px; }
.cb__summary { margin: 0 0 12px; }
+ .cb--resolved .cb__summary { margin: 0; opacity: 0.85; }
.cb__actions { display: flex; gap: 8px; }
.cb__btn { padding: 6px 14px; border-radius: 8px; border: 1px solid var(--ngaf-chat-separator, #e5e7eb); background: transparent; color: inherit; cursor: pointer; }
.cb__btn--primary { background: var(--ngaf-chat-accent, #2563eb); color: #fff; border-color: transparent; }
@@ -31,6 +48,8 @@ import { injectRenderHost } from '@threadplane/render';
})
export class ConfirmBookingComponent {
readonly summary = input();
+ /** Spread back onto props after the ask resolves (undefined while interactive). */
+ readonly confirmed = input(undefined);
private readonly host = injectRenderHost();
protected respond(confirmed: boolean): void {
this.host.result({ confirmed });
diff --git a/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts b/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts
index b1c44a3fd..af548df56 100644
--- a/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts
+++ b/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts
@@ -67,4 +67,11 @@ test('ask chain: clear_day confirm mutates the panel and resumes the run', async
await expect(panel).not.toContainText("Musée d'Orsay");
await expect(page.getByText('Done — day 2 is cleared.')).toBeVisible({ timeout: 30_000 });
+
+ // The confirm card freezes once resolved: the adapter writes the emitted
+ // result back onto the local tool call, so the component re-renders into its
+ // frozen state — the interactive buttons are gone and a resolved line shows.
+ await expect(confirm).toContainText('Day 2 cleared');
+ await expect(confirm.getByRole('button', { name: 'Clear' })).toHaveCount(0);
+ await expect(confirm.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
});
diff --git a/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts b/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts
index 293f27340..9febe9244 100644
--- a/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts
+++ b/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts
@@ -10,19 +10,35 @@ import { ItineraryStore } from './itinerary-store';
* HERE: Clear writes the shared `ItineraryStore` (so the panel updates live)
* and then announces the outcome via `injectRenderHost().result(...)`, which
* becomes the tool result that resumes the run. Cancel never touches the store.
+ *
+ * Once the ask resolves, the adapter writes the emitted value back onto the
+ * local tool call, so this component re-renders with `cleared`/`removed` as
+ * props (chat-tool-views spreads `{...args, ...result, status}` into it). When
+ * `cleared()` is defined we render a FROZEN line with no buttons; the live
+ * interactive card only shows while `cleared()` is still undefined.
*/
@Component({
selector: 'app-clear-day-confirm',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-
+ }
`,
styles: [
`
@@ -32,6 +48,10 @@ import { ItineraryStore } from './itinerary-store';
padding: 16px;
max-width: 360px;
}
+ .cdc--resolved .cdc__summary {
+ margin: 0;
+ opacity: 0.85;
+ }
.cdc__summary {
margin: 0 0 12px;
}
@@ -57,6 +77,9 @@ import { ItineraryStore } from './itinerary-store';
})
export class ClearDayConfirmComponent {
readonly day = input.required();
+ /** Spread back onto props after the ask resolves (undefined while interactive). */
+ readonly cleared = input(undefined);
+ readonly removed = input(undefined);
private readonly store = inject(ItineraryStore);
private readonly host = injectRenderHost();
diff --git a/examples/ag-ui/angular/src/app/client-tools.ts b/examples/ag-ui/angular/src/app/client-tools.ts
index 2546a4d7b..1d553d32e 100644
--- a/examples/ag-ui/angular/src/app/client-tools.ts
+++ b/examples/ag-ui/angular/src/app/client-tools.ts
@@ -18,12 +18,12 @@ export function itineraryClientTools(): ClientToolRegistry {
async () => ({ days: store.days() }),
),
add_stop: action(
- 'Add a stop to a day of the trip itinerary.',
+ 'Add a stop to a day of the trip itinerary. Afterwards, show the updated day with day_card.',
z.object({ day: z.number().int().min(1), place: z.string(), note: z.string().optional() }),
async ({ day, place, note }) => ({ added: store.add(day, place, note) }),
),
move_stop: action(
- 'Move an existing stop (matched by place name) to another day.',
+ 'Move an existing stop (matched by place name) to another day. Afterwards, show the updated day with day_card.',
z.object({ place: z.string(), toDay: z.number().int().min(1) }),
async ({ place, toDay }) => {
const moved = store.move(place, toDay);
@@ -38,7 +38,7 @@ export function itineraryClientTools(): ClientToolRegistry {
ClearDayConfirmComponent,
),
day_card: view(
- "Show the user a recap card for one itinerary day after you've changed it.",
+ "Show the user a visual recap card for one itinerary day. Call it after add_stop or move_stop with the day's full updated place list.",
z.object({ day: z.number().int().min(1), places: z.array(z.string()) }),
DayCardComponent,
),
diff --git a/libs/ag-ui/src/lib/client-tools.spec.ts b/libs/ag-ui/src/lib/client-tools.spec.ts
index e9dab7026..2bd7ac37a 100644
--- a/libs/ag-ui/src/lib/client-tools.spec.ts
+++ b/libs/ag-ui/src/lib/client-tools.spec.ts
@@ -164,7 +164,7 @@ describe('createClientToolsCapability', () => {
expect((args.tools[0] as { name: string }).name).toBe('get_weather');
});
- it('resolve(ok) drops the id from pending() even though store.result stays undefined', () => {
+ it('resolve(ok) drops the id from pending() and writes the result onto the store tool call', () => {
const source = makeSource();
const store = makeStore();
const cap = createClientToolsCapability(source, store);
@@ -173,13 +173,34 @@ describe('createClientToolsCapability', () => {
store.toolCalls.set([{ id: 'c1', name: 'get_weather', args: {}, status: 'complete' }]);
expect(cap.pending()).toHaveLength(1);
- cap.resolve('c1', { ok: true, value: { temp: 70 } });
- // result is still undefined in the store — resolvedIds guard should drop it
+ cap.resolve('c1', { ok: true, value: { cleared: true } });
+ // resolvedIds guard drops it, AND the result is now written onto the store
+ // tool call (belt-and-braces: the result write alone also excludes it).
expect(cap.pending()).toHaveLength(0);
+ const tc = store.toolCalls().find((t) => t.id === 'c1');
+ expect(tc?.result).toEqual({ cleared: true });
+ expect(tc?.status).toBe('complete');
+ expect(tc?.error).toBeUndefined();
});
// ---- resolve — error result ------------------------------------------------
+ it('resolve(error) writes { error } result + error + status=error onto the store tool call', () => {
+ const source = makeSource();
+ const store = makeStore();
+ const cap = createClientToolsCapability(source, store);
+ cap.setCatalog([WEATHER_SPEC]);
+ store.isLoading.set(false);
+ store.toolCalls.set([{ id: 'c1', name: 'get_weather', args: {}, status: 'complete' }]);
+
+ cap.resolve('c1', { ok: false, error: 'boom' });
+
+ const tc = store.toolCalls().find((t) => t.id === 'c1');
+ expect(tc?.result).toEqual({ error: 'boom' });
+ expect(tc?.error).toBe('boom');
+ expect(tc?.status).toBe('error');
+ });
+
it('resolve(error) adds message whose content contains the error string', () => {
const source = makeSource();
const store = makeStore();
diff --git a/libs/ag-ui/src/lib/client-tools.ts b/libs/ag-ui/src/lib/client-tools.ts
index df1ab0549..8ac7f16cd 100644
--- a/libs/ag-ui/src/lib/client-tools.ts
+++ b/libs/ag-ui/src/lib/client-tools.ts
@@ -35,8 +35,11 @@ function safeStringify(v: unknown): string {
* have no backend result, and haven't been resolved client-side yet — but ONLY
* when the run is not in progress (isLoading===false). The backend ends the run
* without emitting TOOL_CALL_RESULT for client tools, so result stays undefined.
- * - resolve(id, result): marks the call as resolved, adds a ToolMessage via
- * source.addMessage, then re-runs the agent with the catalog tools attached.
+ * - resolve(id, result): marks the call as resolved, writes the outcome onto
+ * the local ToolCall in the store (so the transcript freezes: the mounted
+ * ask component re-renders with its emitted value as props and can branch to
+ * a frozen state), adds a ToolMessage via source.addMessage, then re-runs
+ * the agent with the catalog tools attached.
*
* Call catalogAsAgUiTools() to get the current catalog as AG-UI Tool[] for
* threading into runAgent().
@@ -72,12 +75,34 @@ export function createClientToolsCapability(
// Mark as resolved first so pending() drops it immediately.
resolvedIds.update((s) => new Set(s).add(id));
+ // Write the outcome onto the LOCAL ToolCall in the store. The client tool
+ // DID produce a result client-side, so this is semantically correct — and
+ // it freezes the transcript card: toToolViewSpec spreads `{...args,
+ // ...result, status}` into the mounted ask component, so the component
+ // re-renders with its own emitted value as props and can branch to a
+ // resolved/frozen state. The backend ToolMessage never reaches this local
+ // ToolCall, so without this write the card stays interactive forever.
+ const ok = result.ok;
+ const value = (result as { value: unknown }).value;
+ const error = (result as { error: string }).error;
+ store.toolCalls.update((calls) =>
+ calls.map((tc) =>
+ tc.id === id
+ ? {
+ ...tc,
+ result: ok ? value : { error },
+ ...(ok ? {} : { error, status: 'error' as const }),
+ }
+ : tc,
+ ),
+ );
+
// Cast rather than rely on discriminant narrowing: consumer apps that
// compile this source with `strictNullChecks: false` don't narrow the
// ClientToolResult union in a ternary.
- const content = result.ok
- ? safeStringify((result as { value: unknown }).value)
- : `Error: ${(result as { error: string }).error}`;
+ const content = ok
+ ? safeStringify(value)
+ : `Error: ${error}`;
source.addMessage({
id: `tool-${id}`,
diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts
index ef499a7af..26a3d8c61 100644
--- a/libs/langgraph/src/lib/agent.fn.ts
+++ b/libs/langgraph/src/lib/agent.fn.ts
@@ -64,6 +64,7 @@ import { createStreamManagerBridge } from './internals/stream-manager.bridge';
import { buildBranchTree } from './internals/branch-tree';
import { extractCitations } from './internals/extract-citations';
import { createClientToolsCapability, mergeClientTools } from './client-tools';
+import type { ClientToolResultPatch } from './client-tools';
/**
* Walk LangGraph history (newest-first) and pair each AIMessage id with
@@ -345,7 +346,22 @@ export function agent<
rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)),
);
- const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall));
+ // Client-tool resolutions written client-side. The raw `toolCalls$` stream
+ // (and thus `rawToolCalls`) only ever carries backend results — a resolved
+ // client tool (`ask`/`view`) never receives a backend ToolMessage on its
+ // LOCAL call. These overrides layer the client-side outcome over the raw
+ // projection so the transcript card can freeze (see chat-tool-views
+ // toToolViewSpec, which spreads `result` into the mounted component's props).
+ const clientResultOverrides = signal>(new Map());
+
+ const toolCallsNeutral = computed(() => {
+ const overrides = clientResultOverrides();
+ return rawToolCalls().map((tc) => {
+ const neutral = toToolCall(tc);
+ const patch = overrides.get(neutral.id);
+ return patch ? { ...neutral, ...patch } : neutral;
+ });
+ });
const statusNeutral = computed(() => mapStatus(statusSig()));
@@ -384,7 +400,12 @@ export function agent<
// in the submit wrapper below and in the resolve path inside the capability.
const clientToolsCap = createClientToolsCapability(
(payload, opts) => manager.submit(payload, opts),
- { toolCalls: toolCallsNeutral, isLoading },
+ {
+ toolCalls: toolCallsNeutral,
+ isLoading,
+ applyClientResult: (id, patch) =>
+ clientResultOverrides.update((m) => new Map(m).set(id, patch)),
+ },
);
return {
diff --git a/libs/langgraph/src/lib/client-tools.spec.ts b/libs/langgraph/src/lib/client-tools.spec.ts
index f99c5a681..eb245fbf1 100644
--- a/libs/langgraph/src/lib/client-tools.spec.ts
+++ b/libs/langgraph/src/lib/client-tools.spec.ts
@@ -20,6 +20,12 @@ function makeStore(overrides?: {
return {
toolCalls: toolCallsSig,
isLoading: isLoadingSig,
+ // Mirror the real adapter: layer the client-side outcome onto the matching
+ // tool call so tests (and the render chain) can read the frozen result back.
+ applyClientResult: (id, patch) =>
+ toolCallsSig.update((calls) =>
+ calls.map((tc) => (tc.id === id ? { ...tc, ...patch } : tc)),
+ ),
toolCallsSig,
isLoadingSig,
};
@@ -225,7 +231,7 @@ describe('createClientToolsCapability', () => {
expect(payload['client_tools']).toEqual([WEATHER_SPEC]);
});
- it('resolve(ok) drops id from pending() immediately (resolvedIds guard)', () => {
+ it('resolve(ok) drops id from pending() AND writes the result onto the store tool call', () => {
const store = makeStore({ isLoading: false });
const cap = createClientToolsCapability(makeSubmitFn(), store);
cap.setCatalog([WEATHER_SPEC]);
@@ -234,9 +240,14 @@ describe('createClientToolsCapability', () => {
]);
expect(cap.pending()).toHaveLength(1);
- cap.resolve('c1', { ok: true, value: { temp: 70 } });
- // result is still undefined in the store — resolvedIds guard drops it
+ cap.resolve('c1', { ok: true, value: { cleared: true } });
+ // resolvedIds guard drops it, AND the result is now written onto the store
+ // tool call (belt-and-braces: the result write alone also excludes it).
expect(cap.pending()).toHaveLength(0);
+ const tc = store.toolCalls().find((t) => t.id === 'c1');
+ expect(tc?.result).toEqual({ cleared: true });
+ expect(tc?.status).toBe('complete');
+ expect(tc?.error).toBeUndefined();
});
it('resolve does not affect other pending calls', () => {
@@ -294,7 +305,7 @@ describe('createClientToolsCapability', () => {
expect(payload['client_tools']).toEqual([WEATHER_SPEC]);
});
- it('resolve(error) drops id from pending() immediately', () => {
+ it('resolve(error) drops id from pending() AND writes { error } + status=error onto the store', () => {
const store = makeStore({ isLoading: false });
const cap = createClientToolsCapability(makeSubmitFn(), store);
cap.setCatalog([WEATHER_SPEC]);
@@ -305,6 +316,10 @@ describe('createClientToolsCapability', () => {
expect(cap.pending()).toHaveLength(1);
cap.resolve('c2', { ok: false, error: 'boom' });
expect(cap.pending()).toHaveLength(0);
+ const tc = store.toolCalls().find((t) => t.id === 'c2');
+ expect(tc?.result).toEqual({ error: 'boom' });
+ expect(tc?.error).toBe('boom');
+ expect(tc?.status).toBe('error');
});
// ── catalog shipping in normal submit ───────────────────────────────────────
diff --git a/libs/langgraph/src/lib/client-tools.ts b/libs/langgraph/src/lib/client-tools.ts
index 51771d63e..5f339484f 100644
--- a/libs/langgraph/src/lib/client-tools.ts
+++ b/libs/langgraph/src/lib/client-tools.ts
@@ -5,6 +5,16 @@ import type { ClientToolsCapability, ClientToolResult, ClientToolSpec } from '@t
import type { ToolCall } from '@threadplane/chat';
import type { LangGraphSubmitOptions } from './agent.types';
+/**
+ * A patch written onto a local ToolCall when a client tool resolves.
+ * Mirrors the fields the ag-ui adapter writes directly onto its WritableSignal.
+ */
+export interface ClientToolResultPatch {
+ result: unknown;
+ error?: unknown;
+ status?: ToolCall['status'];
+}
+
/**
* Minimal store surface consumed by createClientToolsCapability.
* Typed narrowly so the factory is easy to fake in tests.
@@ -12,6 +22,15 @@ import type { LangGraphSubmitOptions } from './agent.types';
export interface ClientToolsStore {
toolCalls: Signal;
isLoading: Signal;
+ /**
+ * Write a client-tool outcome onto the local ToolCall with the given id.
+ * The LangGraph `toolCalls` signal is a read-only projection of the SDK's
+ * stream, so the adapter layers these patches over the projection rather
+ * than mutating it in place. This freezes the transcript card: toToolViewSpec
+ * spreads the result into the mounted ask component's props on the next
+ * render, letting it branch to a resolved/frozen state.
+ */
+ applyClientResult(id: string, patch: ClientToolResultPatch): void;
}
/**
@@ -114,9 +133,25 @@ export function createClientToolsCapability(
// Cast rather than rely on discriminant narrowing: consumer apps that
// compile this source with `strictNullChecks: false` don't narrow the
// ClientToolResult union in a ternary.
- const content = result.ok
- ? safeStringify((result as { value: unknown }).value)
- : `Error: ${(result as { error: string }).error}`;
+ const ok = result.ok;
+ const value = (result as { value: unknown }).value;
+ const error = (result as { error: string }).error;
+
+ // Write the outcome onto the LOCAL ToolCall (via the adapter's override
+ // layer). The client tool DID produce a result client-side, so this is
+ // semantically correct — and it freezes the transcript card: the mounted
+ // ask component re-renders with its own emitted value as props and can
+ // branch to a resolved/frozen state. Without this, the LOCAL tool call
+ // never gets a result (only the backend ToolMessage does) so the card
+ // stays interactive forever.
+ store.applyClientResult(id, {
+ result: ok ? value : { error },
+ ...(ok ? {} : { error, status: 'error' as const }),
+ });
+
+ const content = ok
+ ? safeStringify(value)
+ : `Error: ${error}`;
// Issue a new run on the same thread. LangGraph's add_messages reducer
// appends the ToolMessage to the thread state. `client_tools` is