diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 6c0f3414f2e..8ca4757735b 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -285,6 +285,29 @@ describe('Copilot Confirm API Route', () => { } ) + it('refuses a not-started report for a call the desktop already claimed', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'browser_snapshot', + status: 'running', + claimedBy: 'desktop-browser', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'The desktop action did not start.', + data: { notStarted: true }, + }) + ) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'The desktop app holds this tool call; only its own result settles it', + }) + }) + it('does not publish when another terminal transition wins indeterminate claim reconciliation', async () => { getAsyncToolCall.mockResolvedValue({ ...existingRow, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts index 07e4f2358c0..9cf43b508c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { leaseDesktopTool, + stopAllDesktopTools, stopDesktopTools, } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' @@ -54,4 +55,19 @@ describe('desktop tool leases', () => { expect(next.signal.aborted).toBe(true) }) + + it('cancels the running tools of every turn when the session ends', () => { + const first = leaseDesktopTool('turn-f') + const second = leaseDesktopTool('turn-g') + + stopAllDesktopTools('signed_out') + const next = leaseDesktopTool('turn-f') + + expect(first.signal.aborted).toBe(true) + expect(second.signal.aborted).toBe(true) + expect(first.signal.reason).toBe('signed_out') + expect(next.signal.aborted).toBe(false) + first.release() + next.release() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts index 8d0d2559de9..5189f62177f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts @@ -8,6 +8,10 @@ interface RunningTurnTools { * Turns with a desktop tool still running in this tab, keyed by the turn's stream id. A tool * outlives the chat view that started it (and any stream reader), so the turn, not the view, owns * its Stop. A turn is held only while one of its tools runs: each tool releases it as it settles. + * Terminal calls take no lease. A terminal call returns once its operation does (a `run` after + * its wait window), and the command it started keeps running in a terminal tab the user can see + * and control. Stop settles the agent's marks on that tab through the turn's resource activity; + * the process itself is the user's to end. */ const runningTurns = new Map() @@ -49,3 +53,12 @@ export function stopDesktopTools(streamId: string, reason: string): void { runningTurns.get(streamId)?.stop.abort(reason) runningTurns.delete(streamId) } + +/** + * Cancels every leased desktop tool running in this tab (browser actions, local file reads and + * imports), so none outlives the session that started it. + */ +export function stopAllDesktopTools(reason: string): void { + for (const turn of runningTurns.values()) turn.stop.abort(reason) + runningTurns.clear() +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index feb4fde0e4a..41fe7b51c47 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1577,7 +1577,12 @@ export function useChat( ) const startClientLocalFilesystemTool = useCallback( - (toolCallId: string, toolName: string, toolArgs: Record) => { + ( + toolCallId: string, + toolName: string, + toolArgs: Record, + turnStreamId: string | undefined + ) => { if ( !isNativeFileTool(toolName) && (!workspaceId || !isUserLocalVfsToolCall(toolName, toolArgs)) @@ -1588,7 +1593,7 @@ export function useChat( return } handledClientLocalFilesystemToolIds.add(toolCallId) - const lease = streamIdRef.current ? leaseDesktopTool(streamIdRef.current) : undefined + const lease = turnStreamId ? leaseDesktopTool(turnStreamId) : undefined const options = { workspaceId, chatId: chatIdRef.current ?? selectedChatIdRef.current, @@ -2226,7 +2231,8 @@ export function useChat( addResource, removeResource, startClientWorkflowTool, - startClientLocalFilesystemTool, + startClientLocalFilesystemTool: (toolCallId, toolName, toolArgs) => + startClientLocalFilesystemTool(toolCallId, toolName, toolArgs, turnStreamId), startClientBrowserTool: startClientBrowserToolForStream, startClientTerminalTool: startClientTerminalToolForStream, startBrowserAgentRun: startBrowserAgentRunForStream, diff --git a/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts index 06fd00b369d..7c45358c7c8 100644 --- a/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts @@ -1470,9 +1470,12 @@ describe('pre-dispatch drops still resolve the waiter', () => { expect(mockReportCompletion).toHaveBeenCalledWith( 'stale-call-1', 'error', - expect.stringContaining('too late'), + expect.stringContaining('not run this time'), expect.objectContaining({ staleEvent: true }) ) + const [, , message] = mockReportCompletion.mock.calls[0] ?? [] + expect(message).toContain('Do not retry it in this turn') + expect(message).toContain('keep this chat open in the Sim desktop app') }) it('marks a stale stateful event outcome unknown and unsafe to retry', async () => { diff --git a/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts b/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts index e4e1739a571..95c908ec0af 100644 --- a/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts @@ -109,6 +109,8 @@ const OUTCOME_UNKNOWN_MESSAGE = 'The Sim window closed while this browser action was in flight. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.' const REPLAY_OUTCOME_UNKNOWN_MESSAGE = 'This browser action was recorded before the Sim page reloaded, but its terminal result could not be recovered. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.' +const STALE_OBSERVATION_NOT_RUN_MESSAGE = + 'Not run: this browser observation reached the Sim desktop app too late to run safely, so it was not run this time and has no result. An observation changes nothing in the browser. Do not retry it in this turn; tell the user to keep this chat open in the Sim desktop app, or to ask again later.' const STALE_STATEFUL_OUTCOME_UNKNOWN_MESSAGE = 'This browser action was delivered too late to recover its exact result. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.' const REPLAY_GUARD_CAPACITY_MESSAGE = @@ -593,7 +595,7 @@ export async function executeBrowserToolOnClient( logger.info('Skipping stale browser tool event', { toolCallId, toolName, age }) const observationOnly = OBSERVATION_ONLY_BROWSER_TOOLS[toolName] const message = observationOnly - ? 'This browser observation was delivered too late to run safely. Ask again to retry it.' + ? STALE_OBSERVATION_NOT_RUN_MESSAGE : STALE_STATEFUL_OUTCOME_UNKNOWN_MESSAGE retainAndReportTerminalCompletion( toolCallId, diff --git a/apps/sim/lib/mothership/tools/client/completion.test.ts b/apps/sim/lib/mothership/tools/client/completion.test.ts index 48f1be02cd6..dcf3aefa4fd 100644 --- a/apps/sim/lib/mothership/tools/client/completion.test.ts +++ b/apps/sim/lib/mothership/tools/client/completion.test.ts @@ -115,6 +115,14 @@ describe('client tool completion reporting', () => { expect(signal?.aborted).toBe(true) }) + it('treats a 409 as final: the desktop app holds the call', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 409 })) + + await expect( + reportClientToolCompletionOnPageExit('tool-1', 'error', 'Browser failed') + ).resolves.toBeUndefined() + }) + it('rejects a non-success response', async () => { fetchMock.mockResolvedValue(new Response(null, { status: 503 })) diff --git a/apps/sim/lib/mothership/tools/client/completion.ts b/apps/sim/lib/mothership/tools/client/completion.ts index 5c97abcad23..9ea7ad5ee7e 100644 --- a/apps/sim/lib/mothership/tools/client/completion.ts +++ b/apps/sim/lib/mothership/tools/client/completion.ts @@ -145,7 +145,7 @@ export async function reportClientToolCompletionOnPageExit( }), keepalive: true, }) - if (!response.ok) { + if (!isSettledDelivery(response, toolCallId)) { throw new CompletionReportError(`Page-exit completion failed with status ${response.status}`) } } diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index caf42b86e29..3adca1b708b 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -13,6 +13,7 @@ vi.mock('@/stores/reset-all-stores', () => { return { resetAllStores: mockResetAllStores } }) +import { leaseDesktopTool } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' import { clearUserData, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' expect(mockModuleLoaded).not.toHaveBeenCalled() @@ -95,4 +96,19 @@ describe('clearUserData', () => { expect(inMemoryResetSucceeded).toBe(false) expect(localStorage.getItem('private-cache')).toBeNull() }) + + it('cancels desktop tools still running for the signed-out identity', async () => { + const localRead = leaseDesktopTool('turn-before-sign-out') + const browserAction = leaseDesktopTool('other-turn-before-sign-out') + mockResetAllStores.mockImplementationOnce(() => { + throw new Error('Chunk unavailable') + }) + + await clearUserData() + + expect(localRead.signal.aborted).toBe(true) + expect(browserAction.signal.aborted).toBe(true) + localRead.release() + browserAction.release() + }) }) diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index ffecaba5aec..a65e02a8846 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -1,11 +1,15 @@ 'use client' import { createLogger } from '@sim/logger' +import { stopAllDesktopTools } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' const logger = createLogger('Stores') export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' +/** Why desktop tools still running for the previous identity were cancelled. */ +const SIGNED_OUT_ABORT_REASON = 'identity_boundary:clearUserData' + interface ClearUserDataOptions { preserveRecentImpersonations?: boolean } @@ -21,6 +25,8 @@ export async function clearUserData(options: ClearUserDataOptions = {}): Promise let cleanupFailed = false let inMemoryResetSucceeded = true + stopAllDesktopTools(SIGNED_OUT_ABORT_REASON) + try { const keysToKeep = [ 'next-favicon',