Skip to content
19 changes: 19 additions & 0 deletions apps/sim/app/api/copilot/confirm/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,25 @@ describe('Copilot Confirm API Route', () => {
)
})

it.each([
['client_tool', 'running', 'success', completeAsyncToolCall],
['browser_snapshot', 'pending', 'error', completePendingAsyncToolCall],
] as const)(
'acknowledges a %s result whose write lost to a settlement that landed first',
async (toolName, storedStatus, status, completion) => {
const row = { ...existingRow, toolName, claimedBy: null }
getAsyncToolCall
.mockResolvedValueOnce({ ...row, status: storedStatus })
.mockResolvedValueOnce({ ...row, status: 'failed' })
completion.mockResolvedValueOnce(null)

const response = await POST(createMockPostRequest({ toolCallId: 'tool-call-123', status }))

expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ toolCallId: 'tool-call-123', status: 'error' })
}
)
Comment thread
waleedlatif1 marked this conversation as resolved.

it('does not publish when another terminal confirmation already won', async () => {
completeAsyncToolCall.mockResolvedValueOnce(null)

Expand Down
91 changes: 54 additions & 37 deletions apps/sim/app/api/copilot/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isBrowserToolName, isCurrentBrowserToolName } from '@sim/browser-protocol'
import type { Span } from '@opentelemetry/api'
import { isBrowserToolName } from '@sim/browser-protocol'
import { createLogger } from '@sim/logger'
import { isTerminalToolName } from '@sim/terminal-protocol'
import { getErrorMessage, toError } from '@sim/utils/errors'
Expand All @@ -12,7 +13,7 @@ import {
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
DESKTOP_TOOL_CLAIM_OWNER,
type AsyncTerminalStatus,
isDeliveredAsyncStatus,
isTerminalAsyncStatus,
isWorkflowToolExecutionClaimable,
Expand All @@ -38,11 +39,9 @@ import {
createUnauthorizedResponse,
} from '@/lib/mothership/request/http'
import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
import {
retainSealedClientToolContext,
sealClientToolCompletion,
} from '@/lib/mothership/request/tools/client-completion-seal.server'
import { sealClientToolSettlement } from '@/lib/mothership/request/tools/client-completion-seal.server'
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
import { getDesktopToolClaimOwner } from '@/lib/mothership/tools/desktop-tools'
import {
createStructuralWorkflowToolCompletionData,
getWorkflowToolCompletionExecutionId,
Expand Down Expand Up @@ -83,6 +82,24 @@ function createConfirmationResponse(
return NextResponse.json({ success: true, message, toolCallId, status })
}

/**
* A result for a call that is already settled — a retried delivery, or one that lost to the server
* settling the call first (Stop, a fast "not started" failure) — answers with the stored outcome.
* Nothing is written or published again, and the reporter stops retrying.
*/
function acknowledgeSettledToolCall(
span: Span,
toolCallId: string,
storedStatus: AsyncTerminalStatus
): NextResponse {
const settledStatus = getWorkflowToolConfirmationStatus(storedStatus)
span.setAttributes({
[TraceAttr.ToolConfirmationStatus]: settledStatus,
[TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered,
})
return createConfirmationResponse(toolCallId, settledStatus, 'Tool call was already settled')
}

/** Atomically finalize or detach a client tool before publishing its wakeup event. */
async function updateToolCallStatus(
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
Expand Down Expand Up @@ -284,26 +301,22 @@ export const POST = withRouteHandler((req: NextRequest) => {
)
}

if (!isWorkflowTool && isTerminalAsyncStatus(existing.status)) {
return acknowledgeSettledToolCall(span, toolCallId, existing.status)
}

const isErrorOrCancelledOutcome =
status === ASYNC_TOOL_CONFIRMATION_STATUS.error ||
status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled
const isNativeClientTool =
isBrowserToolName(existing.toolName) ||
isTerminalToolName(existing.toolName) ||
existing.toolName === 'import_local_files'
const nativeClaimOwner = getDesktopToolClaimOwner(existing.toolName)
const isPreclaimNativeTerminalOutcome =
(isCurrentBrowserToolName(existing.toolName) ||
isTerminalToolName(existing.toolName) ||
existing.toolName === 'import_local_files') &&
nativeClaimOwner !== undefined &&
existing.status === ASYNC_TOOL_STATUS.pending &&
isErrorOrCancelledOutcome
const nativeClaimOwner = isCurrentBrowserToolName(existing.toolName)
? DESKTOP_TOOL_CLAIM_OWNER.browser
: isTerminalToolName(existing.toolName)
? DESKTOP_TOOL_CLAIM_OWNER.terminal
: existing.toolName === 'import_local_files'
? DESKTOP_TOOL_CLAIM_OWNER.files
: undefined
const isIndeterminateNativeExit =
isPreclaimNativeTerminalOutcome &&
status === ASYNC_TOOL_CONFIRMATION_STATUS.error &&
Expand Down Expand Up @@ -401,27 +414,24 @@ export const POST = withRouteHandler((req: NextRequest) => {
}
: {
message: getClientToolCompletionMessage(status),
data: {
...retainSealedClientToolContext(existing.result),
...(await sealClientToolCompletion({
toolCallId,
runId: existing.runId,
userId: authenticatedUserId,
...(isIndeterminateNativeExit
? {
message: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
data: {
error: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
outcomeUnknown: true,
doNotRetry: true,
},
}
: {
...(message !== undefined ? { message } : {}),
...(data !== undefined ? { data } : {}),
}),
})),
},
data: await sealClientToolSettlement(existing.result, {
toolCallId,
runId: existing.runId,
userId: authenticatedUserId,
...(isIndeterminateNativeExit
? {
message: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
data: {
error: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
outcomeUnknown: true,
doNotRetry: true,
},
}
: {
...(message !== undefined ? { message } : {}),
...(data !== undefined ? { data } : {}),
}),
}),
}

const updateOutcome = await updateToolCallStatus(
Expand Down Expand Up @@ -453,6 +463,13 @@ export const POST = withRouteHandler((req: NextRequest) => {
)
: updateOutcome

if (reconciledOutcome === 'conflict' && !isWorkflowTool) {
const settled = await getAsyncToolCall(toolCallId).catch(() => null)
if (settled && isTerminalAsyncStatus(settled.status)) {
return acknowledgeSettledToolCall(span, toolCallId, settled.status)
}
}

if (reconciledOutcome === 'conflict' && isPreclaimNativeTerminalOutcome) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
return createNotFoundResponse('Pending client tool call not found')
Expand Down
11 changes: 3 additions & 8 deletions apps/sim/app/api/desktop/tool/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('@/lib/mothership/async-runs/repository', () => mothershipAsyncRunsMock)

import { POST } from './route'

const claimPendingAsyncToolCall = mothershipAsyncRunsMockFns.mockClaimPendingAsyncToolCall
const claimToolExecution = mothershipAsyncRunsMockFns.mockClaimToolExecution
const getAsyncToolCall = mothershipAsyncRunsMockFns.mockGetAsyncToolCall
const getRunSegment = mothershipAsyncRunsMockFns.mockGetRunSegment
const resolveInvocationWorkspace = mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace
Expand Down Expand Up @@ -50,7 +50,7 @@ describe('desktop tool authorization', () => {
userId: 'user-1',
status: 'active',
})
claimPendingAsyncToolCall.mockResolvedValue({ toolCallId: 'browser-tool', status: 'running' })
claimToolExecution.mockResolvedValue({ outcome: 'claimed' })
})

it('never returns presentation activity as an executable browser argument', async () => {
Expand Down Expand Up @@ -84,7 +84,6 @@ describe('desktop tool authorization', () => {
const response = await POST(request('retired-browser-tool'))

expect(response.status).toBe(403)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
})

it('rejects a replayed browser action after its pending row was claimed', async () => {
Expand All @@ -98,7 +97,6 @@ describe('desktop tool authorization', () => {

const response = await POST(request('browser-tool'))
expect(response.status).toBe(404)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
})

it('rejects workspace VFS calls and mutating legacy local tools', async () => {
Expand Down Expand Up @@ -176,13 +174,11 @@ describe('desktop tool authorization', () => {
{ userId: 'user-1', chatId: 'chat-1', organizationId: 'org-1', workspaceId: undefined },
'target'
)
expect(claimPendingAsyncToolCall).toHaveBeenCalledExactlyOnceWith('import-1', 'desktop-files')
getAsyncToolCall.mockResolvedValue({ ...tool, status: 'running', claimedBy: 'desktop-files' })
expect((await POST(request('import-1', true))).status).toBe(409)
expect((await POST(request('import-1'))).status).toBe(200)
getAsyncToolCall.mockResolvedValue({ ...tool, status: 'running', claimedBy: 'sim-stream' })
expect((await POST(request('import-1'))).status).toBe(404)
expect(claimPendingAsyncToolCall).toHaveBeenCalledOnce()
})

it('rejects inaccessible destinations and lost import claims before exposing files', async () => {
Expand All @@ -197,8 +193,7 @@ describe('desktop tool authorization', () => {
new OrchestrationError('not_found', 'Workspace not found')
)
expect((await POST(request('import-1', true))).status).toBe(404)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
claimPendingAsyncToolCall.mockResolvedValueOnce(null)
claimToolExecution.mockResolvedValueOnce({ outcome: 'existing' })
expect((await POST(request('import-1', true))).status).toBe(409)
})

Expand Down
69 changes: 50 additions & 19 deletions apps/sim/app/api/desktop/tool/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
import { DESKTOP_TOOL_CLAIM_OWNER } from '@/lib/mothership/async-runs/lifecycle'
import {
claimPendingAsyncToolCall,
claimToolExecution,
getAsyncToolCall,
getRunSegment,
type ToolExecutionClaim,
} from '@/lib/mothership/async-runs/repository'
import {
authenticateCopilotRequestSessionOnly,
Expand All @@ -20,12 +21,33 @@ import {
} from '@/lib/mothership/request/http'
import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem'

const admissionClosedResponse = () =>
NextResponse.json(
{ error: 'This chat turn ended or was stopped, so the tool call can no longer run' },
{ status: 410 }
)

/** A refused claim answers the same way for every tool, except how each reports a lost race. */
function refusedClaimResponse(
claim: Exclude<ToolExecutionClaim['outcome'], 'claimed'>,
notPending: () => NextResponse
): NextResponse {
if (claim === 'closed') return admissionClosedResponse()
if (claim === 'awaiting_permission')
return NextResponse.json({ error: 'The user has not approved this tool call' }, { status: 403 })
return notPending()
}

/**
* Electron calls this endpoint from the main process before every privileged
* native model action. It returns only server-persisted canonical tool args;
* Electron validates local-file requests against them and uses them directly
* for browser and terminal tools. The presentation-only `activity` field is
* dropped: desktop actions reject arguments they do not declare.
*
* Nothing is handed over once the run's tool admission has closed (Stop, a
* newer turn, or the run's end), nor for a call held for the user's decision
* that they have not allowed.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
Expand All @@ -37,13 +59,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response

const toolCall = await getAsyncToolCall(parsed.data.body.toolCallId)
if (!toolCall || (toolCall.status !== 'pending' && toolCall.status !== 'running')) {
return createNotFoundResponse('Pending client tool call not found')
}
if (!toolCall) return createNotFoundResponse('Pending client tool call not found')
const run = await getRunSegment(toolCall.runId)
if (!run || run.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Ahead of the status checks: Stop settles the run's open calls in the same commit.
if (run.toolAdmissionClosedAt) return admissionClosedResponse()
if (toolCall.status !== 'pending' && toolCall.status !== 'running') {
return createNotFoundResponse('Pending client tool call not found')
}
if (run.status === 'complete' || run.status === 'error' || run.status === 'cancelled') {
return createNotFoundResponse('Pending client tool call not found')
}
Expand Down Expand Up @@ -84,14 +109,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(projected.body, { status: projected.status })
}
if (parsed.data.body.claim) {
if (
toolCall.status !== 'pending' ||
!(await claimPendingAsyncToolCall(toolCall.toolCallId, DESKTOP_TOOL_CLAIM_OWNER.files))
)
return NextResponse.json(
const alreadyStarted = () =>
NextResponse.json(
{ error: 'This import was already started; inspect its result before retrying' },
{ status: 409 }
)
if (toolCall.status !== 'pending') return alreadyStarted()
const { outcome } = await claimToolExecution({
toolCallId: toolCall.toolCallId,
runId: toolCall.runId,
userId,
claimedBy: DESKTOP_TOOL_CLAIM_OWNER.files,
})
if (outcome !== 'claimed') return refusedClaimResponse(outcome, alreadyStarted)
} else if (
toolCall.status !== 'running' ||
toolCall.claimedBy !== DESKTOP_TOOL_CLAIM_OWNER.files
Expand All @@ -104,16 +134,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// the Electron boundary — a replayed renderer event must not run a command
// or click a button twice.
if (isBrowserTool || isTerminalTool) {
if (toolCall.status !== 'pending') {
return createNotFoundResponse('Pending client tool call not found')
}
const claimed = await claimPendingAsyncToolCall(
toolCall.toolCallId,
isBrowserTool ? DESKTOP_TOOL_CLAIM_OWNER.browser : DESKTOP_TOOL_CLAIM_OWNER.terminal
)
if (!claimed) {
return createNotFoundResponse('Pending client tool call not found')
}
const notPending = () => createNotFoundResponse('Pending client tool call not found')
if (toolCall.status !== 'pending') return notPending()
const { outcome } = await claimToolExecution({
toolCallId: toolCall.toolCallId,
runId: toolCall.runId,
userId,
claimedBy: isBrowserTool
? DESKTOP_TOOL_CLAIM_OWNER.browser
: DESKTOP_TOOL_CLAIM_OWNER.terminal,
})
if (outcome !== 'claimed') return refusedClaimResponse(outcome, notPending)
}

return NextResponse.json({
Expand Down
Loading
Loading