Skip to content

Commit 9efd683

Browse files
committed
fix(chat): name stackless error subclasses and compact stored unfinished tool params
1 parent 6511d43 commit 9efd683

6 files changed

Lines changed: 58 additions & 28 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/code-failure.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ throw e
118118
Node.js v22.19.0
119119
`
120120

121+
/** A stackless error whose class keeps its parent's `name`, annotated by Node. */
122+
const STACKLESS_SUBCLASS_ERROR = `node:internal/modules/run_main:123
123+
triggerUncaughtException(
124+
^
125+
126+
[QuotaExceeded [Error]: over quota] { code: 'E_Q' }
127+
128+
Node.js v22.19.0
129+
`
130+
121131
const STACKLESS_ERROR_WITH_INLINE_PROPERTIES = `file:///home/user/.sim-function-abc.mjs:1
122132
throw e
123133
^
@@ -168,6 +178,7 @@ describe('processCodeFailure', () => {
168178
],
169179
['a stackless error with no properties', STACKLESS_RANGE_ERROR, 'RangeError', 'limit hit'],
170180
['a stackless plain Error', STACKLESS_PLAIN_ERROR, 'Error', 'plain failure'],
181+
['a stackless error subclass', STACKLESS_SUBCLASS_ERROR, 'QuotaExceeded [Error]', 'over quota'],
171182
[
172183
'a stackless error with inline properties',
173184
STACKLESS_ERROR_WITH_INLINE_PROPERTIES,

‎apps/sim/lib/execution/remote-sandbox/code-failure.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import type { SandboxCodeResult, SandboxCommandResult } from '@/lib/execution/re
22

33
/**
44
* A top-level error headline in a Python traceback or Node crash report: `Name: message`,
5-
* Node's `Name [CODE]: message`, or `[NameError: message]` (any inline properties after it)
6-
* for an error printed without a stack. It starts the line, so the properties Node prints indented beneath an error
7-
* (`code: 'X'`, `bytesRead: 0`) never match, and a space follows the colon, so
8-
* source-location headers (`node:internal/x:12`, `file:///x.mjs:3`) don't either. The
9-
* bracketed form needs an error-type name, since Node brackets other thrown values the
10-
* same way (`[Object: null prototype] {`, `[Function: handler]`).
5+
* Node's `Name [CODE]: message` (or `Class [name]: message` for an error class that keeps
6+
* its parent's name), or the same bracketed with any inline properties after it
7+
* (`[NameError: message] { … }`) for an error printed without a stack. It starts the line,
8+
* so the properties Node prints indented beneath an error (`code: 'X'`, `bytesRead: 0`)
9+
* never match, and a space follows the colon, so source-location headers
10+
* (`node:internal/x:12`, `file:///x.mjs:3`) don't either. The bracketed form needs an
11+
* error-type name or that annotation, since Node brackets other thrown values the same
12+
* way (`[Object: null prototype] {`, `[Function: handler]`).
1113
*/
1214
const ERROR_HEADLINE =
13-
/^(?:\[(?:[A-Za-z_$][\w.$]*)?(?:Error|Exception)|[A-Za-z_$][\w.$]*(?: \[[\w.$-]+\])?):(?:\s|$)/
15+
/^(?:\[(?:[A-Za-z_$][\w.$]*)?(?:Error|Exception)|\[?[A-Za-z_$][\w.$]* \[[\w.$-]+\]|[A-Za-z_$][\w.$]*):(?:\s|$)/
1416

1517
/**
1618
* Converts a failed code command into a code result, naming the error from the

‎apps/sim/lib/mothership/chat/display-message.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages'
22
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
3-
import { isLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
3+
import { isStoredAssistantMessage } from '@/lib/mothership/chat/live-message-id'
44
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
55
import {
66
copyPersistedMessageContext,
@@ -216,7 +216,7 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
216216
}
217217

218218
if (msg.contentBlocks && msg.contentBlocks.length > 0) {
219-
const stored = msg.role === 'assistant' && !isLiveAssistantMessageId(msg.id)
219+
const stored = isStoredAssistantMessage(msg)
220220
const displayBlocks = msg.contentBlocks
221221
.map((block) => toDisplayBlock(block, stored))
222222
.filter((block): block is ContentBlock => !!block)

‎apps/sim/lib/mothership/chat/live-message-id.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@ export function getLiveAssistantMessageId(streamId: string): string {
44

55
/**
66
* True for the synthetic id of a streaming/just-streamed assistant message.
7-
* These ids exist only in the client's effective transcript — never in the
8-
* persisted one — so message-scoped server actions (e.g. fork) must not be
9-
* offered until the transcript refetch swaps in the persisted message id.
7+
* These ids exist only in the effective transcript built for the client —
8+
* never in the persisted one — so message-scoped server actions (e.g. fork)
9+
* must not be offered until the transcript refetch swaps in the persisted
10+
* message id.
1011
*/
1112
export function isLiveAssistantMessageId(messageId: string): boolean {
1213
return messageId.startsWith('live-assistant:')
1314
}
15+
16+
/**
17+
* True for a persisted assistant turn. Its turn has ended, so a tool row it left
18+
* pending, executing or awaiting approval did not finish and never runs again.
19+
*/
20+
export function isStoredAssistantMessage(message: { role: string; id: string }): boolean {
21+
return message.role === 'assistant' && !isLiveAssistantMessageId(message.id)
22+
}

‎apps/sim/lib/mothership/chat/persisted-message.test.ts‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
22
import { copilotChatStopBodySchema } from '@/lib/api/contracts/copilot'
33
import type { PersistedToolState } from '@/lib/api/contracts/copilot-messages'
44
import { toDisplayMessage } from '@/lib/mothership/chat/display-message'
5+
import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
56
import type { OrchestratorResult } from '@/lib/mothership/request/types'
67
import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display'
78
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
@@ -635,22 +636,25 @@ describe('compactToolCallParams', () => {
635636
})
636637

637638
it.each(['pending', 'executing', 'awaiting_approval'] as const)(
638-
'keeps a %s call whole: it can still run or await approval from these params',
639+
'keeps a live %s call whole: it can still run or await approval from these params',
639640
(state) => {
640-
const message = sandboxCall(state)
641+
const message = { ...sandboxCall(state), id: getLiveAssistantMessageId('stream-1') }
641642
expect(compactToolCallParams(message)).toBe(message)
642643
}
643644
)
644645

645-
it('bounds an interrupted call, which no reload re-runs or asks to approve', () => {
646-
const compacted = compactToolCallParams(sandboxCall('interrupted'))
647-
const stdout = (
648-
compacted.contentBlocks?.[0].toolCall?.params?.request as {
649-
invocation: { stdout: string }
650-
}
651-
).invocation.stdout
652-
expect(stdout.length).toBeLessThanOrEqual(1024)
653-
})
646+
it.each(['pending', 'executing', 'awaiting_approval', 'interrupted'] as const)(
647+
'bounds a stored %s call, which renders as interrupted and never re-runs or asks to approve',
648+
(state) => {
649+
const compacted = compactToolCallParams(sandboxCall(state))
650+
const stdout = (
651+
compacted.contentBlocks?.[0].toolCall?.params?.request as {
652+
invocation: { stdout: string }
653+
}
654+
).invocation.stdout
655+
expect(stdout.length).toBeLessThanOrEqual(1024)
656+
}
657+
)
654658

655659
it('is idempotent and keeps the reference once compacted', () => {
656660
const compacted = compactToolCallParams(sandboxCall('error'))

‎apps/sim/lib/mothership/chat/persisted-message.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
PersistedToolState,
88
} from '@/lib/api/contracts/copilot-messages'
99
import { buildMothershipErrorTag } from '@/lib/mothership/chat/error-tag'
10+
import { isStoredAssistantMessage } from '@/lib/mothership/chat/live-message-id'
1011
import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations'
1112
import {
1213
mergeAndRedactPersistedBlocks,
@@ -228,10 +229,12 @@ function compactParamRecord(record: Record<string, unknown>): Record<string, unk
228229
}
229230

230231
/**
231-
* Truncate long strings inside settled tool calls' params, keeping every key,
232-
* array entry and short scalar. A pending, executing or awaiting-approval call
233-
* keeps its params whole: a reloaded executing workflow tool re-runs from them
234-
* and the permission card previews them.
232+
* Truncate long strings inside tool calls' params, keeping every key, array
233+
* entry and short scalar. On the live assistant message a pending, executing or
234+
* awaiting-approval call keeps its params whole: a reloaded executing workflow
235+
* tool re-runs from them and the permission card previews them. A stored turn
236+
* has ended, so its unfinished calls render as interrupted and are compacted
237+
* like settled ones.
235238
*
236239
* A projection for the transcript served to the chat UI only. Stored rows keep
237240
* full params: data drains export them verbatim, and fork and import copy
@@ -240,10 +243,11 @@ function compactParamRecord(record: Record<string, unknown>): Record<string, unk
240243
*/
241244
export function compactToolCallParams(message: PersistedMessage): PersistedMessage {
242245
if (!message.contentBlocks?.length) return message
246+
const stored = isStoredAssistantMessage(message)
243247
let changed = false
244248
const contentBlocks = message.contentBlocks.map((block) => {
245249
const toolCall = block.toolCall
246-
if (!toolCall?.params || isUnsettledToolState(toolCall.state)) return block
250+
if (!toolCall?.params || (!stored && isUnsettledToolState(toolCall.state))) return block
247251
const params = compactParamRecord(toolCall.params)
248252
if (params === toolCall.params) return block
249253
changed = true

0 commit comments

Comments
 (0)