Skip to content

Commit b25e7ba

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 c0ef569 commit b25e7ba

7 files changed

Lines changed: 234 additions & 5 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,11 @@ 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 tool call
380+
(`copilot-tool:<toolCallId>`), and that id lives on the frame rather
381+
than the turn-scoped context. Passing the turn context alone made
382+
every preview throw, so the file stopped streaming entirely. */
383+
context: { ...execContext, toolCallId },
380384
workspaceId: execContext.workspaceId,
381385
target: parsedArgs.target,
382386
})
@@ -405,7 +409,7 @@ export async function processFilePreviewStreamEvent(input: {
405409
(operation === 'append' || operation === 'patch')
406410
) {
407411
previewBase = await loadWorkspaceFileTextForPreview(
408-
execContext,
412+
{ ...execContext, toolCallId },
409413
execContext.workspaceId,
410414
fileId
411415
)
@@ -480,7 +484,7 @@ export async function processFilePreviewStreamEvent(input: {
480484
(intent.operation === 'append' || intent.operation === 'patch')
481485
) {
482486
previewBase = await loadWorkspaceFileTextForPreview(
483-
execContext,
487+
{ ...execContext, toolCallId: streamEvent.payload.toolCallId },
484488
execContext.workspaceId,
485489
result.fileId
486490
)

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/tools/server/files/file-preview', async () => {

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

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

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

1637+
/**
1638+
* Go blocks on a result for every checkpointed call. Throwing here used to end
1639+
* the whole turn, so one unrecorded result cost the user the entire response —
1640+
* and the only thing preventing it was a partial frame happening to register
1641+
* the call. Report the failure as that tool's result instead, so the model
1642+
* sees one failed call and can route around it.
1643+
*/
1644+
it('reports a failed result instead of ending the turn when a checkpointed tool has none', async () => {
1645+
const billingAttribution = {
1646+
actorUserId: 'user-1',
1647+
workspaceId: 'ws-1',
1648+
billedAccountUserId: 'owner-1',
1649+
organizationId: 'org-1',
1650+
billingEntity: { type: 'organization' as const, id: 'org-1' },
1651+
billingPeriod: {
1652+
start: '2026-07-01T00:00:00.000Z',
1653+
end: '2026-08-01T00:00:00.000Z',
1654+
},
1655+
payerSubscription: null,
1656+
}
1657+
mockRunStreamLoop.mockImplementationOnce(
1658+
async (
1659+
_fetchUrl: string,
1660+
_fetchOptions: RequestInit,
1661+
context: StreamingContext
1662+
): Promise<void> => {
1663+
// Registered but never resolved — no `result` ever recorded.
1664+
context.toolCalls.set('tool-1', {
1665+
id: 'tool-1',
1666+
name: 'workspace_file',
1667+
status: MothershipStreamV1ToolOutcome.error,
1668+
})
1669+
context.awaitingAsyncContinuation = {
1670+
checkpointId: 'ckpt-1',
1671+
pendingToolCallIds: ['tool-1'],
1672+
}
1673+
}
1674+
)
1675+
mockRunStreamLoop.mockResolvedValueOnce(undefined)
1676+
1677+
await expect(
1678+
runCopilotLifecycle(
1679+
{ message: 'hello', messageId: 'message-1' },
1680+
{
1681+
userId: 'user-1',
1682+
workspaceId: 'ws-1',
1683+
chatId: 'chat-1',
1684+
executionId: 'execution-1',
1685+
runId: 'run-1',
1686+
simRequestId: 'request-1',
1687+
billingAttribution,
1688+
}
1689+
)
1690+
).resolves.toBeDefined()
1691+
1692+
// The turn continued: a second leg ran, carrying the failed result back.
1693+
expect(mockRunStreamLoop).toHaveBeenCalledTimes(2)
1694+
const resumeBody = JSON.parse((mockRunStreamLoop.mock.calls[1]?.[1].body as string) ?? '{}')
1695+
const sent = JSON.stringify(resumeBody)
1696+
expect(sent).toContain('tool-1')
1697+
})
1698+
16371699
it('fails closed instead of sending a secret-bearing tool name on resume', async () => {
16381700
const registry = new ResolvedSecretTraceRegistry([
16391701
{ 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
@@ -1780,7 +1780,27 @@ async function runCheckpointLoop(
17801780
toolStatus: tool?.status,
17811781
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
17821782
})
1783-
throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`)
1783+
/**
1784+
* Go is blocked on a result for every checkpointed call, so throwing
1785+
* here ends the turn outright and the user loses the whole response.
1786+
* Report the failure as that tool's result instead: the model sees one
1787+
* failed call and can retry or route around it, which is how every
1788+
* other tool failure already behaves. Reached only when a call was
1789+
* checkpointed without Sim ever recording a result for it.
1790+
*/
1791+
const failedName = tool?.name ?? ''
1792+
results.push({
1793+
callId: toolCallId,
1794+
name: failedName,
1795+
data: getToolCallTerminalData({
1796+
id: toolCallId,
1797+
name: failedName,
1798+
status: MothershipStreamV1ToolOutcome.error,
1799+
error: `Tool call ${toolCallId} produced no result before resume`,
1800+
}),
1801+
success: false,
1802+
})
1803+
continue
17841804
}
17851805
const name = tool.name || ''
17861806
if (!isResolvedSecretModelContentUnchanged(name, execContext.resolvedSecretTraceRegistry)) {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,33 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl
499499
})
500500
}
501501

502+
/**
503+
* Recovers a tool call's arguments from the raw argument stream.
504+
*
505+
* Arguments reach Sim two ways: whole, on a tool frame's `arguments`, or in
506+
* pieces, as `argumentsDelta` chunks that `handleToolArgsDelta` concatenates
507+
* into `streamingArgs`. Only the first populates `params`. When a call is
508+
* checkpointed before any frame carries `arguments` — which is how the file
509+
* subagent's `workspace_file` calls arrive — `params` stays undefined and the
510+
* tool executes with `{}`, failing its own schema on every required property.
511+
* The arguments were never lost, only unparsed, so recover them here rather
512+
* than dispatching a call known to be incomplete.
513+
*/
514+
function hydrateParamsFromStreamedArgs(toolCall: ToolCallState): void {
515+
if (toolCall.params !== undefined) return
516+
const streamed = toolCall.streamingArgs?.trim()
517+
if (!streamed) return
518+
try {
519+
const parsed = JSON.parse(streamed)
520+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
521+
toolCall.params = parsed as Record<string, unknown>
522+
}
523+
} catch {
524+
// A truncated stream is not recoverable; leave params undefined so the
525+
// tool's own validation reports the failure.
526+
}
527+
}
528+
502529
export async function executeToolAndReport(
503530
toolCallId: string,
504531
context: StreamingContext,
@@ -512,6 +539,8 @@ export async function executeToolAndReport(
512539
message: 'Tool call not found',
513540
})
514541

542+
hydrateParamsFromStreamedArgs(toolCall)
543+
515544
const argsPayload = toolCall.params
516545
? (() => {
517546
try {

0 commit comments

Comments
 (0)