show chat run errors as agent messages (#887)

This commit is contained in:
ekko
2026-05-21 09:05:17 +08:00
committed by GitHub
parent 40109e9c42
commit 3612a76735
6 changed files with 157 additions and 46 deletions
+34 -1
View File
@@ -467,6 +467,18 @@ export function disconnectChatRun(): void {
}
}
function removeSocketListener(socket: Socket, event: string, handler: (...args: any[]) => void): void {
const candidate = socket as Socket & {
off?: (event: string, handler: (...args: any[]) => void) => Socket
removeListener?: (event: string, handler: (...args: any[]) => void) => Socket
}
if (typeof candidate.off === 'function') {
candidate.off(event, handler)
return
}
candidate.removeListener?.(event, handler)
}
/**
* Start a chat run via Socket.IO and stream events back.
* Returns an AbortController-compatible handle for cancellation.
@@ -500,6 +512,23 @@ export function startRunViaSocket(
let closed = false
const socket = connectChatRun()
const handleSocketError = (err: Error) => {
if (closed) return
closed = true
sessionEventHandlers.delete(sid)
onError(err)
}
socket.once('connect_error', handleSocketError)
const handleSocketDisconnect = (reason: string) => {
if (closed || reason === 'io client disconnect') return
handleSocketError(new Error(`Socket disconnected: ${reason}`))
}
socket.once('disconnect', handleSocketDisconnect)
const removeTerminalSocketListeners = () => {
removeSocketListener(socket, 'connect_error', handleSocketError)
removeSocketListener(socket, 'disconnect', handleSocketDisconnect)
}
if (sessionEventHandlers.has(sid)) {
socket.emit('run', body)
@@ -548,6 +577,7 @@ export function startRunViaSocket(
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onRunFailed: (evt: RunEvent) => {
@@ -555,7 +585,8 @@ export function startRunViaSocket(
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
onError(new Error(evt.error || 'Run failed'))
removeTerminalSocketListeners()
onDone()
},
onCompressionStarted: (evt: RunEvent) => {
if (closed) return
@@ -574,6 +605,7 @@ export function startRunViaSocket(
onEvent(evt)
if ((evt as any).queue_length > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onUsageUpdated: (evt: RunEvent) => {
@@ -585,6 +617,7 @@ export function startRunViaSocket(
onEvent(evt)
if ((evt as any).terminal === false) return
closed = true
removeTerminalSocketListeners()
sessionEventHandlers.delete(sid)
onDone()
},
+1 -1
View File
@@ -34,7 +34,7 @@ export interface ChatMessage {
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: string | null
finish_reason?: 'streaming' | 'tool_calls' | 'error' | string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
@@ -34,6 +34,7 @@ const { t } = useI18n();
const toast = useMessage();
const isSystem = computed(() => props.message.role === "system");
const isAgentError = computed(() => props.message.role === "assistant" && props.message.systemType === "error");
const effectiveHeadingIdPrefix = computed(() => props.headingIdPrefix || `msg-${props.message.id}`);
const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command");
@@ -790,6 +791,7 @@ onBeforeUnmount(() => {
class="message-bubble"
:class="{
system: isSystem,
'agent-error': isAgentError,
command: isCommandMessage,
'command-error': isCommandError,
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
@@ -1043,6 +1045,12 @@ onBeforeUnmount(() => {
background-color: $msg-assistant-bg;
border-radius: 10px;
}
.message-bubble.agent-error {
color: $error;
background-color: rgba(var(--error-rgb), 0.06);
border: 1px solid rgba(var(--error-rgb), 0.2);
}
}
&.tool {
@@ -1120,6 +1128,20 @@ onBeforeUnmount(() => {
background-color: rgba(var(--warning-rgb), 0.06);
}
&.agent-error {
color: $error;
background-color: rgba(var(--error-rgb), 0.06);
border: 1px solid rgba(var(--error-rgb), 0.2);
:deep(.markdown-body),
:deep(.markdown-body p),
:deep(.markdown-body li),
:deep(.markdown-body strong),
:deep(.markdown-body code) {
color: $error;
}
}
&.speech-playing {
box-shadow:
0 0 0 2px #ff6b6b,
@@ -41,6 +41,12 @@ const isAgent = computed(() => {
return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const isAgentError = computed(() => {
if (props.message.role !== 'assistant') return false
if (props.message.finish_reason === 'error') return true
return /^Error:\s*/i.test(props.message.content || '')
})
const isSelf = computed(() => {
return !!props.currentUserId && props.message.senderId === props.currentUserId
})
@@ -443,6 +449,7 @@ onBeforeUnmount(() => {
class="msg-content"
:class="{
'agent-content': isAgent,
'agent-error': isAgentError,
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
}"
>
@@ -548,6 +555,20 @@ onBeforeUnmount(() => {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
&.agent .msg-content.agent-error {
color: $error;
background-color: rgba(var(--error-rgb), 0.06);
border: 1px solid rgba(var(--error-rgb), 0.2);
:deep(.markdown-body),
:deep(.markdown-body p),
:deep(.markdown-body li),
:deep(.markdown-body strong),
:deep(.markdown-body code) {
color: $error;
}
}
&.self .msg-content {
background-color: rgba(var(--accent-primary-rgb), 0.1);
}
@@ -834,6 +855,20 @@ onBeforeUnmount(() => {
animation: rainbow-glow 4s linear infinite;
}
&.agent-error {
color: $error;
background-color: rgba(var(--error-rgb), 0.06);
border: 1px solid rgba(var(--error-rgb), 0.2);
:deep(.markdown-body),
:deep(.markdown-body p),
:deep(.markdown-body li),
:deep(.markdown-body strong),
:deep(.markdown-body code) {
color: $error;
}
}
:deep(.mention-highlight) {
color: #409eff;
font-weight: 600;
+31 -38
View File
@@ -567,6 +567,10 @@ export const useChatStore = defineStore('chat', () => {
setPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'approval.resolved') {
clearPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'run.failed') {
addAgentErrorMessage(sessionId, e.error)
serverWorking.value.delete(sessionId)
queueLengths.value.delete(sessionId)
} else if (e.event === 'tool.started') {
const msgs = getSessionMsgs(sessionId)
const toolCallId = e.tool_call_id as string | undefined
@@ -692,6 +696,29 @@ export const useChatStore = defineStore('chat', () => {
}
}
function addAgentErrorMessage(sessionId: string, error?: string | null) {
const content = error ? `Error: ${error}` : 'Run failed'
const msgs = getSessionMsgs(sessionId)
const last = msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sessionId, last.id, {
role: 'assistant',
content,
isStreaming: false,
systemType: 'error',
})
return
}
if (last?.role === 'assistant' && last.systemType === 'error' && last.content === content) return
addMessage(sessionId, {
id: uid(),
role: 'assistant',
content,
timestamp: Date.now(),
systemType: 'error',
})
}
function handleSessionCommandEvent(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
@@ -1319,22 +1346,8 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.failed': {
addAgentErrorMessage(sid, evt.error)
const msgs = getSessionMsgs(sid)
const lastErr = msgs[msgs.length - 1]
if (lastErr?.isStreaming) {
updateMessage(sid, lastErr.id, {
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage(sid, {
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'error' }
@@ -1371,20 +1384,14 @@ export const useChatStore = defineStore('chat', () => {
// onError
(err) => {
console.warn('Socket.IO run stream error:', err.message)
addAgentErrorMessage(sid, err.message)
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
msgs[i] = { ...m, toolStatus: 'error' }
}
})
cleanup()
if (sid === activeSessionId.value) {
void refreshActiveSession()
}
},
undefined,
)
@@ -1756,22 +1763,8 @@ export const useChatStore = defineStore('chat', () => {
} else {
queueLengths.value.delete(sid)
}
addAgentErrorMessage(sid, evt.error)
const msgs = getSessionMsgs(sid)
const lastErr = msgs[msgs.length - 1]
if (lastErr?.isStreaming) {
updateMessage(sid, lastErr.id, {
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage(sid, {
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'error' }
@@ -264,6 +264,13 @@ class AgentClient {
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
): Promise<void> {
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
let streamStarted = false
try {
// Notify room that agent is typing
this.startTyping(roomId)
@@ -335,12 +342,6 @@ class AgentClient {
const bridge = new AgentBridgeClient()
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
const flushedAssistantParts = new Set<string>()
let lastChunk: AgentBridgeOutput | null = null
const started = await bridge.chat(
@@ -355,6 +356,7 @@ class AgentClient {
)
this.emitMessageStreamStart(roomId, streamMessageId)
streamStarted = true
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
lastChunk = chunk
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
@@ -373,6 +375,7 @@ class AgentClient {
partIndex += 1
streamMessageId = groupMessagePartId(runMessageId, partIndex)
this.emitMessageStreamStart(roomId, streamMessageId)
streamStarted = true
return toolBaseId
})
if (chunk.delta) {
@@ -384,6 +387,7 @@ class AgentClient {
if (lastChunk?.status === 'error') {
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
await this.sendAgentErrorMessage(roomId, streamMessageId, lastChunk.error || 'Run failed', msg, reasoningContent)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
@@ -414,11 +418,35 @@ class AgentClient {
onStatus?.('ready')
} catch (err: any) {
logger.error(`[AgentClients] ${this.name}: error handling message: ${err.message}`)
try {
await this.sendAgentErrorMessage(roomId, streamMessageId, err, msg, reasoningContent)
if (streamStarted) this.emitMessageStreamEnd(roomId, streamMessageId)
} catch (sendErr: any) {
logger.warn(`[AgentClients] ${this.name}: failed to send error message: ${sendErr.message}`)
}
this.stopTyping(roomId)
onStatus?.('ready')
}
}
private async sendAgentErrorMessage(
roomId: string,
messageId: string,
error: unknown,
sourceMsg: MentionMessage,
reasoningContent = '',
): Promise<void> {
const detail = error instanceof Error ? error.message : String(error || 'Run failed')
const content = detail.startsWith('Error:') ? detail : `Error: ${detail}`
await this.sendMessage(roomId, content, messageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(sourceMsg),
finish_reason: 'error',
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
}
private async recordBridgeEvents(
roomId: string,
chunk: AgentBridgeOutput,