diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts index cd59ffe8152..c150b07c6e1 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -101,6 +101,34 @@ describe('persisted-message', () => { } ) + it.each([ + ['with its arguments', { category_id: null }], + ['without arguments', undefined], + ])("persists a resolved operation's inherited grouping %s", (_case, params) => { + const persisted = buildPersistedAssistantMessage({ + success: true, + content: '', + toolCalls: [], + contentBlocks: [ + { + type: 'tool_call', + timestamp: 1, + toolCall: { + id: 'gateway-call', + name: 'mcp-server-get_overview', + status: 'success', + params, + activity: { id: 'gauge-read', title: 'Reading Gauge', completedTitle: 'Read Gauge' }, + }, + }, + ], + }) + expect(persisted.contentBlocks?.[0].toolCall?.params).toEqual({ + ...params, + activity: { id: 'gauge-read', title: 'Reading Gauge', completedTitle: 'Read Gauge' }, + }) + }) + it('preserves activity metadata on legacy tool blocks without inferring it from old titles', () => { const message = normalizeMessage({ id: 'message-1', diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index e96b053061a..b533bae1299 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -24,6 +24,7 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/mothership/generated/mothership-stream-v1' +import type { ToolActivity } from '@/lib/mothership/generated/protocol' import type { ContentBlock, LocalToolCallStatus, @@ -273,6 +274,14 @@ export function displayContentOf(content: PersistedMessage): PersistedMessage | // Write: OrchestratorResult → PersistedMessage +/** A call's arguments as history reads them, with an inherited display grouping restored. */ +function persistedParams( + params: Record | undefined, + activity: ToolActivity | undefined +): Record | undefined { + return activity && params?.activity === undefined ? { ...params, activity } : params +} + function resolveToolState(block: ContentBlock): PersistedToolState { const tc = block.toolCall if (!tc) return 'pending' @@ -382,17 +391,14 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { const activityDescription = normalizeToolActivityDescription( block.toolCall.activityDescription ) + const params = persistedParams(block.toolCall.params, block.toolCall.activity) const toolCall: PersistedToolCall = { id: block.toolCall.id, name: block.toolCall.name, state, ...(activityDescription ? { activityDescription } : {}), ...(isSubagentTool && isNonTerminal ? {} : { result: redactedResult }), - ...(isSubagentTool && isNonTerminal - ? {} - : block.toolCall.params - ? { params: block.toolCall.params } - : {}), + ...(isSubagentTool && isNonTerminal ? {} : params ? { params } : {}), ...(block.calledBy ? { calledBy: block.calledBy } : {}), ...(block.toolCall.displayTitle ? { diff --git a/apps/sim/lib/mothership/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts index 9f86f1ba63e..0c426472cbe 100644 --- a/apps/sim/lib/mothership/request/handlers/handlers.test.ts +++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts @@ -1,3 +1,4 @@ +import { flushMacrotask } from '@sim/testing/helpers/async' import { createDeferred } from '@sim/testing/helpers/deferred' import { mothershipAsyncRunsMock, @@ -2252,6 +2253,112 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it("executes a resolved MCP operation with its own arguments under the gateway's grouping", async () => { + isSimExecuted.mockReturnValue(false) + executeTool.mockResolvedValueOnce({ success: true, output: {} }) + const grouping = { id: 'gauge-read', title: 'Reading Gauge', completedTitle: 'Read Gauge' } + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-mcp', + toolName: 'call_integration_tool', + arguments: { + toolId: 'mcp-server-get_overview', + description: 'Pulling the overview', + activity: grouping, + arguments: { category_id: null }, + }, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-mcp', + toolName: 'mcp-server-get_overview', + arguments: { category_id: null }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await flushMacrotask() + + expect(executeTool).toHaveBeenCalledWith( + 'mcp-server-get_overview', + { category_id: null }, + expect.any(Object) + ) + expect(context.toolCalls.get('gateway-mcp')?.activity).toEqual(grouping) + }) + + it("keeps an integration operation's own activity argument under the gateway's grouping", async () => { + isSimExecuted.mockReturnValue(false) + executeTool.mockResolvedValueOnce({ success: true, output: {} }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-work-item', + toolName: 'call_integration_tool', + arguments: { + toolId: 'azure_devops_create_work_item', + description: 'Filing the task', + activity: { id: 'filing', title: 'Filing tasks', completedTitle: 'Filed tasks' }, + arguments: { title: 'Ship it', activity: 'Development' }, + }, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-work-item', + toolName: 'azure_devops_create_work_item', + arguments: { title: 'Ship it', activity: 'Development' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await flushMacrotask() + + expect(executeTool).toHaveBeenCalledWith( + 'azure_devops_create_work_item', + { title: 'Ship it', activity: 'Development' }, + expect.any(Object) + ) + }) + it('clears pending continuation state when a run resumes', async () => { context.awaitingAsyncContinuation = { checkpointId: 'cp-1', diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 533cee21dc5..9b6118a7b2e 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -20,6 +20,7 @@ import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, } from '@/lib/mothership/generated/mothership-stream-v1' +import type { ToolActivity } from '@/lib/mothership/generated/protocol' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/mothership/request/otel' @@ -145,6 +146,11 @@ function applyToolDisplay(toolCall: ToolCallState | undefined): void { const INTEGRATION_GATEWAY_TOOL = 'call_integration_tool' +/** A call's display grouping: carried in its own arguments, or inherited and held beside them. */ +function toolCallActivity(toolCall: ToolCallState | undefined): ToolActivity | undefined { + return toolCall?.activity ?? readToolActivity(toolCall?.params) +} + function handleToolArgsDelta( data: { argumentsDelta: string; toolCallId: string; toolName: string }, context: StreamingContext @@ -579,15 +585,19 @@ async function handleCallPhase( existing.activityDescription ??= activityDescription } /** Persist visible labels even when the introducing discovery call is omitted from the transcript. */ - const suppliedActivity = readToolActivity(args) ?? readToolActivity(existing?.params) + const argsActivity = readToolActivity(args) + const suppliedActivity = argsActivity ?? toolCallActivity(existing) const activity = suppliedActivity?.completedTitle ? suppliedActivity : suppliedActivity && Array.from(context.toolCalls.values()) .filter((tool) => tool.agentId === agentId && tool.parentToolCallId === parentToolCallId) - .map((tool) => readToolActivity(tool.params)) + .map(toolCallActivity) .find((candidate) => candidate?.id === suppliedActivity.id && candidate.completedTitle) - const displayArgs = activity ? { ...args, activity } : args + // Only a label the frame's own arguments carry is completed in place. An inherited one stays + // off the arguments, which execute as given (an MCP schema refuses keys it never declared). + const displayArgs = activity && argsActivity ? { ...args, activity } : args + if (existing && activity && !argsActivity) existing.activity = activity const isSubagent = scope === 'subagent' const ui = getToolCallUI(data) diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index 7ee453ecb4a..95705a23efc 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -3,7 +3,7 @@ import { type MothershipStreamV1CompletionStatus, MothershipStreamV1ToolOutcome, } from '@/lib/mothership/generated/mothership-stream-v1' -import type { StreamActivityReceipt } from '@/lib/mothership/generated/protocol' +import type { StreamActivityReceipt, ToolActivity } from '@/lib/mothership/generated/protocol' import type { RequestTraceV1Span } from '@/lib/mothership/generated/request-trace-v1' import type { ProviderToolCallIdentity } from '@/lib/mothership/request/go/tool-call-identity' import type { StreamEvent } from '@/lib/mothership/request/session' @@ -42,6 +42,12 @@ export interface ToolCallState { activityDescription?: string /** Model-authored activity text for a gateway-resolved integration call. */ integrationDescription?: string + /** + * Display grouping the call inherited rather than carried in its own arguments (a gateway's + * grouping on its resolved operation). Kept out of `params`, which execute as given; it is + * merged back only into the persisted arguments, where history reads it. + */ + activity?: ToolActivity /** Accumulated partial JSON of the arguments while the model streams them. */ streamingArgs?: string params?: Record