diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts index 0ec6aeb0af..bb7f6106a7 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts @@ -183,6 +183,127 @@ describe('ClaudeCodeAdapter', () => { }); }); + describe('ToolSearch tool_reference content (LOBE-7369)', () => { + // CC CLI serializes ToolSearch results as `tool_reference` blocks — no + // `text` or `content` field — which the generic array mapper dropped to + // empty content, leaving the tool message in DB with `content: ''` and + // the UI's StatusIndicator stuck on the spinner. + it('joins tool_reference blocks into newline-separated tool names', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [ + { id: 'ts1', input: { query: 'linear' }, name: 'ToolSearch', type: 'tool_use' }, + ], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [ + { + content: [ + { tool_name: 'mcp__claude_ai_Linear__create_attachment', type: 'tool_reference' }, + { tool_name: 'mcp__claude_ai_Linear__create_document', type: 'tool_reference' }, + { tool_name: 'mcp__claude_ai_Linear__create_issue_label', type: 'tool_reference' }, + ], + tool_use_id: 'ts1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result).toBeDefined(); + expect(result!.data.toolCallId).toBe('ts1'); + expect(result!.data.content).toBe( + [ + 'mcp__claude_ai_Linear__create_attachment', + 'mcp__claude_ai_Linear__create_document', + 'mcp__claude_ai_Linear__create_issue_label', + ].join('\n'), + ); + expect(result!.data.isError).toBe(false); + + const end = events.find((e) => e.type === 'tool_end'); + expect(end).toBeDefined(); + expect(end!.data.toolCallId).toBe('ts1'); + }); + + it('mixes tool_reference with text blocks in a single tool_result', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [ + { id: 'ts1', input: { query: 'search' }, name: 'ToolSearch', type: 'tool_use' }, + ], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [ + { + content: [ + { text: 'Loaded:', type: 'text' }, + { tool_name: 'WebSearch', type: 'tool_reference' }, + ], + tool_use_id: 'ts1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.content).toBe('Loaded:\nWebSearch'); + }); + + it('skips tool_reference entries with no tool_name', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 'ts1', input: { query: 'x' }, name: 'ToolSearch', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [ + { + content: [ + { tool_name: 'A', type: 'tool_reference' }, + { type: 'tool_reference' }, + { tool_name: 'B', type: 'tool_reference' }, + ], + tool_use_id: 'ts1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.content).toBe('A\nB'); + }); + }); + describe('TodoWrite pluginState synthesis', () => { const driveTodoWrite = (adapter: ClaudeCodeAdapter, input: unknown, toolId = 't1') => { adapter.adapt({ subtype: 'init', type: 'system' }); diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.ts index 41886f88c8..8e208bebea 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.ts @@ -327,7 +327,15 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { ? block.content : Array.isArray(block.content) ? block.content - .map((c: any) => c.text || c.content || '') + .map((c: any) => { + // `ToolSearch` results ship as `{type: 'tool_reference', tool_name}` + // blocks — no `text` / `content` field. Without this branch the + // mapper returns '' for every reference, filter drops them all, + // and the tool message lands in DB with empty content — leaving + // the UI's StatusIndicator stuck on the spinner (LOBE-7369). + if (c?.type === 'tool_reference' && c.tool_name) return c.tool_name; + return c.text || c.content || ''; + }) .filter(Boolean) .join('\n') : JSON.stringify(block.content || ''); diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts index bdb0debf3a..1960b6c44f 100644 --- a/src/features/Electron/titlebar/TabBar/styles.ts +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -70,10 +70,10 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({ } `, tabActive: css` - background-color: ${cssVar.colorBgContainer}; + background-color: ${cssVar.colorBgElevated}; &:hover { - background-color: ${cssVar.colorBgContainer}; + background-color: ${cssVar.colorBgElevated}; } `, tabIcon: css` diff --git a/src/server/services/bot/AgentBridgeService.ts b/src/server/services/bot/AgentBridgeService.ts index a7545d0ffa..c70102a38e 100644 --- a/src/server/services/bot/AgentBridgeService.ts +++ b/src/server/services/bot/AgentBridgeService.ts @@ -223,18 +223,27 @@ export class AgentBridgeService { private async finishStartupFailure(params: { error?: unknown; + operationId?: string; progressMessage?: SentMessage; stopped?: boolean; thread: Thread; userMessage: Message; }): Promise { - const { error, progressMessage, stopped, thread, userMessage } = params; + const { error, operationId, progressMessage, stopped, thread, userMessage } = params; const errorMessage = error instanceof Error ? error.message : error ? String(error) : 'Agent execution failed'; + log( + 'finishStartupFailure: thread=%s, operationId=%s, stopped=%s, error=%s', + thread.id, + operationId, + stopped, + errorMessage, + ); + AgentBridgeService.clearActiveThread(thread.id); - const errorContent = stopped ? renderStopped(errorMessage) : renderError(errorMessage); + const errorContent = stopped ? renderStopped(errorMessage) : renderError(operationId); if (progressMessage) { try { @@ -332,9 +341,8 @@ export class AgentBridgeService { } } catch (error) { log('handleMention error: %O', error); - const msg = error instanceof Error ? error.message : String(error); try { - await thread.post(`**Agent Execution Failed**\n\`\`\`\n${msg}\n\`\`\``); + await thread.post(renderError()); } catch (postError) { log('handleMention: failed to post error message: %O', postError); } @@ -760,6 +768,7 @@ export class AgentBridgeService { if (!result.success) { await this.finishStartupFailure({ error: result.error, + operationId: result.operationId, progressMessage, thread, userMessage, @@ -926,8 +935,13 @@ export class AgentBridgeService { if (reason === 'error') { const errorMsg = event.errorMessage || 'Agent execution failed'; + log( + 'onComplete: agent run failed, operationId=%s, errorMessage=%s', + event.operationId, + errorMsg, + ); try { - const errorText = renderError(errorMsg); + const errorText = renderError(event.operationId); if (progressMessage) { await progressMessage.edit(errorText); } else { @@ -1048,11 +1062,15 @@ export class AgentBridgeService { if (!result.success) { clearTimeout(timeout); + log( + 'executeWithCallback[local]: startup failed, operationId=%s, error=%s', + result.operationId, + result.error, + ); + if (progressMessage) { try { - await progressMessage.edit( - renderError(result.error || 'Agent operation failed to start'), - ); + await progressMessage.edit(renderError(result.operationId)); } catch (error) { log('executeWithCallback[local]: failed to edit startup error: %O', error); } @@ -1100,9 +1118,11 @@ export class AgentBridgeService { return; } + log('executeWithCallback[local]: startup error: %s', extractErrorMessage(error)); + if (progressMessage) { try { - await progressMessage.edit(renderError(extractErrorMessage(error))); + await progressMessage.edit(renderError()); } catch (editError) { log('executeWithCallback[local]: failed to edit startup error: %O', editError); } diff --git a/src/server/services/bot/BotCallbackService.ts b/src/server/services/bot/BotCallbackService.ts index 76ac10fd3d..4ae92179f2 100644 --- a/src/server/services/bot/BotCallbackService.ts +++ b/src/server/services/bot/BotCallbackService.ts @@ -39,6 +39,7 @@ export interface BotCallbackBody { lastLLMContent?: string; lastToolsCalling?: any; llmCalls?: number; + operationId?: string; platformThreadId: string; progressMessageId?: string; reason?: string; @@ -227,10 +228,15 @@ export class BotCallbackService { charLimit?: number, canEdit = true, ): Promise { - const { reason, lastAssistantContent, errorMessage } = body; + const { reason, lastAssistantContent, errorMessage, operationId } = body; if (reason === 'error') { - const errorText = renderError(errorMessage || 'Agent execution failed'); + log( + 'handleCompletion: agent run failed, operationId=%s, errorMessage=%s', + operationId, + errorMessage, + ); + const errorText = renderError(operationId); try { if (canEdit && progressMessageId) { await messenger.editMessage(progressMessageId, errorText); diff --git a/src/server/services/bot/BotMessageRouter.ts b/src/server/services/bot/BotMessageRouter.ts index 050008399b..1fc10ba3fb 100644 --- a/src/server/services/bot/BotMessageRouter.ts +++ b/src/server/services/bot/BotMessageRouter.ts @@ -476,8 +476,7 @@ export class BotMessageRouter { } catch (error) { log('onNewMention: unhandled error from handleMention: %O', error); try { - const errMsg = error instanceof Error ? error.message : String(error); - await thread.post(renderError(errMsg)); + await thread.post(renderError()); } catch { // best-effort notification } @@ -553,8 +552,7 @@ export class BotMessageRouter { } catch (error) { log('onSubscribedMessage: unhandled error from handleSubscribedMessage: %O', error); try { - const errMsg = error instanceof Error ? error.message : String(error); - await thread.post(renderError(errMsg)); + await thread.post(renderError()); } catch { // best-effort notification } diff --git a/src/server/services/bot/__tests__/BotCallbackService.test.ts b/src/server/services/bot/__tests__/BotCallbackService.test.ts index 04027b7b62..273ca39785 100644 --- a/src/server/services/bot/__tests__/BotCallbackService.test.ts +++ b/src/server/services/bot/__tests__/BotCallbackService.test.ts @@ -326,9 +326,10 @@ describe('BotCallbackService', () => { // ==================== Completion handling ==================== describe('completion handling', () => { - it('should render error message when reason is error', async () => { + it('should render operation id when reason is error', async () => { const body = makeBody({ errorMessage: 'Model quota exceeded', + operationId: 'op-xyz-1', reason: 'error', type: 'completion', }); @@ -337,11 +338,15 @@ describe('BotCallbackService', () => { expect(mockEditMessage).toHaveBeenCalledWith( 'progress-msg-1', - expect.stringContaining('Model quota exceeded'), + expect.stringContaining('op-xyz-1'), + ); + expect(mockEditMessage).toHaveBeenCalledWith( + 'progress-msg-1', + expect.not.stringContaining('Model quota exceeded'), ); }); - it('should use default error message when errorMessage is not provided', async () => { + it('should render generic failure message when operationId is missing', async () => { const body = makeBody({ reason: 'error', type: 'completion', @@ -349,10 +354,7 @@ describe('BotCallbackService', () => { await service.handleCallback(body); - expect(mockEditMessage).toHaveBeenCalledWith( - 'progress-msg-1', - expect.stringContaining('Agent execution failed'), - ); + expect(mockEditMessage).toHaveBeenCalledWith('progress-msg-1', '**Agent Execution Failed**'); }); it('should render stopped message when reason is interrupted', async () => { @@ -825,6 +827,7 @@ describe('BotCallbackService', () => { errorMessage: 'Rate limit exceeded', hookId: 'bot-completion', hookType: 'onComplete', + operationId: 'op-hook-1', reason: 'error', type: 'completion', }); @@ -833,7 +836,11 @@ describe('BotCallbackService', () => { expect(mockEditMessage).toHaveBeenCalledWith( 'progress-msg-1', - expect.stringContaining('Rate limit exceeded'), + expect.stringContaining('op-hook-1'), + ); + expect(mockEditMessage).toHaveBeenCalledWith( + 'progress-msg-1', + expect.not.stringContaining('Rate limit exceeded'), ); }); }); diff --git a/src/server/services/bot/__tests__/replyTemplate.test.ts b/src/server/services/bot/__tests__/replyTemplate.test.ts index 84a5f196bd..cd68f27edf 100644 --- a/src/server/services/bot/__tests__/replyTemplate.test.ts +++ b/src/server/services/bot/__tests__/replyTemplate.test.ts @@ -327,11 +327,15 @@ describe('replyTemplate', () => { // ==================== renderError ==================== describe('renderError', () => { - it('should wrap error in markdown code block', () => { - expect(renderError('Something went wrong')).toBe( - '**Agent Execution Failed**\n```\nSomething went wrong\n```', + it('should include the operation id when provided', () => { + expect(renderError('op-abc-123')).toBe( + '**Agent Execution Failed**\nOperation ID: `op-abc-123`', ); }); + + it('should fall back to a generic header when no operation id is provided', () => { + expect(renderError()).toBe('**Agent Execution Failed**'); + }); }); // ==================== renderStepProgress (dispatcher) ==================== diff --git a/src/server/services/bot/replyTemplate.ts b/src/server/services/bot/replyTemplate.ts index 05f9d25283..7a860064c5 100644 --- a/src/server/services/bot/replyTemplate.ts +++ b/src/server/services/bot/replyTemplate.ts @@ -188,8 +188,11 @@ export function renderFinalReply(content: string): string { return content.trimEnd(); } -export function renderError(errorMessage: string): string { - return `**Agent Execution Failed**\n\`\`\`\n${errorMessage}\n\`\`\``; +export function renderError(operationId?: string): string { + if (operationId) { + return `**Agent Execution Failed**\nOperation ID: \`${operationId}\``; + } + return `**Agent Execution Failed**`; } export function renderStopped(message = 'Execution stopped.'): string {