claude-code-best/src/server/sessionMessageRouter.ts
DoSun 07f6ebe004 fix: normalize tool names and remove metadata.sessionId from running/completed events
- Apply normalizeToolName consistently in all tool_result paths (wasStreamed=true and wasStreamed=false)
- Remove metadata.sessionId from running state in streamStateTracker content_block_stop
- Remove metadata.sessionId from emitTaskProgress and emitTaskCompleted
- Add streamStateTracker.ts with stream-to-opencode event conversion
- Add subagent task lifecycle events (started/progress/completed)
- Handle tombstone, attachment, progress message types
2026-05-20 11:22:22 +08:00

1030 lines
32 KiB
TypeScript

import { randomUUID } from 'crypto'
import { jsonStringify } from '../utils/slowOperations.js'
import { processStreamEvent, hasActiveStream, consumeStreamedTurn, getCompletedToolInfo, registerAgentSession } from './streamStateTracker.js'
import type { ControlChannel } from './sessionControlChannel.js'
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
import type { SessionMessage } from './transcriptReader.js'
const API_ERROR_PREFIX_RE = /^API Error:\s*/
const COSTRICT_API_ERROR_PREFIX_RE = /^CoStrict API Error:\s*/
type SubagentToolState = {
emittedToolCount: number
assistantMessageID: string
toolPartIDs: Map<number, string>
mainPartID: string
mainMessageID: string
mainToolUseID: string
progressLines: string[]
}
const subagentToolState = new Map<string, SubagentToolState>()
function isApiErrorContent(content: unknown): boolean {
if (typeof content === 'string') {
return API_ERROR_PREFIX_RE.test(content) || COSTRICT_API_ERROR_PREFIX_RE.test(content)
}
if (!Array.isArray(content)) return false
for (const block of content) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if (b.type === 'text' && typeof b.text === 'string') {
const text = b.text as string
if (API_ERROR_PREFIX_RE.test(text) || COSTRICT_API_ERROR_PREFIX_RE.test(text)) return true
}
}
return false
}
function buildErrorFromContent(content: unknown): { name: string; data: { message: string; statusCode?: number; isRetryable?: boolean } } {
let raw = ''
if (typeof content === 'string') {
raw = content.replace(API_ERROR_PREFIX_RE, '').replace(COSTRICT_API_ERROR_PREFIX_RE, '')
} else if (Array.isArray(content)) {
for (const block of content) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if (b.type === 'text' && typeof b.text === 'string') {
raw = (b.text as string).replace(API_ERROR_PREFIX_RE, '').replace(COSTRICT_API_ERROR_PREFIX_RE, '')
break
}
}
}
const statusMatch = raw.match(/^(\d{3})\s+/)
const statusCode = statusMatch ? parseInt(statusMatch[1]!, 10) : undefined
const isRetryable = statusCode === 429 || statusCode === 503 || statusCode === 529
let message = raw
try {
const jsonStart = raw.indexOf('{')
if (jsonStart !== -1) {
const parsed = JSON.parse(raw.slice(jsonStart))
if (parsed?.error?.message) {
message = parsed.error.message
} else if (typeof parsed?.message === 'string') {
message = parsed.message
}
}
} catch {}
return { name: 'APIError', data: { message, statusCode, isRetryable } }
}
function extractToolResultContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if (b.type === 'text' && typeof b.text === 'string') {
parts.push(b.text)
} else if (b.type === 'image') {
const src = b.source as Record<string, unknown> | undefined
if (typeof src?.data === 'string') {
parts.push(`[image: ${src.type}]`)
}
}
}
return parts.join('\n')
}
function getActiveSubagentId(ctx: MessageRouterCtx): string | undefined {
const subs = ctx.getActiveSubagents()
if (subs.size === 0) return undefined
return subs.keys().next().value as string | undefined
}
export type SubagentInfo = {
agentId: string
agentType?: string
description?: string
prompt?: string
toolUseId?: string
}
export type StdoutMessage = {
type: string
[key: string]: unknown
}
export type MessageRouterCtx = {
readonly sessionId: string
readonly silent: boolean
getStatus(): SessionState
getBusyStatus(): SessionBusyStatus
getModel(): string | undefined
getProviderId(): string | undefined
getInitRequestId(): string | null
getLastMessageUuid(): string | null
setStatus(status: SessionState): void
setBusyStatus(status: SessionBusyStatus): void
setModel(model: string): void
setProviderId(id: string): void
setInitData(data: InitData): void
setLastMessageUuid(uuid: string): void
setLastActiveAt(ts: number): void
getActiveSubagents(): Map<string, SubagentInfo>
registerSubagent(info: SubagentInfo): void
unregisterSubagent(agentId: string): void
getControlChannel(): ControlChannel
getPendingPermissions(): Map<string, PendingPermission>
getPendingQuestions(): Map<string, PendingQuestion>
resolveInit(data: InitData): void
resolvePrompt(value: { done: boolean; error?: string }): void
addCost(cost: number): void
addInputTokens(tokens: number): void
addOutputTokens(tokens: number): void
emitEvent(event: string, data: Record<string, unknown>): void
emitOpencodeEvent(event: string, properties: Record<string, unknown>): void
emitBusyStatus(): void
emitMessage(msg: StdoutMessage): void
writeStdin(data: string): void
pushBufferMessage(msg: SessionMessage): void
addTombstonedUuid(uuid: string): void
}
export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
if (
ctx.getStatus() === 'starting' &&
msg.type === 'control_response'
) {
const response = msg.response as Record<string, unknown> | undefined
if (response?.subtype === 'success' && response?.request_id === ctx.getInitRequestId()) {
handleInitResponse(response, ctx)
return
}
}
if (msg.type === 'control_response') {
if (ctx.getControlChannel().tryResolve(msg)) return
}
ctx.emitMessage(msg)
switch (msg.type) {
case 'assistant': {
handleAssistantMessage(msg, ctx)
break
}
case 'user': {
handleUserMessage(msg, ctx)
break
}
case 'result': {
handleResultMessage(msg, ctx)
break
}
case 'control_request': {
handleControlRequest(msg, ctx)
break
}
case 'control_cancel_request': {
handleCancelRequest(msg, ctx)
break
}
case 'stream_event': {
handleStreamEvent(msg, ctx)
break
}
case 'system': {
handleSystemMessage(msg, ctx)
break
}
case 'attachment': {
handleAttachment(msg, ctx)
break
}
case 'progress': {
handleProgress(msg, ctx)
break
}
case 'tombstone': {
handleTombstone(msg, ctx)
break
}
default: {
break
}
}
}
function handleInitResponse(response: Record<string, unknown>, ctx: MessageRouterCtx): void {
const initData = (response.response ?? {}) as InitData
ctx.setInitData(initData)
if (Array.isArray(initData.models) && initData.models.length > 0 && !ctx.getModel()) {
const first = (initData.models as Array<Record<string, string>>)[0]
ctx.setModel(first?.value ?? first?.name)
}
if (initData.account?.apiProvider) {
ctx.setProviderId(initData.account.apiProvider)
}
ctx.setStatus('running')
ctx.setLastActiveAt(Date.now())
ctx.resolveInit(initData)
ctx.emitOpencodeEvent('session.updated', {
sessionID: ctx.sessionId,
status: 'running',
model: ctx.getModel(),
providerID: ctx.getProviderId(),
})
}
function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.setLastActiveAt(Date.now())
ctx.setStatus('running')
if (msg.uuid && typeof msg.uuid === 'string') {
ctx.setLastMessageUuid(msg.uuid)
}
const rawContent = (msg.message as Record<string, unknown>)?.content ?? msg.content
const isApiError = isApiErrorContent(rawContent)
const agentId = (msg.agent_id as string | undefined) || getActiveSubagentId(ctx)
const emitSessionID = agentId && ctx.getActiveSubagents().has(agentId) ? agentId : ctx.sessionId
const wasStreamed = hasActiveStream(emitSessionID) || consumeStreamedTurn(emitSessionID)
if (msg.uuid) {
ctx.pushBufferMessage({
uuid: msg.uuid as string,
type: 'assistant',
role: 'assistant',
content: isApiError ? [] : (msg.message ?? msg.content ?? ''),
timestamp: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now(),
parent_uuid: (msg.parentUuid as string) ?? null,
...(isApiError ? { error: buildErrorFromContent(rawContent) } : {}),
})
}
if (wasStreamed) {
if (Array.isArray(rawContent)) {
for (const block of rawContent) {
const b = block as Record<string, unknown>
if (b.type !== 'tool_result') continue
const toolUseID = b.tool_use_id as string
if (!toolUseID) continue
const toolInfo = getCompletedToolInfo(emitSessionID, toolUseID)
const output = extractToolResultContent(b.content)
const isError = b.is_error === true
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: emitSessionID,
part: {
type: 'tool',
id: toolInfo?.partID ?? randomUUID(),
callID: toolUseID,
tool: toolInfo?.toolName ? normalizeToolName(toolInfo.toolName) : '',
messageID: ctx.getLastMessageUuid() ?? '',
sessionID: emitSessionID,
state: {
status: isError ? 'error' : 'completed',
input: toolInfo?.input ?? {},
title: toolInfo?.title ?? toolInfo?.toolName ?? '',
...(isError ? { error: output } : { output }),
time: { start: toolInfo?.startTime ?? Date.now(), end: Date.now() },
},
},
})
}
}
return
}
let enriched = msg
if (!msg.model && ctx.getModel()) {
enriched = { ...enriched, model: ctx.getModel() }
}
if (!msg.provider_id && ctx.getProviderId()) {
enriched = { ...enriched, provider_id: ctx.getProviderId() }
}
const assistantMsgID = (msg.uuid as string) ?? randomUUID()
ctx.emitOpencodeEvent('message.updated', {
sessionID: emitSessionID,
info: {
id: assistantMsgID,
role: 'assistant',
modelID: enriched.model,
providerID: enriched.provider_id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: msg.timestamp
? new Date(msg.timestamp as string).getTime()
: Date.now(),
},
parentID: (msg.parentUuid as string) ?? null,
...(isApiError ? { error: buildErrorFromContent(rawContent) } : {}),
},
})
if (Array.isArray(rawContent)) {
for (const block of rawContent) {
const part = buildPartFromContentBlock(block as Record<string, unknown>, assistantMsgID, emitSessionID)
if (part) {
if (isApiError && part.type === 'text') continue
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: emitSessionID,
part,
})
}
}
}
}
function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
if (msg.isReplay) return
const agentId = (msg.agent_id as string | undefined) || getActiveSubagentId(ctx)
const emitSessionID = agentId && ctx.getActiveSubagents().has(agentId) ? agentId : ctx.sessionId
let rawContent = msg.message ?? msg.content
if (rawContent && typeof rawContent === 'object' && !Array.isArray(rawContent) && 'content' in (rawContent as Record<string, unknown>)) {
rawContent = (rawContent as Record<string, unknown>).content
}
if (Array.isArray(rawContent)) {
for (const block of rawContent) {
const b = block as Record<string, unknown>
if (b.type !== 'tool_result') continue
const toolUseID = b.tool_use_id as string
if (!toolUseID) continue
const output = extractToolResultContent(b.content)
const isError = b.is_error === true
const toolInfo = getCompletedToolInfo(emitSessionID, toolUseID)
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: emitSessionID,
part: {
type: 'tool',
id: toolInfo?.partID ?? randomUUID(),
callID: toolUseID,
tool: toolInfo?.toolName ? normalizeToolName(toolInfo.toolName) : '',
messageID: toolInfo?.messageID,
sessionID: emitSessionID,
state: {
status: isError ? 'error' : 'completed',
input: toolInfo?.input ?? {},
title: toolInfo?.title ?? toolInfo?.toolName ?? '',
...(isError ? { error: output } : { output }),
time: { start: toolInfo?.startTime, end: Date.now() },
},
},
})
}
}
const content = typeof msg.content === 'string' ? msg.content : ''
if (content.includes('<local-command-stdout>')) return
ctx.emitOpencodeEvent('message.updated', {
sessionID: emitSessionID,
info: {
id: (msg.uuid as string) ?? randomUUID(),
role: 'user',
time: { created: Date.now() },
parentID: null,
},
})
}
function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.setLastActiveAt(Date.now())
ctx.setStatus('running')
const cost = msg.cost_usd as number | undefined
const usage = msg.usage as
| { input_tokens?: number; output_tokens?: number }
| undefined
if (cost) ctx.addCost(cost)
if (usage?.input_tokens) ctx.addInputTokens(usage.input_tokens)
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
ctx.setBusyStatus({ type: 'idle' })
ctx.emitBusyStatus()
ctx.emitOpencodeEvent('session.result', {
sessionID: ctx.sessionId,
subtype: msg.subtype ?? 'success',
costUsd: msg.cost_usd,
usage: msg.usage,
stopReason: msg.stop_reason,
})
const subtype = msg.subtype as string | undefined
if (subtype && subtype !== 'success') {
const errorData = msg.errors as Array<Record<string, unknown>> | undefined
const errorMessage = errorData?.[0]?.message ?? msg.subtype ?? 'Unknown error'
ctx.emitOpencodeEvent('session.error', {
sessionID: ctx.sessionId,
error: {
subtype,
level: 'error',
message: typeof errorMessage === 'string' ? errorMessage : JSON.stringify(errorMessage),
},
})
}
ctx.resolvePrompt({ done: true })
}
function handleStreamEvent(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.setLastActiveAt(Date.now())
const event = msg.event as Record<string, unknown> | undefined
if (!event) return
const agentId = msg.agent_id as string | undefined
const subagentActive = agentId && ctx.getActiveSubagents().has(agentId)
const emitSessionID = subagentActive ? agentId! : ctx.sessionId
const canonicalEvents = processStreamEvent(emitSessionID, event)
for (const ce of canonicalEvents) {
ctx.emitOpencodeEvent(ce.type, ce.properties)
}
}
function handleSystemMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const subtype = msg.subtype as string
switch (subtype) {
case 'task_notification':
emitTaskCompleted(msg, ctx)
break
case 'task_started':
emitTaskStarted(msg, ctx)
break
case 'task_progress':
emitTaskProgress(msg, ctx)
break
case 'api_error':
case 'api_retry':
emitSessionError(msg, ctx)
break
case 'compact_boundary':
case 'microcompact_boundary':
emitCompactionEvent(msg, ctx)
break
case 'stop_hook_summary':
emitHookSummary(msg, ctx)
break
case 'turn_duration':
emitSessionMetrics(msg, ctx)
break
case 'cache_warning':
emitSessionWarning(msg, ctx)
break
case 'informational':
case 'post_turn_summary':
emitSessionInfo(msg, ctx)
break
case 'session_state_changed':
case 'status':
emitSessionStatus(msg, ctx)
break
default:
if (subtype) {
emitSessionInfo(msg, ctx)
}
break
}
}
function handleAttachment(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('message.attachment', {
sessionID: ctx.sessionId,
attachmentType: (msg.attachment as Record<string, unknown>)?.type,
attachment: msg.attachment,
})
}
function handleProgress(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('tool.progress', {
sessionID: ctx.sessionId,
toolUseID: msg.toolUseID,
parentToolUseID: msg.parentToolUseID,
data: msg.data,
})
}
function handleTombstone(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const messageObj = msg.message as Record<string, unknown> | undefined
const targetUuid = messageObj?.uuid as string | undefined
if (targetUuid) {
ctx.addTombstonedUuid(targetUuid)
ctx.emitOpencodeEvent('message.removed', {
sessionID: ctx.sessionId,
messageID: targetUuid,
})
}
}
function handleControlRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const request = msg.request as Record<string, unknown> | undefined
const requestId = msg.request_id as string
if (request?.subtype === 'can_use_tool') {
const toolName = (request.tool_name as string) ?? 'Unknown'
const perm: PendingPermission = {
requestId,
sessionId: ctx.sessionId,
toolName,
toolUseId: (request.tool_use_id as string) ?? '',
input: (request.input as Record<string, unknown>) ?? {},
title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`,
description: `Execute ${request.tool_name}`,
suggestions:
(request.permission_suggestions as Record<string, unknown>[]) ?? [],
}
ctx.getPendingPermissions().set(requestId, perm)
ctx.emitEvent('control_request', { request_id: requestId, request })
if (toolName === 'AskUserQuestion') {
const input = (request.input as Record<string, unknown>) ?? {}
const questions = (input.questions as Array<Record<string, unknown>>) ?? []
ctx.emitOpencodeEvent('question.asked', {
sessionID: ctx.sessionId,
id: requestId,
questions: questions.map(q => ({
question: q.question as string,
header: (q.header as string) ?? '',
options: ((q.options as Array<{ label: string; description: string }>) ?? []).map(o => ({
label: o.label,
description: o.description,
})),
multiple: (q.multiSelect as boolean) ?? false,
custom: false,
})),
tool: {
messageID: '',
callID: perm.toolUseId,
},
})
} else {
ctx.emitOpencodeEvent('permission.asked', {
sessionID: ctx.sessionId,
id: requestId,
permission: toPermissionKey(toolName),
patterns: extractPatterns(perm.input),
metadata: { input: perm.input },
always: [] as string[],
tool: {
messageID: '',
callID: perm.toolUseId,
},
})
}
} else if (request?.subtype === 'elicitation') {
const question: PendingQuestion = {
requestId,
sessionId: ctx.sessionId,
mcpServerName: (request.mcp_server_name as string) ?? '',
message: (request.message as string) ?? '',
mode: (request.mode as string) ?? 'form',
requestedSchema:
(request.requested_schema as Record<string, unknown>) ?? {},
}
ctx.getPendingQuestions().set(requestId, question)
ctx.emitEvent('control_request', { request_id: requestId, request })
ctx.emitOpencodeEvent('question.asked', {
sessionID: ctx.sessionId,
id: requestId,
questions: [{
question: question.message,
header: question.mcpServerName || 'MCP',
options: [] as Array<{ label: string; description: string }>,
multiple: false,
custom: true,
}],
})
} else if (request?.subtype === 'hook_callback') {
ctx.emitEvent('control_request', { request_id: requestId, request })
const callbackId = request.callback_id as string | undefined
if (!callbackId || callbackId === 'AUTO_APPROVE_CALLBACK_ID') {
ctx.writeStdin(jsonStringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Auto-approved by serve',
},
},
},
}))
} else {
ctx.writeStdin(jsonStringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: 'Forwarding to can_use_tool',
},
},
},
}))
}
}
}
function handleCancelRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const cancelRequestId = msg.request_id as string
const wasPerm = ctx.getPendingPermissions().has(cancelRequestId)
const wasQuestion = ctx.getPendingQuestions().has(cancelRequestId)
ctx.getPendingPermissions().delete(cancelRequestId)
ctx.getPendingQuestions().delete(cancelRequestId)
if (wasQuestion) {
ctx.emitOpencodeEvent('question.rejected', {
sessionID: ctx.sessionId,
requestID: cancelRequestId,
})
}
if (wasPerm) {
ctx.emitOpencodeEvent('permission.replied', {
sessionID: ctx.sessionId,
requestID: cancelRequestId,
})
}
}
function buildPartFromContentBlock(block: Record<string, unknown>, messageID: string, sessionID: string): Record<string, unknown> | null {
switch (block.type) {
case 'text':
return { type: 'text', id: randomUUID(), text: block.text as string, messageID, sessionID }
case 'thinking':
return { type: 'reasoning', id: randomUUID(), text: block.thinking as string, messageID, sessionID }
case 'redacted_thinking':
return { type: 'reasoning', id: randomUUID(), text: '', redacted: true, messageID, sessionID }
case 'tool_use': {
const toolInput = normalizeToolInput(block.input as Record<string, unknown>)
return {
type: 'tool',
id: randomUUID(),
callID: block.id as string,
tool: normalizeToolName(block.name as string),
state: {
status: 'running',
input: toolInput,
title: (toolInput?.description as string) ?? (block.name as string),
time: { start: Date.now() },
},
messageID,
sessionID,
}
}
default:
return null
}
}
function normalizeToolName(name: string): string {
return toPermissionKey(name)
}
function emitTaskStarted(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const agentId = (msg.agent_id ?? msg.task_id) as string
const taskType = msg.task_type as string | undefined
const description = msg.description as string
const prompt = msg.prompt as string | undefined
const toolUseId = msg.tool_use_id as string | undefined
const assistantMessageID = randomUUID()
ctx.registerSubagent({
agentId,
agentType: taskType,
description,
prompt,
toolUseId,
})
const toolUseIdForMain = toolUseId
subagentToolState.set(agentId, {
emittedToolCount: 0,
assistantMessageID,
toolPartIDs: new Map(),
mainPartID: '',
mainMessageID: '',
mainToolUseID: toolUseIdForMain ?? '',
progressLines: [],
})
ctx.emitOpencodeEvent('task.started', {
sessionID: ctx.sessionId,
taskID: msg.task_id as string,
toolUseID: toolUseId,
description,
taskType,
workflowName: msg.workflow_name as string | undefined,
prompt,
})
ctx.emitOpencodeEvent('session.created', {
sessionID: agentId,
info: {
id: agentId,
parentID: ctx.sessionId,
title: description || `Subagent ${agentId.slice(0, 8)}`,
agent: taskType ?? 'general-purpose',
createdAt: Date.now(),
status: 'running',
},
})
ctx.emitOpencodeEvent('message.updated', {
sessionID: agentId,
info: {
id: assistantMessageID,
role: 'assistant',
modelID: 'subagent',
time: { created: Date.now() },
parentID: null,
},
})
ctx.emitOpencodeEvent('session.status', {
sessionID: agentId,
status: { type: 'busy' },
})
if (toolUseId) {
registerAgentSession(ctx.sessionId, toolUseId, agentId)
}
}
function emitTaskProgress(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const agentId = (msg.agent_id ?? msg.task_id) as string
const usage = msg.usage as { duration_ms?: number; tool_uses?: number; total_tokens?: number } | undefined
const toolUses = usage?.tool_uses ?? 0
const lastToolName = (msg.last_tool_name as string | undefined) ?? 'Tool'
const description = msg.description as string | undefined
ctx.emitOpencodeEvent('task.progress', {
sessionID: ctx.sessionId,
taskID: msg.task_id as string,
description,
usage,
lastToolName,
summary: msg.summary as string | undefined,
workflowProgress: msg.workflow_progress,
})
const toolState = subagentToolState.get(agentId)
if (!toolState) return
if (!toolState.mainPartID && toolState.mainToolUseID) {
const info = getCompletedToolInfo(ctx.sessionId, toolState.mainToolUseID)
if (info) {
toolState.mainPartID = info.partID
toolState.mainMessageID = info.messageID
}
}
if (description && toolState.mainPartID) {
const lines = toolState.progressLines
lines.push(description)
if (lines.length > 3) lines.splice(0, lines.length - 3)
toolState.progressLines = lines
const mainToolInfo = getCompletedToolInfo(ctx.sessionId, toolState.mainToolUseID)
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: ctx.sessionId,
part: {
type: 'tool',
id: toolState.mainPartID,
callID: toolState.mainToolUseID,
tool: mainToolInfo?.toolName ? normalizeToolName(mainToolInfo.toolName) : 'task',
state: {
status: 'running',
input: mainToolInfo?.input ?? {},
title: mainToolInfo?.title ?? '',
time: { start: mainToolInfo?.startTime ?? Date.now() },
progress: toolState.progressLines,
},
messageID: toolState.mainMessageID,
sessionID: ctx.sessionId,
},
})
}
while (toolState.emittedToolCount < toolUses) {
toolState.emittedToolCount++
const toolIndex = toolState.emittedToolCount
const isLast = toolIndex === toolUses
const toolName = toolIndex === toolUses ? lastToolName : 'Tool'
let partID = toolState.toolPartIDs.get(toolIndex)
if (!partID) {
partID = randomUUID()
toolState.toolPartIDs.set(toolIndex, partID)
}
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: agentId,
part: {
type: 'tool',
id: partID,
callID: `subagent-tool-${toolIndex}`,
tool: normalizeToolName(toolName),
state: {
status: 'completed',
output: isLast && description ? description.replace(/^Running\s*/i, '') : '',
title: `${toolName} #${toolIndex}`,
time: { start: Date.now() - (usage?.duration_ms ?? 0), end: Date.now() },
},
messageID: toolState.assistantMessageID,
sessionID: agentId,
},
})
}
}
function emitTaskCompleted(msg: StdoutMessage, ctx: MessageRouterCtx): void {
const agentId = (msg.agent_id ?? msg.task_id) as string
const status = msg.status as 'completed' | 'failed' | 'stopped'
const summary = msg.summary as string
const toolUseId = msg.tool_use_id as string | undefined
const toolState = subagentToolState.get(agentId)
ctx.emitOpencodeEvent('task.completed', {
sessionID: ctx.sessionId,
taskID: msg.task_id as string,
toolUseID: toolUseId,
status,
summary,
outputFile: msg.output_file as string,
usage: msg.usage,
})
if (toolUseId) {
const agentToolInfo = getCompletedToolInfo(ctx.sessionId, toolUseId)
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: ctx.sessionId,
part: {
type: 'tool',
id: agentToolInfo?.partID ?? randomUUID(),
callID: toolUseId,
tool: agentToolInfo?.toolName ? normalizeToolName(agentToolInfo.toolName) : undefined,
messageID: agentToolInfo?.messageID,
sessionID: ctx.sessionId,
state: {
status: status === 'failed' ? 'error' : 'completed',
input: agentToolInfo?.input ?? {},
...(status === 'failed' ? { error: summary || 'Task failed' } : { output: summary || '' }),
title: summary || '',
time: { start: agentToolInfo?.startTime, end: Date.now() },
},
},
})
}
if (toolState) {
ctx.emitOpencodeEvent('message.updated', {
sessionID: agentId,
info: {
id: toolState.assistantMessageID,
role: 'assistant',
modelID: 'subagent',
time: { completed: Date.now() },
},
})
subagentToolState.delete(agentId)
}
const subagent = ctx.getActiveSubagents().get(agentId)
if (subagent || agentId) {
ctx.emitOpencodeEvent('session.updated', {
sessionID: agentId,
info: {
id: agentId,
status,
summary,
completedAt: Date.now(),
},
})
ctx.emitOpencodeEvent('session.status', {
sessionID: agentId,
status: { type: 'idle' },
})
ctx.emitOpencodeEvent('session.idle', {
sessionID: agentId,
})
ctx.unregisterSubagent(agentId)
}
}
function emitSessionError(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.error', {
sessionID: ctx.sessionId,
error: {
subtype: msg.subtype,
level: msg.level ?? 'error',
message: msg.content ?? (msg.error as Record<string, unknown>)?.message,
retryInMs: msg.retry_in_ms ?? msg.retryInMs,
retryAttempt: msg.retry_attempt ?? msg.retryAttempt,
maxRetries: msg.max_retries ?? msg.maxRetries,
},
})
}
function emitCompactionEvent(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('message.part.updated', {
sessionID: ctx.sessionId,
part: {
type: 'compaction',
id: (msg.uuid as string) ?? randomUUID(),
auto:
msg.subtype === 'microcompact_boundary' ||
((msg.compact_metadata as Record<string, unknown>)?.trigger === 'auto'),
},
})
}
function emitHookSummary(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.hook_summary', {
sessionID: ctx.sessionId,
hookLabel: msg.hook_label ?? msg.hookLabel,
hookCount: msg.hook_count ?? msg.hookCount,
hookErrors: msg.hook_errors ?? msg.hookErrors,
preventedContinuation: msg.prevented_continuation ?? msg.preventedContinuation,
totalDurationMs: msg.total_duration_ms ?? msg.totalDurationMs,
})
}
function emitSessionMetrics(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.metrics', {
sessionID: ctx.sessionId,
turnDuration: msg.duration ?? msg.turn_duration,
})
}
function emitSessionWarning(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.warning', {
sessionID: ctx.sessionId,
message: msg.content,
})
}
function emitSessionInfo(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.info', {
sessionID: ctx.sessionId,
subtype: msg.subtype,
content: msg.content,
})
}
function emitSessionStatus(msg: StdoutMessage, ctx: MessageRouterCtx): void {
ctx.emitOpencodeEvent('session.status', {
sessionID: ctx.sessionId,
status: msg.status,
})
}
function toPermissionKey(toolName: string): string {
const map: Record<string, string> = {
Read: 'read',
Edit: 'edit',
Write: 'edit',
Glob: 'glob',
Grep: 'grep',
LS: 'list',
Bash: 'bash',
PowerShell: 'bash',
Agent: 'task',
WebFetch: 'webfetch',
WebSearch: 'websearch',
TodoRead: 'todoread',
TodoWrite: 'todowrite',
}
return map[toolName] ?? toolName.toLowerCase()
}
function extractPatterns(input: Record<string, unknown>): string[] {
const patterns: string[] = []
for (const key of ['file_path', 'path', 'pattern', 'glob'] as const) {
const v = typeof input[key] === 'string' ? input[key] : ''
if (v) patterns.push(v)
}
const cmd = typeof input.command === 'string' ? input.command : ''
if (cmd) patterns.push(cmd)
return patterns
}
function normalizeToolInput(input: Record<string, unknown>): Record<string, unknown> {
const renames: Record<string, string> = {
file_path: 'filePath',
old_string: 'oldString',
new_string: 'newString',
replace_all: 'replaceAll',
}
for (const [oldKey, newKey] of Object.entries(renames)) {
if (oldKey in input) {
input[newKey] = input[oldKey]
delete input[oldKey]
}
}
return input
}