Skip to content

Commit 85402d0

Browse files
authored
fix(mothership): end refused replay turns cleanly and bound oversized stream events (#8450)
* fix(mothership): bound replay frames and end refused turns instead of handing them off A leased Chat stream persists every event to its Redis replay buffer before delivering or dispatching it. When the buffer refused a write (a frame over the 1 MiB single-write ceiling, or a stream past its 32 MiB budget), the writer threw a generic error, the controller classified it as a takeover and released its lock without finalizing, and every reconnect started a recovery controller that re-received and re-refused the same event until the run deadline. The refused tool call was never dispatched, so the worker waited. Refusals: - The writer throws StreamReplayBudgetExhaustedError and rejects later publishes the same way, but still delivers the turn's terminal error and complete events unpersisted; flush no longer rethrows the refusal. - The controller aborts with that error rather than a supersession, the lifecycle classifies it as an error (not a user cancel), the run is finalized as an error with code replay_budget_exhausted, and the worker is told to stop. Reconnects then see a terminal run and start no controller. - Only a genuine StreamControllerSupersededError still hands the run off. Compaction, applied only to the copy the writer delivers and persists: - Payloads over 256 KiB have long string leaves cut to an 8 KiB head in place, then medium strings to 500 characters, and only then the largest unprotected subtrees replaced by { omitted, bytes }, targeting 128 KiB. A payload-level streamTruncation marker lists every changed field. - Identity fields, UI-read keys, arguments of client-executable calls, file preview events, and generate_api_key results are never compacted; such a frame that stays too large is refused and ends the turn cleanly. - Oversized assistant text splits into contiguous exact pieces, and each tool call forwards only the head of its argument deltas. * fix(mothership): render stream-omitted tool values honestly A call frame whose whole argument object was omitted by stream compaction no longer replaces a tool node's arguments, and the permission card and generic tool output render omitted values as "Too large to show (N KB)" instead of raw marker JSON. * refactor(mothership): replace replay compaction with one string-truncation pass Compaction is now a single linear walk: when a payload's strings could serialize past 256 KiB, string leaves longer than 8K units are cut to their head with an inline "…[truncated, N total]" note, copying only what changes. Identity and UI-read keys, file previews, generate_api_key results, and the arguments of calls the browser executes are never cut; anything still over the write ceiling is refused and ends the turn. The subtree omission, second cut pass, truncation marker, text splitting, argument-delta cap, and the UI stub handling are removed. One predicate, isClientExecutedToolCall, now names the tool calls the browser starts from the call frame; both the stream compaction and the client dispatch use it, and the workflow tool names move beside it. Also fixes three defects in the refusal path: - A superseded controller that is refused an oversized frame no longer expires its successor's stream or clears its abort marker; teardown keys on whether this controller handed off, not on the abort reason. - A refusal after a user Stop stays a cancellation: the abort reason decides. - The per-user hourly ceiling gets its own message instead of telling the user to continue immediately. * fix(mothership): stop stale approval cards, raw backend bodies, and errored-turn spinners - A replayed call frame is history and never gates anything, so Sim now clears the worker's awaiting_approval stamp on it as it already does for live frames. Replays no longer render Allow/Skip cards when approvals are off. - Stop settles every unfinished tool row (pending, executing, or awaiting approval) through one shared predicate, isUnsettledToolState, on the persisted, live, and pending-message paths. - An errored turn settles its unfinished tool rows as errored when it is persisted, so they no longer reload as spinners. - A failed backend response no longer puts the upstream body in the error shown to users: 5xx and non-JSON bodies say the service is temporarily unavailable, 4xx uses the worker's displayMessage or a generic message. The status and body stay on the error for logs. * fix(mothership): never forward an approval stamp on a frame that cannot be gated The worker stamps awaiting_approval on resolved integration calls and relies on Sim to keep or clear it. Sim cleared it on live complete frames that were not gated, but a partial frame returned before that decision and kept the stamp. Only a live, complete call frame can be held behind a prompt, so every replayed or partial frame now drops the stamp at the one point each frame passes through before it is persisted and delivered. With approvals off, no frame, replay, snapshot, or persisted row can carry the stamp to the client. * fix(mothership): keep retrying an unreachable worker through a task replacement A worker task replacement leaves the load balancer answering 502/504 for about a minute. Stream retries stopped after three attempts, about four seconds, and ended the turn with an error. While no worker answers (a 5xx or no connection), retries now continue with the existing backoff for up to two minutes from the first failure, still bounded by the run deadline and by Stop. Each attempt re-sends the same message identity, which the worker treats as a reattach rather than a second run. Streams that end without a terminal keep the three-retry limit. * fix(mothership): settle runs whose terminal events fail, and tighten compaction and cleanup - A controller that lost its lock while handling an ordinary failure no longer expires its successor's stream or clears its abort marker: teardown skips cleanup when this controller handed off or was superseded. - The terminal run status is now written even when publishing the terminal events fails. The write stays scoped to this controller's token and never replaces a terminal status, so a successor's run is left alone. Before, such a failure left the run non-terminal with nothing to settle it. - A failed backend response is described by its status alone: 5xx says the service is temporarily unavailable, anything else that the request could not be processed. - Compaction no longer exempts whole subtrees by key name. Every string the UI reads for identity, status, titles, or targets is far shorter than the cut, and long text keeps its head, so only client-executed call arguments, file previews, and generate_api_key results are left whole. * fix(mothership): settle tool rows left unfinished by any finished turn - A completed turn now persists its unfinished tool rows settled as the live view settled them at complete, so they no longer reload as spinners. - A stored message's unfinished tool rows render as interrupted, which settles rows persisted before these fixes; the live streaming message is unchanged. - The client's error finalize settles pending and awaiting-approval rows too: finalizeResidualToolCalls reports whether it settled anything, and that decides whether the snapshot is rewritten. * fix(mothership): harden replay compaction, stream retries, teardown, and backend errors - Assistant text is never compacted: its length is part of the text receipt a replacement controller and the worker check. When cutting strings alone cannot bound an event, long arrays keep their first 100 items and a count. - Only a gateway error page (502/503/504 without a JSON body) or a failed connection counts as an unreachable worker for the two-minute window; a JSON 5xx from a reachable worker keeps the three-retry limit. A retry that streams again resets the budget for the next outage. - A controller cleans up its stream's buffer and abort marker only while it still holds the chat lock, checked just before cleanup and before releasing the lock, instead of inferring ownership from flags. - A controller superseded while publishing its terminal events leaves the run to its successor; other publish failures still settle the run. - A 4xx shows the worker's own short, plain reason (rate limit, BYOK, plan mode, model selection, version skew) and hides internal identifiers. - File preview snapshots are spaced by size, so a large file's patch preview can no longer fill the stream's replay budget; small files keep their pace. * fix(mothership): separate retry budgets, cap preview totals, and clean up only ended turns - An unreachable worker and a reachable one now have independent retry budgets. Any answer from the worker restarts only the two-minute unreachable window; the three-retry, 30 s budget for failures of a worker that answered keeps its original semantics, so a failure that repeats after every reattach still stops, and a long outage no longer spends the replacement's retries. - Intermediate file preview content is capped at 8M characters per edit; past it the preview holds until the final snapshot, which is always sent. - A controller cleans up its stream only when it finalized the turn and still holds the lock, so a run it leaves recoverable keeps its buffer and any pending Stop. - Remove the unreachable generate_api_key compaction exemption, avoid a second copy of trimmed arrays, and correct the size-estimate comment. * fix(mothership): clean up turns whose terminal publish failed and cap previews per turn in bytes - A controller that ended its turn but could not publish the terminal events still cleans up its stream: the run is settled either way, so the buffer no longer lingers for its full TTL. Only a superseded controller skips cleanup, and the lease check still protects a successor. - The intermediate file preview cap is now 8 MiB of UTF-8 per stream loop, across all of its edits, instead of characters per edit. Each edit's final snapshot is still always sent. * fix(mothership): bound preview frames per write and per turn, and narrow unreachable retries - A file preview frame whose content would not fit one replay write, or that would exceed the turn's preview budget, is skipped instead of sent: the preview holds its last content and the client loads the stored file on completion. A completion too large to send whole drops its tool output. The per-frame limit derives from the replay buffer's single-write ceiling, and the 8 MiB budget now covers every preview frame in the turn, finals included, across all legs. A large file preview no longer ends the turn. - Only a request that fails before any response headers counts as an unreachable worker. A TypeError raised after the response began, including one from handling delivered events, keeps the three-retry budget, so a deterministic failure can no longer retry until the run deadline. - A worker that cannot be reached is reported with the generic "temporarily unavailable" message; the network error stays on the error's cause. - Retry logs and spans count retries from both budgets. * fix(mothership): omit the largest field of an event no cut can bound, and persist closed lanes - An event whose many short values survive every string and array cut, such as an object with thousands of keys, now has its largest bulk field replaced by a size note when it would still exceed one replay write, instead of ending the turn. Identity fields and client-executed arguments are never replaced; such an event is still refused cleanly. - finalizeResidualToolCalls reports closing an open subagent lane as a change, so the error finalizer persists it instead of leaving a stale lane. * fix(mothership): keep sibling fields when omitting bulk, and never cache a skipped preview as file content - The last-resort omission for an event no cut can bound now walks down from the largest field while one child holds most of its parent's bytes and replaces only that node, so the ids, resources, and status fields beside the bulk keep driving the UI's resource updates. - On completion the client seeds the file content cache from preview text only when it received the completed version. When the server skipped the final content frame, the text is an earlier draft, so the stored file loads instead. * fix(mothership): keep omitting bulk until an event fits one replay write A result with more than one large object of short fields left the second one over the write limit after the first was omitted, so the event was refused and the turn ended. Omission now repeats until the event fits or nothing large enough is left; each pass removes at least 64 KiB, so it always terminates. * fix(mothership): shed oversized replay events in one pass and retry mid-body stream cuts - Replace the replay-compaction omit loop with one bounded pass over memoized exact serialized sizes: recurse into the smallest child that covers the overage, otherwise drop the largest children whole, keeping small siblings - Map a non-abort body-read failure from the worker stream to a retryable WorkerStreamInterruptedError on the reachable budget, with the generic message and the original error on cause - Log the cause message on retry warnings and orchestration failures - Count replayed file preview content toward a restored turn's preview budget * refactor(mothership): tidy replay compaction and stream retry after review - Share the replay write ceiling between compaction and file previews - Scan for the smallest sufficient child only once one can cover the need, and reuse one size memo across every compaction step - Stop retrying a raw TypeError; fetch and body-read failures are already wrapped as WorkerUnreachableError and WorkerStreamInterruptedError - Use absolute imports, a private retry counter, a typed unsettled-state set, and hasUnsettledTool naming - Cover a timed-out body read and the preview completion edge cases * fix(mothership): bound preview metadata frames and settle stopped rows on every save - Compact every preview phase except content and completion, which the preview adapter already bounds; a model-written patch search string could otherwise exceed one replay write in an edit_meta frame - Settle unfinished tool rows as stopped in buildPersistedAssistantMessage for a cancelled turn, so background and API callers that persist the result directly never save pending, executing, or awaiting-approval rows
1 parent 10726ce commit 85402d0

41 files changed

Lines changed: 3334 additions & 312 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/copilot/confirm/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ import {
4242
retainSealedClientToolContext,
4343
sealClientToolCompletion,
4444
} from '@/lib/mothership/request/tools/client-completion-seal.server'
45+
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
4546
import {
4647
createStructuralWorkflowToolCompletionData,
4748
getWorkflowToolCompletionExecutionId,
4849
getWorkflowToolCompletionMessage,
4950
getWorkflowToolConfirmationStatus,
5051
getWorkflowToolLaunchError,
51-
isWorkflowToolName,
5252
resolveWorkflowToolTargetId,
5353
WORKFLOW_EXECUTION_BUSY,
5454
type WorkflowToolLaunchError,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
3+
import { markMessageStopped } from '@/app/workspace/[workspaceId]/home/hooks/message-reconcile'
4+
5+
describe('markMessageStopped', () => {
6+
it.each(['executing', 'pending', 'awaiting_approval'] as const)(
7+
'settles a %s tool row as stopped',
8+
(state) => {
9+
const message: PersistedMessage = {
10+
id: 'assistant-1',
11+
role: 'assistant',
12+
content: '',
13+
timestamp: '2026-09-29T00:00:00.000Z',
14+
contentBlocks: [
15+
{
16+
type: 'tool',
17+
endedAt: 1,
18+
toolCall: { id: 'call-1', name: 'gmail_read_v2', state },
19+
},
20+
],
21+
}
22+
23+
const stopped = markMessageStopped(message)
24+
25+
expect(stopped.contentBlocks?.[0].toolCall?.state).toBe('cancelled')
26+
}
27+
)
28+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
import { isBrowserToolName } from '@sim/browser-protocol'
99
import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages'
1010
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
11-
import { normalizeMessage, withBlockTiming } from '@/lib/mothership/chat/persisted-message'
11+
import {
12+
isUnsettledToolState,
13+
normalizeMessage,
14+
withBlockTiming,
15+
} from '@/lib/mothership/chat/persisted-message'
1216
import {
1317
MothershipStreamV1CompletionStatus,
1418
MothershipStreamV1EventType,
@@ -18,7 +22,7 @@ import {
1822
MothershipStreamV1ToolPhase,
1923
} from '@/lib/mothership/generated/mothership-stream-v1'
2024
import type { StreamBatchEvent } from '@/lib/mothership/request/session/types'
21-
import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools'
25+
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
2226
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
2327
import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats'
2428
import { isZeroStreamCursor } from './stream-protocol'
@@ -142,18 +146,18 @@ export function buildAssistantSnapshotMessage(params: {
142146
}
143147

144148
export function markMessageStopped(message: PersistedMessage): PersistedMessage {
145-
const hasExecutingTool = message.contentBlocks?.some(
146-
(block) => block.toolCall?.state === 'executing'
149+
const hasUnsettledTool = message.contentBlocks?.some((block) =>
150+
isUnsettledToolState(block.toolCall?.state)
147151
)
148152
const hasOpenBlock = message.contentBlocks?.some((block) => block.endedAt === undefined)
149-
if (!hasExecutingTool && !hasOpenBlock) {
153+
if (!hasUnsettledTool && !hasOpenBlock) {
150154
return message
151155
}
152156

153157
const stopTs = Date.now()
154158
const nextBlocks = (message.contentBlocks ?? []).map((block) => {
155159
const stamped = block.endedAt === undefined ? { ...block, endedAt: stopTs } : block
156-
if (stamped.toolCall?.state !== 'executing') {
160+
if (!stamped.toolCall || !isUnsettledToolState(stamped.toolCall.state)) {
157161
return stamped
158162
}
159163
return {

‎apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it } from 'vitest'
22
import type { FilePreviewSession } from '@/lib/mothership/request/session'
3-
import { deriveFilePreviewSession } from './apply-file-preview-phase'
3+
import {
4+
deriveFilePreviewSession,
5+
previewHoldsFinalContent,
6+
} from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase'
47

58
const NOW = '2026-06-08T00:00:00.000Z'
69

@@ -94,3 +97,40 @@ describe('deriveFilePreviewSession', () => {
9497
expect(third?.previewText).toBe('ABC')
9598
})
9699
})
100+
101+
describe('previewHoldsFinalContent', () => {
102+
const complete = (previewVersion?: number) => ({
103+
previewPhase: 'file_preview_complete' as const,
104+
toolCallId: 'tool-1',
105+
toolName: 'prepare_file_edit' as const,
106+
...(previewVersion !== undefined ? { previewVersion } : {}),
107+
})
108+
109+
it('holds the final content when the last content received is the completed version', () => {
110+
const prev = session({ previewText: 'final text', previewVersion: 7 })
111+
112+
expect(previewHoldsFinalContent(prev, complete(7))).toBe(true)
113+
})
114+
115+
it('does not hold it when later versions were never received, so the stored file must load', () => {
116+
const prev = session({ previewText: 'an earlier draft', previewVersion: 5 })
117+
118+
expect(previewHoldsFinalContent(prev, complete(7))).toBe(false)
119+
})
120+
121+
it('does not hold it when no content was received at all', () => {
122+
expect(previewHoldsFinalContent(undefined, complete(7))).toBe(false)
123+
})
124+
125+
it('does not hold it when the session exists but received no text', () => {
126+
const prev = session({ previewText: '', previewVersion: 7 })
127+
128+
expect(previewHoldsFinalContent(prev, complete(7))).toBe(false)
129+
})
130+
131+
it('holds the received text when the completion carries no version to compare', () => {
132+
const prev = session({ previewText: 'final text', previewVersion: 3 })
133+
134+
expect(previewHoldsFinalContent(prev, complete())).toBe(true)
135+
})
136+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,17 @@ export function deriveFilePreviewSession(
9999
}
100100
}
101101
}
102+
103+
/**
104+
* Whether the preview text the client holds is the edit's final content. The
105+
* server skips a content frame too large for the stream, so a completion whose
106+
* version is newer than the last content received means the text is an earlier
107+
* draft, and only the stored file is the saved result.
108+
*/
109+
export function previewHoldsFinalContent(
110+
prev: FilePreviewSession | undefined,
111+
completion: Extract<SyntheticFilePreviewPayload, { previewPhase: 'file_preview_complete' }>
112+
): boolean {
113+
if (!prev || prev.previewText.length === 0) return false
114+
return completion.previewVersion === undefined || prev.previewVersion >= completion.previewVersion
115+
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { useQueryClient } from '@tanstack/react-query'
1212
import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session'
1313
import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract'
1414
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
15-
import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase'
15+
import {
16+
deriveFilePreviewSession,
17+
previewHoldsFinalContent,
18+
} from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase'
1619
import {
1720
buildCompletedPreviewSessions,
1821
type FilePreviewSessionsState,
@@ -381,7 +384,7 @@ export function useFilePreviewController({
381384
sessionId: nextSession.id,
382385
suppressActivation: !shouldActivateOnComplete,
383386
})
384-
if (hasRenderableFilePreviewContent(nextSession)) {
387+
if (previewHoldsFinalContent(prevSession, payload)) {
385388
seedCompletedPreviewContentCache(fileId, nextSession.previewText)
386389
}
387390
if (workspaceId) invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ vi.mock('@/lib/mothership/resources/extraction', () => ({
44
isResourceToolName: vi.fn(() => false),
55
extractResourcesFromToolResult: vi.fn(() => []),
66
}))
7-
vi.mock('@/lib/mothership/tools/workflow-tools', () => ({
8-
isWorkflowToolName: vi.fn(() => false),
9-
}))
107
vi.mock(
118
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry',
129
() => ({ invalidateResourceQueries: vi.fn() })

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts‎

Lines changed: 20 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
extractResourcesFromToolResult,
1515
isResourceToolName,
1616
} from '@/lib/mothership/resources/extraction'
17-
import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem'
18-
import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools'
17+
import {
18+
isClientExecutedToolCall,
19+
isWorkflowToolName,
20+
} from '@/lib/mothership/tools/client-executed-tools'
1921
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
2022
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
2123
import {
@@ -188,56 +190,23 @@ export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void
188190
const name = payload.toolName
189191
const isPartial =
190192
payload.partial === true || payload.status === MothershipStreamV1ToolStatus.generating
191-
if (isWorkflowToolName(name) && !isPartial) {
192-
const shouldStartWorkflowTool =
193-
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
194-
node?.kind === 'tool' &&
195-
node.status === 'running' &&
196-
!node.result
197-
if (shouldStartWorkflowTool) {
198-
const args = payload.arguments as Record<string, unknown> | undefined
193+
const args = payload.arguments as Record<string, unknown> | undefined
194+
const shouldStartClientTool =
195+
isClientExecutedToolCall(name, args) &&
196+
!isPartial &&
197+
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
198+
node?.kind === 'tool' &&
199+
node.status === 'running' &&
200+
!node.result
201+
if (shouldStartClientTool) {
202+
if (isWorkflowToolName(name)) {
199203
deps.startClientWorkflowTool(rawId, name, args ?? {})
200-
}
201-
}
202-
const localFilesystemArgs = payload.arguments as Record<string, unknown> | undefined
203-
if ((isNativeFileTool(name) || isUserLocalVfsToolCall(name, localFilesystemArgs)) && !isPartial) {
204-
const shouldStartLocalFilesystemTool =
205-
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
206-
node?.kind === 'tool' &&
207-
node.status === 'running' &&
208-
!node.result
209-
if (shouldStartLocalFilesystemTool) {
210-
deps.startClientLocalFilesystemTool(rawId, name, localFilesystemArgs ?? {})
211-
}
212-
}
213-
if (isCurrentBrowserToolName(name) && !isPartial) {
214-
const shouldStartBrowserTool =
215-
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
216-
node?.kind === 'tool' &&
217-
node.status === 'running' &&
218-
!node.result
219-
if (shouldStartBrowserTool) {
220-
deps.startClientBrowserTool(
221-
rawId,
222-
name,
223-
(payload.arguments as Record<string, unknown> | undefined) ?? {},
224-
parsed.ts
225-
)
226-
}
227-
}
228-
if (isTerminalToolName(name) && !isPartial) {
229-
const shouldStartTerminalTool =
230-
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
231-
node?.kind === 'tool' &&
232-
node.status === 'running' &&
233-
!node.result
234-
if (shouldStartTerminalTool) {
235-
deps.startClientTerminalTool(
236-
rawId,
237-
name,
238-
(payload.arguments as Record<string, unknown> | undefined) ?? {},
239-
parsed.ts
240-
)
204+
} else if (isCurrentBrowserToolName(name)) {
205+
deps.startClientBrowserTool(rawId, name, args ?? {}, parsed.ts)
206+
} else if (isTerminalToolName(name)) {
207+
deps.startClientTerminalTool(rawId, name, args ?? {}, parsed.ts)
208+
} else {
209+
deps.startClientLocalFilesystemTool(rawId, name, args ?? {})
241210
}
242211
}
243212
ops.flush()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { finalizeResidualToolCalls } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers'
3+
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
4+
5+
describe('finalizeResidualToolCalls', () => {
6+
it.each(['executing', 'awaiting_approval'] as const)(
7+
'settles a %s tool row with the turn outcome on Stop',
8+
(status) => {
9+
const blocks: ContentBlock[] = [
10+
{ type: 'tool_call', toolCall: { id: 'call-1', name: 'gmail_read_v2', status } },
11+
]
12+
13+
finalizeResidualToolCalls(blocks, 'cancelled')
14+
15+
expect(blocks[0].toolCall?.status).toBe('cancelled')
16+
}
17+
)
18+
19+
it('reports whether any tool row was left to settle', () => {
20+
const open: ContentBlock[] = [
21+
{ type: 'tool_call', toolCall: { id: 'call-1', name: 'read', status: 'awaiting_approval' } },
22+
]
23+
const settled: ContentBlock[] = [
24+
{ type: 'tool_call', toolCall: { id: 'call-2', name: 'read', status: 'success' } },
25+
]
26+
27+
expect(finalizeResidualToolCalls(open, 'error')).toBe(true)
28+
expect(finalizeResidualToolCalls(settled, 'error')).toBe(false)
29+
})
30+
31+
it('reports closing an open subagent lane as a change to persist', () => {
32+
const blocks: ContentBlock[] = [{ type: 'subagent', content: 'research' }]
33+
34+
expect(finalizeResidualToolCalls(blocks, 'error')).toBe(true)
35+
expect(blocks[0].endedAt).toEqual(expect.any(Number))
36+
})
37+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { isRecordLike } from '@sim/utils/object'
3+
import { isUnsettledToolState } from '@/lib/mothership/chat/persisted-message'
34
import {
45
CallIntegrationTool,
56
CreateEmptyFile,
@@ -68,17 +69,17 @@ export function asPayloadRecord(value: unknown): StreamPayload | undefined {
6869
}
6970

7071
/**
71-
* Settles any tool row still `executing` at a turn terminal by propagating the
72-
* turn's outcome — the deterministic replacement for the old `interrupted`
73-
* invention. A clean `complete` means the turn succeeded, so a straggler is
74-
* settled `success` (with explicit tool/span terminals from the backend there
75-
* are normally none); a stop settles `cancelled`; an error settles `error`.
72+
* Settles every unfinished tool row (running, pending, or awaiting approval) at
73+
* a turn terminal by propagating the turn's outcome: a clean `complete` settles
74+
* a straggler `success`, a stop `cancelled`, an error `error`. Also closes any
75+
* open subagent lane. Returns whether it settled a row or closed a lane.
7676
*/
7777
export function finalizeResidualToolCalls(
7878
blocks: ContentBlock[],
7979
turnTerminal: 'complete' | 'cancelled' | 'error'
80-
): void {
80+
): boolean {
8181
const endedAt = Date.now()
82+
let settled = false
8283
const propagated =
8384
turnTerminal === 'cancelled'
8485
? ToolCallStatus.cancelled
@@ -93,10 +94,12 @@ export function finalizeResidualToolCalls(
9394
// transport-based gating.
9495
if (block.type === 'subagent' && block.endedAt === undefined) {
9596
block.endedAt = endedAt
97+
settled = true
9698
continue
9799
}
98100
const tc = block.toolCall
99-
if (!tc || tc.status !== ToolCallStatus.executing) continue
101+
if (!tc || !isUnsettledToolState(tc.status)) continue
102+
settled = true
100103
tc.status = propagated
101104
if (propagated === ToolCallStatus.cancelled) {
102105
tc.displayTitle = 'Stopped by user'
@@ -105,6 +108,7 @@ export function finalizeResidualToolCalls(
105108
block.endedAt = endedAt
106109
}
107110
}
111+
return settled
108112
}
109113

110114
function stringParam(value: unknown): string | undefined {

0 commit comments

Comments
 (0)