mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
v0.6.59: gpt 5.5, security hardening, parallel subagents rendering
This commit is contained in:
@@ -32,7 +32,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const returnUrl = request.nextUrl.searchParams.get('returnUrl')
|
||||
|
||||
if (!shopDomain) {
|
||||
const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : ''
|
||||
const safeReturnUrl =
|
||||
returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : ''
|
||||
const returnUrlJsLiteral = JSON.stringify(safeReturnUrl)
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -120,7 +122,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const returnUrl = '${returnUrlParam}';
|
||||
const returnUrl = ${returnUrlJsLiteral};
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
let shop = document.getElementById('shop').value.trim().toLowerCase();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { normalizeEmail } from '@/lib/invitations/core'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
|
||||
const logger = createLogger('CredentialSetInviteToken')
|
||||
@@ -111,6 +112,21 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
|
||||
}
|
||||
|
||||
if (invitation.email) {
|
||||
const sessionEmail = session.user.email
|
||||
if (!sessionEmail || normalizeEmail(sessionEmail) !== normalizeEmail(invitation.email)) {
|
||||
logger.warn('Rejected credential set invitation accept due to email mismatch', {
|
||||
invitationId: invitation.id,
|
||||
credentialSetId: invitation.credentialSetId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'This invitation was sent to a different email address' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(credentialSetMember)
|
||||
|
||||
@@ -156,32 +156,86 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
|
||||
*/
|
||||
function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
const segments: MessageSegment[] = []
|
||||
let group: AgentGroupSegment | null = null
|
||||
const pushGroup = (nextGroup: AgentGroupSegment, isOpen = false) => {
|
||||
segments.push({ ...nextGroup, isOpen })
|
||||
const groupsByKey = new Map<string, AgentGroupSegment>()
|
||||
let activeGroupKey: string | null = null
|
||||
|
||||
const groupKey = (name: string, parentToolCallId: string | undefined) =>
|
||||
parentToolCallId ? `${name}:${parentToolCallId}` : `${name}:legacy`
|
||||
|
||||
const resolveGroupKey = (name: string, parentToolCallId: string | undefined) => {
|
||||
if (parentToolCallId) return groupKey(name, parentToolCallId)
|
||||
if (activeGroupKey && groupsByKey.get(activeGroupKey)?.agentName === name) {
|
||||
return activeGroupKey
|
||||
}
|
||||
for (const [key, g] of groupsByKey) {
|
||||
if (g.agentName === name && g.isOpen) return key
|
||||
}
|
||||
return groupKey(name, undefined)
|
||||
}
|
||||
|
||||
const ensureGroup = (
|
||||
name: string,
|
||||
parentToolCallId: string | undefined
|
||||
): { group: AgentGroupSegment; created: boolean } => {
|
||||
const key = resolveGroupKey(name, parentToolCallId)
|
||||
const existing = groupsByKey.get(key)
|
||||
if (existing) return { group: existing, created: false }
|
||||
const group: AgentGroupSegment = {
|
||||
type: 'agent_group',
|
||||
id: `agent-${key}-${segments.length}`,
|
||||
agentName: name,
|
||||
agentLabel: resolveAgentLabel(name),
|
||||
items: [],
|
||||
isDelegating: false,
|
||||
isOpen: false,
|
||||
}
|
||||
segments.push(group)
|
||||
groupsByKey.set(key, group)
|
||||
return { group, created: true }
|
||||
}
|
||||
|
||||
const findGroupForSubagentChunk = (
|
||||
parentToolCallId: string | undefined
|
||||
): AgentGroupSegment | undefined => {
|
||||
if (parentToolCallId) {
|
||||
for (const [key, g] of groupsByKey) {
|
||||
if (key.endsWith(`:${parentToolCallId}`)) return g
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (activeGroupKey) return groupsByKey.get(activeGroupKey)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const flushLanes = () => {
|
||||
for (const g of groupsByKey.values()) {
|
||||
g.isOpen = false
|
||||
g.isDelegating = false
|
||||
}
|
||||
groupsByKey.clear()
|
||||
activeGroupKey = null
|
||||
}
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
|
||||
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
|
||||
if (!block.content || !group) continue
|
||||
group.isDelegating = false
|
||||
const lastItem = group.items[group.items.length - 1]
|
||||
if (!block.content) continue
|
||||
const g = findGroupForSubagentChunk(block.parentToolCallId)
|
||||
if (!g) continue
|
||||
g.isDelegating = false
|
||||
const lastItem = g.items[g.items.length - 1]
|
||||
if (lastItem?.type === 'text') {
|
||||
lastItem.content += block.content
|
||||
} else {
|
||||
group.items.push({ type: 'text', content: block.content })
|
||||
g.items.push({ type: 'text', content: block.content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'thinking') {
|
||||
if (!block.content?.trim()) continue
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
flushLanes()
|
||||
const last = segments[segments.length - 1]
|
||||
if (last?.type === 'thinking' && last.endedAt === undefined) {
|
||||
last.content += block.content
|
||||
@@ -201,21 +255,19 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
if (block.type === 'text') {
|
||||
if (!block.content) continue
|
||||
if (block.subagent) {
|
||||
if (group && group.agentName === block.subagent) {
|
||||
group.isDelegating = false
|
||||
const lastItem = group.items[group.items.length - 1]
|
||||
const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId))
|
||||
if (g) {
|
||||
g.isDelegating = false
|
||||
const lastItem = g.items[g.items.length - 1]
|
||||
if (lastItem?.type === 'text') {
|
||||
lastItem.content += block.content
|
||||
} else {
|
||||
group.items.push({ type: 'text', content: block.content })
|
||||
g.items.push({ type: 'text', content: block.content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
flushLanes()
|
||||
const last = segments[segments.length - 1]
|
||||
if (last?.type === 'text') {
|
||||
last.content += block.content
|
||||
@@ -228,34 +280,23 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
if (block.type === 'subagent') {
|
||||
if (!block.content) continue
|
||||
const key = block.content
|
||||
if (group && group.agentName === key) continue
|
||||
|
||||
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[key]
|
||||
let inheritedDelegation = false
|
||||
if (group && dispatchToolName) {
|
||||
const last: AgentGroupItem | undefined = group.items[group.items.length - 1]
|
||||
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
|
||||
inheritedDelegation = !isToolDone(last.data.status) && Boolean(last.data.streamingArgs)
|
||||
group.items.pop()
|
||||
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[key]
|
||||
if (dispatchToolName) {
|
||||
const mship = groupsByKey.get(groupKey('mothership', undefined))
|
||||
if (mship) {
|
||||
const last = mship.items[mship.items.length - 1]
|
||||
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
|
||||
inheritedDelegation = !isToolDone(last.data.status) && Boolean(last.data.streamingArgs)
|
||||
mship.items.pop()
|
||||
}
|
||||
}
|
||||
if (group.items.length > 0) {
|
||||
pushGroup(group)
|
||||
}
|
||||
group = null
|
||||
} else if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
|
||||
group = {
|
||||
type: 'agent_group',
|
||||
id: `agent-${key}-${i}`,
|
||||
agentName: key,
|
||||
agentLabel: resolveAgentLabel(key),
|
||||
items: [],
|
||||
isDelegating: inheritedDelegation,
|
||||
isOpen: false,
|
||||
}
|
||||
groupsByKey.delete(groupKey('mothership', undefined))
|
||||
const { group: g } = ensureGroup(key, block.parentToolCallId)
|
||||
if (inheritedDelegation) g.isDelegating = true
|
||||
g.isOpen = true
|
||||
activeGroupKey = resolveGroupKey(key, block.parentToolCallId)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -267,95 +308,75 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy
|
||||
|
||||
if (isDispatch) {
|
||||
if (!group || group.agentName !== tc.name) {
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
group = {
|
||||
type: 'agent_group',
|
||||
id: `agent-${tc.name}-${i}`,
|
||||
agentName: tc.name,
|
||||
agentLabel: resolveAgentLabel(tc.name),
|
||||
items: [],
|
||||
isDelegating: false,
|
||||
isOpen: false,
|
||||
}
|
||||
}
|
||||
group.isDelegating = isDelegatingTool(tc)
|
||||
groupsByKey.delete(groupKey('mothership', undefined))
|
||||
const { group: g } = ensureGroup(tc.name, tc.id)
|
||||
g.isDelegating = isDelegatingTool(tc)
|
||||
g.isOpen = g.isDelegating
|
||||
continue
|
||||
}
|
||||
|
||||
const tool = toToolData(tc)
|
||||
|
||||
if (tc.calledBy && group && group.agentName === tc.calledBy) {
|
||||
group.isDelegating = false
|
||||
group.items.push({ type: 'tool', data: tool })
|
||||
} else if (tc.calledBy) {
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
group = {
|
||||
type: 'agent_group',
|
||||
id: `agent-${tc.calledBy}-${i}`,
|
||||
agentName: tc.calledBy,
|
||||
agentLabel: resolveAgentLabel(tc.calledBy),
|
||||
items: [{ type: 'tool', data: tool }],
|
||||
isDelegating: false,
|
||||
isOpen: false,
|
||||
}
|
||||
if (tc.calledBy) {
|
||||
const { group: g, created } = ensureGroup(tc.calledBy, block.parentToolCallId)
|
||||
g.isDelegating = false
|
||||
if (created && block.parentToolCallId) g.isOpen = true
|
||||
g.items.push({ type: 'tool', data: tool })
|
||||
activeGroupKey = resolveGroupKey(tc.calledBy, block.parentToolCallId)
|
||||
} else {
|
||||
if (group && group.agentName === 'mothership') {
|
||||
group.items.push({ type: 'tool', data: tool })
|
||||
} else {
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
group = {
|
||||
type: 'agent_group',
|
||||
id: `agent-mothership-${i}`,
|
||||
agentName: 'mothership',
|
||||
agentLabel: 'Mothership',
|
||||
items: [{ type: 'tool', data: tool }],
|
||||
isDelegating: false,
|
||||
isOpen: false,
|
||||
}
|
||||
}
|
||||
const { group: g } = ensureGroup('mothership', undefined)
|
||||
g.items.push({ type: 'tool', data: tool })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'options') {
|
||||
if (!block.options?.length) continue
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
flushLanes()
|
||||
segments.push({ type: 'options', items: block.options })
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'subagent_end') {
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
if (block.parentToolCallId) {
|
||||
for (const [key, g] of groupsByKey) {
|
||||
if (key.endsWith(`:${block.parentToolCallId}`)) {
|
||||
g.isOpen = false
|
||||
g.isDelegating = false
|
||||
}
|
||||
}
|
||||
if (activeGroupKey?.endsWith(`:${block.parentToolCallId}`)) {
|
||||
activeGroupKey = null
|
||||
}
|
||||
} else {
|
||||
for (const [key, g] of groupsByKey) {
|
||||
if (key.endsWith(':legacy') && g.agentName !== 'mothership') {
|
||||
g.isOpen = false
|
||||
g.isDelegating = false
|
||||
}
|
||||
}
|
||||
if (activeGroupKey?.endsWith(':legacy')) {
|
||||
activeGroupKey = null
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'stopped') {
|
||||
if (group) {
|
||||
pushGroup(group)
|
||||
group = null
|
||||
}
|
||||
flushLanes()
|
||||
segments.push({ type: 'stopped' })
|
||||
}
|
||||
}
|
||||
|
||||
if (group) pushGroup(group, true)
|
||||
return segments
|
||||
const visibleSegments = segments.filter(
|
||||
(segment) =>
|
||||
segment.type !== 'agent_group' ||
|
||||
segment.items.length > 0 ||
|
||||
segment.isDelegating ||
|
||||
segment.isOpen
|
||||
)
|
||||
|
||||
return visibleSegments
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,12 +449,6 @@ export function MessageContent({
|
||||
isStreaming &&
|
||||
!hasTrailingContent &&
|
||||
(lastSegment.type === 'thinking' || hasSubagentEnded || allLastGroupToolsDone)
|
||||
const lastOpenSubagentGroupId = [...segments]
|
||||
.reverse()
|
||||
.find(
|
||||
(segment): segment is AgentGroupSegment =>
|
||||
segment.type === 'agent_group' && segment.agentName !== 'mothership' && segment.isOpen
|
||||
)?.id
|
||||
|
||||
return (
|
||||
<div className='space-y-[10px]'>
|
||||
@@ -488,8 +503,8 @@ export function MessageContent({
|
||||
items={segment.items}
|
||||
isDelegating={segment.isDelegating}
|
||||
isStreaming={isStreaming}
|
||||
autoCollapse={allToolsDone && hasFollowingText}
|
||||
defaultExpanded={segment.id === lastOpenSubagentGroupId}
|
||||
autoCollapse={!segment.isOpen && allToolsDone && hasFollowingText}
|
||||
defaultExpanded={segment.isOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -131,6 +131,7 @@ import type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
QueuedMessage,
|
||||
ToolCallInfo,
|
||||
} from '../types'
|
||||
import { ToolCallStatus } from '../types'
|
||||
|
||||
@@ -701,7 +702,9 @@ function parseStreamBatchResponse(value: unknown): StreamBatchResponse {
|
||||
|
||||
function toRawPersistedContentBlock(block: ContentBlock): Record<string, unknown> | null {
|
||||
const persisted = toRawPersistedContentBlockBody(block)
|
||||
return persisted ? withBlockTiming(persisted, block) : null
|
||||
if (!persisted) return null
|
||||
if (block.parentToolCallId) persisted.parentToolCallId = block.parentToolCallId
|
||||
return withBlockTiming(persisted, block)
|
||||
}
|
||||
|
||||
function toRawPersistedContentBlockBody(block: ContentBlock): Record<string, unknown> | null {
|
||||
@@ -1215,7 +1218,7 @@ export function useChat(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
assistantId: string,
|
||||
expectedGen?: number,
|
||||
options?: { preserveExistingState?: boolean }
|
||||
options?: { preserveExistingState?: boolean; suppressWorkflowToolStarts?: boolean }
|
||||
) => Promise<{ sawStreamError: boolean; sawComplete: boolean }>
|
||||
>(async () => ({ sawStreamError: false, sawComplete: false }))
|
||||
const attachToExistingStreamRef = useRef<
|
||||
@@ -1457,6 +1460,9 @@ export function useChat(
|
||||
if (handledClientWorkflowToolIdsRef.current.has(toolCallId)) {
|
||||
return
|
||||
}
|
||||
if (recoveringClientWorkflowToolIdsRef.current.has(toolCallId)) {
|
||||
return
|
||||
}
|
||||
handledClientWorkflowToolIdsRef.current.add(toolCallId)
|
||||
|
||||
ensureWorkflowToolResource(toolArgs)
|
||||
@@ -1467,41 +1473,41 @@ export function useChat(
|
||||
|
||||
const recoverPendingClientWorkflowTools = useCallback(
|
||||
async (nextMessages: ChatMessage[]) => {
|
||||
const pending: ToolCallInfo[] = []
|
||||
|
||||
for (const message of nextMessages) {
|
||||
for (const block of message.contentBlocks ?? []) {
|
||||
const toolCall = block.toolCall
|
||||
if (!toolCall || !isWorkflowToolName(toolCall.name)) {
|
||||
continue
|
||||
}
|
||||
if (toolCall.status !== 'executing') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!toolCall || !isWorkflowToolName(toolCall.name)) continue
|
||||
if (toolCall.status !== 'executing') continue
|
||||
if (
|
||||
handledClientWorkflowToolIdsRef.current.has(toolCall.id) ||
|
||||
recoveringClientWorkflowToolIdsRef.current.has(toolCall.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
recoveringClientWorkflowToolIdsRef.current.add(toolCall.id)
|
||||
pending.push(toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const toolArgs = toolCall.params ?? {}
|
||||
const targetWorkflowId = ensureWorkflowToolResource(toolArgs)
|
||||
for (const toolCall of pending) {
|
||||
try {
|
||||
const toolArgs = toolCall.params ?? {}
|
||||
const targetWorkflowId = ensureWorkflowToolResource(toolArgs)
|
||||
|
||||
if (targetWorkflowId) {
|
||||
const rebound = await bindRunToolToExecution(toolCall.id, targetWorkflowId)
|
||||
if (rebound) {
|
||||
handledClientWorkflowToolIdsRef.current.add(toolCall.id)
|
||||
continue
|
||||
}
|
||||
if (targetWorkflowId) {
|
||||
const rebound = await bindRunToolToExecution(toolCall.id, targetWorkflowId)
|
||||
if (rebound) {
|
||||
handledClientWorkflowToolIdsRef.current.add(toolCall.id)
|
||||
continue
|
||||
}
|
||||
|
||||
startClientWorkflowTool(toolCall.id, toolCall.name, toolArgs)
|
||||
} finally {
|
||||
recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
|
||||
}
|
||||
|
||||
recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
|
||||
startClientWorkflowTool(toolCall.id, toolCall.name, toolArgs)
|
||||
} finally {
|
||||
recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1701,7 +1707,7 @@ export function useChat(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
assistantId: string,
|
||||
expectedGen?: number,
|
||||
options?: { preserveExistingState?: boolean }
|
||||
options?: { preserveExistingState?: boolean; suppressWorkflowToolStarts?: boolean }
|
||||
) => {
|
||||
const decoder = new TextDecoder()
|
||||
streamReaderRef.current = reader
|
||||
@@ -1731,6 +1737,7 @@ export function useChat(
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
if (blocks[i].type === 'subagent' && blocks[i].content) {
|
||||
activeSubagent = blocks[i].content
|
||||
activeSubagentParentToolCallId = blocks[i].parentToolCallId
|
||||
break
|
||||
}
|
||||
if (blocks[i].type === 'subagent_end') {
|
||||
@@ -1760,23 +1767,45 @@ export function useChat(
|
||||
if (block && block.endedAt === undefined) block.endedAt = toEventMs(ts)
|
||||
}
|
||||
|
||||
const ensureTextBlock = (subagentName: string | undefined, ts?: string): ContentBlock => {
|
||||
const ensureTextBlock = (
|
||||
subagentName: string | undefined,
|
||||
parentToolCallId: string | undefined,
|
||||
ts?: string
|
||||
): ContentBlock => {
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.type === 'text' && last.subagent === subagentName) return last
|
||||
if (
|
||||
last?.type === 'text' &&
|
||||
last.subagent === subagentName &&
|
||||
last.parentToolCallId === parentToolCallId
|
||||
) {
|
||||
return last
|
||||
}
|
||||
stampBlockEnd(last, ts)
|
||||
const b: ContentBlock = { type: 'text', content: '', timestamp: toEventMs(ts) }
|
||||
if (subagentName) b.subagent = subagentName
|
||||
if (parentToolCallId) b.parentToolCallId = parentToolCallId
|
||||
blocks.push(b)
|
||||
return b
|
||||
}
|
||||
|
||||
const ensureThinkingBlock = (subagentName: string | undefined, ts?: string): ContentBlock => {
|
||||
const ensureThinkingBlock = (
|
||||
subagentName: string | undefined,
|
||||
parentToolCallId: string | undefined,
|
||||
ts?: string
|
||||
): ContentBlock => {
|
||||
const targetType = subagentName ? 'subagent_thinking' : 'thinking'
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.type === targetType && last.subagent === subagentName) return last
|
||||
if (
|
||||
last?.type === targetType &&
|
||||
last.subagent === subagentName &&
|
||||
last.parentToolCallId === parentToolCallId
|
||||
) {
|
||||
return last
|
||||
}
|
||||
stampBlockEnd(last, ts)
|
||||
const b: ContentBlock = { type: targetType, content: '', timestamp: toEventMs(ts) }
|
||||
if (subagentName) b.subagent = subagentName
|
||||
if (parentToolCallId) b.parentToolCallId = parentToolCallId
|
||||
blocks.push(b)
|
||||
return b
|
||||
}
|
||||
@@ -1793,9 +1822,27 @@ export function useChat(
|
||||
return activeSubagent
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (tag: string, subagentName?: string, ts?: string) => {
|
||||
const resolveParentForSubagentBlock = (
|
||||
subagent: string | undefined,
|
||||
scopedParent: string | undefined
|
||||
): string | undefined => {
|
||||
if (!subagent) return undefined
|
||||
if (scopedParent) return scopedParent
|
||||
if (activeSubagent === subagent) return activeSubagentParentToolCallId
|
||||
for (const [parent, name] of subagentByParentToolCallId) {
|
||||
if (name === subagent) return parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (
|
||||
tag: string,
|
||||
subagentName?: string,
|
||||
parentToolCallId?: string,
|
||||
ts?: string
|
||||
) => {
|
||||
if (runningText.includes(tag)) return
|
||||
const tb = ensureTextBlock(subagentName, ts)
|
||||
const tb = ensureTextBlock(subagentName, parentToolCallId, ts)
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
tb.content = `${tb.content ?? ''}${prefix}${tag}`
|
||||
runningText += `${prefix}${tag}`
|
||||
@@ -2008,7 +2055,11 @@ export function useChat(
|
||||
if (chunk) {
|
||||
const eventTs = typeof parsed.ts === 'string' ? parsed.ts : undefined
|
||||
if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) {
|
||||
const tb = ensureThinkingBlock(scopedSubagent, eventTs)
|
||||
const scopedParentForBlock = resolveParentForSubagentBlock(
|
||||
scopedSubagent,
|
||||
scopedParentToolCallId
|
||||
)
|
||||
const tb = ensureThinkingBlock(scopedSubagent, scopedParentForBlock, eventTs)
|
||||
tb.content = (tb.content ?? '') + chunk
|
||||
flushText()
|
||||
break
|
||||
@@ -2019,7 +2070,11 @@ export function useChat(
|
||||
lastContentSource !== contentSource &&
|
||||
runningText.length > 0 &&
|
||||
!runningText.endsWith('\n')
|
||||
const tb = ensureTextBlock(scopedSubagent, eventTs)
|
||||
const scopedParentForBlock = resolveParentForSubagentBlock(
|
||||
scopedSubagent,
|
||||
scopedParentToolCallId
|
||||
)
|
||||
const tb = ensureTextBlock(scopedSubagent, scopedParentForBlock, eventTs)
|
||||
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
|
||||
tb.content = (tb.content ?? '') + normalizedChunk
|
||||
runningText += normalizedChunk
|
||||
@@ -2355,9 +2410,17 @@ export function useChat(
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolMap.has(id)) {
|
||||
const existingToolCall = toolMap.has(id)
|
||||
? blocks[toolMap.get(id)!]?.toolCall
|
||||
: undefined
|
||||
const isNewToolCall = !existingToolCall
|
||||
if (isNewToolCall) {
|
||||
stampBlockEnd(blocks[blocks.length - 1])
|
||||
toolMap.set(id, blocks.length)
|
||||
const parentToolCallIdForBlock = resolveParentForSubagentBlock(
|
||||
scopedSubagent,
|
||||
scopedParentToolCallId
|
||||
)
|
||||
blocks.push({
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
@@ -2368,6 +2431,9 @@ export function useChat(
|
||||
params: args,
|
||||
calledBy: scopedSubagent,
|
||||
},
|
||||
...(parentToolCallIdForBlock
|
||||
? { parentToolCallId: parentToolCallIdForBlock }
|
||||
: {}),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (name === ReadTool.id || isResourceToolName(name)) {
|
||||
@@ -2385,7 +2451,14 @@ export function useChat(
|
||||
flush()
|
||||
|
||||
if (isWorkflowToolName(name) && !isPartial) {
|
||||
startClientWorkflowTool(id, name, args ?? {})
|
||||
const shouldStartWorkflowTool =
|
||||
!options?.suppressWorkflowToolStarts &&
|
||||
(isNewToolCall ||
|
||||
(existingToolCall?.status === ToolCallStatus.executing &&
|
||||
!existingToolCall.result))
|
||||
if (shouldStartWorkflowTool) {
|
||||
startClientWorkflowTool(id, name, args ?? {})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -2488,9 +2561,13 @@ export function useChat(
|
||||
break
|
||||
}
|
||||
const spanData = asPayloadRecord(payload.data)
|
||||
const parentToolCallId =
|
||||
scopedParentToolCallId ??
|
||||
(typeof spanData?.tool_call_id === 'string' ? spanData.tool_call_id : undefined)
|
||||
const parentToolCallIdFromData =
|
||||
typeof spanData?.tool_call_id === 'string'
|
||||
? spanData.tool_call_id
|
||||
: typeof spanData?.toolCallId === 'string'
|
||||
? spanData.toolCallId
|
||||
: undefined
|
||||
const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData
|
||||
const isPendingPause = spanData?.pending === true
|
||||
const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId
|
||||
if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
|
||||
@@ -2505,7 +2582,12 @@ export function useChat(
|
||||
activeSubagentParentToolCallId = parentToolCallId
|
||||
if (!isSameActiveSubagent) {
|
||||
stampBlockEnd(blocks[blocks.length - 1])
|
||||
blocks.push({ type: 'subagent', content: name, timestamp: Date.now() })
|
||||
blocks.push({
|
||||
type: 'subagent',
|
||||
content: name,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
if (name === FILE_SUBAGENT_ID && !isSameActiveSubagent) {
|
||||
applyPreviewSessionUpdate({
|
||||
@@ -2549,14 +2631,23 @@ export function useChat(
|
||||
if (name) {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const b = blocks[i]
|
||||
if (b.type === 'subagent' && b.content === name && b.endedAt === undefined) {
|
||||
if (
|
||||
b.type === 'subagent' &&
|
||||
b.content === name &&
|
||||
b.endedAt === undefined &&
|
||||
(!parentToolCallId || b.parentToolCallId === parentToolCallId)
|
||||
) {
|
||||
b.endedAt = endNow
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
stampBlockEnd(blocks[blocks.length - 1])
|
||||
blocks.push({ type: 'subagent_end', timestamp: endNow })
|
||||
blocks.push({
|
||||
type: 'subagent_end',
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
timestamp: endNow,
|
||||
})
|
||||
flush()
|
||||
}
|
||||
break
|
||||
@@ -2567,6 +2658,7 @@ export function useChat(
|
||||
appendInlineErrorTag(
|
||||
buildInlineErrorTag(parsed.payload),
|
||||
scopedSubagent,
|
||||
resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId),
|
||||
typeof parsed.ts === 'string' ? parsed.ts : undefined
|
||||
)
|
||||
break
|
||||
@@ -2671,6 +2763,7 @@ export function useChat(
|
||||
let latestCursor = afterCursor
|
||||
let seedEvents = opts.initialBatch?.events ?? []
|
||||
let streamStatus = opts.initialBatch?.status ?? 'unknown'
|
||||
let suppressSeedWorkflowStarts = seedEvents.length > 0
|
||||
|
||||
const isStaleReconnect = () =>
|
||||
streamGenRef.current !== expectedGen || abortControllerRef.current?.signal.aborted === true
|
||||
@@ -2689,11 +2782,15 @@ export function useChat(
|
||||
buildReplayStream(seedEvents).getReader(),
|
||||
assistantId,
|
||||
expectedGen,
|
||||
{ preserveExistingState: true }
|
||||
{
|
||||
preserveExistingState: true,
|
||||
suppressWorkflowToolStarts: suppressSeedWorkflowStarts,
|
||||
}
|
||||
)
|
||||
latestCursor = String(seedEvents[seedEvents.length - 1]?.eventId ?? latestCursor)
|
||||
lastCursorRef.current = latestCursor
|
||||
seedEvents = []
|
||||
suppressSeedWorkflowStarts = false
|
||||
|
||||
if (replayResult.sawStreamError) {
|
||||
return { error: true, aborted: false }
|
||||
@@ -2998,6 +3095,7 @@ export function useChat(
|
||||
...(display ? { display } : {}),
|
||||
calledBy: block.toolCall.calledBy,
|
||||
},
|
||||
...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}),
|
||||
...timing,
|
||||
}
|
||||
}
|
||||
@@ -3005,6 +3103,7 @@ export function useChat(
|
||||
type: block.type,
|
||||
content: block.content,
|
||||
...(block.subagent ? { lane: 'subagent' } : {}),
|
||||
...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}),
|
||||
...timing,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface ContentBlock {
|
||||
options?: OptionItem[]
|
||||
timestamp?: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
}
|
||||
|
||||
export interface ChatMessageAttachment {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { isExecutionStreamHttpError, useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { WorkflowValidationError } from '@/serializer'
|
||||
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
@@ -60,6 +60,13 @@ const logger = createLogger('useWorkflowExecution')
|
||||
*/
|
||||
const activeReconnections = new Set<string>()
|
||||
|
||||
function isReconnectTerminal(error: unknown): boolean {
|
||||
return (
|
||||
isExecutionStreamHttpError(error) &&
|
||||
(error.httpStatus === 404 || error.httpStatus === 403 || error.httpStatus === 401)
|
||||
)
|
||||
}
|
||||
|
||||
interface DebugValidationResult {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
@@ -1283,8 +1290,7 @@ export function useWorkflowExecution() {
|
||||
} else {
|
||||
if (!executor) {
|
||||
try {
|
||||
const httpStatus =
|
||||
isRecord(error) && typeof error.httpStatus === 'number' ? error.httpStatus : undefined
|
||||
const httpStatus = isExecutionStreamHttpError(error) ? error.httpStatus : undefined
|
||||
const storeAddConsole = useTerminalConsoleStore.getState().addConsole
|
||||
|
||||
if (httpStatus && activeWorkflowId) {
|
||||
@@ -1867,8 +1873,6 @@ export function useWorkflowExecution() {
|
||||
activeReconnections.add(reconnectWorkflowId)
|
||||
|
||||
executionStream.cancel(reconnectWorkflowId)
|
||||
setCurrentExecutionId(reconnectWorkflowId, executionId)
|
||||
setIsExecuting(reconnectWorkflowId, true)
|
||||
|
||||
const workflowEdges = useWorkflowStore.getState().edges
|
||||
const activeBlocksSet = new Set<string>()
|
||||
@@ -1891,13 +1895,47 @@ export function useWorkflowExecution() {
|
||||
includeStartConsoleEntry: true,
|
||||
})
|
||||
|
||||
clearExecutionEntries(executionId)
|
||||
|
||||
const capturedExecutionId = executionId
|
||||
const MAX_ATTEMPTS = 5
|
||||
const BASE_DELAY_MS = 1000
|
||||
const MAX_DELAY_MS = 15000
|
||||
|
||||
let activated = false
|
||||
const ensureActivated = () => {
|
||||
if (activated || cleanupRan) return
|
||||
activated = true
|
||||
setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId)
|
||||
setIsExecuting(reconnectWorkflowId, true)
|
||||
clearExecutionEntries(capturedExecutionId)
|
||||
}
|
||||
|
||||
const wrapHandler =
|
||||
<T>(handler: (data: T) => void) =>
|
||||
(data: T) => {
|
||||
ensureActivated()
|
||||
handler(data)
|
||||
}
|
||||
|
||||
const cleanupFailedReconnect = () => {
|
||||
const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId && currentId !== capturedExecutionId) return
|
||||
|
||||
const hasRunningEntry = useTerminalConsoleStore
|
||||
.getState()
|
||||
.getWorkflowEntries(reconnectWorkflowId)
|
||||
.some((entry) => entry.isRunning && entry.executionId === capturedExecutionId)
|
||||
|
||||
if (activated || hasRunningEntry) {
|
||||
cancelRunningEntries(reconnectWorkflowId)
|
||||
}
|
||||
|
||||
if (currentId === capturedExecutionId) {
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const attemptReconnect = async (attempt: number): Promise<void> => {
|
||||
if (cleanupRan || reconnectionComplete) return
|
||||
|
||||
@@ -1914,38 +1952,39 @@ export function useWorkflowExecution() {
|
||||
fromEventId,
|
||||
callbacks: {
|
||||
onEventId: (eid) => {
|
||||
ensureActivated()
|
||||
fromEventId = eid
|
||||
},
|
||||
onBlockStarted: handlers.onBlockStarted,
|
||||
onBlockCompleted: handlers.onBlockCompleted,
|
||||
onBlockError: handlers.onBlockError,
|
||||
onBlockChildWorkflowStarted: handlers.onBlockChildWorkflowStarted,
|
||||
onBlockStarted: wrapHandler(handlers.onBlockStarted),
|
||||
onBlockCompleted: wrapHandler(handlers.onBlockCompleted),
|
||||
onBlockError: wrapHandler(handlers.onBlockError),
|
||||
onBlockChildWorkflowStarted: wrapHandler(handlers.onBlockChildWorkflowStarted),
|
||||
onExecutionCompleted: () => {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (!activated) {
|
||||
clearExecutionPointer(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
const currentId = useExecutionStore
|
||||
.getState()
|
||||
.getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) return
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
},
|
||||
onExecutionError: (data) => {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (!activated) {
|
||||
clearExecutionPointer(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
const currentId = useExecutionStore
|
||||
.getState()
|
||||
.getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) return
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
@@ -1957,16 +1996,16 @@ export function useWorkflowExecution() {
|
||||
})
|
||||
},
|
||||
onExecutionCancelled: () => {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (!activated) {
|
||||
clearExecutionPointer(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
const currentId = useExecutionStore
|
||||
.getState()
|
||||
.getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
return
|
||||
}
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
if (currentId !== capturedExecutionId) return
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
@@ -1978,6 +2017,17 @@ export function useWorkflowExecution() {
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (isReconnectTerminal(error)) {
|
||||
logger.info('Reconnection skipped; run buffer no longer exists', {
|
||||
executionId: capturedExecutionId,
|
||||
})
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
clearExecutionPointer(reconnectWorkflowId)
|
||||
cleanupFailedReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('Execution reconnection attempt failed', {
|
||||
executionId: capturedExecutionId,
|
||||
attempt,
|
||||
@@ -1986,17 +2036,27 @@ export function useWorkflowExecution() {
|
||||
if (!cleanupRan && !reconnectionComplete && attempt < MAX_ATTEMPTS) {
|
||||
return attemptReconnect(attempt + 1)
|
||||
}
|
||||
if (!cleanupRan && !reconnectionComplete) {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
cleanupFailedReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!reconnectionComplete && !cleanupRan) {
|
||||
reconnectionComplete = true
|
||||
activeReconnections.delete(reconnectWorkflowId)
|
||||
const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId === capturedExecutionId) {
|
||||
cancelRunningEntries(reconnectWorkflowId)
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
if (activated) {
|
||||
const currentId = useExecutionStore
|
||||
.getState()
|
||||
.getCurrentExecutionId(reconnectWorkflowId)
|
||||
if (currentId === capturedExecutionId) {
|
||||
cancelRunningEntries(reconnectWorkflowId)
|
||||
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||
setIsExecuting(reconnectWorkflowId, false)
|
||||
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
|
||||
const logger = createLogger('useExecutionStream')
|
||||
|
||||
export class ExecutionStreamHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly httpStatus: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ExecutionStreamHttpError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isExecutionStreamHttpError(error: unknown): error is ExecutionStreamHttpError {
|
||||
return error instanceof ExecutionStreamHttpError
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects errors caused by the browser killing a fetch (page refresh, navigation, tab close).
|
||||
* These should be treated as clean disconnects, not execution errors.
|
||||
@@ -205,11 +219,13 @@ export function useExecutionStream() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
const error = new Error(errorResponse.error || 'Failed to start execution')
|
||||
const error = new ExecutionStreamHttpError(
|
||||
errorResponse.error || 'Failed to start execution',
|
||||
response.status
|
||||
)
|
||||
if (errorResponse && typeof errorResponse === 'object') {
|
||||
Object.assign(error, { executionResult: errorResponse })
|
||||
}
|
||||
Object.assign(error, { httpStatus: response.status })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -279,15 +295,18 @@ export function useExecutionStream() {
|
||||
try {
|
||||
errorResponse = await response.json()
|
||||
} catch {
|
||||
const error = new Error(`Server error (${response.status}): ${response.statusText}`)
|
||||
Object.assign(error, { httpStatus: response.status })
|
||||
throw error
|
||||
throw new ExecutionStreamHttpError(
|
||||
`Server error (${response.status}): ${response.statusText}`,
|
||||
response.status
|
||||
)
|
||||
}
|
||||
const error = new Error(errorResponse.error || 'Failed to start execution')
|
||||
const error = new ExecutionStreamHttpError(
|
||||
errorResponse.error || 'Failed to start execution',
|
||||
response.status
|
||||
)
|
||||
if (errorResponse && typeof errorResponse === 'object') {
|
||||
Object.assign(error, { executionResult: errorResponse })
|
||||
}
|
||||
Object.assign(error, { httpStatus: response.status })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -335,7 +354,9 @@ export function useExecutionStream() {
|
||||
`/api/workflows/${workflowId}/executions/${executionId}/stream?from=${fromEventId}`,
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
if (!response.ok) throw new Error(`Reconnect failed (${response.status})`)
|
||||
if (!response.ok) {
|
||||
throw new ExecutionStreamHttpError(`Reconnect failed (${response.status})`, response.status)
|
||||
}
|
||||
if (!response.body) throw new Error('No response body')
|
||||
|
||||
await processSSEStream(response.body.getReader(), callbacks, 'Reconnect')
|
||||
|
||||
@@ -46,7 +46,11 @@ function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined
|
||||
|
||||
function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined {
|
||||
const displayed = toDisplayBlockBody(block)
|
||||
return displayed ? withBlockTiming(displayed, block) : undefined
|
||||
if (!displayed) return undefined
|
||||
if (block.parentToolCallId && displayed.parentToolCallId === undefined) {
|
||||
displayed.parentToolCallId = block.parentToolCallId
|
||||
}
|
||||
return withBlockTiming(displayed, block)
|
||||
}
|
||||
|
||||
function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefined {
|
||||
|
||||
@@ -77,11 +77,16 @@ function appendTextBlock(
|
||||
content: string,
|
||||
options: {
|
||||
lane?: 'subagent'
|
||||
parentToolCallId?: string
|
||||
}
|
||||
): void {
|
||||
if (!content) return
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.type === MothershipStreamV1EventType.text && last.lane === options.lane) {
|
||||
if (
|
||||
last?.type === MothershipStreamV1EventType.text &&
|
||||
last.lane === options.lane &&
|
||||
last.parentToolCallId === options.parentToolCallId
|
||||
) {
|
||||
last.content = `${typeof last.content === 'string' ? last.content : ''}${content}`
|
||||
return
|
||||
}
|
||||
@@ -89,6 +94,7 @@ function appendTextBlock(
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
...(options.lane ? { lane: options.lane } : {}),
|
||||
...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}),
|
||||
content,
|
||||
})
|
||||
}
|
||||
@@ -122,10 +128,24 @@ function buildLiveAssistantMessage(params: {
|
||||
return activeSubagent
|
||||
}
|
||||
|
||||
const resolveParentForSubagentBlock = (
|
||||
subagent: string | undefined,
|
||||
scopedParent: string | undefined
|
||||
): string | undefined => {
|
||||
if (!subagent) return undefined
|
||||
if (scopedParent) return scopedParent
|
||||
if (activeSubagent === subagent) return activeSubagentParentToolCallId
|
||||
for (const [parent, name] of subagentByParentToolCallId) {
|
||||
if (name === subagent) return parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ensureToolBlock = (input: {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
calledBy?: string
|
||||
parentToolCallId?: string
|
||||
displayTitle?: string
|
||||
params?: Record<string, unknown>
|
||||
result?: { success: boolean; output?: unknown; error?: string }
|
||||
@@ -155,6 +175,7 @@ function buildLiveAssistantMessage(params: {
|
||||
? { display: existingToolCall.display }
|
||||
: {}),
|
||||
}
|
||||
if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -176,6 +197,7 @@ function buildLiveAssistantMessage(params: {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(input.parentToolCallId ? { parentToolCallId: input.parentToolCallId } : {}),
|
||||
}
|
||||
toolIndexById.set(input.toolCallId, blocks.length)
|
||||
blocks.push(nextBlock)
|
||||
@@ -219,8 +241,10 @@ function buildLiveAssistantMessage(params: {
|
||||
runningText.length > 0 &&
|
||||
!runningText.endsWith('\n')
|
||||
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
|
||||
const parentForBlock = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
appendTextBlock(blocks, normalizedChunk, {
|
||||
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
})
|
||||
runningText += normalizedChunk
|
||||
lastContentSource = contentSource
|
||||
@@ -239,11 +263,14 @@ function buildLiveAssistantMessage(params: {
|
||||
continue
|
||||
}
|
||||
|
||||
const parentForBlock = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
|
||||
if (payload.phase === MothershipStreamV1ToolPhase.result) {
|
||||
ensureToolBlock({
|
||||
toolCallId,
|
||||
toolName: payload.toolName,
|
||||
calledBy: scopedSubagent,
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
state: resolveStreamToolOutcome(payload),
|
||||
result: {
|
||||
success: payload.success,
|
||||
@@ -258,6 +285,7 @@ function buildLiveAssistantMessage(params: {
|
||||
toolCallId,
|
||||
toolName: payload.toolName,
|
||||
calledBy: scopedSubagent,
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
displayTitle,
|
||||
params: isRecord(payload.arguments) ? payload.arguments : undefined,
|
||||
state: typeof payload.status === 'string' ? payload.status : 'executing',
|
||||
@@ -270,9 +298,13 @@ function buildLiveAssistantMessage(params: {
|
||||
}
|
||||
|
||||
const spanData = asPayloadRecord(parsed.payload.data)
|
||||
const parentToolCallId =
|
||||
scopedParentToolCallId ??
|
||||
(typeof spanData?.tool_call_id === 'string' ? spanData.tool_call_id : undefined)
|
||||
const parentToolCallIdFromData =
|
||||
typeof spanData?.tool_call_id === 'string'
|
||||
? spanData.tool_call_id
|
||||
: typeof spanData?.toolCallId === 'string'
|
||||
? spanData.toolCallId
|
||||
: undefined
|
||||
const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData
|
||||
const name = typeof parsed.payload.agent === 'string' ? parsed.payload.agent : scopedAgentId
|
||||
if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
|
||||
if (parentToolCallId) {
|
||||
@@ -285,6 +317,7 @@ function buildLiveAssistantMessage(params: {
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
|
||||
content: name,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -308,6 +341,7 @@ function buildLiveAssistantMessage(params: {
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.end,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
})
|
||||
}
|
||||
continue
|
||||
@@ -343,8 +377,10 @@ function buildLiveAssistantMessage(params: {
|
||||
}
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
const content = `${prefix}${tag}`
|
||||
const errorParent = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
appendTextBlock(blocks, content, {
|
||||
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
|
||||
...(errorParent ? { parentToolCallId: errorParent } : {}),
|
||||
})
|
||||
runningText += content
|
||||
continue
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface PersistedContentBlock {
|
||||
toolCall?: PersistedToolCall
|
||||
timestamp?: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
}
|
||||
|
||||
export interface PersistedFileAttachment {
|
||||
@@ -101,9 +102,16 @@ export function withBlockTiming<T>(target: T, src: { timestamp?: number; endedAt
|
||||
return target
|
||||
}
|
||||
|
||||
function withBlockParent<T>(target: T, src: { parentToolCallId?: string }): T {
|
||||
if (src.parentToolCallId) {
|
||||
;(target as { parentToolCallId?: string }).parentToolCallId = src.parentToolCallId
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
function mapContentBlock(block: ContentBlock): PersistedContentBlock {
|
||||
const persisted = mapContentBlockBody(block)
|
||||
return withBlockTiming(persisted, block)
|
||||
return withBlockParent(withBlockTiming(persisted, block), block)
|
||||
}
|
||||
|
||||
function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
|
||||
@@ -265,6 +273,7 @@ interface RawBlock {
|
||||
status?: string
|
||||
timestamp?: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
toolCall?: {
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -321,6 +330,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock {
|
||||
if (block.kind) result.kind = block.kind as MothershipStreamV1SpanPayloadKind
|
||||
if (block.lifecycle) result.lifecycle = block.lifecycle as MothershipStreamV1SpanLifecycleEvent
|
||||
if (block.status) result.status = block.status as MothershipStreamV1CompletionStatus
|
||||
if (block.parentToolCallId) result.parentToolCallId = block.parentToolCallId
|
||||
if (block.toolCall) {
|
||||
result.toolCall = {
|
||||
id: block.toolCall.id ?? '',
|
||||
@@ -438,6 +448,9 @@ function normalizeBlock(block: RawBlock): PersistedContentBlock {
|
||||
if (typeof block.endedAt === 'number' && result.endedAt === undefined) {
|
||||
result.endedAt = block.endedAt
|
||||
}
|
||||
if (block.parentToolCallId && result.parentToolCallId === undefined) {
|
||||
result.parentToolCallId = block.parentToolCallId
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -361,28 +361,29 @@ export async function runStreamLoop(
|
||||
flushSubagentThinkingBlock(context)
|
||||
flushThinkingBlock(context)
|
||||
if (spanEvt === MothershipStreamV1SpanLifecycleEvent.start) {
|
||||
const lastParent = context.subAgentParentStack[context.subAgentParentStack.length - 1]
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (toolCallId) {
|
||||
if (lastParent !== toolCallId) {
|
||||
if (!context.subAgentParentStack.includes(toolCallId)) {
|
||||
context.subAgentParentStack.push(toolCallId)
|
||||
}
|
||||
context.subAgentParentToolCallId = toolCallId
|
||||
context.subAgentContent[toolCallId] ??= ''
|
||||
context.subAgentToolCalls[toolCallId] ??= []
|
||||
}
|
||||
if (
|
||||
subagentName &&
|
||||
!(
|
||||
lastParent === toolCallId &&
|
||||
lastBlock?.type === 'subagent' &&
|
||||
lastBlock.content === subagentName
|
||||
)
|
||||
) {
|
||||
context.contentBlocks.push({
|
||||
type: 'subagent',
|
||||
content: subagentName,
|
||||
timestamp: Date.now(),
|
||||
if (toolCallId && subagentName) {
|
||||
const openParents = (context.openSubagentParents ??= new Set<string>())
|
||||
if (!openParents.has(toolCallId)) {
|
||||
openParents.add(toolCallId)
|
||||
context.contentBlocks.push({
|
||||
type: 'subagent',
|
||||
content: subagentName,
|
||||
parentToolCallId: toolCallId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.warn('subagent start missing toolCallId or agent name', {
|
||||
hasToolCallId: Boolean(toolCallId),
|
||||
hasSubagentName: Boolean(subagentName),
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -391,27 +392,33 @@ export async function runStreamLoop(
|
||||
if (isPendingPause) {
|
||||
return
|
||||
}
|
||||
if (context.subAgentParentStack.length > 0) {
|
||||
context.subAgentParentStack.pop()
|
||||
if (toolCallId) {
|
||||
const idx = context.subAgentParentStack.lastIndexOf(toolCallId)
|
||||
if (idx >= 0) {
|
||||
context.subAgentParentStack.splice(idx, 1)
|
||||
} else {
|
||||
logger.warn('subagent end without matching start', { toolCallId })
|
||||
}
|
||||
} else {
|
||||
logger.warn('subagent end without matching start')
|
||||
logger.warn('subagent end missing toolCallId')
|
||||
}
|
||||
context.subAgentParentToolCallId =
|
||||
context.subAgentParentStack.length > 0
|
||||
? context.subAgentParentStack[context.subAgentParentStack.length - 1]
|
||||
: undefined
|
||||
if (subagentName) {
|
||||
if (toolCallId) {
|
||||
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
|
||||
const b = context.contentBlocks[i]
|
||||
if (
|
||||
b.type === 'subagent' &&
|
||||
b.content === subagentName &&
|
||||
b.endedAt === undefined
|
||||
b.endedAt === undefined &&
|
||||
b.parentToolCallId === toolCallId
|
||||
) {
|
||||
b.endedAt = Date.now()
|
||||
break
|
||||
}
|
||||
}
|
||||
context.openSubagentParents?.delete(toolCallId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,10 +22,17 @@ export function handleTextEvent(scope: ToolScope): StreamHandler {
|
||||
const parentToolCallId = getScopedParentToolCallId(event, context)
|
||||
if (!parentToolCallId) return
|
||||
if (event.payload.channel === MothershipStreamV1TextChannel.thinking) {
|
||||
if (
|
||||
context.currentSubagentThinkingBlock &&
|
||||
context.currentSubagentThinkingBlock.parentToolCallId !== parentToolCallId
|
||||
) {
|
||||
flushSubagentThinkingBlock(context)
|
||||
}
|
||||
if (!context.currentSubagentThinkingBlock) {
|
||||
context.currentSubagentThinkingBlock = {
|
||||
type: 'subagent_thinking',
|
||||
content: '',
|
||||
parentToolCallId,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
@@ -40,7 +47,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler {
|
||||
}
|
||||
context.subAgentContent[parentToolCallId] =
|
||||
(context.subAgentContent[parentToolCallId] || '') + chunk
|
||||
addContentBlock(context, { type: 'subagent_text', content: chunk })
|
||||
addContentBlock(context, { type: 'subagent_text', content: chunk, parentToolCallId })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ function registerSubagentToolCall(
|
||||
type: 'tool_call',
|
||||
toolCall,
|
||||
calledBy: parentToolCall?.name,
|
||||
parentToolCallId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ContentBlock {
|
||||
calledBy?: string
|
||||
timestamp: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
}
|
||||
|
||||
export interface StreamingContext {
|
||||
@@ -86,6 +87,7 @@ export interface StreamingContext {
|
||||
subAgentParentStack: string[]
|
||||
subAgentContent: Record<string, string>
|
||||
subAgentToolCalls: Record<string, ToolCallState[]>
|
||||
openSubagentParents?: Set<string>
|
||||
pendingContent: string
|
||||
streamComplete: boolean
|
||||
wasAborted: boolean
|
||||
@@ -136,31 +138,12 @@ export interface OrchestratorOptions {
|
||||
onComplete?: (result: OrchestratorResult) => void | Promise<void>
|
||||
onError?: (error: Error) => void | Promise<void>
|
||||
abortSignal?: AbortSignal
|
||||
/**
|
||||
* Invoked when the orchestrator infers that the run was aborted via
|
||||
* an out-of-band signal (currently: a Redis abort marker observed
|
||||
* at SSE body close). Callers wire this to fire their local
|
||||
* `AbortController` so `signal.reason` is set and `recordCancelled`
|
||||
* classifies as `explicit_stop` rather than `unknown`.
|
||||
*/
|
||||
onAbortObserved?: (reason: string) => void
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
export interface OrchestratorResult {
|
||||
success: boolean
|
||||
/**
|
||||
* True iff the non-success outcome was a user-initiated cancel
|
||||
* (abort signal fired or client disconnected). Lets callers treat
|
||||
* cancels differently from actual errors — notably, `buildOnComplete`
|
||||
* must NOT finalize the chat row on cancel, because the browser's
|
||||
* `/api/copilot/chat/stop` POST owns writing the partial assistant
|
||||
* content and clearing `conversationId` in one UPDATE. Finalizing
|
||||
* here would race and clear `conversationId` first, making the stop
|
||||
* UPDATE match zero rows and the partial content vanish on refetch.
|
||||
*
|
||||
* Always false when `success=true`.
|
||||
*/
|
||||
cancelled?: boolean
|
||||
content: string
|
||||
contentBlocks: ContentBlock[]
|
||||
|
||||
@@ -183,6 +183,49 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
contextWindow: 1047576,
|
||||
releaseDate: '2025-04-14',
|
||||
},
|
||||
// GPT-5.5 family
|
||||
{
|
||||
id: 'gpt-5.5-pro',
|
||||
pricing: {
|
||||
input: 30.0,
|
||||
output: 180.0,
|
||||
updatedAt: '2026-04-23',
|
||||
},
|
||||
capabilities: {
|
||||
nativeStructuredOutputs: true,
|
||||
reasoningEffort: {
|
||||
values: ['none', 'low', 'medium', 'high', 'xhigh'],
|
||||
},
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 1050000,
|
||||
releaseDate: '2026-04-23',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
pricing: {
|
||||
input: 5.0,
|
||||
cachedInput: 0.5,
|
||||
output: 30.0,
|
||||
updatedAt: '2026-04-23',
|
||||
},
|
||||
capabilities: {
|
||||
nativeStructuredOutputs: true,
|
||||
reasoningEffort: {
|
||||
values: ['none', 'low', 'medium', 'high', 'xhigh'],
|
||||
},
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 1050000,
|
||||
releaseDate: '2026-04-23',
|
||||
recommended: true,
|
||||
},
|
||||
// GPT-5.4 family
|
||||
{
|
||||
id: 'gpt-5.4-pro',
|
||||
@@ -219,7 +262,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
contextWindow: 1050000,
|
||||
releaseDate: '2026-03-05',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
|
||||
Reference in New Issue
Block a user