diff --git a/.playwright/scripts/__tests__/agentInput.e2e.mjs b/.playwright/scripts/__tests__/agentInput.e2e.mjs index 9f452c88..ce1eeb65 100644 --- a/.playwright/scripts/__tests__/agentInput.e2e.mjs +++ b/.playwright/scripts/__tests__/agentInput.e2e.mjs @@ -188,8 +188,8 @@ test('model retries retain their run budget, expose progress and allow cancellin await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-finite.png'), animations: 'disabled' }) await expect(activity).toContainText('正在思考') await expect(activity).not.toContainText('重试') - await activity.getByRole('button', { name: '查看思考详情', exact: true }).click() - await expect(activity.locator('.buddy-chat-run-activity__reasoning')).toContainText('Inspecting the recovered request.') + await page.locator('.buddy-chat-reasoning-entry__header').click() + await expect(page.locator('.buddy-chat-reasoning-entry__body')).toContainText('Inspecting the recovered request.') await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-recovered-reasoning.png'), animations: 'disabled' }) finishThinking.resolve() await expect.poll(() => completedRuns(instance.home)).toBe(1) @@ -256,6 +256,173 @@ test('model retries retain their run budget, expose progress and allow cancellin } }) +test('activity groups retain open reasoning across tools, reply phases and canvas replay', async ({ buddy }) => { + const gates = Object.fromEntries(['tools', 'arguments', 'thinking', 'reply', 'finish'].map(name => [name, Promise.withResolvers()])) + const requests = [] + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { + response.writeHead(404).end() + return + } + const chunks = [] + for await (const chunk of request) + chunks.push(chunk) + requests.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + const common = { id: `activity-${requests.length}`, model: 'activity-fixture', object: 'chat.completion.chunk', created: 1 } + const send = (delta, finishReason = null) => response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + send({ role: 'assistant' }) + if (requests.length === 1) { + send({ reasoning_content: '先检查工作目录,再确认文件是否可读。' }) + await gates.tools.promise + send({ tool_calls: [{ index: 0, id: 'list-workspace', type: 'function', function: { name: 'ls', arguments: '{' } }] }) + await gates.arguments.promise + send({ tool_calls: [ + { index: 0, function: { arguments: '"path":"."}' } }, + { index: 1, id: 'read-missing', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'activity-fixture-missing.txt' }) } }, + ] }) + send({}, 'tool_calls') + } + else { + await gates.thinking.promise + send({ reasoning_content: '目录检查已完成,文件不存在。保留异常信息,继续整理结果。' }) + await gates.reply.promise + send({ content: '检查完成:工作目录可访问,指定文件不存在。' }) + await gates.finish.promise + send({}, 'stop') + } + response.end('data: [DONE]\n\n') + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const instance = await buddy.createInstance('activity-groups') + try { + const application = await instance.launch() + await useSyntheticCredentialStorage(application) + const { page } = application + await page.evaluate(async (baseUrl) => { + const providers = window.lexoraDesktop.localChat.providers + await providers.upsertCustom({ id: 'activity-fixture', displayName: 'Activity fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'activity-fixture', name: 'Activity fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] }) + const stop = providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'activity-fixture' && challenge.type === 'secret') + void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await providers.login('activity-fixture', 'api_key') + } + finally { + stop() + } + await providers.setDefaultModel({ providerId: 'activity-fixture', modelId: 'activity-fixture', reasoning: null }) + }, `http://127.0.0.1:${server.address().port}/v1`) + await page.reload() + await page.locator('.desktop-chat-composer__prosemirror:visible').fill('检查工作目录和指定文件,并说明结果。') + await page.getByRole('button', { name: '发送消息', exact: true }).click() + const activity = page.locator('.buddy-chat-run-activity') + const group = page.locator('.buddy-chat-activity-group') + await expect(activity).toContainText('正在思考') + await expect(group.locator('.buddy-chat-reasoning-entry__header')).toContainText('正在思考') + await group.locator('.buddy-chat-reasoning-entry__header').click() + const originalBody = await group.locator('.buddy-chat-reasoning-entry__body').elementHandle() + await expect(group.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录') + gates.tools.resolve() + await expect(activity).toContainText('准备中') + await expect(group).toHaveAttribute('data-status', 'completed') + expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + gates.arguments.resolve() + await expect.poll(() => requests.length).toBe(2) + await expect(group).toHaveAttribute('data-status', 'completed') + await expect(group.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次') + await expect(group.locator('.buddy-chat-activity-group__header')).toHaveAttribute('aria-expanded', 'true') + expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + await expect(activity).toContainText('正在处理') + await expect(group.locator('.buddy-chat-activity-group__issues')).toContainText('1 项异常') + const failedTool = group.locator('[data-tool-call-id="read-missing"]') + await expectInlineToolStatus(failedTool) + const groupIcon = await group.locator('.buddy-chat-activity-group__header > .buddy-chat-activity-row__icon').elementHandle() + await page.mouse.move(0, 0) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-settled-light.png'), animations: 'disabled' }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + gates.thinking.resolve() + await expect(group).toHaveAttribute('data-status', 'running') + expect(await groupIcon.evaluate(element => element.isConnected)).toBe(true) + expect(await groupIcon.evaluate(element => getComputedStyle(element).animationName)).toBe('none') + await expect(group.locator('.buddy-chat-activity-group__status')).toHaveText('运行中') + await expect(group.locator('.buddy-shimmer-text--continuous')).toHaveCount(1) + expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('3s') + expect(await activity.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('1.8s') + await expect(group.locator('.buddy-chat-reasoning-entry__header').last()).toHaveAttribute('aria-expanded', 'false') + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationName)).toBe('none') + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) + await expect(page.locator('.buddy-app')).toHaveClass(/is-dark/) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-running-dark.png'), animations: 'disabled' }) + await group.evaluate(element => element.style.maxWidth = '320px') + expect(await group.evaluate(element => element.scrollWidth <= element.clientWidth + 4)).toBe(true) + await expectInlineToolStatus(failedTool) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-running-narrow.png'), animations: 'disabled' }) + await group.evaluate(element => element.style.removeProperty('max-width')) + gates.reply.resolve() + await expect(activity).toContainText('正在回复') + await expect(group).toHaveAttribute('data-status', 'completed') + expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + gates.finish.resolve() + await expect.poll(() => completedRuns(instance.home)).toBe(1) + await expect(activity).toHaveCount(0) + expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + await page.getByTestId('conversation-canvas-toggle').click() + await page.locator('.conversation-node[data-kind="answer"]').click() + const detail = page.getByTestId('canvas-node-detail') + await expect(detail.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次') + await detail.locator('.buddy-chat-activity-group__header').click() + await expectInlineToolStatus(detail.locator('[data-tool-call-id="read-missing"]')) + await detail.locator('.buddy-chat-reasoning-entry__header').first().click() + await expect(detail.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-canvas-dark.png'), animations: 'disabled' }) + expect(application.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + for (const gate of Object.values(gates)) + gate.resolve() + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + +async function expectInlineToolStatus(row) { + const spacing = await row.evaluate((element) => { + const status = element.querySelector('.buddy-chat-tool__status') + const headerBounds = element.querySelector('.buddy-chat-tool__header').getBoundingClientRect() + const iconBounds = element.querySelector('.buddy-chat-activity-row__icon').getBoundingClientRect() + const targetBounds = status.previousElementSibling.getBoundingClientRect() + const statusBounds = status.getBoundingClientRect() + const chevronBounds = element.querySelector('.buddy-chat-activity-row__chevron').getBoundingClientRect() + const textBaselines = [...element.querySelectorAll('.buddy-chat-tool__title, .buddy-chat-tool__summary, .buddy-chat-tool__context, .buddy-chat-tool__status > span')].map((text) => { + const marker = document.createElement('span') + marker.style.cssText = 'display: inline-block; width: 0; height: 0; vertical-align: baseline;' + text.append(marker) + const baseline = marker.getBoundingClientRect().top + marker.remove() + return baseline + }) + return { + targetGap: statusBounds.left - targetBounds.right, + chevronGap: chevronBounds.left - statusBounds.right, + textBaselineOffset: Math.max(...textBaselines) - Math.min(...textBaselines), + iconCenterOffset: Math.abs(iconBounds.y + iconBounds.height / 2 - headerBounds.y - headerBounds.height / 2), + chevronCenterOffset: Math.abs(chevronBounds.y + chevronBounds.height / 2 - headerBounds.y - headerBounds.height / 2), + } + }) + expect(spacing.targetGap).toBeGreaterThanOrEqual(0) + expect(spacing.targetGap).toBeLessThanOrEqual(12) + expect(spacing.chevronGap).toBeGreaterThanOrEqual(0) + expect(spacing.chevronGap).toBeLessThanOrEqual(12) + expect(spacing.textBaselineOffset).toBeLessThan(1) + expect(spacing.iconCenterOffset).toBeLessThan(1) + expect(spacing.chevronCenterOffset).toBeLessThan(1) +} + async function useSyntheticCredentialStorage({ app, page }) { await app.evaluate(({ app, safeStorage }) => { if (app.getName() !== 'Lexora Buddy Test') diff --git a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts index 9fbbad72..a1fe57e1 100644 --- a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts @@ -230,6 +230,21 @@ describe('projectPiEvent product messages', () => { }]) }) + it.each([undefined, 'commentary', 'final_answer'] as const)('distinguishes %s text from thinking, tool arguments and empty message starts', (textPhase) => { + const state = createPiEventProjectionState() + const partial: AssistantMessage = { api: 'openai-responses', content: [{ type: 'text', text: '', textSignature: JSON.stringify({ v: 1, id: 'text', phase: textPhase }) }], model: 'fixture', provider: 'fixture', role: 'assistant', stopReason: 'pending', timestamp: 1, usage: emptyUsage() } + projectPiEvent({ type: 'message_start', message: partial }, state) + expect(state.progress?.phase).toBe('model_streaming') + projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'text_start', contentIndex: 0, partial } }, state) + expect(state.progress?.phase).toBe(textPhase === 'commentary' ? 'model_streaming' : 'model_responding') + projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'thinking_end', contentIndex: 1, content: 'Previous thought', partial } }, state) + expect(state.progress?.phase).toBe(textPhase === 'commentary' ? 'model_streaming' : 'model_responding') + projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: 'Visible text', partial } }, state) + expect(state.progress?.phase).toBe('model_streaming') + const preparing = projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'toolcall_start', contentIndex: 1, partial } }, state) + expect(preparing.events).toEqual([{ type: 'run.progress', payload: { phase: 'preparing', toolName: null } }]) + }) + it('keeps Harness-tracked file changes out of run outputs', () => { const state = createPiEventProjectionState() projectPiEvent({ @@ -482,7 +497,7 @@ describe('projectPiEvent product messages', () => { messageId, }, type: 'message.block.started', - }]) + }, { type: 'run.progress', payload: { phase: 'model_thinking', toolName: null } }]) expect(projectPiEvent({ assistantMessageEvent: { contentIndex: 0, @@ -519,7 +534,7 @@ describe('projectPiEvent product messages', () => { messageId, }, type: 'message.block.completed', - }]) + }, { type: 'run.progress', payload: { phase: 'model_streaming', toolName: null } }]) expect(JSON.stringify(completed)).not.toContain('private-provider-signature') }) diff --git a/apps/buddy/service/src/agent/events/projectPiEvent.ts b/apps/buddy/service/src/agent/events/projectPiEvent.ts index 5cf25116..78a47c97 100644 --- a/apps/buddy/service/src/agent/events/projectPiEvent.ts +++ b/apps/buddy/service/src/agent/events/projectPiEvent.ts @@ -431,6 +431,36 @@ function progressProjection( function projectMessageUpdate( event: AssistantMessageEvent, state: PiEventProjectionState, +): PiEventProjection { + const content = projectMessageContentUpdate(event, state) + let phase: BuddyRunProgress['phase'] | null = null + switch (event.type) { + case 'thinking_start': + case 'thinking_delta': + phase = 'model_thinking' + break + case 'text_start': + case 'text_delta': + phase = resolvePiTextPhase(event.partial.content[event.contentIndex]) === 'commentary' ? 'model_streaming' : 'model_responding' + break + case 'thinking_end': + phase = state.progress?.phase === 'model_thinking' ? 'model_streaming' : null + break + case 'text_end': + phase = state.progress?.phase === 'model_responding' ? 'model_streaming' : null + break + case 'toolcall_start': + case 'toolcall_delta': + case 'toolcall_end': + phase = 'preparing' + break + } + return { events: [...content.events, ...(phase ? progressProjection(state, phase).events : [])] } +} + +function projectMessageContentUpdate( + event: AssistantMessageEvent, + state: PiEventProjectionState, ): PiEventProjection { const messageId = state.assistantMessageId ?? randomUUID() state.assistantMessageId = messageId diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts index 50d0733e..0f5bebd3 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts @@ -218,6 +218,8 @@ describe('buddyAgentRunner', () => { 'message.started', 'run.progress', 'message.delta', + 'run.progress', + 'message.delta', 'message.completed', 'usage.recorded', ]) @@ -265,6 +267,8 @@ describe('buddyAgentRunner', () => { 'message.started', 'run.progress', 'message.delta', + 'run.progress', + 'message.delta', 'message.completed', ]) }) diff --git a/apps/buddy/shared/runs/runProgress.ts b/apps/buddy/shared/runs/runProgress.ts index 62197975..e5384d6a 100644 --- a/apps/buddy/shared/runs/runProgress.ts +++ b/apps/buddy/shared/runs/runProgress.ts @@ -13,6 +13,8 @@ export const buddyRunProgressSchema = z.object({ 'idle', 'model_requesting', 'model_streaming', + 'model_thinking', + 'model_responding', 'preparing', 'awaiting_approval', 'tool_executing', diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts index 87fdf464..759a553a 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -321,23 +321,14 @@ export default { 'desktop.chat.activityWebCount': 'Web actions: {count}', 'desktop.chat.activityToolCount': 'Tool calls: {count}', 'desktop.chat.activityApprovalCount': '{count} awaiting approval', - 'desktop.chat.activityPreparingCount': '{count} preparing', - 'desktop.chat.activityExecutingTool': 'Running: {tool}', - 'desktop.chat.activityExecutingCount': 'Running {count} tools', - 'desktop.chat.activityPreparingTools': 'Preparing {count} tools', - 'desktop.chat.activityViewReasoning': 'View reasoning', - 'desktop.chat.activityViewCall': 'View current tool call', - 'desktop.chat.activityNextCall': 'View next tool call', 'desktop.chat.activityRunDuration': 'Elapsed this turn', 'desktop.chat.processReasoningInterrupted': 'Reasoning interrupted', 'desktop.chat.compactionInterrupted': 'Context compaction interrupted', - 'desktop.chat.activityRunningCount': 'Running: {count}', 'desktop.chat.activityCalls': 'Calls: {count}', 'desktop.chat.activityIssueCount': 'Issues: {count}', 'desktop.chat.activityNextIssue': 'Go to next issue', 'desktop.chat.processReasoning': 'Reasoning', 'desktop.chat.processAwaitingApproval': 'Waiting for approval', - 'desktop.chat.processReasoningDone': 'Reasoning completed', 'desktop.chat.processReasoningRunning': 'Thinking', 'desktop.chat.processToolCommand': 'Run command', 'desktop.chat.processToolAutomation': 'Automation', @@ -504,9 +495,12 @@ export default { 'desktop.chat.retryUnlimited': 'Unlimited retries', 'desktop.chat.retryElapsed': 'Elapsed {duration}', 'desktop.chat.progressModelRequesting': 'Waiting for model', - 'desktop.chat.progressModelStreaming': 'Generating response', + 'desktop.chat.progressModelResponding': 'Responding', + 'desktop.chat.progressStopping': 'Stopping', + 'desktop.chat.progressQueued': 'Waiting to start', + 'desktop.chat.progressApproval': 'Approval pending', 'desktop.chat.progressPreparing': 'Preparing', - 'desktop.chat.progressToolExecuting': 'Running tool', + 'desktop.chat.progressToolExecuting': 'Running', 'desktop.chat.draftRestoration.restoring': 'Restoring unsent input. Your input will be saved once restoration finishes.', 'desktop.chat.draftRestoration.failed': 'Unsent input could not be restored. Current input is not saved yet. Retry before sending or quitting.', 'desktop.chat.draftRestoration.conflict': 'Saved content conflicts with input entered during restoration. New input cannot be saved until this is resolved.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts index e65ddd5b..1b74f692 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -319,23 +319,14 @@ export default { 'desktop.chat.activityWebCount': '网页操作 {count} 次', 'desktop.chat.activityToolCount': '工具调用 {count} 次', 'desktop.chat.activityApprovalCount': '{count} 项待批准', - 'desktop.chat.activityPreparingCount': '{count} 项准备中', - 'desktop.chat.activityExecutingTool': '正在{tool}', - 'desktop.chat.activityExecutingCount': '正在执行 {count} 项调用', - 'desktop.chat.activityPreparingTools': '正在准备 {count} 项调用', - 'desktop.chat.activityViewReasoning': '查看思考详情', - 'desktop.chat.activityViewCall': '查看当前调用', - 'desktop.chat.activityNextCall': '查看下一项调用', 'desktop.chat.activityRunDuration': '本轮耗时', 'desktop.chat.processReasoningInterrupted': '思考已中断', 'desktop.chat.compactionInterrupted': '上下文整理已中断', - 'desktop.chat.activityRunningCount': '{count} 项运行中', 'desktop.chat.activityCalls': '{count} 次调用', 'desktop.chat.activityIssueCount': '{count} 项异常', 'desktop.chat.activityNextIssue': '定位下一项异常', 'desktop.chat.processReasoning': '思考', 'desktop.chat.processAwaitingApproval': '等待批准', - 'desktop.chat.processReasoningDone': '思考完成', 'desktop.chat.processReasoningRunning': '正在思考', 'desktop.chat.processToolCommand': '运行命令', 'desktop.chat.processToolAutomation': '自动化', @@ -502,9 +493,12 @@ export default { 'desktop.chat.retryUnlimited': '不限次数', 'desktop.chat.retryElapsed': '已用 {duration}', 'desktop.chat.progressModelRequesting': '等待模型响应', - 'desktop.chat.progressModelStreaming': '生成回复中', + 'desktop.chat.progressModelResponding': '正在回复', + 'desktop.chat.progressStopping': '正在停止', + 'desktop.chat.progressQueued': '等待开始', + 'desktop.chat.progressApproval': '有操作待批准', 'desktop.chat.progressPreparing': '准备中', - 'desktop.chat.progressToolExecuting': '调用工具中', + 'desktop.chat.progressToolExecuting': '正在执行', 'desktop.chat.draftRestoration.restoring': '正在恢复未发送内容,完成后将自动保存当前输入。', 'desktop.chat.draftRestoration.failed': '未发送内容尚未恢复,当前输入还未保存。请重试恢复后再发送或退出。', 'desktop.chat.draftRestoration.conflict': '已保存内容与恢复期间的输入有冲突。处理前,新输入还无法保存。', diff --git a/apps/buddy/src/modules/tasks/contracts.ts b/apps/buddy/src/modules/tasks/contracts.ts index 8cd1ba59..715202b5 100644 --- a/apps/buddy/src/modules/tasks/contracts.ts +++ b/apps/buddy/src/modules/tasks/contracts.ts @@ -127,6 +127,7 @@ export interface TaskComposer { } export interface TaskExecution { + stoppingRunId: State queuedMessages: State pendingQueueActions: State> cancelQueuedMessage: (id: string) => Promise diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts index 6f6a1aa2..252bd954 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts @@ -1,20 +1,20 @@ import type { ChatAgentReasoningNode, ChatAgentToolNode, ChatAgentTurnNode } from '../chatAgentTurn' import { describe, expect, it } from 'vitest' import { presentChatActivityLayout } from '../chatActivityLayout' -import { reasoningPreview, summarizeChatActivity, summarizeChatActivityCounts } from '../chatActivitySummary' +import { summarizeChatActivity, summarizeChatActivityCounts } from '../chatActivitySummary' import { createChatAgentActivityProjector } from '../chatAgentActivities' import { canExpandChatTool, describeChatTool } from '../chatToolDisplay' import { projectExtensionActionTool } from '../chatTranscriptActivities' describe('activity grouping', () => { - it('keeps the beginning in historical previews and follows the latest visible content while thinking', () => { - const text = '**Initial observation**\n\nChecking details\n\n**Latest finding**\n```\n\n' - expect(reasoningPreview(text)).toBe('Initial observation') - expect(reasoningPreview(text, true)).toBe('Latest finding') - const longParagraph = `${'Earlier work '.repeat(8_000)}Current result` - expect(reasoningPreview(longParagraph, true)).toHaveLength(240) - expect(reasoningPreview(longParagraph, true).endsWith('Current result')).toBe(true) - expect(reasoningPreview('\n ** \n```\n', true)).toBe('') + it('derives local execution, approval and interruption independently from issue counts', () => { + const projector = createChatAgentActivityProjector() + expect(projector.project([thought('a', 'running'), tool('bad', 'failed'), tool('pending', 'awaiting_approval')])[0]).toMatchObject({ status: 'running', issueCount: 1, approvalCount: 1, reasoningCount: 1 }) + expect(projector.project([thought('a'), tool('pending', 'awaiting_approval')])[0]).toMatchObject({ status: 'awaiting_approval', approvalCount: 1 }) + expect(projector.project([tool('preparing', 'preparing'), tool('pending', 'awaiting_approval')])[0]).toMatchObject({ status: 'preparing', approvalCount: 1 }) + expect(projector.project([thought('a', 'interrupted'), tool('done')])[0]).toMatchObject({ status: 'interrupted' }) + expect(projector.project([tool('cancelled', 'cancelled'), tool('done')])[0]).toMatchObject({ status: 'interrupted' }) + expect(projector.project([thought('a'), tool('done')])[0]).toMatchObject({ status: 'completed' }) }) it('keeps alternating thinking and calls together and separates public narration and compaction', () => { @@ -54,7 +54,7 @@ describe('activity grouping', () => { expect(row).toMatchObject({ toolCount: 3, counts: [{ category: 'read', count: 3 }] }) if (row.kind !== 'activity-group') throw new Error('Expected activity group') - expect(summarizeChatActivity(row, 'zh-CN')).toMatchObject({ label: '读取 1 个文件', target: '' }) + expect(summarizeChatActivity(row, 'zh-CN')).toMatchObject({ label: '思考 · 读取 1 个文件' }) }) it('settles a completed group immediately using its own tool semantics', () => { @@ -188,7 +188,7 @@ describe('tool display registration', () => { const group = createChatAgentActivityProjector().project([action, thought('checking'), node, tool('read')])[0]! expect(group).toMatchObject({ toolCount: 3, counts: [{ category: 'other', count: 2 }, { category: 'read', count: 1 }] }) if (group.kind === 'activity-group') - expect(summarizeChatActivity(group, 'zh-CN').label).toBe('读取 1 个文件 · 工具调用 2 次') + expect(summarizeChatActivity(group, 'zh-CN').label).toBe('思考 · 读取 1 个文件 · 工具调用 2 次') }) it('does not expose denied output and preserves an unfamiliar tool name', () => { diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatCurrentActivity.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatCurrentActivity.spec.ts index a0770b77..c5bcd007 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatCurrentActivity.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatCurrentActivity.spec.ts @@ -13,37 +13,47 @@ function turn(nodes: ChatAgentTurnNode[], phase: NonNullable { - it('separates preparing and approval counts from tools actually running', () => { + it('keeps local details in their groups and gives approval priority during parallel execution', () => { const running = [tool('one', 'running'), tool('two', 'running')] const preparing = tool('three', 'preparing') const approval = tool('four', 'awaiting_approval') - const current = describeChatCurrentActivity(turn([...running, preparing, approval, thought]), 'zh-CN')! - expect(current.label).toBe('等待批准') - expect(current.detail).toBe('2 项运行中 · 1 项待批准 · 1 项准备中') - expect(current.tools.map(node => node.id)).toEqual(['four', 'one', 'two', 'three']) - const parallel = describeChatCurrentActivity(turn([...running, preparing]), 'zh-CN')! - expect(parallel).toMatchObject({ label: '正在执行 2 项调用', target: '', detail: '1 项准备中', reasoning: null }) - expect(describeChatCurrentActivity(turn([preparing]), 'zh-CN')).toMatchObject({ label: '正在准备 1 项调用', target: '运行命令' }) - }) - - it('keeps long commands in their original tool instead of the current status', () => { - const node = tool('one', 'running') - const current = describeChatCurrentActivity(turn([node]), 'zh-CN')! - expect(current).toMatchObject({ label: '正在运行命令', target: '', detail: '' }) - expect(current.tools[0]).toBe(node) - expect(describeChatCurrentActivity(turn([node]), 'en-US')?.label).toBe('Running: Run command') + expect(describeChatCurrentActivity(turn([...running, preparing, approval, thought]), 'zh-CN')).toEqual({ label: '有操作待批准', active: false, warning: true }) + expect(describeChatCurrentActivity(turn([...running, preparing]), 'zh-CN')).toEqual({ label: '正在执行', active: true }) + expect(describeChatCurrentActivity(turn([preparing]), 'zh-CN')).toEqual({ label: '准备中', active: true }) + expect(describeChatCurrentActivity(turn(running), 'en-US')).toEqual({ label: 'Running', active: true }) }) it('moves between reasoning, compaction and model progress using the current event state', () => { - expect(describeChatCurrentActivity(turn([tool('done', 'completed'), thought]), 'zh-CN')).toMatchObject({ label: '正在思考', target: 'Details', reasoning: thought, tools: [] }) - expect(describeChatCurrentActivity(turn([thought, compaction]), 'zh-CN')).toMatchObject({ label: '正在整理上下文', reasoning: null }) + expect(describeChatCurrentActivity(turn([tool('done', 'completed'), thought]), 'zh-CN')).toEqual({ label: '正在思考', active: true }) + expect(describeChatCurrentActivity(turn([thought, compaction]), 'zh-CN')).toEqual({ label: '正在整理上下文', active: true }) const nodes = [{ ...thought, status: 'completed' } as ChatAgentTurnNode, { ...compaction, status: 'completed' } as ChatAgentTurnNode] expect(describeChatCurrentActivity(turn(nodes), 'zh-CN')?.label).toBe('等待模型响应') - expect(describeChatCurrentActivity(turn(nodes, 'model_streaming'), 'zh-CN')?.label).toBe('生成回复中') + expect(describeChatCurrentActivity(turn(nodes, 'model_streaming'), 'zh-CN')?.label).toBe('正在处理') + expect(describeChatCurrentActivity(turn(nodes, 'model_responding'), 'zh-CN')?.label).toBe('正在回复') + expect(describeChatCurrentActivity(turn(nodes, 'tool_executing'), 'zh-CN')?.label).toBe('正在处理') expect(describeChatCurrentActivity({ ...turn([thought]), status: 'cancelled' }, 'zh-CN')).toBeNull() }) }) +describe('waiting and cancellation feedback', () => { + it('only animates retry requests, not their deadlines, and keeps their budgets', () => { + const current = turn([]) + const retry = { attempt: 2, maxAttempts: 'unlimited' as const, retryAt: '2026-09-10T00:00:05Z' } + current.progress = { phase: 'model_requesting', toolName: null, retry } + expect(describeChatCurrentActivity(current, 'zh-CN', Date.parse('2026-09-10T00:00:03Z'))).toEqual({ label: '2 秒后重试', active: false, warning: true, retry }) + expect(describeChatCurrentActivity(current, 'zh-CN', Date.parse('2026-09-10T00:00:06Z'))?.active).toBe(false) + current.progress.retry = { ...retry, retryAt: null } + expect(describeChatCurrentActivity(current, 'zh-CN')).toMatchObject({ label: '正在重试', active: true }) + expect(describeChatCurrentActivity(current, 'zh-CN', Date.now(), true)).toEqual({ label: '正在停止', active: false }) + }) + + it('waits to start without inventing a queue and hides feedback only after termination', () => { + expect(describeChatCurrentActivity({ ...turn([]), status: 'queued' }, 'zh-CN')).toEqual({ label: '等待开始', active: false }) + for (const status of ['completed', 'failed', 'cancelled'] as const) + expect(describeChatCurrentActivity({ ...turn([thought]), status }, 'zh-CN', Date.now(), true)).toBeNull() + }) +}) + describe('compaction display', () => { it('only shows token results after success and keeps interrupted and failed states distinct', () => { expect(describeChatCompaction(compaction, 'zh-CN')).toMatchObject({ active: true, label: '正在整理上下文', detail: '', warning: false }) diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatStreamingMessage.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatStreamingMessage.spec.ts index 5115c0a9..6cb21b74 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatStreamingMessage.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatStreamingMessage.spec.ts @@ -5,6 +5,7 @@ import type { ChatAgentTurn } from '../chatStreamingMessage' import { describe, expect, it } from 'vitest' import { createChatAgentActivityProjector } from '../chatAgentActivities' +import { createChatAgentTurnReducer } from '../chatAgentTurn' import * as chatProjections from '../chatStreamingMessage' import { canExpandChatTool, describeChatTool } from '../chatToolDisplay' import { @@ -353,6 +354,49 @@ describe('projectStreamingAssistantMessage', () => { })]) }) + it('shows a real thinking start before content but does not retain empty terminal blocks', () => { + const events = [event(1, 'message.block.started', { contentIndex: 0, kind: 'reasoning', messageId: 'thought' })] + expect(projectChatAgentTurns([], [run('running')])[0]?.nodes).toEqual([]) + expect(projectChatAgentTurns(events, [run('running')])[0]?.nodes).toMatchObject([{ kind: 'reasoning', text: '', status: 'running' }]) + expect(projectChatAgentTurns(events, [run('cancelled')])[0]?.nodes).toEqual([]) + expect(projectChatAgentTurns([...events, event(2, 'message.block.completed', { contentIndex: 0, kind: 'reasoning', messageId: 'thought', content: '' })], [run('running')])[0]?.nodes).toEqual([]) + }) + + it('settles thinking as text begins even when its end event is delayed, and resumes only on real reasoning', () => { + const thought = [ + event(1, 'message.block.started', { contentIndex: 0, kind: 'reasoning', messageId: 'stream' }), + event(2, 'message.block.delta', { contentIndex: 0, kind: 'reasoning', messageId: 'stream', delta: 'Checking' }), + ] + const responding = [...thought, event(3, 'message.delta', { contentIndex: 1, messageId: 'stream', delta: 'Answer' })] + const reducer = createChatAgentTurnReducer(run('running')) + reducer.append(thought) + const thinking = reducer.project() + reducer.append([responding[2]!]) + const settled = reducer.project() + expect(settled).not.toBe(thinking) + expect(settled.nodes).toMatchObject([{ status: 'completed', text: 'Checking' }]) + reducer.append([event(4, 'message.delta', { contentIndex: 1, messageId: 'stream', delta: ' continues' })]) + expect(reducer.project()).toBe(settled) + expect(projectChatAgentTurns(thought, [run('running')])[0]?.nodes).toMatchObject([{ status: 'running', text: 'Checking' }]) + expect(projectChatAgentTurns([...thought, event(3, 'run.progress', { phase: 'preparing', toolName: null })], [run('running')])[0]?.nodes).toMatchObject([{ status: 'completed' }]) + expect(projectChatAgentTurns([...thought, event(3, 'run.progress', { phase: 'preparing', toolName: 'read' })], [run('running')])[0]?.nodes).toMatchObject([{ status: 'running' }]) + expect(projectChatAgentTurns(responding, [run('running')])[0]?.nodes).toMatchObject([{ status: 'completed', text: 'Checking' }]) + expect(projectChatAgentTurns([...responding, event(4, 'message.block.delta', { contentIndex: 0, kind: 'reasoning', messageId: 'stream', delta: ' more' })], [run('running')])[0]?.nodes).toMatchObject([{ status: 'running', text: 'Checking more' }]) + expect(projectChatAgentTurns([...thought, event(3, 'message.block.started', { contentIndex: 1, kind: 'text', messageId: 'stream', phase: 'commentary' })], [run('running')])[0]?.nodes[0]).toMatchObject({ status: 'completed' }) + }) + + it.each(['failed', 'length'])('retains interrupted thinking after a %s message while a retry waits', (stopReason) => { + const events = [ + event(1, 'message.block.delta', { contentIndex: 0, kind: 'reasoning', messageId: 'attempt-1', delta: 'Partial thought' }), + event(2, 'message.completed', { messageId: 'attempt-1', content: { text: '' }, stopReason }), + event(3, 'run.progress', { phase: 'model_requesting', toolName: null, retry: { attempt: 1, maxAttempts: 3, retryAt: '2026-08-14T00:00:05Z' } }), + ] + expect(projectChatAgentTurns(events, [run('running')])[0]?.nodes).toMatchObject([{ status: 'interrupted', text: 'Partial thought' }]) + expect(projectChatAgentTurns(events, [run('cancelled')])[0]?.nodes).toMatchObject([{ status: 'interrupted', text: 'Partial thought' }]) + const next = [...events, event(4, 'message.block.delta', { contentIndex: 0, kind: 'reasoning', messageId: 'attempt-2', delta: 'Next thought' })] + expect(projectChatAgentTurns(next, [run('running')])[0]?.nodes).toMatchObject([{ status: 'interrupted', text: 'Partial thought' }, { status: 'running', text: 'Next thought' }]) + }) + it('removes empty reasoning blocks and duplicate tool-use narration', () => { const process = projectChatRunProcessesForTest([ event(1, 'message.block.started', { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatActivitySummary.ts b/apps/buddy/src/modules/tasks/model/transcript/chatActivitySummary.ts index 6c0748a1..1e0b9516 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatActivitySummary.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatActivitySummary.ts @@ -2,7 +2,6 @@ import type { ChatAgentActivityGroup } from './chatAgentActivities' import type { ChatToolCategory, ChatToolIcon } from './chatToolRegistry' import type { BuddyI18nKey, BuddyLocale } from '@/i18n/buddyI18n' import { translateBuddy } from '@/i18n/buddyI18n' -import { normalizeProcessNarration } from './chatToolPresentation' const countLabels: Record = { read: 'desktop.chat.activityReadCount', @@ -24,19 +23,11 @@ const summaryOrder: readonly ChatToolCategory[] = ['create', 'edit', 'read', 'se export interface ChatActivitySummary { label: string - target: string icon: ChatToolIcon | 'reasoning' } export function summarizeChatActivity(group: ChatAgentActivityGroup, language: BuddyLocale): ChatActivitySummary { - const reasoning = group.nodes.findLast(node => node.kind === 'reasoning' && node.status !== 'running') - return { - label: group.counts.length - ? summarizeChatActivityCounts(group, language) - : translateBuddy(language, reasoning?.status === 'interrupted' ? 'desktop.chat.processReasoningInterrupted' : 'desktop.chat.processReasoningDone'), - target: group.toolCount === 0 && reasoning?.kind === 'reasoning' ? reasoningPreview(reasoning.text) : '', - icon: group.icon, - } + return { label: summarizeChatActivityCounts(group, language), icon: group.icon } } export function summarizeChatActivityCounts(group: ChatAgentActivityGroup, language: BuddyLocale, limit = 3): string { @@ -50,17 +41,7 @@ export function summarizeChatActivityCounts(group: ChatAgentActivityGroup, langu const remaining = counts.slice(limit).reduce((total, entry) => total + entry.count, 0) if (remaining) labels.push(translateBuddy(language, 'desktop.chat.activityMoreCalls', { count: remaining })) + if (group.reasoningCount) + labels.unshift(translateBuddy(language, 'desktop.chat.processReasoning')) return labels.join(' · ') } - -export function reasoningPreview(text: string, fromEnd = false): string { - const lines = text.trim().split('\n') - if (fromEnd) - lines.reverse() - for (const line of lines) { - const preview = normalizeProcessNarration(fromEnd ? line.slice(-240) : line.slice(0, 240)) - if (preview) - return preview - } - return '' -} diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts b/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts index 850c97a2..bef7c313 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts @@ -9,6 +9,9 @@ export interface ChatAgentActivityGroup { nodes: ReadonlyArray issueCount: number toolCount: number + reasoningCount: number + approvalCount: number + status: 'running' | 'preparing' | 'awaiting_approval' | 'interrupted' | 'completed' icon: ChatToolIcon | 'reasoning' counts: ReadonlyArray<{ category: ChatToolCategory, count: number, files: number | null }> } @@ -61,10 +64,22 @@ function summarizeGroup(id: string, nodes: ChatAgentActivityGroup['nodes']): Cha const files = new Map | null>() let issueCount = 0 let toolCount = 0 + let reasoningCount = 0 + let approvalCount = 0 + let running = false + let preparing = false + let interrupted = false let icon: ChatAgentActivityGroup['icon'] = 'reasoning' for (const node of nodes) { - if (node.kind === 'reasoning') + running ||= node.status === 'running' + preparing ||= node.status === 'preparing' + interrupted ||= node.status === 'interrupted' || node.status === 'cancelled' + if (node.status === 'awaiting_approval') + approvalCount++ + if (node.kind === 'reasoning') { + reasoningCount++ continue + } toolCount++ const { category, icon: toolIcon } = getChatToolRegistration(node) icon = icon === 'reasoning' || icon === toolIcon ? toolIcon : 'activity' @@ -84,5 +99,6 @@ function summarizeGroup(id: string, nodes: ChatAgentActivityGroup['nodes']): Cha if (isChatToolIssue(node)) issueCount++ } - return { id, kind: 'activity-group', nodes, issueCount, toolCount, icon, counts: [...counts].map(([category, count]) => ({ category, count, files: files.get(category)?.size ?? null })) } + const status = running ? 'running' : preparing ? 'preparing' : approvalCount ? 'awaiting_approval' : interrupted ? 'interrupted' : 'completed' + return { id, kind: 'activity-group', nodes, issueCount, toolCount, reasoningCount, approvalCount, status, icon, counts: [...counts].map(([category, count]) => ({ category, count, files: files.get(category)?.size ?? null })) } } diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts index 73060798..e7b179f1 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts @@ -137,6 +137,15 @@ export function createChatAgentTurnReducer( } } + function settleReasoning(messageId?: string, status: 'completed' | 'interrupted' = 'completed') { + for (const node of reasoning.values()) { + if (node.status === 'running' && (messageId === undefined || node.id === `reasoning:${messageId}:${node.contentIndex}`)) { + reasoning.set(node.id, { ...node, status }) + projection = null + } + } + } + function apply(event: LocalRunEvent) { const payload = readPayload(event.payload) if (!payload) @@ -164,8 +173,11 @@ export function createChatAgentTurnReducer( } if (event.type === 'run.progress') { const parsed = buddyRunProgressSchema.safeParse(payload) - if (parsed.success) + if (parsed.success) { progress = parsed.data.phase === 'idle' ? null : parsed.data + if (progress?.phase === 'preparing' && progress.toolName === null) + settleReasoning() + } return } if (event.type.startsWith('context.compaction.')) { @@ -179,6 +191,7 @@ export function createChatAgentTurnReducer( if (!messageId || contentIndex === null) return if (payload.kind === 'text') { + settleReasoning(messageId) const phase = readAssistantTextPhase(payload.phase) if (phase !== 'commentary') return @@ -227,6 +240,7 @@ export function createChatAgentTurnReducer( if (event.type === 'message.block.delta') { reasoning.set(id, { ...node, + status: 'running', text: node.text + readString(payload.delta), }) } @@ -245,6 +259,7 @@ export function createChatAgentTurnReducer( if (event.type === 'message.delta') { const phase = readAssistantTextPhase(payload.phase) const messageId = readString(payload.messageId) + settleReasoning(messageId) const contentIndex = readNonnegativeInteger(payload.contentIndex) if (phase !== 'commentary' || !messageId || contentIndex === null) return @@ -271,6 +286,7 @@ export function createChatAgentTurnReducer( const content = readPayload(payload.content) if (!messageId) return + settleReasoning(messageId, payload.stopReason === 'failed' || payload.stopReason === 'length' ? 'interrupted' : 'completed') const phase = readAssistantTextPhase(payload.phase) const isCommentary = phase === 'commentary' || (!phase && payload.stopReason === 'tool_use') @@ -461,7 +477,7 @@ export function createChatAgentTurnReducer( if (projection) return projection const terminal = run.status !== 'queued' && run.status !== 'running' - const reasoningNodes = [...reasoning.values()].filter(node => node.text.trim()) + const reasoningNodes = [...reasoning.values()].filter(node => node.text.trim() || (!terminal && node.status === 'running')) const narrationNodes = [...text.values()].filter(node => node.text.trim()) const awaitingApproval = [...tools.values()] .filter(node => node.status === 'awaiting_approval') diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatCurrentActivity.ts b/apps/buddy/src/modules/tasks/model/transcript/chatCurrentActivity.ts index 91f06637..98a2d297 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatCurrentActivity.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatCurrentActivity.ts @@ -1,90 +1,52 @@ import type { BuddyRunRetry } from '@buddy-shared/runs/runProgress' -import type { ChatAgentReasoningNode, ChatAgentToolNode, ChatAgentTurn } from './chatAgentTurn' +import type { ChatAgentTurn } from './chatAgentTurn' import type { BuddyI18nKey, BuddyLocale } from '@/i18n/buddyI18n' import { translateBuddy } from '@/i18n/buddyI18n' -import { reasoningPreview } from './chatActivitySummary' -import { describeChatTool } from './chatToolDisplay' export interface ChatCurrentActivity { label: string - target: string - detail: string + active: boolean + warning?: boolean retry?: BuddyRunRetry - reasoning: ChatAgentReasoningNode | null - tools: readonly ChatAgentToolNode[] } -export function describeChatCurrentActivity(turn: ChatAgentTurn, language: BuddyLocale, now = Date.now()): ChatCurrentActivity | null { +export function describeChatCurrentActivity(turn: ChatAgentTurn, language: BuddyLocale, now = Date.now(), stopping = false): ChatCurrentActivity | null { if (turn.status !== 'queued' && turn.status !== 'running') return null - const running: ChatAgentToolNode[] = [] - const preparing: ChatAgentToolNode[] = [] - const approvals: ChatAgentToolNode[] = [] - let reasoning: ChatAgentReasoningNode | null = null - let compacting = false - for (const node of turn.nodes) { - if (node.kind === 'tool') { - if (node.status === 'running') - running.push(node) - else if (node.status === 'preparing') - preparing.push(node) - else if (node.status === 'awaiting_approval') - approvals.push(node) - } - else if (node.kind === 'reasoning' && node.status === 'running') { - reasoning = node - } - else if (node.kind === 'compaction' && node.status === 'running') { - compacting = true - } - } const t = (key: BuddyI18nKey, params?: Record) => translateBuddy(language, key, params) - const tools = [...approvals, ...running, ...preparing] - const activity: ChatCurrentActivity = { label: '', target: '', detail: '', reasoning: null, tools } - if (approvals.length) { - activity.label = t('desktop.chat.processAwaitingApproval') - activity.detail = [ - running.length ? t('desktop.chat.activityRunningCount', { count: running.length }) : '', - t('desktop.chat.activityApprovalCount', { count: approvals.length }), - preparing.length ? t('desktop.chat.activityPreparingCount', { count: preparing.length }) : '', - ].filter(Boolean).join(' · ') - return activity - } + if (stopping) + return { label: t('desktop.chat.progressStopping'), active: false } + if (turn.status === 'queued') + return { label: t('desktop.chat.progressQueued'), active: false } + if (turn.nodes.some(node => node.kind === 'tool' && node.status === 'awaiting_approval') || turn.progress?.phase === 'awaiting_approval') + return { label: t('desktop.chat.progressApproval'), active: false, warning: true } const retry = turn.progress?.retry if (retry) { const seconds = retry.retryAt ? Math.max(0, Math.ceil((Date.parse(retry.retryAt) - now) / 1000)) : 0 return { - ...activity, label: seconds > 0 ? t('desktop.chat.retryWaiting', { seconds }) : t('desktop.chat.retryRequesting'), + active: retry.retryAt === null, + warning: true, retry, - tools: [], } } - if (compacting) - return { ...activity, label: t('desktop.chat.compactionStarted') } - if (running.length) { - const single = running.length === 1 ? describeChatTool(running[0]!, language) : null - activity.label = single - ? t('desktop.chat.activityExecutingTool', { tool: single.label }) - : t('desktop.chat.activityExecutingCount', { count: running.length }) - activity.target = single && running[0]!.presentation.card !== 'terminal' ? single.target : '' - activity.detail = preparing.length ? t('desktop.chat.activityPreparingCount', { count: preparing.length }) : '' - return activity - } - if (preparing.length) { - activity.label = t('desktop.chat.activityPreparingTools', { count: preparing.length }) - activity.target = preparing.length === 1 ? describeChatTool(preparing[0]!, language).label : '' - return activity - } - if (reasoning) - return { ...activity, label: t('desktop.chat.processReasoningRunning'), target: reasoningPreview(reasoning.text, true), reasoning } + if (turn.nodes.some(node => node.kind === 'compaction' && node.status === 'running')) + return { label: t('desktop.chat.compactionStarted'), active: true } + if (turn.nodes.some(node => node.kind === 'tool' && node.status === 'running')) + return { label: t('desktop.chat.progressToolExecuting'), active: true } + if (turn.nodes.some(node => node.kind === 'tool' && node.status === 'preparing')) + return { label: t('desktop.chat.progressPreparing'), active: true } + if (turn.nodes.some(node => node.kind === 'reasoning' && node.status === 'running')) + return { label: t('desktop.chat.processReasoningRunning'), active: true } const progressLabels = { idle: 'desktop.chat.activity', - awaiting_approval: 'desktop.chat.processAwaitingApproval', + awaiting_approval: 'desktop.chat.progressApproval', preparing: 'desktop.chat.progressPreparing', model_requesting: 'desktop.chat.progressModelRequesting', - model_streaming: 'desktop.chat.progressModelStreaming', - tool_executing: 'desktop.chat.progressToolExecuting', + model_streaming: 'desktop.chat.activity', + model_thinking: 'desktop.chat.processReasoningRunning', + model_responding: 'desktop.chat.progressModelResponding', + tool_executing: 'desktop.chat.activity', } as const satisfies Record['phase'], BuddyI18nKey> - return { ...activity, label: t(turn.progress ? progressLabels[turn.progress.phase] : 'desktop.chat.activity') } + return { label: t(turn.progress ? progressLabels[turn.progress.phase] : 'desktop.chat.activity'), active: true } } diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts index 3bba7751..971596a1 100644 --- a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts @@ -18,8 +18,11 @@ describe('useChatTurnExecution cancellation ownership', () => { it('applies cancellation to the current projection and preserves its Draft', async () => { const fixture = createFixture() const cancelling = fixture.execution.cancelActiveRun() + expect(fixture.execution.stoppingRunId.value).toBe(fixture.run.id) + expect(fixture.projectedRuns.value[0]?.status).toBe('running') fixture.pending.resolve({ ...fixture.run, status: 'cancelled' }) await cancelling + expect(fixture.execution.stoppingRunId.value).toBeNull() expect(fixture.projectedRuns.value).toEqual([{ ...fixture.run, status: 'cancelled' }]) expect(fixture.drafts.draft.value).toBe('pending input') @@ -30,8 +33,10 @@ describe('useChatTurnExecution cancellation ownership', () => { it('reports a cancellation failure only in the originating view', async () => { const fixture = createFixture() const cancelling = fixture.execution.cancelActiveRun() + expect(fixture.execution.stoppingRunId.value).toBe(fixture.run.id) fixture.pending.reject(new Error('cancel failed')) await cancelling + expect(fixture.execution.stoppingRunId.value).toBeNull() expect(fixture.error.value).toBeTruthy() expect(fixture.projectedRuns.value).toEqual([fixture.run]) @@ -62,6 +67,7 @@ describe('useChatTurnExecution cancellation ownership', () => { const fixture = createFixture() const cancelling = fixture.execution.cancelActiveRun() fixture.navigate(navigation) + expect(fixture.execution.stoppingRunId.value).toBeNull() fixture.pending.resolve({ ...fixture.run, status: 'cancelled' }) await cancelling diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts index 73b9d398..b6a8a531 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts @@ -16,7 +16,7 @@ import type { TaskIndexData } from '@/modules/tasks/state/task-index/useTaskInde import type { RuntimeSupervisorStore } from '@/platform/runtime/useRuntimeSupervisorStore' import { isBuddyRunChatCommand, parseBuddyChatCommand } from '@buddy-shared/conversation/buddyChatCommands' import { getBuddyUserContentResourceIds } from '@buddy-shared/conversation/buddyUserContent' -import { computed, onScopeDispose, readonly, shallowRef, watch } from 'vue' +import { computed, onScopeDispose, readonly, shallowReactive, shallowRef, watch } from 'vue' import { translateBuddy } from '@/i18n/buddyI18n' import { createRequestIdRegistry } from '@/modules/tasks/model/requests/chatRequestIdentity' import { resolveLocalChatErrorMessage } from '@/shared/lib/localChatError' @@ -68,12 +68,18 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { const isSending = shallowRef(false) const requestIds = createRequestIdRegistry() const pendingCancellationWatches = new Set<() => void>() + const pendingCancellationIds = shallowReactive(new Set()) + const stoppingRunId = computed(() => { + const run = options.activeRun.value + return run && pendingCancellationIds.has(run.id) ? run.id : null + }) let isDisposed = false onScopeDispose(() => { isDisposed = true for (const stop of pendingCancellationWatches) stop() pendingCancellationWatches.clear() + pendingCancellationIds.clear() }, true) const canSend = computed(() => options.runtimeSupervisor.runtimeState.value.status === 'ready' @@ -258,8 +264,9 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { async function cancelActiveRun() { const run = options.activeRun.value - if (!run || isDisposed) + if (!run || isDisposed || pendingCancellationIds.has(run.id)) return + pendingCancellationIds.add(run.id) const navigationVersion = options.session.generation() let sourceViewChanged = false const stopWatchingView = watch( @@ -286,6 +293,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { finally { stopWatchingView() pendingCancellationWatches.delete(stopWatchingView) + pendingCancellationIds.delete(run.id) } } @@ -301,6 +309,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { ...queue, canSend: readonly(canSend), cancelActiveRun, + stoppingRunId: readonly(stoppingRunId), isSending: readonly(isSending), send, } diff --git a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts index efe52131..3f7d7c2c 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts @@ -509,6 +509,7 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi editingMessageId, isMutatingBranch: readonly(isMutatingBranch), isSending: readonly(isSending), + stoppingRunId: readonly(execution.stoppingRunId), regenerateAssistant, resolveApproval, resolvingApprovalActions: readonly(resolvingApprovalActions), diff --git a/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue b/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue index 6676db2c..6ba45598 100644 --- a/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue +++ b/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue @@ -12,12 +12,12 @@ import BuddyChatMessageBody from '../transcript/BuddyChatMessageBody.vue' import BuddyChatRunActivity from '../transcript/BuddyChatRunActivity.vue' import BuddyChatTokenUsage from '../transcript/BuddyChatTokenUsage.vue' import { useChatContent } from '../transcript/chatContentContext' -import { useChatActivityNavigation } from '../transcript/useChatActivityNavigation' const props = defineProps<{ target: ConversationNodeDetailRequest rows: readonly ChatTranscriptRow[] language: BuddyLocale + stoppingRunId?: string | null loading: boolean error: string | null canEdit: boolean @@ -26,7 +26,6 @@ const props = defineProps<{ const emit = defineEmits<{ close: [], reload: [], edit: [], openArtifact: [id: string], openChanges: [id: string] }>() const { t } = useBuddyI18n(() => props.language) const { writeClipboardText } = useChatContent() -const activityNavigation = useChatActivityNavigation()