diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 6d841febfb..c481523e9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -21,8 +21,6 @@ export interface NestedAgentGroup { id: string agentName: string agentLabel: string - /** The agent's latest tag — the collapsed row's live status. */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -36,8 +34,6 @@ export type AgentGroupItem = interface AgentGroupProps { agentName: string agentLabel: string - /** The agent's latest tag — shown inline after the label. */ - intent?: string items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean @@ -111,7 +107,6 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { export function AgentGroup({ agentName, agentLabel, - intent, items, isDelegating = false, isStreaming = false, @@ -119,10 +114,30 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // The status line is the agent's own narration — no tool-title - // fallback: the task-message protocol reminder makes every agent open with - // an intent tag, so a bare label means the run has not produced one yet. - const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel + // Collapsed status line: the latest tool call, always in its RUNNING + // phrasing — it never flips to the completed rewrite (that lives in the + // expanded log). With parallel tools, the most recently started + // still-running one wins, with a +N for its running siblings; between + // rounds the last tool's title stays frozen; a closed lane shows the bare + // name. + const status = (() => { + if (!isLaneOpen) return undefined + let running: string | undefined + let runningCount = 0 + let lastAny: string | undefined + for (const it of items) { + if (it.type !== 'tool') continue + const title = it.data.displayTitle || String(it.data.toolName ?? '') + lastAny = title + if (it.data.status === ToolCallStatus.executing) { + running = title + runningCount += 1 + } + } + if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running + return lastAny + })() + const headerText = status ? `${agentLabel} — ${status}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -220,7 +235,6 @@ export function AgentGroup({ tag (parsed upstream from its text). */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -224,37 +222,12 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { * Streamed chunks and resume legs are concatenated verbatim, so a token split * like `v2.` + `1` is never mutated. */ -const INTENT_TAG_RE = /([\s\S]*?)<\/intent>\n?/g - -/** - * Extracts complete tags from a group's accumulated text: the last - * tag becomes the group's live status line and every complete tag is removed - * from the rendered prose. Runs on the accumulated buffer each append, so a - * tag split across streamed chunks is picked up once its close arrives — - * covering the span path, the legacy block path, and persisted reloads alike. - */ -function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void { - let lastIntent: string | undefined - const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => { - const intent = inner.trim() - if (intent) lastIntent = intent - return '' - }) - if (lastIntent !== undefined) { - item.content = stripped - group.intent = lastIntent - } -} - function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { lastItem.content += content - extractGroupIntents(group, lastItem) } else { - const item = { type: 'text' as const, content } - group.items.push(item) - extractGroupIntents(group, item) + group.items.push({ type: 'text', content }) } } @@ -408,7 +381,6 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -653,7 +625,6 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) @@ -985,7 +956,6 @@ function MessageContentInner({ key={segment.id} agentName={segment.agentName} agentLabel={segment.agentLabel} - intent={segment.intent} items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 4a61a120c4..6088ee0419 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -168,7 +168,6 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { type: 'subagent', content: node.agentId, ...(node.displayName ? { subagentName: node.displayName } : {}), - ...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -272,7 +271,6 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { data: { ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), }, }, scopeFor(block), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 0af21ac7e2..85c0b420e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -260,46 +260,6 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').displayName).toBe('Pricing research') }) - it('parses intent tags out of subagent text into the agent status', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'Drafting chapter outline\nStarting on the outline now.'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - expect(text && text.kind === 'text' ? text.text : '').not.toContain('') - expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline') - }) - - it('handles an intent tag split across deltas and takes the latest tag', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'ok. Writing first chap'), - textEv(4, 'tertext after. Reviewing draft'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - const rendered = text && text.kind === 'text' ? text.text : '' - expect(rendered).toContain('ok. ') - expect(rendered).toContain('text after. ') - expect(rendered).not.toContain('intent>') - }) - it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 820b41884e..d016e22c9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -87,10 +87,6 @@ export interface AgentNode extends NodeBase { triggerToolCallId?: string /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ displayName?: string - /** The agent's latest tag — the collapsed card's live status line. */ - currentIntent?: string - /** Streaming carry for an intent tag split across text deltas (never serialized). */ - intentCarry?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -301,57 +297,6 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void { closeOpenText(model, spanId, 'thinking', atMs) } -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -/** Length of the longest buf suffix that could still grow into `token`. */ -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams a subagent's assistant text through the protocol: complete - * tags update the owning agent's currentIntent and are removed from the prose; - * a tag split across deltas is carried until its close arrives. The returned - * string is what the transcript should render. - */ -function filterIntentText(owner: AgentNode, incoming: string): string { - let buf = (owner.intentCarry ?? '') + incoming - owner.intentCarry = '' - let out = '' - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) owner.intentCarry = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - owner.intentCarry = rest - } - break - } - const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (intent) owner.currentIntent = intent - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return out -} - function appendText( model: TurnModel, spanId: string, @@ -517,15 +462,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.text: { const payload = envelope.payload ensureSubagentLane(model, spanId, scope, seq, tsMs) - let text = payload.text - if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') { - const ownerId = model.agentBySpanId.get(spanId) - const owner = ownerId ? model.nodes.get(ownerId) : undefined - if (owner && owner.kind === 'agent') { - text = filterIntentText(owner, text) - } - } - appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs) + appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -629,7 +566,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const displayName = asString(data?.name) - const restoredIntent = asString(data?.intent) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -649,7 +585,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId if (displayName) existing.displayName = displayName - if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -672,7 +607,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), ...(displayName ? { displayName } : {}), - ...(restoredIntent ? { currentIntent: restoredIntent } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 2f0124bacf..eedb402ba8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -116,8 +116,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string - /** The agent's latest tag at serialization time — the collapsed card's status line. */ - subagentIntent?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 91ce490657..28a348a583 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -96,7 +96,6 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi type: ContentBlockType.subagent, content: block.content, ...(block.name ? { subagentName: block.name } : {}), - ...(block.intent ? { subagentIntent: block.intent } : {}), } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 9e8c9ba26e..c57e49b5a8 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -55,8 +55,6 @@ export interface PersistedContentBlock { content?: string /** Orchestrator-chosen display name on a subagent start block. */ name?: string - /** The agent's latest tag at persistence time. */ - intent?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -250,7 +248,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } case 'subagent_text': return { @@ -445,8 +442,6 @@ interface RawBlock { /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ name?: string subagentName?: string - intent?: string - subagentIntent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -515,7 +510,6 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { } if (block.agent) result.agent = block.agent if (block.name) result.name = block.name - if (block.intent) result.intent = block.intent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -598,7 +592,6 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } } diff --git a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts deleted file mode 100644 index d4e4abc430..0000000000 --- a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { handleTextEvent } from '@/lib/copilot/request/handlers/text' -import type { StreamingContext } from '@/lib/copilot/request/types' - -function laneTextEvent(text: string) { - return { - type: 'text', - payload: { channel: 'assistant', text }, - scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' }, - } as never -} - -function makeContext(): StreamingContext { - return { - contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }], - subAgentContent: {}, - subagentThinkingBlocks: new Map(), - isInThinkingBlock: false, - } as unknown as StreamingContext -} - -describe('subagent intent extraction (server relay)', () => { - it('strips a split tag and stamps the lane block intent', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler(laneTextEvent('Drafting outline\nStarting now.'), - ctx, - {} as never, - {} as never - ) - - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Drafting outline') - const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text') - expect(text?.content).toBe('Starting now.') - expect(ctx.subAgentContent['tc-1']).toBe('Starting now.') - }) - - it('takes the latest tag and keeps surrounding prose', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler( - laneTextEvent('aOnebTwoc'), - ctx, - {} as never, - {} as never - ) - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Two') - expect(ctx.subAgentContent['tc-1']).toBe('abc') - }) -}) diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 9aaaae2094..8f110a82b2 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -1,5 +1,4 @@ import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, @@ -9,72 +8,6 @@ import { getScopedSpanIdentity, } from './types' -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams one subagent lane's text through the protocol: complete - * tags are removed from the persisted prose and the latest one is returned; - * a tag split across chunks is carried (per lane) until its close arrives. - */ -function filterLaneIntent( - context: StreamingContext, - laneId: string, - incoming: string -): { text: string; intent?: string } { - const carries = (context.subagentIntentCarry ??= {}) - let buf = (carries[laneId] ?? '') + incoming - carries[laneId] = '' - let out = '' - let intent: string | undefined - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) carries[laneId] = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - carries[laneId] = rest - } - break - } - const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (inner) intent = inner - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return intent !== undefined ? { text: out, intent } : { text: out } -} - -/** Stamps the latest intent onto the lane's open `subagent` start block. */ -function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void { - for (let i = context.contentBlocks.length - 1; i >= 0; i--) { - const b = context.contentBlocks[i] - if (b.type === 'subagent' && b.parentToolCallId === laneId) { - b.subagentIntent = intent - return - } - } -} - export function handleTextEvent(scope: ToolScope): StreamHandler { return (event, context) => { if (event.type !== 'text') { @@ -115,17 +48,11 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (context.isInThinkingBlock) { flushThinkingBlock(context) } - // Catch the lane's protocol server-side: the latest tag becomes - // the persisted subagent block's status and the stored prose is stripped, - // so every surface (live, persisted, replay) agrees on both. - const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk) - if (intent) stampLaneIntent(context, parentToolCallId, intent) - if (!cleanChunk) return context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + cleanChunk + (context.subAgentContent[parentToolCallId] || '') + chunk addContentBlock(context, { type: 'subagent_text', - content: cleanChunk, + content: chunk, parentToolCallId, ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 3983b6f82c..35127f3edf 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -82,8 +82,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block. */ subagentName?: string - /** The agent's latest tag. */ - subagentIntent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run @@ -150,8 +148,6 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map - /** Per-lane carry for an tag split across streamed chunks. */ - subagentIntentCarry?: Record isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record