Skip to content

Commit fc102fa

Browse files
committed
feat(main-agent): superagent into main agent
1 parent 0672667 commit fc102fa

18 files changed

Lines changed: 404 additions & 29 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,30 @@ describe('ToolCallItem', () => {
4949
const markup = renderToStaticMarkup(
5050
<ToolCallItem
5151
toolName='gmail_read_v2'
52-
displayTitle='Gmail: Searching for invoice emails'
52+
displayTitle='Searching for invoice emails'
5353
status='executing'
5454
/>
5555
)
5656

5757
expect(markup).toContain('<svg')
58-
expect(markup).toContain('Gmail: Searching for invoice emails')
58+
expect(markup).toContain('Searching for invoice emails')
59+
})
60+
61+
it('renders the integration icon from a provisional gateway toolId', () => {
62+
vi.mocked(getBlockByToolName).mockReturnValueOnce({
63+
name: 'Gmail',
64+
icon: (props: SVGProps<SVGSVGElement>) => <svg {...props} data-testid='gmail-icon' />,
65+
} as ReturnType<typeof getBlockByToolName>)
66+
const markup = renderToStaticMarkup(
67+
<ToolCallItem
68+
toolName='call_integration_tool'
69+
displayTitle='Read recent emails'
70+
status='executing'
71+
streamingArgs='{"toolId":"gmail_read_v2","description":"Read recent emails"'
72+
/>
73+
)
74+
75+
expect(markup).toContain('<svg')
76+
expect(markup).toContain('Read recent emails')
5977
})
6078
})

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { useMemo } from 'react'
22
import { ShimmerText } from '@/components/ui'
3-
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
3+
import {
4+
CallIntegrationTool,
5+
Read as ReadTool,
6+
WorkspaceFile,
7+
} from '@/lib/copilot/generated/tool-catalog-v1'
48
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
9+
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
510
import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display'
611
import { getBareIconStyle } from '@/blocks/icon-color'
712
import { getBlockByToolName } from '@/blocks/registry'
@@ -53,6 +58,15 @@ export function ToolCallItem({
5358
return typeof path === 'string' ? getReadTargetBlock(path) : undefined
5459
}, [toolName, params])
5560

61+
// Like read's VFS-target resolution above, the gateway uses its exact
62+
// discovered toolId only as a deterministic registry lookup. This renders
63+
// the real integration brand while Go validates/resolves the operation.
64+
const gatewayBlock = useMemo(() => {
65+
if (toolName !== CallIntegrationTool.id) return undefined
66+
const toolId = params?.toolId ?? extractStreamingStringArgument(streamingArgs, 'toolId')
67+
return typeof toolId === 'string' ? getBlockByToolName(toolId) : undefined
68+
}, [toolName, params, streamingArgs])
69+
5670
const liveWorkspaceFileTitle = useMemo(() => {
5771
if (toolName !== WorkspaceFile.id || !streamingArgs) return null
5872
const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/)
@@ -89,7 +103,7 @@ export function ToolCallItem({
89103
? (getToolCompletedTitle(liveTitle) ?? liveTitle)
90104
: liveTitle
91105

92-
const BlockIcon = (readBlock ?? getBlockByToolName(toolName))?.icon
106+
const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon
93107

94108
return (
95109
<div className='flex items-center gap-[6px] pl-6'>

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { dispatchStreamEvent } from './dispatch-stream-event'
2121
import { createStreamLoopContext, type StreamLoopContext } from './stream-context'
2222
import { makeStreamLoopDeps, ref } from './stream-test-helpers'
2323
import type { ToolNode } from './turn-model'
24+
import { contentBlocksToModel, modelToContentBlocks } from './turn-model-serialize'
2425

2526
let seq = 0
2627
function toolEnv(payload: Record<string, unknown>): PersistedStreamEventEnvelope {
@@ -124,3 +125,127 @@ describe('tool events (dispatch → model + side effects)', () => {
124125
expect(toolNode(ctx, 'wf-1').status).toBe('success')
125126
})
126127
})
128+
129+
describe('integration gateway (full wire sequence → published snapshot)', () => {
130+
const GATEWAY = 'call_integration_tool'
131+
const CALL_ID = 'ig-1'
132+
133+
const generating = () =>
134+
toolEnv({
135+
phase: 'call',
136+
executor: 'go',
137+
mode: 'sync',
138+
toolCallId: CALL_ID,
139+
toolName: GATEWAY,
140+
status: 'generating',
141+
})
142+
143+
const argsDelta = (argumentsDelta: string) =>
144+
toolEnv({
145+
phase: 'args_delta',
146+
executor: 'go',
147+
mode: 'sync',
148+
toolCallId: CALL_ID,
149+
toolName: GATEWAY,
150+
argumentsDelta,
151+
})
152+
153+
const gatewayFinalCall = () =>
154+
toolEnv({
155+
phase: 'call',
156+
executor: 'go',
157+
mode: 'sync',
158+
toolCallId: CALL_ID,
159+
toolName: GATEWAY,
160+
arguments: {
161+
toolId: 'gmail_read_v2',
162+
description: 'Read recent emails',
163+
arguments: { maxResults: 5 },
164+
},
165+
})
166+
167+
const resolvedOperationCall = () =>
168+
toolEnv({
169+
phase: 'call',
170+
executor: 'sim',
171+
mode: 'async',
172+
toolCallId: CALL_ID,
173+
toolName: 'gmail_read_v2',
174+
arguments: { maxResults: 5, credentialId: 'cred-1' },
175+
})
176+
177+
/** The exact toolCall snapshot the browser publishes for this row. */
178+
function publishedToolCall(ctx: StreamLoopContext) {
179+
const blocks = modelToContentBlocks(ctx.state.model)
180+
const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID)
181+
expect(block?.toolCall).toBeDefined()
182+
return block!.toolCall!
183+
}
184+
185+
it('brands the row from streamed args while generating, then rebinds to the resolved operation', () => {
186+
const ctx = createStreamLoopContext(makeStreamLoopDeps())
187+
188+
// Provisional frame: neutral label, never the humanized gateway name.
189+
dispatchStreamEvent(ctx, generating())
190+
expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration')
191+
192+
// toolId alone brands only the icon (row component); text stays neutral.
193+
dispatchStreamEvent(ctx, argsDelta('{"toolId":"gmail_read_v2",'))
194+
expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration')
195+
expect(publishedToolCall(ctx).streamingArgs).toContain('"toolId":"gmail_read_v2"')
196+
197+
// The model-authored activity phrase becomes the row text as it completes.
198+
dispatchStreamEvent(ctx, argsDelta('"description":"Read recent emails",'))
199+
expect(publishedToolCall(ctx).displayTitle).toBe('Read recent emails')
200+
201+
dispatchStreamEvent(ctx, argsDelta('"arguments":{"maxResults":5}}'))
202+
dispatchStreamEvent(ctx, gatewayFinalCall())
203+
expect(publishedToolCall(ctx)).toEqual(
204+
expect.objectContaining({
205+
name: GATEWAY,
206+
displayTitle: 'Read recent emails',
207+
})
208+
)
209+
210+
// Second authoritative frame (same call id): rebind to the exact operation.
211+
dispatchStreamEvent(ctx, resolvedOperationCall())
212+
const rebound = publishedToolCall(ctx)
213+
expect(rebound).toEqual(
214+
expect.objectContaining({
215+
name: 'gmail_read_v2',
216+
displayTitle: 'Read recent emails',
217+
integrationDescription: 'Read recent emails',
218+
params: { maxResults: 5, credentialId: 'cred-1' },
219+
})
220+
)
221+
expect(rebound.streamingArgs).toBeUndefined()
222+
223+
dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2'))
224+
expect(publishedToolCall(ctx)).toEqual(
225+
expect.objectContaining({
226+
name: 'gmail_read_v2',
227+
status: 'success',
228+
displayTitle: 'Read recent emails',
229+
})
230+
)
231+
})
232+
233+
it('keeps the rebound branding across a snapshot rebuild (reconnect round-trip)', () => {
234+
const ctx = createStreamLoopContext(makeStreamLoopDeps())
235+
dispatchStreamEvent(ctx, generating())
236+
dispatchStreamEvent(ctx, gatewayFinalCall())
237+
dispatchStreamEvent(ctx, resolvedOperationCall())
238+
dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2'))
239+
240+
const rebuilt = contentBlocksToModel(modelToContentBlocks(ctx.state.model))
241+
const blocks = modelToContentBlocks(rebuilt)
242+
const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID)
243+
expect(block?.toolCall).toEqual(
244+
expect.objectContaining({
245+
name: 'gmail_read_v2',
246+
status: 'success',
247+
displayTitle: 'Read recent emails',
248+
})
249+
)
250+
})
251+
})

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { isRecordLike } from '@sim/utils/object'
33
import {
4+
CallIntegrationTool,
45
CrawlWebsite,
56
CreateFile,
67
CreateWorkflow,
@@ -29,6 +30,7 @@ import {
2930
WorkspaceFileOperation,
3031
} from '@/lib/copilot/generated/tool-catalog-v1'
3132
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
33+
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
3234
import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display'
3335
import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
3436
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
@@ -216,6 +218,33 @@ function functionExecuteTitle(title: string | undefined): string {
216218
return title ?? 'Running code'
217219
}
218220

221+
/**
222+
* Row text for an integration-gateway tool call: the model-authored activity
223+
* `description`, readable the moment it completes in the still-streaming
224+
* argument buffer — the same pattern `read`/`workspace_file` use for their
225+
* streaming VFS targets. The trusted integration branding is the ICON, which
226+
* the row component derives deterministically from the streamed `toolId` (or
227+
* the rebound operation name) via Sim's block registry — the text carries no
228+
* integration name. After Go's authoritative frame rebinds the row to the
229+
* exact operation (e.g. `gmail_read_v2`), the preserved
230+
* `integrationDescription` keeps the same text. Returns undefined until a
231+
* description is readable (callers fall back to the neutral gateway label).
232+
*/
233+
export function resolveIntegrationToolDisplayTitle(tool: {
234+
name: string
235+
args?: Record<string, unknown>
236+
streamingArgs?: string
237+
integrationDescription?: string
238+
}): string | undefined {
239+
if (tool.name === CallIntegrationTool.id) {
240+
const description =
241+
stringParam(tool.args?.description) ??
242+
extractStreamingStringArgument(tool.streamingArgs, 'description')?.trim()
243+
return description || undefined
244+
}
245+
return tool.integrationDescription
246+
}
247+
219248
export function resolveToolDisplayTitle(name: string, args?: Record<string, unknown>): string {
220249
// Cases that enrich the title with live workspace/block names from the client
221250
// stores. Everything else is resolved by the shared name+args resolver, which

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
22
import {
3+
resolveIntegrationToolDisplayTitle,
34
resolveStreamingToolDisplayTitle,
45
resolveToolDisplayTitle,
56
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers'
@@ -38,10 +39,14 @@ function toolStatusToNode(status: ToolCallStatus): NodeStatus {
3839

3940
/**
4041
* Resolves a tool row's display title with the same precedence the live handler
41-
* used: the streaming-args title wins while args stream, then the arg-derived
42-
* title, then the explicit `ui.title`.
42+
* used: the integration gateway's model-authored activity description first
43+
* (live even mid-argument-stream; the integration brand is the row icon), then
44+
* the streaming-args title while args stream, then the arg-derived title, then
45+
* the explicit `ui.title`.
4346
*/
4447
function toolDisplayTitle(node: ToolNode): string | undefined {
48+
const integrationTitle = resolveIntegrationToolDisplayTitle(node)
49+
if (integrationTitle) return integrationTitle
4550
const streamingTitle = node.streamingArgs
4651
? resolveStreamingToolDisplayTitle(node.name, node.streamingArgs)
4752
: undefined
@@ -126,6 +131,9 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] {
126131
name: node.name,
127132
status: nodeToToolStatus(node.status),
128133
...(displayTitle ? { displayTitle } : {}),
134+
...(node.integrationDescription
135+
? { integrationDescription: node.integrationDescription }
136+
: {}),
129137
...(node.args ? { params: node.args } : {}),
130138
...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}),
131139
...(node.result
@@ -292,6 +300,11 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
292300
arguments: tc.params,
293301
// Preserve a server-provided title that isn't derivable from args.
294302
...(tc.displayTitle ? { ui: { title: tc.displayTitle } } : {}),
303+
// Rebound gateway rows keep their model-authored activity phrase
304+
// across a snapshot rebuild (the resolved args no longer carry it).
305+
...(tc.integrationDescription
306+
? { integrationDescription: tc.integrationDescription }
307+
: {}),
295308
},
296309
scopeFor(block),
297310
block.timestamp

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
MothershipStreamV1SpanPayloadKind,
99
MothershipStreamV1ToolPhase,
1010
} from '@/lib/copilot/generated/mothership-stream-v1'
11+
import { CallIntegrationTool } from '@/lib/copilot/generated/tool-catalog-v1'
1112
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
13+
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
1214
import { CONTEXT_COMPACTION_DISPLAY_TITLE } from '@/lib/copilot/tools/tool-display'
1315

1416
/**
@@ -56,6 +58,13 @@ export interface ToolNode extends NodeBase {
5658
args?: Record<string, unknown>
5759
streamingArgs?: string
5860
uiTitle?: string
61+
/**
62+
* Model-authored activity phrase for a gateway-resolved integration call
63+
* (e.g. "Reading recent emails"). Captured when the authoritative resolved
64+
* frame rebinds the node's name to the exact operation, because the resolved
65+
* args no longer carry the gateway's `description` field.
66+
*/
67+
integrationDescription?: string
5968
/** Per-call `ui.hidden` flag — the node is tracked for side effects but not rendered. */
6069
hidden?: boolean
6170
result?: { success: boolean; output?: unknown; error?: string }
@@ -181,6 +190,26 @@ function asString(value: unknown): string | undefined {
181190
return typeof value === 'string' ? value : undefined
182191
}
183192

193+
/**
194+
* The integration gateway intentionally emits a second authoritative call frame
195+
* under the SAME provider call id once Go resolves the exact server-owned
196+
* operation (call_integration_tool -> e.g. gmail_read_v2). Rebind the node to
197+
* that operation so the row brands from the real integration (name -> block
198+
* registry) and keep only the model-authored `description` for presentation —
199+
* the caller then replaces args with the resolved operation args, which no
200+
* longer carry the gateway envelope fields.
201+
*/
202+
function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void {
203+
if (node.name !== CallIntegrationTool.id) return
204+
if (!toolName || toolName === CallIntegrationTool.id) return
205+
const description =
206+
asString(node.args?.description)?.trim() ||
207+
extractStreamingStringArgument(node.streamingArgs, 'description')?.trim()
208+
if (description) node.integrationDescription = description
209+
node.name = toolName
210+
node.streamingArgs = undefined
211+
}
212+
184213
/**
185214
* Reads a wire event payload as a generic record. The payload is a wide
186215
* discriminated union; the reducer accesses fields uniformly, so this narrows
@@ -474,7 +503,13 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
474503
seq,
475504
tsMs
476505
)
506+
rebindResolvedIntegrationCall(node, toolName)
477507
if (isRecord(payload.arguments)) node.args = payload.arguments
508+
// Only the snapshot-replay path (contentBlocksToModel) carries this
509+
// field — the live wire never does; it restores the rebound gateway
510+
// description across a preserve-state rebuild.
511+
const restoredDescription = asString(payload.integrationDescription)
512+
if (restoredDescription) node.integrationDescription = restoredDescription
478513
// Tool-call titles are derived from the tool name (+args) at serialize
479514
// time; the stream only carries behavioral flags now.
480515
const ui = isRecord(payload.ui) ? payload.ui : undefined

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export interface ToolCallInfo {
7373
name: string
7474
status: ToolCallStatus
7575
displayTitle?: string
76+
/** Model-authored activity phrase for a gateway-resolved integration call. */
77+
integrationDescription?: string
7678
params?: Record<string, unknown>
7779
calledBy?: string
7880
result?: ToolCallResult

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export const CallIntegrationTool: ToolCatalogEntry = {
268268
},
269269
description: {
270270
description:
271-
'Short present-progressive UI phrase describing this invocation, without the integration name (for example "Searching for invoice emails").',
271+
'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").',
272272
type: 'string',
273273
},
274274
toolId: { description: 'Exact toolId returned by search_integration_tools.', type: 'string' },

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
5151
},
5252
description: {
5353
description:
54-
'Short present-progressive UI phrase describing this invocation, without the integration name (for example "Searching for invoice emails").',
54+
'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").',
5555
type: 'string',
5656
},
5757
toolId: {

0 commit comments

Comments
 (0)