mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🐛 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
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:
parent
16df8350fe
commit
b4aa51baaa
9 changed files with 198 additions and 31 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) ====================
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue