Skip to content

Commit 336480e

Browse files
committed
fix(scope): scope subagent tools
1 parent 7d0c4a9 commit 336480e

4 files changed

Lines changed: 159 additions & 6 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,33 @@ describe('turn-terminal propagation', () => {
522522
expect(tool(m, 'tc-1').status).toBe('error')
523523
})
524524
})
525+
526+
describe('reduceEvent — span-start owner reconciliation', () => {
527+
it('corrects a nonempty mismatched provisional lane owner from the authoritative start', () => {
528+
const model = createTurnModel()
529+
// A content event races ahead of the span start; its scope names the
530+
// FORWARDING caller (superagent), not the lane's real owner.
531+
reduceEvent(
532+
model,
533+
envelope(
534+
1,
535+
'text',
536+
{ channel: 'assistant', text: 'early chunk' },
537+
{ lane: 'subagent', spanId: 'S1', agentId: 'superagent', parentToolCallId: 'd1' }
538+
)
539+
)
540+
reduceEvent(
541+
model,
542+
envelope(
543+
2,
544+
'span',
545+
{ kind: 'subagent', event: 'start', agent: 'workflow', data: { tool_call_id: 'd1' } },
546+
{ lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' }
547+
)
548+
)
549+
const laneId = model.agentBySpanId.get('S1')
550+
const lane = laneId ? model.nodes.get(laneId) : undefined
551+
if (!lane || lane.kind !== 'agent') throw new Error('expected agent lane for S1')
552+
expect((lane as AgentNode).agentId).toBe('workflow')
553+
})
554+
})

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -521,8 +521,11 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
521521
// that raced ahead of this start. That event's scope may omit agentId
522522
// (contract-optional) while only this start carries payload.agent —
523523
// an empty agentId makes the downstream parsers drop the whole lane.
524-
// Reconcile instead of ignoring the start.
525-
if (!existing.agentId && agentId) existing.agentId = agentId
524+
// Reconcile instead of ignoring the start — and overwrite a NONEMPTY
525+
// mismatched provisional owner too: a racing content event's
526+
// scope.agentId can name the forwarding caller (e.g. superagent),
527+
// while this start's payload.agent is the authoritative lane owner.
528+
if (agentId && existing.agentId !== agentId) existing.agentId = agentId
526529
if (!existing.triggerToolCallId && triggerToolCallId) {
527530
existing.triggerToolCallId = triggerToolCallId
528531
}

apps/sim/lib/copilot/chat/effective-transcript.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,96 @@ describe('isLiveAssistantMessageId', () => {
384384
expect(isLiveAssistantMessageId('')).toBe(false)
385385
})
386386
})
387+
388+
describe('tool ownership is call-frame authoritative', () => {
389+
const toolEvent = (
390+
seq: number,
391+
phase: 'call' | 'result',
392+
toolCallId: string,
393+
scope?: Record<string, unknown>
394+
): StreamBatchEvent =>
395+
toBatchEvent(seq, {
396+
v: 1,
397+
seq,
398+
ts: '2026-04-15T12:00:01.000Z',
399+
type: MothershipStreamV1EventType.tool,
400+
stream: { streamId: 'stream-1' },
401+
payload:
402+
phase === 'call'
403+
? { phase: 'call', toolCallId, toolName: 'read', arguments: { path: 'a.md' } }
404+
: { phase: 'result', toolCallId, toolName: 'read', success: true, output: 'ok' },
405+
...(scope ? { scope } : {}),
406+
// double-cast-allowed: synthetic test envelope; the reducer reads only the fields set here
407+
} as unknown as StreamBatchEvent['event'])
408+
409+
const superagentScope = {
410+
lane: 'subagent',
411+
agentId: 'superagent',
412+
parentToolCallId: 'dispatch-1',
413+
spanId: 'S1',
414+
}
415+
416+
const ownership = (result: ReturnType<typeof buildEffectiveChatTranscript>) => {
417+
const blocks = (result[1].contentBlocks ?? []) as Array<Record<string, unknown>>
418+
const tool = blocks.find((b) => b.type === MothershipStreamV1EventType.tool)
419+
const tc = tool?.toolCall as Record<string, unknown> | undefined
420+
return { calledBy: tc?.calledBy, parentToolCallId: tool?.parentToolCallId, spanId: tool?.spanId }
421+
}
422+
423+
it('an unscoped main call clears provisional subagent attribution', () => {
424+
// The observed dev bug: a mis-scoped replayed result seeded the main
425+
// read under Superagent, and nothing could ever move it back.
426+
const result = buildEffectiveChatTranscript({
427+
messages: [buildUserMessage('stream-1', 'Hello')],
428+
activeStreamId: 'stream-1',
429+
streamSnapshot: {
430+
events: [
431+
toolEvent(1, 'result', 'fc_1', superagentScope),
432+
toolEvent(2, 'call', 'fc_1'),
433+
],
434+
previewSessions: [],
435+
status: 'active',
436+
},
437+
})
438+
const own = ownership(result)
439+
expect(own.calledBy).toBeUndefined()
440+
expect(own.parentToolCallId).toBeUndefined()
441+
expect(own.spanId).toBeUndefined()
442+
})
443+
444+
it('a later mis-scoped result cannot re-parent a settled main tool', () => {
445+
const result = buildEffectiveChatTranscript({
446+
messages: [buildUserMessage('stream-1', 'Hello')],
447+
activeStreamId: 'stream-1',
448+
streamSnapshot: {
449+
events: [
450+
toolEvent(1, 'call', 'fc_1'),
451+
toolEvent(2, 'result', 'fc_1', superagentScope),
452+
],
453+
previewSessions: [],
454+
status: 'active',
455+
},
456+
})
457+
const own = ownership(result)
458+
expect(own.calledBy).toBeUndefined()
459+
expect(own.parentToolCallId).toBeUndefined()
460+
})
461+
462+
it('a genuinely scoped subagent call keeps its ownership', () => {
463+
const result = buildEffectiveChatTranscript({
464+
messages: [buildUserMessage('stream-1', 'Hello')],
465+
activeStreamId: 'stream-1',
466+
streamSnapshot: {
467+
events: [
468+
toolEvent(1, 'call', 'fc_2', superagentScope),
469+
toolEvent(2, 'result', 'fc_2'),
470+
],
471+
previewSessions: [],
472+
status: 'active',
473+
},
474+
})
475+
const own = ownership(result)
476+
expect(own.calledBy).toBe('superagent')
477+
expect(own.parentToolCallId).toBe('dispatch-1')
478+
})
479+
})

apps/sim/lib/copilot/chat/effective-transcript.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,17 @@ function buildLiveAssistantMessage(params: {
149149
return scopedParent
150150
}
151151

152+
// Tool ownership (calledBy / parent / span identity) is CALL-FRAME
153+
// authoritative: once a call frame for a tool id has been reduced, later
154+
// scoped results or replayed duplicates must not re-parent the tool. Before
155+
// a call frame arrives, ownership stays provisional (result-first replay
156+
// arrival is legal) and the call frame settles it — including CLEARING
157+
// stale subagent attribution when the call is main-lane (unscoped). Without
158+
// the clear, one mis-scoped replayed event pinned main tools under a
159+
// subagent (observed: Sim's reads rendered under Superagent) with no later
160+
// event able to correct it.
161+
const toolOwnershipSettled = new Set<string>()
162+
152163
const ensureToolBlock = (input: {
153164
toolCallId: string
154165
toolName: string
@@ -160,7 +171,10 @@ function buildLiveAssistantMessage(params: {
160171
params?: Record<string, unknown>
161172
result?: { success: boolean; output?: unknown; error?: string }
162173
state?: string
174+
isCallFrame?: boolean
163175
}): RawPersistedBlock => {
176+
const ownershipWritable = input.isCallFrame === true || !toolOwnershipSettled.has(input.toolCallId)
177+
if (input.isCallFrame) toolOwnershipSettled.add(input.toolCallId)
164178
const existingIndex = toolIndexById.get(input.toolCallId)
165179
if (existingIndex !== undefined) {
166180
const existing = blocks[existingIndex]
@@ -172,7 +186,7 @@ function buildLiveAssistantMessage(params: {
172186
state:
173187
input.state ??
174188
(typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'),
175-
...(input.calledBy ? { calledBy: input.calledBy } : {}),
189+
...(ownershipWritable && input.calledBy ? { calledBy: input.calledBy } : {}),
176190
...(input.params ? { params: input.params } : {}),
177191
...(input.result ? { result: input.result } : {}),
178192
...(input.displayTitle
@@ -185,9 +199,21 @@ function buildLiveAssistantMessage(params: {
185199
? { display: existingToolCall.display }
186200
: {}),
187201
}
188-
if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId
189-
if (input.spanId) existing.spanId = input.spanId
190-
if (input.parentSpanId) existing.parentSpanId = input.parentSpanId
202+
if (ownershipWritable) {
203+
if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId
204+
if (input.spanId) existing.spanId = input.spanId
205+
if (input.parentSpanId) existing.parentSpanId = input.parentSpanId
206+
if (input.isCallFrame && !input.calledBy) {
207+
// Authoritative main-lane call: clear any provisionally-seeded
208+
// subagent attribution so the tool renders under Sim, not the
209+
// forwarding caller.
210+
const tc = asPayloadRecord(existing.toolCall)
211+
if (tc) delete tc.calledBy
212+
delete existing.parentToolCallId
213+
delete existing.spanId
214+
delete existing.parentSpanId
215+
}
216+
}
191217
return existing
192218
}
193219

@@ -332,6 +358,7 @@ function buildLiveAssistantMessage(params: {
332358
),
333359
params: isRecordLike(payload.arguments) ? payload.arguments : undefined,
334360
state: typeof payload.status === 'string' ? payload.status : 'executing',
361+
isCallFrame: payload.phase === MothershipStreamV1ToolPhase.call,
335362
})
336363
continue
337364
}

0 commit comments

Comments
 (0)