Skip to content

Commit 6381473

Browse files
authored
fix(mothership): hold a tool call's display grouping beside its arguments (#9296)
* fix(mothership): hold a tool call's display grouping beside its arguments * fix(mothership): keep the protocol schemas out of the persisted-message contract
1 parent b8be2f0 commit 6381473

19 files changed

Lines changed: 289 additions & 120 deletions

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ActivityTextColumn } from '@/components/ui/activity-status'
55
import { ThinkingLoader } from '@/components/ui/thinking-loader'
66
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
77
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
8-
import { readToolActivity } from '@/lib/mothership/tools/activity'
8+
import { readCallActivity } from '@/lib/mothership/tools/activity'
99
import { getToolDisplayTitle } from '@/lib/mothership/tools/display'
1010
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
1111
import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
@@ -135,7 +135,7 @@ export function AgentGroupView({
135135
const activityDescriptor =
136136
groupActivity ??
137137
tools
138-
.map((tool) => readToolActivity(tool.params, tool.streamingArgs))
138+
.map(readCallActivity)
139139
.reverse()
140140
.find((entry) => entry?.title || entry?.completedTitle)
141141
const browserAgentAvailable = isBrowserAgentAvailable()

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
RunCode,
99
SearchWorkspace,
1010
} from '@/lib/mothership/generated/tool-catalog-v1'
11-
import { getToolActivitySummaryActions, readToolActivity } from '@/lib/mothership/tools/activity'
11+
import { getToolActivitySummaryActions, readCallActivity } from '@/lib/mothership/tools/activity'
1212
import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/display'
1313
import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args'
1414
import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
@@ -228,7 +228,7 @@ export function ToolActivityGroup({
228228
const groupedActivity =
229229
activity ??
230230
tools
231-
.map((tool) => readToolActivity(tool.params, tool.streamingArgs))
231+
.map(readCallActivity)
232232
.reverse()
233233
.find((entry) => entry?.title || entry?.completedTitle)
234234
/** Tense follows liveness: a live group, or one with a call still running, reads in progress. */

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { isPlainRecord } from '@sim/utils/object'
1616
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
1717
import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1'
1818
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
19-
import { readToolActivity } from '@/lib/mothership/tools/activity'
19+
import { readCallActivity } from '@/lib/mothership/tools/activity'
2020
import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools'
2121
import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils'
2222
import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state'
@@ -248,6 +248,7 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
248248
displayTitle,
249249
activityDescription,
250250
status: tc.status,
251+
activity: tc.activity,
251252
params: tc.params,
252253
result: tc.result,
253254
streamingArgs: tc.streamingArgs,
@@ -567,10 +568,7 @@ function groupByActivity(segments: MessageSegment[], isStreaming: boolean): Mess
567568
const groups: AgentGroupSegment[] = []
568569
let current: AgentGroupSegment | undefined
569570
for (const item of segment.items) {
570-
const activity =
571-
item.type === 'tool'
572-
? readToolActivity(item.data.params, item.data.streamingArgs)
573-
: undefined
571+
const activity = item.type === 'tool' ? readCallActivity(item.data) : undefined
574572
if (activity) labels.set(activity.id, { ...labels.get(activity.id), ...activity })
575573
if (!current || (activity && activity.id !== current.activity?.id)) {
576574
current = {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ function toRawPersistedContentBlockBody(block: ContentBlock): PersistedContentBl
8888
...(block.toolCall.activityDescription
8989
? { activityDescription: block.toolCall.activityDescription }
9090
: {}),
91+
...(block.toolCall.activity ? { activity: block.toolCall.activity } : {}),
9192
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
9293
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
9394
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
23
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
34
import {
45
type AgentNode,
@@ -80,9 +81,54 @@ describe('activity metadata replay', () => {
8081
])
8182
const blocks = modelToContentBlocks(model)
8283
expect(blocks.filter((block) => block.toolCall)).toHaveLength(1)
83-
expect(blocks.find((block) => block.toolCall)?.toolCall?.params?.activity).toEqual(activity)
84+
expect(blocks.find((block) => block.toolCall)?.toolCall?.activity).toEqual(activity)
8485
const replay = modelToContentBlocks(contentBlocksToModel(blocks))
85-
expect(replay.find((block) => block.toolCall)?.toolCall?.params?.activity).toEqual(activity)
86+
expect(replay.find((block) => block.toolCall)?.toolCall?.activity).toEqual(activity)
87+
})
88+
89+
const resolvedOperation = (ownActivity: unknown, grouping?: ToolActivity) =>
90+
build([
91+
env(1, 'tool', {
92+
phase: 'call',
93+
toolCallId: 'gateway',
94+
toolName: 'call_integration_tool',
95+
arguments: {
96+
toolId: 'azure_devops_create_work_item',
97+
description: 'Filing the task',
98+
...(grouping ? { activity: grouping } : {}),
99+
arguments: { title: 'Ship it', activity: ownActivity },
100+
},
101+
}),
102+
...[2, 3].map((seq) =>
103+
env(seq, 'tool', {
104+
phase: 'call',
105+
toolCallId: 'gateway',
106+
toolName: 'azure_devops_create_work_item',
107+
arguments: { title: 'Ship it', activity: ownActivity },
108+
})
109+
),
110+
])
111+
112+
it("keeps a resolved operation's own activity argument beside the gateway's grouping", () => {
113+
const grouping = { id: 'filing', title: 'Filing tasks', completedTitle: 'Filed tasks' }
114+
const ownActivity = 'Development'
115+
const model = resolvedOperation(ownActivity, grouping)
116+
for (const blocks of [
117+
modelToContentBlocks(model),
118+
modelToContentBlocks(contentBlocksToModel(modelToContentBlocks(model))),
119+
]) {
120+
const toolCall = blocks.find((block) => block.toolCall)?.toolCall
121+
expect(toolCall?.params).toEqual({ title: 'Ship it', activity: ownActivity })
122+
expect(toolCall?.activity).toEqual(grouping)
123+
}
124+
})
125+
126+
it("never takes a live resolved operation's own activity argument for a grouping", () => {
127+
const toolCall = modelToContentBlocks(resolvedOperation({ id: 'case-42' })).find(
128+
(block) => block.toolCall
129+
)?.toolCall
130+
expect(toolCall?.params).toEqual({ title: 'Ship it', activity: { id: 'case-42' } })
131+
expect(toolCall?.activity).toBeUndefined()
86132
})
87133
})
88134

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
22
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
3-
import { readToolActivity } from '@/lib/mothership/tools/activity'
3+
import { readCallActivity } from '@/lib/mothership/tools/activity'
44
import {
55
resolveNamedCliToolDisplayTitle,
66
type ToolResourceContext,
@@ -141,7 +141,8 @@ export function modelToContentBlocks(
141141

142142
if (node.kind === 'tool') {
143143
/** Hidden discovery may introduce an activity; carry its labels onto visible calls and replay. */
144-
const supplied = node.activity ?? readToolActivity(node.args, node.streamingArgs)
144+
// Not `node.args`: the reducer already moved a frame's grouping onto `node.activity`.
145+
const supplied = readCallActivity(node)
145146
if (supplied?.completedTitle) {
146147
const lane = activities.get(node.spanId) ?? new Map<string, ToolActivity>()
147148
if (!lane.has(supplied.id)) lane.set(supplied.id, supplied)
@@ -166,9 +167,8 @@ export function modelToContentBlocks(
166167
...(node.integrationDescription
167168
? { integrationDescription: node.integrationDescription }
168169
: {}),
169-
...(node.args || activity
170-
? { params: { ...node.args, ...(activity ? { activity } : {}) } }
171-
: {}),
170+
...(node.args ? { params: node.args } : {}),
171+
...(activity ? { activity } : {}),
172172
...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}),
173173
...(node.result
174174
? {
@@ -403,6 +403,7 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
403403
toolCallId: tc.id,
404404
toolName: tc.name,
405405
arguments: tc.params,
406+
...(tc.activity ? { activity: tc.activity } : {}),
406407
...(tc.activityDescription ? { activityDescription: tc.activityDescription } : {}),
407408
// Carries an unanswered permission prompt across a snapshot rebuild
408409
// so the reloaded row is still actionable rather than a spinner.

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ToolActivity } from '@/lib/mothership/generated/protocol'
1414
import { CallIntegrationTool } from '@/lib/mothership/generated/tool-catalog-v1'
1515
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
1616
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
17+
import { readFrameActivity } from '@/lib/mothership/tools/activity'
1718
import {
1819
CONTEXT_COMPACTION_DISPLAY_TITLE,
1920
normalizeToolActivityDescription,
@@ -72,6 +73,8 @@ export interface ToolNode extends NodeBase {
7273
status: NodeStatus
7374
args?: Record<string, unknown>
7475
activity?: ToolActivity
76+
/** A gateway call rebound to its resolved operation, whose arguments are the operation's own. */
77+
resolvedOperation?: boolean
7578
streamingArgs?: string
7679
uiTitle?: string
7780
/** Model-authored activity text preserved across stream and snapshot replay. */
@@ -240,6 +243,7 @@ function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void {
240243
if (description) node.integrationDescription = description
241244
node.name = toolName
242245
node.streamingArgs = undefined
246+
node.resolvedOperation = true
243247
}
244248

245249
/**
@@ -524,6 +528,8 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
524528
seq,
525529
tsMs
526530
)
531+
const args = isRecordLike(payload.arguments) ? payload.arguments : undefined
532+
const frameActivity = readFrameActivity({ toolName, args }, node)
527533
rebindResolvedIntegrationCall(node, toolName)
528534
node.activityDescription ??= normalizeToolActivityDescription(payload.activityDescription)
529535
// Sim stamps this onto the call frame for a tool it is holding behind a
@@ -538,16 +544,17 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
538544
// back into an ordinary running row without waiting for the result.
539545
node.status = 'running'
540546
}
541-
if (isRecordLike(payload.arguments)) {
542-
node.args = payload.arguments
543-
const activity = ToolActivity.safeParse(payload.arguments.activity)
544-
if (!node.activity && activity.success) node.activity = activity.data
545-
}
546-
// Only the snapshot-replay path (contentBlocksToModel) carries this
547-
// field — the live wire never does; it restores the rebound gateway
548-
// description across a preserve-state rebuild.
547+
// Only the snapshot-replay path (contentBlocksToModel) carries `activity`
548+
// and `integrationDescription` — the live wire never does; they restore
549+
// the grouping and the rebound gateway description across a rebuild.
550+
const restoredActivity = isRecordLike(payload.activity)
551+
? ToolActivity.safeParse(payload.activity)
552+
: undefined
553+
if (restoredActivity?.success) node.activity = restoredActivity.data
549554
const restoredDescription = asString(payload.integrationDescription)
550555
if (restoredDescription) node.integrationDescription = restoredDescription
556+
if (args) node.args = args
557+
node.activity ??= frameActivity
551558
const ui = isRecordLike(payload.ui) ? payload.ui : undefined
552559
if (ui?.hidden === true) node.hidden = true
553560
} else if (phase === MothershipStreamV1ToolPhase.args_delta) {

‎apps/sim/app/workspace/[workspaceId]/home/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/searc
22
import type { MothershipChat } from '@/lib/api/contracts/mothership-chats'
33
import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors'
44
import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
5+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
56
import type { ChatContext } from '@/stores/panel'
67
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
78

@@ -74,6 +75,8 @@ export interface ToolCallData {
7475
displayTitle: string
7576
activityDescription?: string
7677
status: ToolCallStatus
78+
/** Display grouping, held beside `params`. */
79+
activity?: ToolActivity
7780
params?: Record<string, unknown>
7881
result?: ToolCallResult
7982
streamingArgs?: string
@@ -90,6 +93,8 @@ export interface ToolCallInfo {
9093
activityDescription?: string
9194
/** Model-authored activity phrase for a gateway-resolved integration call. */
9295
integrationDescription?: string
96+
/** Display grouping, held beside `params`; older history carries it in `params.activity`. */
97+
activity?: ToolActivity
9398
params?: Record<string, unknown>
9499
calledBy?: string
95100
result?: ToolCallResult

‎apps/sim/lib/api/contracts/copilot-messages.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import { z } from 'zod'
23
import {
34
MothershipStreamV1CompletionStatus,
@@ -8,6 +9,7 @@ import {
89
MothershipStreamV1ToolOutcome,
910
MothershipStreamV1ToolPhase,
1011
} from '@/lib/mothership/generated/mothership-stream-v1'
12+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
1113

1214
const persistedToolStateSchema = z.enum([
1315
...Object.values(MothershipStreamV1ToolOutcome),
@@ -23,6 +25,12 @@ const persistedToolCallSchema = z.object({
2325
name: z.string(),
2426
state: persistedToolStateSchema,
2527
activityDescription: z.string().optional(),
28+
/**
29+
* Display grouping, held beside `params`; older rows carry it in `params.activity`. Validated
30+
* where history is read (`normalizeMessage`), which keeps the protocol schemas out of every page
31+
* that imports this contract.
32+
*/
33+
activity: z.custom<ToolActivity>(isRecordLike).optional(),
2634
params: z.record(z.string(), z.unknown()).optional(),
2735
result: z
2836
.object({ success: z.boolean(), output: z.unknown().optional(), error: z.string().optional() })

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ function toToolCallInfo(block: PersistedContentBlock, stored: boolean): ToolCall
5656
status,
5757
displayTitle: status === ToolCallStatus.cancelled ? 'Stopped by user' : tc.display?.title,
5858
...(activityDescription ? { activityDescription } : {}),
59+
...(tc.activity ? { activity: tc.activity } : {}),
5960
params: tc.params,
6061
calledBy: tc.calledBy,
6162
result: tc.result,

0 commit comments

Comments
 (0)