From 72602b5fd10a2d24900ad0f0be3036d663425df8 Mon Sep 17 00:00:00 2001 From: QAyong Date: Tue, 29 Sep 2026 22:59:45 +0800 Subject: [PATCH] fix(buddy): stop chat presentation immediately --- .../tasks/model/transcript/chatAgentTurn.ts | 3 +- .../runs/__tests__/useChatRunSync.spec.ts | 50 +++++++++++ .../__tests__/useChatTurnExecution.spec.ts | 42 +++++++++- .../src/modules/tasks/state/runs/typing.ts | 3 + .../tasks/state/runs/useChatRunProjection.ts | 82 ++++++++++++++++--- .../tasks/state/runs/useChatRunSync.ts | 3 + .../tasks/state/runs/useChatTurnExecution.ts | 34 +++++++- .../modules/tasks/state/useTaskCapability.ts | 4 +- docs/specs/feature-008-instant-chat-stop.md | 67 +++++++++++++++ 9 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 docs/specs/feature-008-instant-chat-stop.md diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts index e7b179f1..68160a1d 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts @@ -492,7 +492,8 @@ export function createChatAgentTurnReducer( if ( node.kind === 'text' || !terminal - || (node.status !== 'preparing' && node.status !== 'running') + || (node.status !== 'preparing' && node.status !== 'running' + && !(run.status === 'cancelled' && node.kind === 'tool' && node.status === 'awaiting_approval')) ) { return node } diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts index 9fcf6015..b667662a 100644 --- a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts @@ -6,9 +6,59 @@ import { describe, expect, it, vi } from 'vitest' import { effectScope, ref } from 'vue' import { projectPersistedChatTranscriptRows } from '../../../model/transcript/chatPersistedTranscriptRows' +import { projectChatTranscript } from '../../../model/transcript/chatTranscriptProjection' import { useChatRunSync } from '../useChatRunSync' describe('useChatRunSync', () => { + it('stops presentation immediately while retaining execution ownership and reconciles the final result', async () => { + const running = run('run-a', 'conversation-a') + const initialEvents: LocalRunEvent[] = [ + { ...event(running.id, 1), type: 'message.started', payload: { messageId: 'answer' } }, + { ...event(running.id, 2), type: 'message.delta', payload: { messageId: 'answer', delta: 'Visible answer', phase: 'answer' } }, + ] + const question = timelineMessage(running.triggeringMessageId, running.conversationId, running.branchId, 1) + let page = timelinePage([question], null, [running], initialEvents) + const sync = useChatRunSync({ activeBranchId: ref(running.branchId), activeConversationId: ref(running.conversationId), api: createApi({ listTimeline: async () => page }), onError: vi.fn() }) + try { + await sync.refreshActiveConversation() + sync.cancelRunPresentation(running.id) + expect(sync.runs.value[0]).toMatchObject({ status: 'cancelled', errorCode: 'RUN_CANCELLED' }) + expect(sync.executionRuns.value[0]?.status).toBe('running') + expect(sync.messages.value.find(message => message.id === 'answer')?.content).toEqual({ text: 'Visible answer' }) + const rows = projectChatTranscript({ timelineItems: sync.timelineItems.value, runs: sync.runs.value, runEvents: sync.runEventBuckets.value.get(running.id)?.events, outputs: [] }).rows + expect(rows.some(row => row.kind === 'activity')).toBe(false) + const answer = rows.find(row => row.kind === 'message' && row.message.id === 'answer') + expect(answer?.kind === 'message' ? answer.streaming : true).toBeUndefined() + + page = timelinePage([question], null, [running], [...initialEvents, + { ...event(running.id, 3), type: 'message.delta', payload: { messageId: 'answer', delta: ' late text', phase: 'answer' } }, + ]) + await sync.refreshActiveConversation() + expect(sync.runs.value[0]?.status).toBe('cancelled') + expect(sync.messages.value.find(message => message.id === 'answer')?.content).toEqual({ text: 'Visible answer' }) + + const final = { ...timelineMessage('answer', running.conversationId, running.branchId, 2, 'assistant'), runId: running.id, content: { text: 'Final saved answer' } } + page = timelinePage([question, final], null, [{ ...running, status: 'cancelled', completedAt: '2026-08-14T00:00:03.000Z' }]) + await sync.refreshActiveConversation() + expect(sync.executionRuns.value[0]?.status).toBe('cancelled') + expect(sync.messages.value.find(message => message.id === 'answer')?.content).toEqual(final.content) + } + finally { sync.dispose() } + }) + + it('restores authoritative presentation if the cancellation request fails', async () => { + const running = run('run-a', 'conversation-a') + const sync = useChatRunSync({ activeBranchId: ref(running.branchId), activeConversationId: ref(running.conversationId), api: createApi({}), onError: vi.fn() }) + try { + await sync.refreshActiveConversation() + sync.cancelRunPresentation(running.id) + expect(sync.runs.value[0]?.status).toBe('cancelled') + sync.restoreRunPresentation(running.id) + expect(sync.runs.value[0]?.status).toBe('running') + } + finally { sync.dispose() } + }) + it('refreshes a running action on an older page and removes only automatic skipped actions from the transcript', async () => { const action: Extract = { kind: 'extension-action', 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 971596a1..217587d8 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,6 +18,7 @@ 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.presentation.cancelRunPresentation).toHaveBeenCalledWith(fixture.run.id) expect(fixture.execution.stoppingRunId.value).toBe(fixture.run.id) expect(fixture.projectedRuns.value[0]?.status).toBe('running') fixture.pending.resolve({ ...fixture.run, status: 'cancelled' }) @@ -39,11 +40,46 @@ describe('useChatTurnExecution cancellation ownership', () => { expect(fixture.execution.stoppingRunId.value).toBeNull() expect(fixture.error.value).toBeTruthy() + expect(fixture.presentation.restoreRunPresentation).toHaveBeenCalledWith(fixture.run.id) expect(fixture.projectedRuns.value).toEqual([fixture.run]) expect(fixture.drafts.draft.value).toBe('pending input') expect(fixture.execution.isSending.value).toBe(false) }) + it('accepts a fresh message immediately and starts it only after cancellation finishes', async () => { + const f = createFixture() + f.selectedModel.value = { modelId: 'model-a', providerId: 'provider-a' } as LocalRuntimeModelOption + const snapshot = f.drafts.snapshot('conversation:conversation-a:branch-a') + f.drafts.confirmOpen(snapshot, { + content: snapshot.content, + draftId: snapshot.draftId, + executionConfig: { approvalPolicy: snapshot.approvalPolicy, executionProfile: snapshot.executionProfile }, + modelSelection: null, + revision: 1, + scope: { kind: 'conversation_branch', conversationId: 'conversation-a', branchId: 'branch-a' }, + updatedAt: '2026-09-08T00:00:00.000Z', + }) + const receipt = { + id: 'fresh-message', conversationId: 'conversation-a', branchId: 'branch-a', + draftReceipt: { draftId: snapshot.draftId, sourceRevision: 1, committedRevision: 2 }, + } + f.api.chat.enqueue.mockResolvedValue(receipt) + f.api.chat.steerQueued.mockResolvedValue(true) + const cancelling = f.execution.cancelActiveRun() + await f.execution.cancelActiveRun() + expect(f.api.chat.cancel).toHaveBeenCalledTimes(1) + + expect(await f.execution.send('pending input')).toBe(true) + expect(f.api.chat.enqueue).toHaveBeenCalledTimes(1) + expect(f.api.chat.startTurn).not.toHaveBeenCalled() + expect(f.api.chat.steerQueued).not.toHaveBeenCalled() + expect(f.execution.isSending.value).toBe(false) + + f.pending.resolve({ ...f.run, status: 'cancelled' }) + await cancelling + await vi.waitFor(() => expect(f.api.chat.steerQueued).toHaveBeenCalledWith({ id: receipt.id, conversationId: receipt.conversationId, branchId: receipt.branchId })) + }) + it.each(['success', 'error'] as const)('ignores a late cancellation %s after its owner is disposed', async (outcome) => { const fixture = createFixture() const cancelling = fixture.execution.cancelActiveRun() @@ -153,7 +189,8 @@ function createFixture() { const error = shallowRef(null) const pending = deferred() const composerTarget = useComposerTarget({ drafts, conversationId: session.activeConversationId, branchId: session.activeBranchId, persist: async () => true }) - const api = { chat: { listQueue: async () => [], enqueue: vi.fn(), cancelQueued: vi.fn(), steerQueued: vi.fn(), cancel: () => pending.promise, executeCommand: vi.fn(), startTurn: vi.fn() } } + const api = { chat: { listQueue: async () => [], enqueue: vi.fn(), cancelQueued: vi.fn(), steerQueued: vi.fn(), cancel: vi.fn(() => pending.promise), executeCommand: vi.fn(), startTurn: vi.fn() } } + const presentation = { cancelRunPresentation: vi.fn(), restoreRunPresentation: vi.fn() } const selectedModel = shallowRef(null) const execution = useChatTurnExecution({ composerTarget, @@ -172,6 +209,7 @@ function createFixture() { onActionCommandRunStarted: () => {}, persistWorkspaceState: async () => true, runSync: { + ...presentation, refreshActiveConversation: async () => {}, applyRunStart: () => {}, upsertRuns: (runs) => { @@ -201,7 +239,7 @@ function createFixture() { drafts.updateComposerContent('current view input', null) error.value = 'current view status' } - return { api, drafts, error, execution, navigate, pending, projectedRuns, run, scope, selectedModel } + return { api, drafts, error, execution, navigate, pending, presentation, projectedRuns, run, scope, selectedModel } })! } diff --git a/apps/buddy/src/modules/tasks/state/runs/typing.ts b/apps/buddy/src/modules/tasks/state/runs/typing.ts index 551d47b5..34d0ce18 100644 --- a/apps/buddy/src/modules/tasks/state/runs/typing.ts +++ b/apps/buddy/src/modules/tasks/state/runs/typing.ts @@ -30,6 +30,9 @@ export interface ChatRunProjectionState { } export interface ChatRunSync extends ChatRunProjectionState { + executionRuns: Readonly>> + cancelRunPresentation: (runId: string) => void + restoreRunPresentation: (runId: string) => void isLoadingConversation: Readonly> isLoadingOlderMessages: Readonly> applyEditedTurn: (turn: LocalTurnStart, userMessageId: string) => void diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatRunProjection.ts b/apps/buddy/src/modules/tasks/state/runs/useChatRunProjection.ts index 26ff33ee..a4c87623 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatRunProjection.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatRunProjection.ts @@ -1,10 +1,11 @@ import type { LocalChangeSetSummary } from '@buddy-shared/changes/changeApi' -import type { LocalConversationTimelineItem, LocalConversationTimelinePage, LocalMessage } from '@buddy-shared/conversation/conversationApi' +import type { LocalConversationTimelineItem, LocalConversationTimelinePage } from '@buddy-shared/conversation/conversationApi' import type { LocalApproval } from '@buddy-shared/permissions/approvalApi' import type { LocalRun, LocalRunEvent, LocalRunOutput } from '@buddy-shared/runs/runApi' -import type { ChatRunEventBuckets } from '../../model/runs/typing' +import type { ChatRunEventBucket, ChatRunEventBuckets } from '../../model/runs/typing' import type { ChatRunProjectionState } from './typing' -import { computed, shallowRef } from 'vue' +import { computed, shallowReactive, shallowRef } from 'vue' +import { projectChatRunStreamingMessages } from '../../model/transcript/chatRunStreamingMessages' import { hasChatRunEventSequenceGap, mergeChatRunEventBuckets, @@ -24,9 +25,6 @@ import { export function useChatRunProjection() { const timelineItems = shallowRef>([]) - const messages = computed>(() => timelineItems.value.filter( - (item): item is Extract => item.kind === 'message', - )) const runs = shallowRef>([]) const runSignalEvents = shallowRef>([]) const runEventBuckets = shallowRef(new Map()) @@ -36,8 +34,65 @@ export function useChatRunProjection() { const timelineCursor = shallowRef(null) const hasOlderMessages = computed(() => timelineCursor.value !== null) const knownRunIds = new Set() + // Presentation stops immediately; authoritative runs still own execution until cleanup finishes. + const cancelledPresentations = shallowReactive(new Map + }>()) let hasLoadedTimelinePage = false + const presentedRuns = computed(() => runs.value.map(run => cancelledPresentations.get(run.id)?.run ?? run)) + const presentedBuckets = computed(() => { + if (!cancelledPresentations.size) + return runEventBuckets.value + const buckets = new Map(runEventBuckets.value) + for (const [runId, presentation] of cancelledPresentations) { + if (knownRunIds.has(runId)) + buckets.set(runId, presentation.bucket) + } + return buckets + }) + const presentedTimeline = computed(() => { + const items = timelineItems.value.flatMap((item) => { + if (item.kind !== 'message' || !item.runId) + return [item] + const presentation = cancelledPresentations.get(item.runId) + if (!presentation) + return [item] + const frozen = presentation.messages.get(item.id) + return frozen ? [frozen] : [] + }) + const frozenMessages = [...cancelledPresentations].flatMap(([runId, presentation]) => + knownRunIds.has(runId) ? [...presentation.messages.values()] : []) + return mergeTailTimelineItems(items, frozenMessages) + }) + + function cancelRunPresentation(runId: string) { + const run = runs.value.find(run => run.id === runId) + if (!run || (run.status !== 'queued' && run.status !== 'running') || cancelledPresentations.has(runId)) + return + const completedAt = new Date().toISOString() + const bucket = runEventBuckets.value.get(runId) + const events = bucket?.events ?? [] + // Keep the visible partial answer when terminal projection stops accepting streaming deltas. + const messages = new Map(timelineItems.value.flatMap(item => + item.kind === 'message' && item.runId === runId ? [[item.id, item] as const] : [])) + for (const candidate of projectChatRunStreamingMessages(run, events)) { + if (!messages.has(candidate.message.id)) + messages.set(candidate.message.id, { ...candidate.message, kind: 'message' }) + } + cancelledPresentations.set(runId, { + run: { ...run, status: 'cancelled', completedAt, errorCode: 'RUN_CANCELLED' }, + bucket: { events, revision: (bucket?.revision ?? 0) + 1, update: null }, + messages, + }) + } + + function restoreRunPresentation(runId: string) { + cancelledPresentations.delete(runId) + } + function mergePage(page: LocalConversationTimelinePage) { upsertRuns(page.runs) const events = mergeTimelineEvents( @@ -87,6 +142,8 @@ export function useChatRunProjection() { for (const run of incoming) { byId.set(run.id, run) knownRunIds.add(run.id) + if (run.status !== 'queued' && run.status !== 'running') + restoreRunPresentation(run.id) } runs.value = [...byId.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt)) } @@ -114,19 +171,22 @@ export function useChatRunProjection() { } const state: ChatRunProjectionState = { - approvals, + approvals: computed(() => approvals.value.filter(approval => !cancelledPresentations.has(approval.runId))), changeSets, hasOlderMessages, - messages, - runEventBuckets, + messages: computed(() => presentedTimeline.value.filter((item): item is Extract => item.kind === 'message')), + runEventBuckets: presentedBuckets, runOutputs, - runs, + runs: presentedRuns, runSignalEvents, - timelineItems, + timelineItems: presentedTimeline, } return { state, + executionRuns: runs, + cancelRunPresentation, + restoreRunPresentation, appendEvents, applySnapshot, clear, diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts b/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts index d3655d92..1bcd6a7d 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts @@ -307,6 +307,9 @@ export function useChatRunSync(options: ChatRunSyncOptions): ChatRunSync { return { ...projection.state, + executionRuns: projection.executionRuns, + cancelRunPresentation: projection.cancelRunPresentation, + restoreRunPresentation: projection.restoreRunPresentation, applyEditedTurn: (turn, messageId) => applyReplacementTurn(turn, messageId, false), applyRegeneratedTurn: turn => applyReplacementTurn(turn, turn.run.triggeringMessageId, true), applyRunStart, diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts index b6a8a531..715619f2 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts @@ -1,5 +1,6 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' import type { ParsedBuddyChatCommand } from '@buddy-shared/conversation/buddyChatCommands' +import type { LocalChatQueueTarget } from '@buddy-shared/conversation/chatQueueApi' import type { BuddyUserContentV1 } from '@buddy-shared/conversation/buddyUserContent' import type { LocalPromptContextItem } from '@buddy-shared/conversation/chatApi' import type { BuddyApprovalPolicy } from '@buddy-shared/permissions/approvalPolicy' @@ -57,7 +58,7 @@ export interface UseChatTurnExecutionOptions { onActionCommandRunStarted: (runId: string) => void onDraftCommitted?: (draftId: string, conversationId: string) => void persistWorkspaceState: () => Promise - runSync: Pick + runSync: Pick runtimeSupervisor: Pick setErrorMessage: (message: string | null) => void unavailableCommandMessage: () => string @@ -69,6 +70,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { const requestIds = createRequestIdRegistry() const pendingCancellationWatches = new Set<() => void>() const pendingCancellationIds = shallowReactive(new Set()) + const cancellationCompletions = new Map }>() const stoppingRunId = computed(() => { const run = options.activeRun.value return run && pendingCancellationIds.has(run.id) ? run.id : null @@ -80,6 +82,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { stop() pendingCancellationWatches.clear() pendingCancellationIds.clear() + cancellationCompletions.clear() }, true) const canSend = computed(() => options.runtimeSupervisor.runtimeState.value.status === 'ready' @@ -116,6 +119,9 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { return executeActionCommand(command, contextItems) const sourceScopeKey = options.draftScopeKey.value + const cancellation = [...cancellationCompletions.values()].find(({ run }) => + run.conversationId === options.session.activeConversationId.value + && run.branchId === options.session.activeBranchId.value) const navigationVersion = options.session.generation() const isSourceViewCurrent = () => options.session.isCurrent(navigationVersion) && options.draftScopeKey.value === sourceScopeKey @@ -133,10 +139,12 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { const expectedRevision = confirmedDraft.revision! const operationKey = `turn:${confirmedDraft.draftId}:${expectedRevision}` const requestId = requestIds.resolve(operationKey) - if (options.activeRun.value || queue.queuedMessages.value.length) { + if (cancellation || options.activeRun.value || queue.queuedMessages.value.length) { const result = await options.api.chat.enqueue({ draftId: confirmedDraft.draftId, expectedRevision, requestId }) requestIds.release(operationKey) options.composerTarget.complete(result.draftReceipt, sourceScopeKey, sourceScopeKey) + if (cancellation) + void dispatchAfterCancellation(result, cancellation.completion) await queue.refreshQueue() return true } @@ -267,6 +275,9 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { if (!run || isDisposed || pendingCancellationIds.has(run.id)) return pendingCancellationIds.add(run.id) + const cancellation = Promise.withResolvers() + cancellationCompletions.set(run.id, { run, completion: cancellation.promise }) + options.runSync.cancelRunPresentation(run.id) const navigationVersion = options.session.generation() let sourceViewChanged = false const stopWatchingView = watch( @@ -285,8 +296,11 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { const cancelled = await options.api.chat.cancel(run.id) if (isSourceViewCurrent()) options.runSync.upsertRuns([cancelled]) + cancellation.resolve(cancelled.status === 'cancelled') } catch (error) { + options.runSync.restoreRunPresentation(run.id) + cancellation.resolve(false) if (isSourceViewCurrent()) setNormalizedError(error) } @@ -294,6 +308,22 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { stopWatchingView() pendingCancellationWatches.delete(stopWatchingView) pendingCancellationIds.delete(run.id) + cancellationCompletions.delete(run.id) + } + } + + async function dispatchAfterCancellation(target: LocalChatQueueTarget, completion: Promise) { + if (!await completion || isDisposed) + return + try { + // Only the message explicitly sent after Stop resumes; older paused inputs stay paused. + await options.api.chat.steerQueued({ id: target.id, conversationId: target.conversationId, branchId: target.branchId }) + await queue.refreshQueue() + } + catch (error) { + if (!isDisposed && options.session.activeConversationId.value === target.conversationId + && options.session.activeBranchId.value === target.branchId) + setNormalizedError(error) } } diff --git a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts index 3f7d7c2c..676817d7 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts @@ -100,7 +100,7 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi timelineItems, } = runSync - const activeRun = computed(() => runs.value.find( + const activeRun = computed(() => runSync.executionRuns.value.find( run => run.status === 'queued' || run.status === 'running', ) ?? null) const hasAvailableProvider = computed(() => modelProviders.providers.value.some( @@ -493,7 +493,7 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi }, }, execution: { - activeRun: readonly(activeRun), + activeRun: computed(() => runs.value.find(run => run.status === 'queued' || run.status === 'running') ?? null), approvalViews: readonly(approvalViews), canMutateBranch: readonly(canMutateBranch), canSend: readonly(canSend), diff --git a/docs/specs/feature-008-instant-chat-stop.md b/docs/specs/feature-008-instant-chat-stop.md new file mode 100644 index 00000000..0722882e --- /dev/null +++ b/docs/specs/feature-008-instant-chat-stop.md @@ -0,0 +1,67 @@ +# Spec-008:点击停止立即结束聊天运行展示 + +**日期:** 2026-09-29 +**状态:** 已实现,自动检查通过,待桌面手动验收 +**分支:** `codex/instant-chat-stop`(本次功能分支) + +## 1. 背景 + +AI 正在调用工具或执行命令时,原来的停止流程会等待后台中断及清理完成,输入框才恢复发送按钮。用户容易认为点击无效,连续点击几次。目标是获得类似 XTLaw 的操作感受:点击后立即停止当前运行展示,可以继续输入和提交下一条消息。 + +## 2. 已实现的行为 + +- 点击停止时,同步把当前轮次的展示标记为已取消,输入框恢复发送按钮。 +- 保留点击时已经生成的部分回答,停止等待授权及运行中工具的展示。 +- 在后台收尾期间,冻结该轮次的消息和事件展示,迟到的输出不会让界面重新进入运行状态。 +- 仍立即调用已有后台取消接口;后台真实执行状态继续保留,直到中断和清理结束。 +- 收尾期间发送的新消息通过已有队列接收并清空已提交的草稿;取消成功后尝试启动这条新消息,避免与旧执行重叠。 +- 停止前已有的排队消息维持暂停;只有停止后明确提交的新消息触发自动衔接。 +- 取消请求失败时,恢复真实运行展示,并在仍停留于原会话时显示错误。 +- 后台真实终态到达后,以后台记录替换临时展示;切换会话时不会把旧会话的临时内容带入新会话。 + +## 3. 方案与边界 + +本次把“用户看到的运行状态”和“后台是否释放执行”分开。前者立即取消,后者仍沿用现有中断流程。下一轮调度、权限和分支操作仍依据真实执行状态判断。 + +与 XTLaw 的主要区别:XTLaw 的普通取消入口很快返回,Lexora 本次保留后台取消接口等待收尾的行为,通过前端临时展示提前响应点击。两者都需要后台处理真实中断;本次没有保证操作系统中的命令进程在点击瞬间完成退出。 + +范围仅包括聊天停止展示及停止后新消息的衔接。不修改后台取消协议、进程终止策略、已有命令超时或其他界面布局;不新增依赖。 + +## 4. 文件与术语说明 + +以下路径均相对于仓库根目录。 + +| 原名称 | 中文含义 | 用途 | +|---|---|---| +| `apps/buddy/src/modules/tasks/state/runs/useChatRunProjection.ts` | 聊天运行展示状态 | 保存取消时的回答和事件快照,冻结展示并与后台终态对齐 | +| `cancelRunPresentation` / `restoreRunPresentation` | 取消展示 / 恢复展示的方法 | 在等待后台响应前更新界面,失败时撤销临时状态 | +| `executionRuns` | 真实执行状态列表 | 保持后台占用判断,不受临时展示影响 | +| `apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts` / `typing.ts` | 状态同步入口及类型定义 | 暴露展示操作和真实执行状态 | +| `apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts` | 聊天提交与取消逻辑 | 立即取消展示,等待后台结束后衔接新消息 | +| `apps/buddy/src/modules/tasks/state/useTaskCapability.ts` | 聊天工作区能力入口 | 界面使用展示状态,内部操作使用真实执行状态 | +| `apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts` | 工具执行过程展示 | 把取消轮次中仍等待授权的工具标为已中断 | +| `steerQueued` | 启动指定排队消息的接口 | 取消完成后请求执行停止后新提交的消息 | + +## 5. 验收与检查 + +- [x] 后台取消尚未返回时,已立即触发取消展示。 +- [x] 保留部分回答,后续增量输出不会继续刷新取消轮次。 +- [x] 真实执行状态保留,收到后台终态后完成对齐。 +- [x] 取消失败时恢复展示,重复停止不重复发起取消。 +- [x] 收尾期间可以提交下一条消息,后台取消完成后才调用启动接口。 +- [x] 类型检查及 3 个相关测试文件中的 69 项测试通过。 +- [ ] 在实际桌面应用中验证长命令中断时的按钮变化和下一条消息衔接。 + +自动检查使用 `pnpm --filter @uselexora/lexora-buddy type-check`(桌面应用类型检查命令),以及以下相关测试文件: + +- `apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts`(展示冻结、部分回答及状态恢复检查)。 +- `apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts`(停止请求与新消息衔接检查)。 +- `apps/buddy/src/modules/tasks/model/transcript/__tests__/chatStreamingMessage.spec.ts`(回答增量展示的相关回归检查)。 + +最小手动验收:重启开发版,启动一个耗时工具或命令,在执行期间点击停止。预期按钮立即恢复发送,已生成内容保留;马上发送下一条,预期它先被接收,在旧执行释放后开始。观察旧轮次不再持续刷出迟到内容。 + +## 6. 限制与未决事项 + +界面立即停止不代表进程已经退出;真实清理耗时由已有后台实现决定。取消失败或后台拒绝启动新消息时,队列及错误信息仍遵循现有处理。本次自动检查覆盖停止后提交一条新消息的衔接,尚未通过实际桌面操作验收连续提交多条消息的行为。 + +没有需要用户选择的设计事项;剩余验收项如上。