v0.6.59: gpt 5.5, security hardening, parallel subagents rendering

This commit is contained in:
Vikhyath Mondreti
2026-04-27 10:02:06 -07:00
committed by GitHub
15 changed files with 559 additions and 252 deletions

View File

@@ -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();

View File

@@ -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)

View File

@@ -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>
)

View File

@@ -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,
}
})

View File

@@ -133,6 +133,7 @@ export interface ContentBlock {
options?: OptionItem[]
timestamp?: number
endedAt?: number
parentToolCallId?: string
}
export interface ChatMessageAttachment {

View File

@@ -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())
}
}
}
}

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -340,6 +340,7 @@ function registerSubagentToolCall(
type: 'tool_call',
toolCall,
calledBy: parentToolCall?.name,
parentToolCallId,
})
}
}

View File

@@ -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[]

View File

@@ -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',