Skip to content

Commit 11eac75

Browse files
committed
fix(agent): render deferred tool UI results
1 parent 1f68c5e commit 11eac75

7 files changed

Lines changed: 274 additions & 24 deletions

File tree

‎packages/agent/src/inner-loop/resume-tool-results.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
221221
const settledIds = new Set(state.settledAsyncCallIds ?? []);
222222

223223
const envelopes: models.BaseInputsUnion[] = [];
224+
const uiToolResults: Array<{
225+
callId: string;
226+
name: string;
227+
input: Record<string, unknown>;
228+
output: unknown;
229+
}> = [];
224230
/** callId → the terminal lifecycle status persisted for that entry. */
225231
const settledNow = new Map<string, ToolTaskStatus>();
226232

@@ -249,6 +255,7 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
249255
const envelope = buildResumeEnvelope(entry, task, request.tools);
250256

251257
envelopes.push(buildTaskResultMessage(envelope));
258+
collectUiToolResult(uiToolResults, envelope, task);
252259
// Persist the entry's real terminal status. 'expired' / 'timed_out'
253260
// have no ToolTaskStatus member — they persist as 'failed'.
254261
settledNow.set(
@@ -313,7 +320,7 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
313320

314321
// Continue the conversation: the envelopes are already in persisted
315322
// history, so no fresh input is supplied.
316-
return callModel(
323+
const result = callModel(
317324
client,
318325
{
319326
...request.run,
@@ -325,6 +332,28 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
325332
},
326333
options,
327334
);
335+
result.queueUiToolResults(uiToolResults);
336+
return result;
337+
}
338+
339+
function collectUiToolResult(
340+
results: Array<{
341+
callId: string;
342+
name: string;
343+
input: Record<string, unknown>;
344+
output: unknown;
345+
}>,
346+
envelope: ToolTaskResultEnvelope,
347+
task: PendingAsyncTool,
348+
): void {
349+
if (envelope.status === 'completed' && task.input !== undefined) {
350+
results.push({
351+
callId: task.callId,
352+
name: task.name,
353+
input: task.input,
354+
output: envelope.result,
355+
});
356+
}
328357
}
329358

330359
/**

‎packages/agent/src/lib/async-tool-registry.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export class AsyncToolRegistry {
147147
callId: string;
148148
taskId: string;
149149
name: string;
150+
input: Record<string, unknown>;
150151
expiresAt?: number;
151152
pollAfterMs?: number;
152153
}): ToolTask {
@@ -155,6 +156,7 @@ export class AsyncToolRegistry {
155156
callId: entry.callId,
156157
toolName: entry.name,
157158
mode: 'defer',
159+
input: entry.input,
158160
...(entry.expiresAt !== undefined && {
159161
expiresAt: entry.expiresAt,
160162
}),
@@ -301,6 +303,9 @@ export class AsyncToolRegistry {
301303
mode: t.mode,
302304
status: t.status,
303305
startedAt: t.startedAt,
306+
...(t.input !== undefined && {
307+
input: t.input,
308+
}),
304309
...(t.expiresAt !== undefined && {
305310
expiresAt: t.expiresAt,
306311
}),

‎packages/agent/src/lib/model-result.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,12 @@ export class ModelResult<
707707
> | null = null;
708708
private uiBroadcaster: ToolEventBroadcaster<ToolUiFragmentEvent> | null = null;
709709
private pendingUiFragments = new Set<Promise<void>>();
710+
private queuedUiToolResults: Array<{
711+
callId: string;
712+
name: string;
713+
input: Record<string, unknown>;
714+
output: unknown;
715+
}> = [];
710716
private uiBroadcasterCompletionPromise: Promise<void> | null = null;
711717
private turnBroadcasterCompletionPromise: Promise<void> | null = null;
712718
private initialStreamPipeStarted = false;
@@ -2775,10 +2781,12 @@ export class ModelResult<
27752781
}
27762782

27772783
const registry = this.ensureAsyncToolRegistry();
2784+
const input = (tc.arguments ?? {}) as Record<string, unknown>;
27782785
const liveTask = registry.trackDeferred({
27792786
callId: tc.id,
27802787
taskId: invocation.taskId,
27812788
name: String(tc.name),
2789+
input,
27822790
...(invocation.pollAfterMs !== undefined && {
27832791
pollAfterMs: invocation.pollAfterMs,
27842792
}),
@@ -2793,6 +2801,7 @@ export class ModelResult<
27932801
mode: 'defer',
27942802
status: 'working',
27952803
startedAt: liveTask.startedAt,
2804+
input,
27962805
...(invocation.pollAfterMs !== undefined && {
27972806
pollAfterMs: invocation.pollAfterMs,
27982807
}),
@@ -3797,10 +3806,12 @@ export class ModelResult<
37973806
if (collision) {
37983807
return collision;
37993808
}
3809+
const input = (toolCall.arguments ?? {}) as Record<string, unknown>;
38003810
const liveTask = registry.trackDeferred({
38013811
callId: toolCall.id,
38023812
taskId: invocation.taskId,
38033813
name: String(toolCall.name),
3814+
input,
38043815
...(invocation.expiresAt !== undefined && {
38053816
expiresAt: invocation.expiresAt,
38063817
}),
@@ -3816,6 +3827,7 @@ export class ModelResult<
38163827
mode: 'defer',
38173828
status: 'working',
38183829
startedAt: liveTask.startedAt,
3830+
input,
38193831
...(invocation.pollAfterMs !== undefined && {
38203832
pollAfterMs: invocation.pollAfterMs,
38213833
}),
@@ -4422,6 +4434,41 @@ export class ModelResult<
44224434
};
44234435
}
44244436

4437+
/** @internal Queue externally resumed tool results for the UI lifecycle. */
4438+
queueUiToolResults(
4439+
results: Array<{
4440+
callId: string;
4441+
name: string;
4442+
input: Record<string, unknown>;
4443+
output: unknown;
4444+
}>,
4445+
): void {
4446+
this.queuedUiToolResults.push(...results);
4447+
}
4448+
4449+
private dispatchQueuedUiToolResults(): void {
4450+
const queued = this.queuedUiToolResults;
4451+
this.queuedUiToolResults = [];
4452+
for (const result of queued) {
4453+
const tool = this.options.tools?.find(
4454+
(candidate) => isClientTool(candidate) && candidate.function.name === result.name,
4455+
);
4456+
if (tool) {
4457+
this.dispatchUiFragment({
4458+
toolCall: {
4459+
id: result.callId,
4460+
name: result.name,
4461+
arguments: result.input,
4462+
} as ParsedToolCall<Tool>,
4463+
tool,
4464+
result: {
4465+
result: result.output,
4466+
},
4467+
});
4468+
}
4469+
}
4470+
}
4471+
44254472
private dispatchUiFragment(value: {
44264473
toolCall: ParsedToolCall<Tool>;
44274474
tool: Tool;
@@ -6945,9 +6992,8 @@ export class ModelResult<
69456992
*/
69466993
getUiStream(): AsyncIterableIterator<UiStreamEvent> {
69476994
return async function* (this: ModelResult<TTools, TShared>) {
6948-
await this.initStreamGuarded();
6949-
69506995
if (!this.options.tools?.length) {
6996+
await this.initStreamGuarded();
69516997
let streamFailed = false;
69526998
try {
69536999
if (this.reusableStream) {
@@ -6973,6 +7019,8 @@ export class ModelResult<
69737019
const uiBroadcaster = this.uiBroadcaster;
69747020
const uiConsumer = uiBroadcaster.createConsumer();
69757021
try {
7022+
this.dispatchQueuedUiToolResults();
7023+
await this.initStreamGuarded();
69767024
const { consumer, executionPromise } = this.startTurnBroadcasterExecution();
69777025
if (!this.uiBroadcasterCompletionPromise) {
69787026
this.uiBroadcasterCompletionPromise = executionPromise.finally(async () => {

‎packages/agent/src/lib/tool-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,8 @@ export interface PendingAsyncTool {
16221622
status: ToolTaskStatus;
16231623
/** Unix ms when the task started. */
16241624
startedAt: number;
1625+
/** The original call arguments, retained for deferred UI rendering. */
1626+
input?: Record<string, unknown>;
16251627
/** Unix ms after which the task is considered expired. */
16261628
expiresAt?: number;
16271629
/** Poll-interval hint surfaced to the model and external pollers. */

‎packages/agent/tests/unit/async-tool-deferred.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ describe('tool.deferred — pause & placeholder', () => {
152152
name: 'request_legal_review',
153153
mode: 'defer',
154154
status: 'working',
155+
input: {
156+
contractId: 'c-9',
157+
},
155158
});
156159

157160
// No follow-up request — the loop paused after the placeholder.
@@ -724,6 +727,9 @@ describe('tool.deferred — cross-process resume', () => {
724727
callId: 'call_d1',
725728
taskId: 'ticket_c-9',
726729
mode: 'defer',
730+
input: {
731+
contractId: 'c-9',
732+
},
727733
});
728734
});
729735

‎packages/agent/tests/unit/async-tool-registry.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,35 @@ describe('AsyncToolRegistry — timeout settlement', () => {
8484
});
8585
});
8686

87+
describe('AsyncToolRegistry — deferred input', () => {
88+
it('retains input in settlement and persistence snapshots', () => {
89+
const registry = new AsyncToolRegistry();
90+
const task = registry.trackDeferred({
91+
callId: 'call_d1',
92+
taskId: 'task_d1',
93+
name: 'weather',
94+
input: {
95+
city: 'Lisbon',
96+
},
97+
});
98+
99+
expect(task.input).toEqual({
100+
city: 'Lisbon',
101+
});
102+
expect(registry.snapshot()[0]).toMatchObject({
103+
input: {
104+
city: 'Lisbon',
105+
},
106+
});
107+
registry.cancelTask('task_d1');
108+
expect(registry.takeSettled()[0]).toMatchObject({
109+
input: {
110+
city: 'Lisbon',
111+
},
112+
});
113+
});
114+
});
115+
87116
describe('AsyncToolRegistry — grace-window visibility (register/untrack)', () => {
88117
it('a registered (not yet tracked) task is reachable by steer and cancel', () => {
89118
const registry = new AsyncToolRegistry();

0 commit comments

Comments
 (0)