From 3f0cd4e32214e1c73c5d689c76d8e9a6a977677b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 18:35:41 -0700 Subject: [PATCH 1/4] feat(examples/ag-ui): steer the model to day_card after itinerary writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpen the add_stop/move_stop/day_card catalog descriptions so the model follows writes with the day_card recap view (verified live: add_stop → day_card → continuation). Descriptions-only — still no system-prompt coaching. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/ag-ui/angular/src/app/client-tools.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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, ), From 185686bb574e2be8b8833318b3e8fdb7e9c1f938 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 19:06:24 -0700 Subject: [PATCH 2/4] fix(ag-ui,langgraph): write client tool results onto the local tool call on resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a client `ask`/`view` tool resolves, only the backend ToolMessage carried the outcome — the LOCAL ToolCall stayed result-less, so the transcript card never froze. resolve() now writes the emitted value onto the local ToolCall: ok → result=value/status stays complete; error → result={error}, error set, status='error'. chat-tool-views' toToolViewSpec already spreads result into the mounted component's props, so the card can branch to a resolved state on the next render. ag-ui writes its WritableSignal directly; langgraph layers an override map over the read-only toolCalls projection (the SDK stream only ever carries backend results) via a new store.applyClientResult hook. Co-Authored-By: Claude Fable 5 --- libs/ag-ui/src/lib/client-tools.spec.ts | 27 ++++++++++++-- libs/ag-ui/src/lib/client-tools.ts | 35 +++++++++++++++--- libs/langgraph/src/lib/agent.fn.ts | 25 ++++++++++++- libs/langgraph/src/lib/client-tools.spec.ts | 23 ++++++++++-- libs/langgraph/src/lib/client-tools.ts | 41 +++++++++++++++++++-- 5 files changed, 134 insertions(+), 17 deletions(-) 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 From fceff1900408e9b943a4e1439999f1a7a69201e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 19:07:33 -0700 Subject: [PATCH 3/4] feat(examples,cockpit): ask confirm cards freeze into resolved states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clear_day and confirm_booking ask components now receive their own emitted value back as props once resolved (the adapters write the result onto the local tool call). Each branches on an optional input — cleared / confirmed undefined → interactive card; defined → a frozen line with no buttons ("Day N cleared — M removed" / "Kept day N" and "Booking confirmed" / "Booking cancelled"). e2e: both ask tests now assert the frozen text appears AND the action buttons are gone after resolving. Co-Authored-By: Claude Fable 5 --- .../angular/e2e/client-tools.spec.ts | 7 ++++ .../src/app/confirm-booking.component.ts | 31 ++++++++++++---- .../e2e/itinerary-client-tools.spec.ts | 7 ++++ .../src/app/clear-day-confirm.component.ts | 35 +++++++++++++++---- 4 files changed, 68 insertions(+), 12 deletions(-) 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/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: ` -
-

Clear all {{ count() }} stops on day {{ day() }}?

-
- - + @if (cleared() === undefined) { +
+

Clear all {{ count() }} stops on day {{ day() }}?

+
+ + +
-
+ } @else if (cleared() === true) { +
+

Day {{ day() }} cleared — {{ removed() }} removed ✓

+
+ } @else { +
+

Kept day {{ day() }} — clear cancelled

+
+ } `, 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(); From 3b63dcae39fe26dbfd25147f1280e3411a13d778 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 19:19:10 -0700 Subject: [PATCH 4/4] feat(cockpit): langgraph confirm-booking freezes into resolved states (mirror ag-ui) --- .../angular/e2e/client-tools.spec.ts | 7 +++++ .../src/app/confirm-booking.component.ts | 31 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) 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 });