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
7 changes: 7 additions & 0 deletions cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,49 @@ 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: `
<div class="cb">
<p class="cb__summary">{{ summary() }}</p>
<div class="cb__actions">
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
@if (confirmed() === undefined) {
<div class="cb">
<p class="cb__summary">{{ summary() }}</p>
<div class="cb__actions">
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
</div>
</div>
</div>
} @else if (confirmed() === true) {
<div class="cb cb--resolved">
<p class="cb__summary">Booking confirmed ✓</p>
</div>
} @else {
<div class="cb cb--resolved">
<p class="cb__summary">Booking cancelled</p>
</div>
}
`,
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; }
`],
})
export class ConfirmBookingComponent {
readonly summary = input<string>();
/** Spread back onto props after the ask resolves (undefined while interactive). */
readonly confirmed = input<boolean | undefined>(undefined);
private readonly host = injectRenderHost();
protected respond(confirmed: boolean): void {
this.host.result({ confirmed });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,49 @@ 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: `
<div class="cb">
<p class="cb__summary">{{ summary() }}</p>
<div class="cb__actions">
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
@if (confirmed() === undefined) {
<div class="cb">
<p class="cb__summary">{{ summary() }}</p>
<div class="cb__actions">
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
</div>
</div>
</div>
} @else if (confirmed() === true) {
<div class="cb cb--resolved">
<p class="cb__summary">Booking confirmed ✓</p>
</div>
} @else {
<div class="cb cb--resolved">
<p class="cb__summary">Booking cancelled</p>
</div>
}
`,
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; }
`],
})
export class ConfirmBookingComponent {
readonly summary = input<string>();
/** Spread back onto props after the ask resolves (undefined while interactive). */
readonly confirmed = input<boolean | undefined>(undefined);
private readonly host = injectRenderHost();
protected respond(confirmed: boolean): void {
this.host.result({ confirmed });
Expand Down
7 changes: 7 additions & 0 deletions examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
35 changes: 29 additions & 6 deletions examples/ag-ui/angular/src/app/clear-day-confirm.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div class="cdc">
<p class="cdc__summary">Clear all {{ count() }} stops on day {{ day() }}?</p>
<div class="cdc__actions">
<button type="button" class="cdc__btn cdc__btn--primary" (click)="clear()">Clear</button>
<button type="button" class="cdc__btn" (click)="cancel()">Cancel</button>
@if (cleared() === undefined) {
<div class="cdc">
<p class="cdc__summary">Clear all {{ count() }} stops on day {{ day() }}?</p>
<div class="cdc__actions">
<button type="button" class="cdc__btn cdc__btn--primary" (click)="clear()">Clear</button>
<button type="button" class="cdc__btn" (click)="cancel()">Cancel</button>
</div>
</div>
</div>
} @else if (cleared() === true) {
<div class="cdc cdc--resolved">
<p class="cdc__summary">Day {{ day() }} cleared — {{ removed() }} removed ✓</p>
</div>
} @else {
<div class="cdc cdc--resolved">
<p class="cdc__summary">Kept day {{ day() }} — clear cancelled</p>
</div>
}
`,
styles: [
`
Expand All @@ -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;
}
Expand All @@ -57,6 +77,9 @@ import { ItineraryStore } from './itinerary-store';
})
export class ClearDayConfirmComponent {
readonly day = input.required<number>();
/** Spread back onto props after the ask resolves (undefined while interactive). */
readonly cleared = input<boolean | undefined>(undefined);
readonly removed = input<number | undefined>(undefined);
private readonly store = inject(ItineraryStore);
private readonly host = injectRenderHost();

Expand Down
6 changes: 3 additions & 3 deletions examples/ag-ui/angular/src/app/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
),
Expand Down
27 changes: 24 additions & 3 deletions libs/ag-ui/src/lib/client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
35 changes: 30 additions & 5 deletions libs/ag-ui/src/lib/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -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}`,
Expand Down
25 changes: 23 additions & 2 deletions libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -345,7 +346,22 @@ export function agent<
rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)),
);

const toolCallsNeutral = computed<ToolCall[]>(() => 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<ReadonlyMap<string, ClientToolResultPatch>>(new Map());

const toolCallsNeutral = computed<ToolCall[]>(() => {
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<AgentStatus>(() => mapStatus(statusSig()));

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading