mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Keep agent background progress inside agent cards
Desktop receives task lifecycle events for local subagents in addition to the visible Agent tool call. Rendering those events as standalone transcript cards exposed internal task types such as local_agent and duplicated the Agent surface. The store now keeps agent lifecycle state for Agent tool notifications without inserting separate transcript background cards, while non-agent background work remains visible with user-facing labels. The long workspace preview test now has a wider timeout so coverage mode does not fail a behaviorally passing case. Constraint: CLI task events use internal task_type values such as local_agent and local_bash. Rejected: Rename local_agent in the standalone card | still leaves duplicate Agent and task cards for one subagent. Confidence: high Scope-risk: moderate Directive: Do not render local_agent or remote_agent as standalone transcript cards unless the Agent tool card no longer exists. Tested: bun run verify passed=8 failed=0 skipped=2 Not-tested: Live provider-backed desktop session smoke.
This commit is contained in:
parent
11f83a76c5
commit
d7315df18b
@ -200,7 +200,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders background agent progress inline in the transcript', () => {
|
||||
it('renders non-agent background progress inline in the transcript', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -212,14 +212,14 @@ describe('MessageList nested tool calls', () => {
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'background-task-agent-1',
|
||||
id: 'background-task-shell-1',
|
||||
type: 'background_task',
|
||||
timestamp: 2,
|
||||
task: {
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-tool-1',
|
||||
taskId: 'shell-task-1',
|
||||
toolUseId: 'shell-tool-1',
|
||||
status: 'running',
|
||||
taskType: 'local_agent',
|
||||
taskType: 'local_bash',
|
||||
summary: 'Running Playwright checks',
|
||||
usage: {
|
||||
totalTokens: 1200,
|
||||
@ -244,27 +244,27 @@ describe('MessageList nested tool calls', () => {
|
||||
render(<MessageList />)
|
||||
|
||||
const card = screen.getByTestId('background-task-event-card')
|
||||
expect(card.textContent).toContain('local_agent')
|
||||
expect(card.textContent).toContain('Background command')
|
||||
expect(card.textContent).toContain('running')
|
||||
expect(card.textContent).toContain('Running Playwright checks')
|
||||
expect(card.textContent).toContain('1,200 tokens')
|
||||
expect(card.textContent).toContain('45s')
|
||||
})
|
||||
|
||||
it('renders stopped background agents as neutral transcript events', () => {
|
||||
it('renders stopped non-agent background tasks as neutral transcript events', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'background-task-agent-stopped',
|
||||
id: 'background-task-shell-stopped',
|
||||
type: 'background_task',
|
||||
timestamp: 2,
|
||||
task: {
|
||||
taskId: 'agent-task-stopped',
|
||||
toolUseId: 'agent-tool-stopped',
|
||||
taskId: 'shell-task-stopped',
|
||||
toolUseId: 'shell-tool-stopped',
|
||||
status: 'stopped',
|
||||
taskType: 'local_agent',
|
||||
summary: 'Agent "Code review for todo app" was stopped',
|
||||
taskType: 'local_bash',
|
||||
summary: 'Command "bun test" was stopped',
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
@ -281,6 +281,77 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(card.querySelector('.text-\\[var\\(--color-error\\)\\]')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses user-facing labels for workflow and unknown background tasks', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'background-task-workflow',
|
||||
type: 'background_task',
|
||||
timestamp: 2,
|
||||
task: {
|
||||
taskId: 'workflow-task',
|
||||
status: 'running',
|
||||
taskType: 'local_workflow',
|
||||
summary: 'Running release checklist',
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'background-task-unknown',
|
||||
type: 'background_task',
|
||||
timestamp: 3,
|
||||
task: {
|
||||
taskId: 'unknown-task',
|
||||
status: 'completed',
|
||||
summary: 'Finished background work',
|
||||
startedAt: 1,
|
||||
updatedAt: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const cards = screen.getAllByTestId('background-task-event-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]?.textContent).toContain('Background workflow')
|
||||
expect(cards[1]?.textContent).toContain('Background task')
|
||||
})
|
||||
|
||||
it('does not render agent background task events as separate transcript cards', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'background-task-agent-hidden',
|
||||
type: 'background_task',
|
||||
timestamp: 2,
|
||||
task: {
|
||||
taskId: 'agent-task-hidden',
|
||||
toolUseId: 'agent-tool-hidden',
|
||||
status: 'running',
|
||||
taskType: 'local_agent',
|
||||
summary: 'Running Read',
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(screen.queryByTestId('background-task-event-card')).toBeNull()
|
||||
expect(screen.queryByText('local_agent')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the full transcript when scrolling away from latest', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -219,6 +219,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
|
||||
const isStopped = task.status === 'stopped'
|
||||
const duration = formatBackgroundTaskDuration(task.usage?.durationMs)
|
||||
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
|
||||
const label = getBackgroundTaskLabel(task.taskType, t)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
@ -242,7 +243,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Bot size={14} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{task.taskType || t('chat.backgroundAgents.agent')}
|
||||
{label}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t(`chat.backgroundAgents.status.${task.status}`)}
|
||||
@ -267,6 +268,25 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentBackgroundTaskMessage(message: UIMessage): boolean {
|
||||
if (message.type !== 'background_task') return false
|
||||
if (message.task.taskType === 'local_agent' || message.task.taskType === 'remote_agent') {
|
||||
return true
|
||||
}
|
||||
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
|
||||
message.task.summary ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
function getBackgroundTaskLabel(
|
||||
taskType: string | undefined,
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
if (taskType === 'local_bash') return t('chat.backgroundTasks.command')
|
||||
if (taskType === 'local_workflow') return t('chat.backgroundTasks.workflow')
|
||||
return t('chat.backgroundTasks.task')
|
||||
}
|
||||
|
||||
function SelectableChatMessage({
|
||||
sessionId,
|
||||
messageId,
|
||||
@ -377,6 +397,9 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
if (msg.type === 'assistant_text' && !msg.content.trim()) {
|
||||
continue
|
||||
}
|
||||
if (isAgentBackgroundTaskMessage(msg)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
|
||||
continue
|
||||
@ -412,7 +435,7 @@ function isTurnResponseMessage(message: UIMessage) {
|
||||
message.type === 'assistant_text' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'background_task' ||
|
||||
(message.type === 'background_task' && !isAgentBackgroundTaskMessage(message)) ||
|
||||
message.type === 'error' ||
|
||||
message.type === 'task_summary'
|
||||
)
|
||||
|
||||
@ -970,7 +970,7 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
|
||||
})
|
||||
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
||||
}, 10_000)
|
||||
}, 20_000)
|
||||
|
||||
it('renders image previews from workspace files', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
|
||||
@ -966,6 +966,9 @@ export const en = {
|
||||
'chat.backgroundAgents.title': 'Background agents',
|
||||
'chat.backgroundAgents.count': '{count} active or recent',
|
||||
'chat.backgroundAgents.agent': 'agent',
|
||||
'chat.backgroundTasks.command': 'Background command',
|
||||
'chat.backgroundTasks.workflow': 'Background workflow',
|
||||
'chat.backgroundTasks.task': 'Background task',
|
||||
'chat.backgroundAgents.tokens': '{count} tokens',
|
||||
'chat.backgroundAgents.status.running': 'running',
|
||||
'chat.backgroundAgents.status.completed': 'completed',
|
||||
|
||||
@ -968,6 +968,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.backgroundAgents.title': '后台 Agent',
|
||||
'chat.backgroundAgents.count': '{count} 个运行中或最近任务',
|
||||
'chat.backgroundAgents.agent': 'agent',
|
||||
'chat.backgroundTasks.command': '后台命令',
|
||||
'chat.backgroundTasks.workflow': '后台工作流',
|
||||
'chat.backgroundTasks.task': '后台任务',
|
||||
'chat.backgroundAgents.tokens': '{count} tokens',
|
||||
'chat.backgroundAgents.status.running': '运行中',
|
||||
'chat.backgroundAgents.status.completed': '已完成',
|
||||
|
||||
@ -436,15 +436,6 @@ describe('chatStore history mapping', () => {
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.messages).toMatchObject([
|
||||
{ id: 'visible-message', type: 'assistant_text', content: 'already rendered' },
|
||||
{
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Agent completed',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goal-complete',
|
||||
type: 'goal_event',
|
||||
@ -1255,7 +1246,7 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks background agent task lifecycle events for desktop visibility', () => {
|
||||
it('tracks background agent task lifecycle without duplicating transcript cards', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
|
||||
|
||||
@ -1289,18 +1280,7 @@ describe('chatStore history mapping', () => {
|
||||
taskType: 'local_agent',
|
||||
prompt: 'Run E2E verification',
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'agent-task-1',
|
||||
status: 'running',
|
||||
description: 'Verify the todo app',
|
||||
},
|
||||
},
|
||||
])
|
||||
const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp
|
||||
expect(insertedTaskTimestamp).toBe(new Date('2026-04-06T00:00:02.000Z').getTime())
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-06T00:00:03.000Z'))
|
||||
|
||||
@ -1331,16 +1311,7 @@ describe('chatStore history mapping', () => {
|
||||
durationMs: 45000,
|
||||
},
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1)
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'agent-task-1',
|
||||
status: 'running',
|
||||
summary: 'Running Playwright checks',
|
||||
},
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-06T00:00:04.000Z'))
|
||||
|
||||
@ -1376,19 +1347,110 @@ describe('chatStore history mapping', () => {
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1)
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'agent-task-1',
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps non-agent background tasks visible and updates the existing transcript card', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
data: {
|
||||
task_id: 'shell-task-1',
|
||||
tool_use_id: 'shell-tool-1',
|
||||
description: 'Run desktop checks',
|
||||
task_type: 'local_bash',
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'shell-task-1',
|
||||
toolUseId: 'shell-tool-1',
|
||||
status: 'running',
|
||||
taskType: 'local_bash',
|
||||
description: 'Run desktop checks',
|
||||
},
|
||||
},
|
||||
])
|
||||
const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z'))
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_progress',
|
||||
data: {
|
||||
task_id: 'shell-task-1',
|
||||
tool_use_id: 'shell-tool-1',
|
||||
description: 'Run desktop checks',
|
||||
summary: 'Running Vitest',
|
||||
last_tool_name: 'Bash',
|
||||
task_type: 'local_bash',
|
||||
},
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.messages).toHaveLength(1)
|
||||
expect(session?.messages[0]).toMatchObject({
|
||||
type: 'background_task',
|
||||
task: {
|
||||
taskId: 'shell-task-1',
|
||||
status: 'running',
|
||||
summary: 'Running Vitest',
|
||||
lastToolName: 'Bash',
|
||||
},
|
||||
})
|
||||
expect(session?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes stale agent task transcript cards by matching tool use id', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
messages: [{
|
||||
id: 'background-task-old-agent-task',
|
||||
type: 'background_task',
|
||||
timestamp: 1,
|
||||
task: {
|
||||
taskId: 'old-agent-task',
|
||||
toolUseId: 'agent-tool-1',
|
||||
status: 'running',
|
||||
taskType: 'local_agent',
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
data: {
|
||||
task_id: 'new-agent-task',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
task_type: 'local_agent',
|
||||
description: 'Review app',
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears local desktop chat state when the server confirms /clear', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@ -234,10 +234,17 @@ function upsertBackgroundTaskMessage(
|
||||
task: BackgroundAgentTask,
|
||||
timestamp: number,
|
||||
): UIMessage[] {
|
||||
const existingIndex = messages.findIndex((message) =>
|
||||
const isSameTaskMessage = (message: UIMessage) =>
|
||||
message.type === 'background_task' &&
|
||||
(message.task.taskId === task.taskId ||
|
||||
(task.toolUseId && message.task.toolUseId === task.toolUseId)))
|
||||
(task.toolUseId && message.task.toolUseId === task.toolUseId))
|
||||
|
||||
if (isAgentBackgroundTask(task)) {
|
||||
return messages.filter((message) => !isSameTaskMessage(message))
|
||||
}
|
||||
|
||||
const existingIndex = messages.findIndex((message) =>
|
||||
isSameTaskMessage(message))
|
||||
if (existingIndex === -1) {
|
||||
return [...messages, {
|
||||
id: `background-task-${task.taskId}`,
|
||||
@ -264,6 +271,15 @@ function mergeBackgroundTaskMessages(
|
||||
return [...merged].sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
function isAgentBackgroundTask(task: Pick<BackgroundAgentTask, 'taskType' | 'summary'>): boolean {
|
||||
if (task.taskType === 'local_agent' || task.taskType === 'remote_agent') {
|
||||
return true
|
||||
}
|
||||
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
|
||||
task.summary ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
function mergeRestoredTerminalGoalEvents(
|
||||
messages: UIMessage[],
|
||||
restoredMessages: UIMessage[],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user