Skip to content

Commit 3a9bd01

Browse files
committed
fix(mothership): scope a desktop tool's Stop to its turn
A desktop tool outlives the chat view that started it, so its Stop belongs to the turn, not the view: tools are keyed by the turn's stream id, which survives reader replacement and remounts and differs between chats. Stop in another chat leaves them running, and Stop from a view reopened on the turn still reaches them.
1 parent 4425571 commit 3a9bd01

3 files changed

Lines changed: 68 additions & 12 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { LRUCache } from 'lru-cache'
2+
3+
/**
4+
* Turns whose desktop tools may still be running in this tab. A tool outlives the chat view that
5+
* started it (and any stream reader), so the turn, not the view, owns its Stop. Bounded: a tool
6+
* runs for minutes, far fewer turns than this.
7+
*/
8+
const turnStops = new LRUCache<string, AbortController>({ max: 64 })
9+
10+
/**
11+
* The lifetime of a desktop tool (a browser action, a local file read or import) started for a
12+
* turn: only the user's Stop of that turn ends it. Replacing the stream reader, leaving the chat
13+
* view, or stopping another chat's turn leaves it running to finish and report its own result.
14+
*/
15+
export function desktopToolLifetime(streamId: string): AbortSignal {
16+
let stop = turnStops.get(streamId)
17+
if (!stop) {
18+
stop = new AbortController()
19+
turnStops.set(streamId, stop)
20+
}
21+
return stop.signal
22+
}
23+
24+
/** Cancels the desktop tools of a turn the user stopped, from whichever view started them. */
25+
export function stopDesktopTools(streamId: string, reason: string): void {
26+
turnStops.get(streamId)?.abort(reason)
27+
turnStops.delete(streamId)
28+
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2212,7 +2212,7 @@ describe('useChat remount send recovery', () => {
22122212
const toolSignal = lifetimeOf()
22132213
if (!(toolSignal instanceof AbortSignal))
22142214
throw new Error('The desktop action has no lifetime')
2215-
return { ...chat, toolSignal, replays }
2215+
return { ...chat, toolSignal, replays, streamId: () => streamId }
22162216
}
22172217

22182218
beforeEach(() => {
@@ -2264,6 +2264,36 @@ describe('useChat remount send recovery', () => {
22642264
expect(toolSignal.aborted).toBe(true)
22652265
})
22662266

2267+
it('keeps running when the user stops a turn in another chat', async () => {
2268+
const { toolSignal, navigate, getResult } = await startDesktopAction()
2269+
navigate('chat-other', { ...history, id: 'chat-other' })
2270+
await act(async () => {
2271+
void getResult().sendMessage('Something else')
2272+
})
2273+
2274+
await act(async () => {
2275+
await getResult().stopGeneration()
2276+
})
2277+
2278+
expect(toolSignal.aborted).toBe(false)
2279+
})
2280+
2281+
it('is still cancelled by Stop from the chat view reopened on its turn', async () => {
2282+
const { toolSignal, unmount, streamId } = await startDesktopAction()
2283+
unmount()
2284+
const reopened = renderUseChatInChat(chatId, {
2285+
...history,
2286+
activeStreamId: streamId() ?? null,
2287+
})
2288+
await waitFor(() => reopened.getResult().isSending)
2289+
2290+
await act(async () => {
2291+
await reopened.getResult().stopGeneration()
2292+
})
2293+
2294+
expect(toolSignal.aborted).toBe(true)
2295+
})
2296+
22672297
it('is cancelled when the user stops the chat', async () => {
22682298
const { toolSignal, getResult } = await startDesktopAction()
22692299

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools
9191
import { initTerminalTransport } from '@/lib/terminal/transport'
9292
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
9393
import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url'
94+
import {
95+
desktopToolLifetime,
96+
stopDesktopTools,
97+
} from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes'
9498
import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview'
9599
import {
96100
captureResourceActivityScope,
@@ -925,13 +929,6 @@ export function useChat(
925929
const reconnectExhaustedRecheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
926930

927931
const abortControllerRef = useRef<AbortController | null>(null)
928-
/**
929-
* The lifetime of the desktop tools (browser actions, local file reads and imports) this view
930-
* starts: only the user's Stop ends it. It belongs to no stream reader, so replacing the reader
931-
* (the window returning to view, a history reconnect) or leaving the chat view leaves a running
932-
* tool alone to finish and report, and a Stop still reaches tools a replaced reader started.
933-
*/
934-
const desktopToolStopRef = useRef<AbortController | null>(null)
935932
const detachedChatResolutionControllersRef = useRef<Set<AbortController> | null>(null)
936933
const detachedChatResolutionControllers = (detachedChatResolutionControllersRef.current ??=
937934
new Set())
@@ -1586,7 +1583,7 @@ export function useChat(
15861583
const options = {
15871584
workspaceId,
15881585
chatId: chatIdRef.current ?? selectedChatIdRef.current,
1589-
signal: (desktopToolStopRef.current ??= new AbortController()).signal,
1586+
signal: streamIdRef.current ? desktopToolLifetime(streamIdRef.current) : undefined,
15901587
}
15911588
/**
15921589
* Dynamic on purpose: the local-filesystem executor only runs for desktop-local
@@ -2162,7 +2159,9 @@ export function useChat(
21622159
shouldContinue?: () => boolean
21632160
}
21642161
) => {
2165-
const browserToolSignal = (desktopToolStopRef.current ??= new AbortController()).signal
2162+
const browserToolSignal = streamIdRef.current
2163+
? desktopToolLifetime(streamIdRef.current)
2164+
: undefined
21662165
const activityTracker = getResourceActivityTracker(
21672166
expectedGen ?? streamGenRef.current,
21682167
options?.targetChatId
@@ -4434,8 +4433,7 @@ export function useChat(
44344433
)
44354434
}
44364435
clearResourceActivity(stopActivityTracker, true)
4437-
desktopToolStopRef.current?.abort(USER_STOP_ABORT_REASON)
4438-
desktopToolStopRef.current = null
4436+
if (sid) stopDesktopTools(sid, USER_STOP_ABORT_REASON)
44394437

44404438
// Establish the stream boundary immediately after synchronous activity
44414439
// settlement. Native cancellation above is deliberately fire-and-forget,

0 commit comments

Comments
 (0)