Skip to content

Commit 5b5f59b

Browse files
committed
fix(copilot): recover tool arguments lost when a call is checkpointed mid-generation
Tool arguments reach Sim two ways: whole on a frame's `arguments`, or in pieces as `args_delta` chunks that accumulate into `streamingArgs`. Only the first populated `params`, so a call checkpointed before any frame carried `arguments` executed with `{}` and failed its own schema on every required property. The file subagent's `workspace_file` calls arrive exactly that way, which left the agent retrying and then routing around the tool entirely. - executor: hydrate `params` from the streamed deltas before dispatch, covering both normal dispatch and the never-dispatched resume path. - handlers: record the subagent channel at registration rather than only on a finalized frame, so the workspace_file -> edit_content intent handoff can find its intent instead of reporting "No workspace_file context found". - preview adapter: pass the frame's tool call id into file delegation, which derives its audit id from it. Without it every preview threw and no file content streamed at all. - run: a checkpointed call with no recorded result now reports a failed result instead of throwing, which ended the whole turn and cost the user the entire response. Each fix has a regression test verified to fail without it.
1 parent 9d29705 commit 5b5f59b

7 files changed

Lines changed: 228 additions & 5 deletions

File tree

apps/sim/lib/copilot/request/go/file-preview-adapter.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,8 @@ export async function processFilePreviewStreamEvent(input: {
376376
if (toolCallId && parsedArgs) {
377377
const { operation, title, contentType, edit } = parsedArgs
378378
const target = await resolvePreviewTarget({
379-
context: execContext,
379+
/** File delegation derives its audit id from the frame's tool call id. */
380+
context: { ...execContext, toolCallId },
380381
workspaceId: execContext.workspaceId,
381382
target: parsedArgs.target,
382383
})
@@ -405,7 +406,7 @@ export async function processFilePreviewStreamEvent(input: {
405406
(operation === 'append' || operation === 'patch')
406407
) {
407408
previewBase = await loadWorkspaceFileTextForPreview(
408-
execContext,
409+
{ ...execContext, toolCallId },
409410
execContext.workspaceId,
410411
fileId
411412
)
@@ -480,7 +481,7 @@ export async function processFilePreviewStreamEvent(input: {
480481
(intent.operation === 'append' || intent.operation === 'patch')
481482
) {
482483
previewBase = await loadWorkspaceFileTextForPreview(
483-
execContext,
484+
{ ...execContext, toolCallId: streamEvent.payload.toolCallId },
484485
execContext.workspaceId,
485486
result.fileId
486487
)

apps/sim/lib/copilot/request/go/stream.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
3838
}) ?? null,
3939
}))
4040
vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({
41-
listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock },
41+
/* `executeCopilotFileUseCase` reads `useCase.operation.id` to check the
42+
operation is registered before running it, so a use-case mock without an
43+
`operation` throws before `execute` is ever reached. */
44+
listAllWorkspaceFiles: {
45+
execute: listAllWorkspaceFilesMock,
46+
operation: { id: 'files.list' },
47+
},
4248
}))
4349

4450
vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({

apps/sim/lib/copilot/request/handlers/handlers.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,109 @@ describe('sse-handlers tool lifecycle', () => {
887887
)
888888
})
889889

890+
/**
891+
* Arguments reach Sim either whole on a frame or in `args_delta` pieces. A
892+
* call checkpointed before any frame carries `arguments` used to execute with
893+
* `{}` — its own schema then rejected every required property — even though
894+
* the full argument JSON had already arrived as deltas.
895+
*/
896+
it('executes with arguments recovered from args_delta frames', async () => {
897+
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
898+
context.toolCalls.set('parent-1', {
899+
id: 'parent-1',
900+
name: 'file',
901+
status: 'pending',
902+
startTime: Date.now(),
903+
})
904+
905+
const call = {
906+
toolCallId: 'sub-tool-delta',
907+
toolName: 'workspace_file',
908+
executor: MothershipStreamV1ToolExecutor.sim,
909+
mode: MothershipStreamV1ToolMode.async,
910+
phase: MothershipStreamV1ToolPhase.call,
911+
}
912+
const scope = { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' } as const
913+
914+
// Registered while still generating, with no arguments on the frame.
915+
await subAgentHandlers.tool(
916+
{
917+
type: MothershipStreamV1EventType.tool,
918+
scope,
919+
payload: { ...call, status: 'generating' },
920+
} as StreamEvent,
921+
context,
922+
execContext,
923+
{ interactive: false, timeout: 1000 }
924+
)
925+
926+
for (const argumentsDelta of ['{"operation":"update",', '"title":"Set contents"}']) {
927+
await subAgentHandlers.tool(
928+
{
929+
type: MothershipStreamV1EventType.tool,
930+
scope,
931+
payload: {
932+
toolCallId: 'sub-tool-delta',
933+
toolName: 'workspace_file',
934+
phase: 'args_delta',
935+
argumentsDelta,
936+
},
937+
} as unknown as StreamEvent,
938+
context,
939+
execContext,
940+
{ interactive: false, timeout: 1000 }
941+
)
942+
}
943+
944+
await executeToolAndReport('sub-tool-delta', context, execContext, {
945+
interactive: false,
946+
timeout: 1000,
947+
})
948+
949+
expect(executeTool).toHaveBeenCalledWith(
950+
'workspace_file',
951+
{ operation: 'update', title: 'Set contents' },
952+
expect.any(Object)
953+
)
954+
})
955+
956+
/**
957+
* A call can be checkpointed while every frame it received is still
958+
* `generating`. The subagent channel must already be on the tool call by
959+
* then: the workspace_file -> edit_content intent handoff scopes on it, and
960+
* recording it only on a finalized frame left edit_content reporting
961+
* "No workspace_file context found" for a write that had in fact succeeded.
962+
*/
963+
it('records the subagent channel from a generating frame, before any final frame', async () => {
964+
context.toolCalls.set('parent-1', {
965+
id: 'parent-1',
966+
name: 'file',
967+
status: 'pending',
968+
startTime: Date.now(),
969+
})
970+
971+
await subAgentHandlers.tool(
972+
{
973+
type: MothershipStreamV1EventType.tool,
974+
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' },
975+
payload: {
976+
toolCallId: 'sub-tool-partial',
977+
toolName: 'workspace_file',
978+
executor: MothershipStreamV1ToolExecutor.sim,
979+
mode: MothershipStreamV1ToolMode.async,
980+
phase: MothershipStreamV1ToolPhase.call,
981+
status: 'generating',
982+
arguments: { operation: 'update' },
983+
},
984+
} satisfies StreamEvent,
985+
context,
986+
execContext,
987+
{ interactive: false, timeout: 1000 }
988+
)
989+
990+
expect(context.toolCalls.get('sub-tool-partial')?.parentToolCallId).toBe('parent-1')
991+
})
992+
890993
it('updates stored params when a subagent generating event is followed by the final tool call', async () => {
891994
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
892995
context.toolCalls.set('parent-1', {

apps/sim/lib/copilot/request/handlers/tool.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,11 @@ function registerSubagentToolCall(
583583
status: 'pending',
584584
agentId,
585585
params: args,
586+
/**
587+
* The invoking subagent's channel is recorded before the frame finalizes;
588+
* the workspace_file -> edit_content intent handoff scopes on this id.
589+
*/
590+
parentToolCallId,
586591
startTime: Date.now(),
587592
}
588593
applyToolDisplay(toolCall)

apps/sim/lib/copilot/request/lifecycle/run.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,68 @@ describe('runCopilotLifecycle', () => {
15011501
}
15021502
})
15031503

1504+
/**
1505+
* Go blocks on a result for every checkpointed call. Throwing here used to end
1506+
* the whole turn, so one unrecorded result cost the user the entire response —
1507+
* and the only thing preventing it was a partial frame happening to register
1508+
* the call. Report the failure as that tool's result instead, so the model
1509+
* sees one failed call and can route around it.
1510+
*/
1511+
it('reports a failed result instead of ending the turn when a checkpointed tool has none', async () => {
1512+
const billingAttribution = {
1513+
actorUserId: 'user-1',
1514+
workspaceId: 'ws-1',
1515+
billedAccountUserId: 'owner-1',
1516+
organizationId: 'org-1',
1517+
billingEntity: { type: 'organization' as const, id: 'org-1' },
1518+
billingPeriod: {
1519+
start: '2026-07-01T00:00:00.000Z',
1520+
end: '2026-08-01T00:00:00.000Z',
1521+
},
1522+
payerSubscription: null,
1523+
}
1524+
mockRunStreamLoop.mockImplementationOnce(
1525+
async (
1526+
_fetchUrl: string,
1527+
_fetchOptions: RequestInit,
1528+
context: StreamingContext
1529+
): Promise<void> => {
1530+
/** Registered but never resolved, so no `result` is ever recorded. */
1531+
context.toolCalls.set('tool-1', {
1532+
id: 'tool-1',
1533+
name: 'workspace_file',
1534+
status: MothershipStreamV1ToolOutcome.error,
1535+
})
1536+
context.awaitingAsyncContinuation = {
1537+
checkpointId: 'ckpt-1',
1538+
pendingToolCallIds: ['tool-1'],
1539+
}
1540+
}
1541+
)
1542+
mockRunStreamLoop.mockResolvedValueOnce(undefined)
1543+
1544+
await expect(
1545+
runCopilotLifecycle(
1546+
{ message: 'hello', messageId: 'message-1' },
1547+
{
1548+
userId: 'user-1',
1549+
workspaceId: 'ws-1',
1550+
chatId: 'chat-1',
1551+
executionId: 'execution-1',
1552+
runId: 'run-1',
1553+
simRequestId: 'request-1',
1554+
billingAttribution,
1555+
}
1556+
)
1557+
).resolves.toBeDefined()
1558+
1559+
/** The turn continued: a second leg ran, carrying the failed result back. */
1560+
expect(mockRunStreamLoop).toHaveBeenCalledTimes(2)
1561+
const resumeBody = JSON.parse((mockRunStreamLoop.mock.calls[1]?.[1].body as string) ?? '{}')
1562+
const sent = JSON.stringify(resumeBody)
1563+
expect(sent).toContain('tool-1')
1564+
})
1565+
15041566
it('preserves a resume tool name that collides with a configured secret', async () => {
15051567
const registry = new ResolvedSecretTraceRegistry([
15061568
{ name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' },

apps/sim/lib/copilot/request/lifecycle/run.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,27 @@ async function runCheckpointLoop(
11841184
toolStatus: tool?.status,
11851185
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
11861186
})
1187-
throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`)
1187+
/**
1188+
* Go is blocked on a result for every checkpointed call, so throwing
1189+
* here ends the turn outright and the user loses the whole response.
1190+
* Report the failure as that tool's result instead: the model sees one
1191+
* failed call and can retry or route around it, which is how every
1192+
* other tool failure already behaves. Reached only when a call was
1193+
* checkpointed without Sim ever recording a result for it.
1194+
*/
1195+
const failedName = tool?.name ?? ''
1196+
results.push({
1197+
callId: toolCallId,
1198+
name: failedName,
1199+
data: getToolCallTerminalData({
1200+
id: toolCallId,
1201+
name: failedName,
1202+
status: MothershipStreamV1ToolOutcome.error,
1203+
error: `Tool call ${toolCallId} produced no result before resume`,
1204+
}),
1205+
success: false,
1206+
})
1207+
continue
11881208
}
11891209
const name = tool.name || ''
11901210
results.push({

apps/sim/lib/copilot/request/tools/executor.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,30 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl
497497
})
498498
}
499499

500+
/**
501+
* Recovers a tool call's arguments from the raw argument stream.
502+
*
503+
* Arguments reach Sim two ways: whole, on a tool frame's `arguments`, or in
504+
* pieces, as `argumentsDelta` chunks that `handleToolArgsDelta` concatenates
505+
* into `streamingArgs`. Only the first populates `params`. When a call is
506+
* checkpointed before any frame carries `arguments` — which is how the file
507+
* subagent's `workspace_file` calls arrive — `params` stays undefined and the
508+
* tool executes with `{}`, failing its own schema on every required property.
509+
* The arguments were never lost, only unparsed, so recover them here rather
510+
* than dispatching a call known to be incomplete.
511+
*/
512+
function hydrateParamsFromStreamedArgs(toolCall: ToolCallState): void {
513+
if (toolCall.params !== undefined) return
514+
const streamed = toolCall.streamingArgs?.trim()
515+
if (!streamed) return
516+
try {
517+
const parsed = JSON.parse(streamed)
518+
if (isRecordLike(parsed)) toolCall.params = parsed
519+
} catch {
520+
/** Leave truncated input undefined so the tool's validation reports it. */
521+
}
522+
}
523+
500524
export async function executeToolAndReport(
501525
toolCallId: string,
502526
context: StreamingContext,
@@ -510,6 +534,8 @@ export async function executeToolAndReport(
510534
message: 'Tool call not found',
511535
})
512536

537+
hydrateParamsFromStreamedArgs(toolCall)
538+
513539
const argsPayload = toolCall.params
514540
? (() => {
515541
try {

0 commit comments

Comments
 (0)