🐛 fix(hetero-agent): persist streamed text alongside tool writes; collapse workflow summary (#13968)

* 🐛 fix(hetero-agent): persist accumulated text alongside tools[] writes

Carry the latest streamed content/reasoning into the same UPDATE that
writes tools[], so the DB row stays in sync with the in-memory stream.
Without this, gateway `tool_end → fetchAndReplaceMessages` reads a
tools-only row and clobbers the UI's streamed text.

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

*  feat(workflow-summary): collapse summary when many tool kinds

When a turn calls >4 distinct tool kinds, list only the top 3 by count
and append "+N more · X calls total[ · Y failed]". Keeps the inline
summary scannable on long tool-heavy turns instead of running off the
line. Short turns keep the existing full list.

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

* 💄 style(claude-code): use chip style for Skill inspector name

Replace the colon+highlight text with a pill-shaped chip containing the
SkillsIcon and skill name. Gives the Skill activation readout visual
parity with other tool chips and prevents long skill names from
overflowing the inspector line.

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

*  test(agent-documents): assert on rendered title, not filename

#13940 changed DocumentItem to prefer document.title over filename, but
the sidebar test still expected 'brief.md' / 'example.com'. Align the
assertions with the current behavior so the suite is green on canary.

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

* 💄 style(tab-bar): show agent avatar on agent/topic tabs

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-19 17:13:46 +08:00 committed by GitHub
parent ccbb75da06
commit 77fd0f13f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 197 additions and 39 deletions

View file

@ -480,6 +480,9 @@
"viewMode.wideScreen": "Widescreen",
"workflow.awaitingConfirmation": "Awaiting your confirmation",
"workflow.failedSuffix": "(failed)",
"workflow.summaryFailed": "{{count}} failed",
"workflow.summaryMoreTools": "+{{count}} more",
"workflow.summaryTotalCalls": "{{count}} calls total",
"workflow.thoughtForDuration": "Thought for {{duration}}",
"workflow.toolDisplayName.activateDevice": "Activated device",
"workflow.toolDisplayName.activateSkill": "Activated a skill",

View file

@ -480,6 +480,9 @@
"viewMode.wideScreen": "宽屏",
"workflow.awaitingConfirmation": "需要你确认",
"workflow.failedSuffix": "(失败)",
"workflow.summaryFailed": "{{count}} 次失败",
"workflow.summaryMoreTools": "等 {{count}} 种工具",
"workflow.summaryTotalCalls": "共 {{count}} 次调用",
"workflow.thoughtForDuration": "思考了 {{duration}}",
"workflow.toolDisplayName.activateDevice": "激活了设备",
"workflow.toolDisplayName.activateSkill": "启用了技能",

View file

@ -1,17 +1,46 @@
'use client';
import {
highlightTextStyles,
inspectorTextStyles,
shinyTextStyles,
} from '@lobechat/shared-tool-ui/styles';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type SkillArgs } from '../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
gap: 6px;
align-items: center;
min-width: 0;
margin-inline-start: 6px;
padding-block: 2px;
padding-inline: 10px;
border-radius: 999px;
background: ${cssVar.colorFillTertiary};
`,
skillIcon: css`
flex-shrink: 0;
color: ${cssVar.colorTextDescription};
`,
skillName: css`
overflow: hidden;
min-width: 0;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
@ -31,10 +60,10 @@ export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
>
<span>{label}</span>
{skillName && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{skillName}</span>
</>
<span className={styles.chip}>
<SkillsIcon className={styles.skillIcon} size={12} />
<span className={styles.skillName}>{skillName}</span>
</span>
)}
</div>
);

View file

@ -384,6 +384,8 @@ export const formatReasoningDuration = (ms: number): string => {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
};
const WORKFLOW_SUMMARY_TOP_N = 3;
export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string => {
const tools = blocks.flatMap((b) => b.tools ?? []);
@ -395,16 +397,57 @@ export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string
groups.set(tool.apiName, existing);
}
const toolParts: string[] = [];
for (const [apiName, { count, errorCount }] of groups) {
let part = getToolDisplayName(apiName);
if (count > 1) part += ` (${count})`;
if (errorCount > 0)
part += ` ${t('workflow.failedSuffix', { defaultValue: '(failed)', ns: 'chat' })}`;
toolParts.push(part);
}
const entries = [...groups.entries()];
const totalCalls = entries.reduce((sum, [, { count }]) => sum + count, 0);
const totalErrors = entries.reduce((sum, [, { errorCount }]) => sum + errorCount, 0);
let result = toolParts.join(', ');
let result: string;
// Few tool kinds: list each one fully (current behavior). "+1 more" reads awkwardly,
// so we only collapse when there are at least 2 extra kinds beyond the top N.
if (entries.length <= WORKFLOW_SUMMARY_TOP_N + 1) {
const toolParts = entries.map(([apiName, { count, errorCount }]) => {
let part = getToolDisplayName(apiName);
if (count > 1) part += ` (${count})`;
if (errorCount > 0)
part += ` ${t('workflow.failedSuffix', { defaultValue: '(failed)', ns: 'chat' })}`;
return part;
});
result = toolParts.join(', ');
} else {
const sorted = [...entries].sort(([, a], [, b]) => b.count - a.count);
const top = sorted.slice(0, WORKFLOW_SUMMARY_TOP_N);
const remainingKinds = sorted.length - WORKFLOW_SUMMARY_TOP_N;
const topText = top
.map(([apiName, { count }]) => {
const name = getToolDisplayName(apiName);
return count > 1 ? `${name} (${count})` : name;
})
.join(', ');
const segments: string[] = [
`${topText} ${t('workflow.summaryMoreTools', {
count: remainingKinds,
defaultValue: '+{{count}} more',
ns: 'chat',
})}`,
t('workflow.summaryTotalCalls', {
count: totalCalls,
defaultValue: '{{count}} calls total',
ns: 'chat',
}),
];
if (totalErrors > 0) {
segments.push(
t('workflow.summaryFailed', {
count: totalErrors,
defaultValue: '{{count}} failed',
ns: 'chat',
}),
);
}
result = segments.join(' · ');
}
const totalReasoningMs = blocks.reduce((sum, b) => sum + (b.reasoning?.duration ?? 0), 0);
if (totalReasoningMs > 0) {

View file

@ -1,6 +1,13 @@
'use client';
import { ActionIcon, ContextMenuTrigger, Flexbox, type GenericItemType, Icon } from '@lobehub/ui';
import {
ActionIcon,
Avatar,
ContextMenuTrigger,
Flexbox,
type GenericItemType,
Icon,
} from '@lobehub/ui';
import { cx } from 'antd-style';
import { X } from 'lucide-react';
import { memo, useCallback } from 'react';
@ -91,7 +98,17 @@ const TabItem = memo<TabItemProps>(
gap={6}
onClick={handleClick}
>
{item.icon && <Icon className={styles.tabIcon} icon={item.icon} size="small" />}
{item.avatar ? (
<Avatar
emojiScaleWithBackground
avatar={item.avatar}
background={item.backgroundColor}
shape="square"
size={16}
/>
) : (
item.icon && <Icon className={styles.tabIcon} icon={item.icon} size="small" />
)}
<span className={styles.tabTitle}>{item.title}</span>
<ActionIcon
className={cx('closeIcon', styles.closeIcon)}

View file

@ -529,6 +529,9 @@ export default {
'viewMode.normal': 'Standard',
'viewMode.wideScreen': 'Widescreen',
'workflow.failedSuffix': '(failed)',
'workflow.summaryFailed': '{{count}} failed',
'workflow.summaryMoreTools': '+{{count}} more',
'workflow.summaryTotalCalls': '{{count}} calls total',
'workflow.thoughtForDuration': 'Thought for {{duration}}',
'workflow.toolDisplayName.activateDevice': 'Activated device',
'workflow.toolDisplayName.activateSkill': 'Activated a skill',

View file

@ -121,7 +121,7 @@ describe('AgentDocumentsGroup', () => {
render(<AgentDocumentsGroup />);
const item = await screen.findByText('brief.md');
const item = await screen.findByText('Brief');
expect(item).toBeInTheDocument();
expect(screen.getByText('A short brief')).toBeInTheDocument();
@ -160,18 +160,18 @@ describe('AgentDocumentsGroup', () => {
render(<AgentDocumentsGroup />);
expect(screen.getByText('brief.md')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.getByText('Brief')).toBeInTheDocument();
expect(screen.getByText('Example')).toBeInTheDocument();
fireEvent.click(screen.getByText('Web'));
expect(screen.queryByText('brief.md')).not.toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.queryByText('Brief')).not.toBeInTheDocument();
expect(screen.getByText('Example')).toBeInTheDocument();
fireEvent.click(screen.getByText('Documents'));
expect(screen.getByText('brief.md')).toBeInTheDocument();
expect(screen.queryByText('example.com')).not.toBeInTheDocument();
expect(screen.getByText('Brief')).toBeInTheDocument();
expect(screen.queryByText('Example')).not.toBeInTheDocument();
});
it('renders empty state when no documents', () => {

View file

@ -928,6 +928,41 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
expect(glob1Create![0].parentId).toBe(turn5AssistantId);
expect(glob2Create![0].parentId).toBe(turn5AssistantId);
});
/**
* Regression: when a turn has text BEFORE tool_use under the same message.id,
* the tools[] write must carry the accumulated content too. Otherwise the
* gateway handler's `tool_end → fetchAndReplaceMessages` reads a tools-only
* row and clobbers the in-memory streamed text in the UI.
*/
it('should persist accumulated text alongside tools when turn has text + tool_use', async () => {
const writes: Array<{ assistantId: string; content?: string; toolIds?: string[] }> = [];
mockUpdateMessage.mockImplementation(async (id: string, val: any) => {
if (val.tools) {
writes.push({
assistantId: id,
content: val.content,
toolIds: val.tools.map((t: any) => t.id),
});
}
});
await runWithEvents([
ccInit(),
// text streams first, then tool_use — same msg.id
ccText('msg_01', 'Let me check the file...'),
ccToolUse('msg_01', 'toolu_read', 'Read', { file_path: '/a.ts' }),
ccToolResult('toolu_read', 'file content'),
ccResult(),
]);
const toolWrites = writes.filter((w) => w.toolIds?.includes('toolu_read'));
expect(toolWrites.length).toBeGreaterThanOrEqual(1);
// Every tools[] write for this assistant must carry the accumulated text
for (const w of toolWrites) {
expect(w.content).toBe('Let me check the file...');
}
});
});
// ────────────────────────────────────────────────────

View file

@ -135,12 +135,17 @@ interface ToolPersistenceState {
* - assistant.tools[].result_msg_id is set to the created tool message id, so
* the UI's parse() step can link tool messages back to the assistant turn
* (otherwise they render as orphan warnings).
* - Carries the latest accumulated text/reasoning into the same UPDATE, so DB
* stays in sync with what's been streamed. Without this, gateway handler's
* `tool_end → fetchAndReplaceMessages` would read a tools-only/no-content
* row and clobber the in-memory streamed text in the UI.
*/
const persistNewToolCalls = async (
incoming: ToolCallPayload[],
state: ToolPersistenceState,
assistantMessageId: string,
context: ConversationContext,
snapshot: { content: string; reasoning: string },
) => {
const freshTools = incoming.filter((t) => !state.persistedIds.has(t.id));
if (freshTools.length === 0) return;
@ -149,6 +154,13 @@ const persistNewToolCalls = async (
// Claude Code echoing tool_use blocks) are safely deduped.
for (const tool of freshTools) state.persistedIds.add(tool.id);
const buildUpdate = (): Record<string, any> => {
const update: Record<string, any> = { tools: state.payloads };
if (snapshot.content) update.content = snapshot.content;
if (snapshot.reasoning) update.reasoning = { content: snapshot.reasoning };
return update;
};
// ─── PHASE 1: Write tools[] to assistant FIRST, WITHOUT result_msg_id ───
//
// LobeHub's conversation-flow parser filters tool messages by matching
@ -161,11 +173,10 @@ const persistNewToolCalls = async (
// No orphan window.
for (const tool of freshTools) state.payloads.push({ ...tool } as ChatToolPayload);
try {
await messageService.updateMessage(
assistantMessageId,
{ tools: state.payloads },
{ agentId: context.agentId, topicId: context.topicId },
);
await messageService.updateMessage(assistantMessageId, buildUpdate(), {
agentId: context.agentId,
topicId: context.topicId,
});
} catch (err) {
console.error('[HeterogeneousAgent] Failed to pre-register assistant tools:', err);
}
@ -201,11 +212,10 @@ const persistNewToolCalls = async (
// ─── PHASE 3: Re-write assistant.tools[] with the result_msg_ids ───
// Without this, the UI can't hydrate tool results back into the inspector.
try {
await messageService.updateMessage(
assistantMessageId,
{ tools: state.payloads },
{ agentId: context.agentId, topicId: context.topicId },
);
await messageService.updateMessage(assistantMessageId, buildUpdate(), {
agentId: context.agentId,
topicId: context.topicId,
});
} catch (err) {
console.error('[HeterogeneousAgent] Failed to finalize assistant tools:', err);
}
@ -508,8 +518,23 @@ export const executeHeterogeneousAgent = async (
if (chunk?.chunkType === 'tools_calling') {
const tools = chunk.toolsCalling as ToolCallPayload[];
if (tools?.length) {
// Snapshot accumulators sync — must travel with the same step's
// assistantMessageId. A late-bound getter would read NEW step's
// content if a step transition lands between scheduling and
// execution, while assistantMessageId would still be the OLD
// one (also captured sync) → cross-step contamination.
const snapshot = {
content: accumulatedContent,
reasoning: accumulatedReasoning,
};
persistQueue = persistQueue.then(() =>
persistNewToolCalls(tools, toolState, currentAssistantMessageId, context),
persistNewToolCalls(
tools,
toolState,
currentAssistantMessageId,
context,
snapshot,
),
);
}
}