🐛 fix: hetero-agent ToolSearch content + bot IM reply + titlebar polish (#13998)
Some checks are pending
E2E CI / Check Duplicate Run (push) Waiting to run
E2E CI / Test Web App (push) Blocked by required conditions
Release Desktop Canary / Calculate Canary Version (push) Waiting to run
Release Desktop Canary / Code quality check (push) Blocked by required conditions
Release Desktop Canary / Build Desktop App (push) Blocked by required conditions
Release Desktop Canary / Merge macOS Release Files (push) Blocked by required conditions
Release Desktop Canary / Publish Canary Release (push) Blocked by required conditions
Release Desktop Canary / Publish to S3 (push) Blocked by required conditions
Release Desktop Canary / Cleanup Old Canary Releases (push) Blocked by required conditions
Test CI / Check Duplicate Run (push) Waiting to run
Test CI / Test Packages (push) Blocked by required conditions
Test CI / Test App (shard 1/3) (push) Blocked by required conditions
Test CI / Test App (shard 2/3) (push) Blocked by required conditions
Test CI / Test App (shard 3/3) (push) Blocked by required conditions
Test CI / Merge and Upload App Coverage (push) Blocked by required conditions
Test CI / Test Desktop App (push) Blocked by required conditions
Test CI / Test Database (push) Blocked by required conditions

* 💄 style(electron): use colorBgElevated for active title-bar tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 fix(bot): show operation id instead of raw error in IM failure reply

Replace the error message content in bot-facing failure replies with the
operation id so end users don't see raw runtime errors; errors are still
logged server-side for debugging and correlation via operation id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): extract tool_name from ToolSearch tool_reference blocks

CC CLI returns ToolSearch results as `tool_reference` content blocks with
only a `tool_name` field — no `text`/`content` — so the generic array
mapper collapsed every entry to '' and persisted empty content, keeping
the UI tool StatusIndicator stuck on the spinner (LOBE-7369).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-20 23:11:34 +08:00 committed by GitHub
parent 16df8350fe
commit b4aa51baaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 198 additions and 31 deletions

View file

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

View file

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

View file

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

View file

@ -223,18 +223,27 @@ export class AgentBridgeService {
private async finishStartupFailure(params: {
error?: unknown;
operationId?: string;
progressMessage?: SentMessage;
stopped?: boolean;
thread: Thread<ThreadState>;
userMessage: Message;
}): Promise<void> {
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);
}

View file

@ -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<void> {
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);

View file

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

View file

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

View file

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

View file

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