From e9f1b1f7ec40012b4d3dce70b0c248ee2f99036e Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:31:04 +0800 Subject: [PATCH 1/5] fix: stop stale context compaction indicators Treat unresolved context compaction activity as failed once its owning assistant turn finishes, so both transcript and usage footer stop spinning. Model: gpt-5.6-sol --- ...09-10-context-compaction-terminal-state.md | 49 +++++++++++++++++++ ...10-context-compaction-terminal-state.zh.md | 39 +++++++++++++++ .../components/src/components/ai-gui/view.tsx | 18 +++++-- .../src/lib/session-context-compaction.ts | 22 +++++++-- .../tests/session-context-compaction.test.ts | 32 ++++++++++-- 5 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md new file mode 100644 index 000000000..97162fbde --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -0,0 +1,49 @@ +# Stop stale context-compaction indicators at the turn boundary + +Status: implemented +Translation: current + +[中文](2026-09-10-context-compaction-terminal-state.zh.md) + +## Abstract + +A failed remote context-compaction request could leave its tool-call item in +`pending` or `in_progress` even after Lody had finalized the containing assistant +turn, so both the transcript and session-usage footer kept showing an indefinite +spinner. The UI now treats the finished turn as the authoritative activity +boundary and projects any unresolved compaction inside it as failed, while +preserving the provider's durable history item unchanged. This repairs existing +affected histories as well as future failures without requiring a protocol +simulator, but it does not attempt to reproduce provider-specific wire output. + +## Decision + +The compaction activity item and its owning assistant turn have different +writers. Provider updates own the tool-call status, while Lody's turn finalizer +owns `SessionHistory.finished`. A transport failure can therefore finalize the +turn without receiving the tool call's terminal update. + +Rendering now resolves one effective compaction status for both consumers. A +`pending` or `in_progress` item remains active only while its assistant turn is +unfinished; once the turn is finished, it is displayed as failed and no longer +contributes to the session-level compacting state. Explicit `completed` and +`failed` provider states remain unchanged. + +This is a projection rule, not a history migration. Rewriting the persisted tool +call would erase the distinction between provider evidence and Lody's recovery +inference, and fixing only the future error path would leave already affected +sessions stuck. The turn boundary is available in both render paths and is the +narrowest reliable terminal signal. + +## Scope and verification + +This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570). It is distinct +from [issue #267](https://github.com/LodyAI/Lody/issues/267), where an interrupted +manual `/compact` may leave an actually active backend turn; this change does not +alter ACP lifecycle or cancellation behavior. + +Unit coverage verifies unfinished active states, finished unresolved states, and +explicit terminal states. Component type checking covers propagation of the turn +boundary through both assistant tool-call render paths. No Model API Simulator or +end-to-end test was added; provider-specific protocol reproduction remains out of +scope for this UI state-recovery fix. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md new file mode 100644 index 000000000..b97ca56e9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -0,0 +1,39 @@ +# 在 turn 边界终止陈旧的上下文压缩状态 + +Status: implemented +Translation: current + +[English](2026-09-10-context-compaction-terminal-state.md) + +## 摘要 + +远端上下文压缩请求失败后,其 tool-call 条目可能仍停留在 `pending` 或 +`in_progress`,即使 Lody 已经结束了承载它的 assistant turn;因此消息流和会话用量 +底栏都会无限显示旋转状态。现在 UI 将已结束的 turn 作为活动边界,把其中未收敛的压缩 +状态投影为失败,同时保持 provider 写入的持久历史不变。这个处理既能修复已经受影响的 +历史,也覆盖后续失败,并不依赖协议模拟器;但它不会尝试复刻特定 provider 的线协议 +输出。 + +## 决策 + +压缩活动条目和承载它的 assistant turn 由不同主体写入。Provider 更新负责 tool-call +状态,Lody 的 turn finalizer 负责 `SessionHistory.finished`。因此传输失败可能结束 turn, +却收不到 tool call 的终态更新。 + +渲染层现在为两个消费方解析同一个有效压缩状态。`pending` 或 `in_progress` 仅在 +assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为失败,也不再计入会话级 +“正在压缩”状态。Provider 明确写入的 `completed` 和 `failed` 状态保持不变。 + +这是一条投影规则,不是历史迁移。重写持久化 tool call 会抹掉 provider 证据和 Lody +恢复推断之间的区别,而只修复未来的错误路径又会让已经受影响的会话继续卡住。两个渲染 +路径都能取得 turn 边界,它是最窄且可靠的终止信号。 + +## 范围与验证 + +本修复对应 [issue #570](https://github.com/LodyAI/Lody/issues/570)。它不同于 +[issue #267](https://github.com/LodyAI/Lody/issues/267):后者是手动 `/compact` 被中断后, +后端 turn 可能确实仍处于活跃状态;本次改动不调整 ACP 生命周期或取消行为。 + +单元测试覆盖未结束的活跃状态、已结束但未收敛的状态,以及显式终态。组件类型检查覆盖 +turn 边界在两条 assistant tool-call 渲染路径中的传递。本次没有增加 Model API +Simulator 或端到端测试;特定 provider 的协议复现不属于这次 UI 状态恢复的范围。 diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index 1d967a6df..117b0c9d5 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -167,6 +167,7 @@ import { TerminalComponent } from './terminal-component'; import { prepareTerminalOutputBlocksPreview } from './terminal-preview'; import { type DurationUnitLabels, formatDurationCompact } from '@/lib/format-duration'; import { resolveSessionHistoryDurationMs } from '@/lib/session-history-duration'; +import { resolveContextCompactionDisplayStatus } from '@/lib/session-context-compaction'; import { cn } from '@/lib/utils'; import { ConversationColumn } from '@/components/shared/conversation-column'; import { CreatedSessionOperationCard } from './created-session-operation-card'; @@ -3413,12 +3414,14 @@ const AssistantToolCallVirtualRow = memo( sessionId, messageId, entry, + turnFinished, onFilePathClick, fontSize, }: { sessionId: SessionId; messageId: string; entry: AssistantToolCallRenderItem; + turnFinished: boolean; onFilePathClick?: (filePath: string) => void; fontSize: ConversationFontSize; }) { @@ -3443,6 +3446,7 @@ const AssistantToolCallVirtualRow = memo( @@ -4345,6 +4352,7 @@ const renderAssistantContent = ( messageId: string; itemIndex: number; isStreaming?: boolean; + turnFinished?: boolean; onFilePathClick?: (filePath: string) => void; conversationFontSize?: ConversationFontSize; /** This turn's plan approval is still unanswered — see `PlanPanel`. */ @@ -4439,6 +4447,7 @@ const renderAssistantContent = ( @@ -5703,6 +5712,7 @@ const ToolCallCard = memo(function ToolCallCard({ onExpandedChange, onFilePathClick, inlineOutput = false, + turnFinished = false, }: { toolCall: ToolCallMessage; sessionId: SessionId; @@ -5711,6 +5721,7 @@ const ToolCallCard = memo(function ToolCallCard({ onExpandedChange?: (expanded: boolean) => void; onFilePathClick?: (filePath: string) => void; inlineOutput?: boolean; + turnFinished?: boolean; }) { const { t } = useTranslation(); if (toolCall.activityKind === 'codex_retry') { @@ -5725,8 +5736,9 @@ const ToolCallCard = memo(function ToolCallCard({ ); } if (toolCall.activityKind === 'context_compaction') { - const isCompacting = toolCall.status === 'pending' || toolCall.status === 'in_progress'; - const StatusIcon = isCompacting ? Loader2 : toolCall.status === 'failed' ? AlertCircle : Check; + const status = resolveContextCompactionDisplayStatus(toolCall.status, turnFinished); + const isCompacting = status === 'pending' || status === 'in_progress'; + const StatusIcon = isCompacting ? Loader2 : status === 'failed' ? AlertCircle : Check; return (
{isCompacting ? t('sessions.activity.compactingContext', 'Compacting context') - : toolCall.status === 'failed' + : status === 'failed' ? t('sessions.activity.contextCompactionFailed', 'Context compaction failed') : t('sessions.activity.contextCompacted', 'Context compacted')} diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index e7d16dc21..4c16b3218 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,14 +1,28 @@ -import type { SessionHistory } from '@lody/shared'; +import type { MessageContent, SessionHistory } from '@lody/shared'; + +type ToolCallStatus = Extract['status']; + +export const resolveContextCompactionDisplayStatus = ( + status: ToolCallStatus, + isTurnFinished: boolean +): ToolCallStatus => { + if (isTurnFinished && (status === 'pending' || status === 'in_progress')) { + return 'failed'; + } + return status; +}; export const isSessionContextCompacting = ( - history: readonly Pick[] + history: readonly Pick[] ): boolean => { for (let entryIndex = history.length - 1; entryIndex >= 0; entryIndex -= 1) { - const items = history[entryIndex]?.items ?? []; + const entry = history[entryIndex]; + const items = entry?.items ?? []; for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = items[itemIndex]; if (item?.type !== 'tool_call' || item.activityKind !== 'context_compaction') continue; - return item.status === 'pending' || item.status === 'in_progress'; + const status = resolveContextCompactionDisplayStatus(item.status, entry?.finished === true); + return status === 'pending' || status === 'in_progress'; } } return false; diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index 44b63072d..ffa00281e 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory } from '@lody/shared'; -import { isSessionContextCompacting } from '../src/lib/session-context-compaction'; +import { + isSessionContextCompacting, + resolveContextCompactionDisplayStatus, +} from '../src/lib/session-context-compaction'; -const historyWithStatus = (status: 'pending' | 'in_progress' | 'completed' | 'failed') => +const historyWithStatus = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', + finished = false +) => [ { + finished, items: [ { type: 'tool_call', @@ -16,7 +23,21 @@ const historyWithStatus = (status: 'pending' | 'in_progress' | 'completed' | 'fa }, ], }, - ] as Pick[]; + ] as Pick[]; + +describe('resolveContextCompactionDisplayStatus', () => { + it('ends an unresolved compaction when its owning turn has finished', () => { + expect(resolveContextCompactionDisplayStatus('pending', true)).toBe('failed'); + expect(resolveContextCompactionDisplayStatus('in_progress', true)).toBe('failed'); + }); + + it('preserves active compactions and provider terminal states', () => { + expect(resolveContextCompactionDisplayStatus('pending', false)).toBe('pending'); + expect(resolveContextCompactionDisplayStatus('in_progress', false)).toBe('in_progress'); + expect(resolveContextCompactionDisplayStatus('completed', true)).toBe('completed'); + expect(resolveContextCompactionDisplayStatus('failed', true)).toBe('failed'); + }); +}); describe('isSessionContextCompacting', () => { it('tracks pending and in-progress compaction tool calls', () => { @@ -28,4 +49,9 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('completed'))).toBe(false); expect(isSessionContextCompacting(historyWithStatus('failed'))).toBe(false); }); + + it('stops loading when the owning turn finishes without a terminal tool update', () => { + expect(isSessionContextCompacting(historyWithStatus('pending', true))).toBe(false); + expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(false); + }); }); From e9d53df96964684ed90d5173b07b349496a81328 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:36:40 +0800 Subject: [PATCH 2/5] docs: link context compaction fix PR Record the implementation PR in both Agent Note translations. Model: gpt-5.6-sol --- .../2026-09-10-context-compaction-terminal-state.md | 9 +++++---- .../2026-09-10-context-compaction-terminal-state.zh.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index 97162fbde..8d5624384 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -37,10 +37,11 @@ narrowest reliable terminal signal. ## Scope and verification -This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570). It is distinct -from [issue #267](https://github.com/LodyAI/Lody/issues/267), where an interrupted -manual `/compact` may leave an actually active backend turn; this change does not -alter ACP lifecycle or cancellation behavior. +This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570) in +[PR #573](https://github.com/LodyAI/Lody/pull/573). It is distinct from +[issue #267](https://github.com/LodyAI/Lody/issues/267), where an interrupted manual +`/compact` may leave an actually active backend turn; this change does not alter +ACP lifecycle or cancellation behavior. Unit coverage verifies unfinished active states, finished unresolved states, and explicit terminal states. Component type checking covers propagation of the turn diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index b97ca56e9..8095dd8c6 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -30,7 +30,8 @@ assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为失 ## 范围与验证 -本修复对应 [issue #570](https://github.com/LodyAI/Lody/issues/570)。它不同于 +本修复通过 [PR #573](https://github.com/LodyAI/Lody/pull/573) 处理 +[issue #570](https://github.com/LodyAI/Lody/issues/570)。它不同于 [issue #267](https://github.com/LodyAI/Lody/issues/267):后者是手动 `/compact` 被中断后, 后端 turn 可能确实仍处于活跃状态;本次改动不调整 ACP 生命周期或取消行为。 From c104b9128e5f0b24687abe2598009090d26870b1 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:58:29 +0800 Subject: [PATCH 3/5] fix: distinguish stopped context compaction Add lifecycle regression coverage across finalization, persisted history reload, late completion, and a subsequent compaction turn. Model: gpt-5.6-sol --- ...09-10-context-compaction-terminal-state.md | 27 ++--- ...10-context-compaction-terminal-state.zh.md | 20 ++-- .../message-handler-acp-batching.test.ts | 98 +++++++++++++++++++ locales/en.json | 1 + locales/zh_CN.json | 1 + .../components/src/components/ai-gui/view.tsx | 13 ++- .../src/lib/session-context-compaction.ts | 5 +- .../tests/session-context-compaction.test.ts | 4 +- 8 files changed, 144 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index 8d5624384..15899dfca 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -11,10 +11,10 @@ A failed remote context-compaction request could leave its tool-call item in `pending` or `in_progress` even after Lody had finalized the containing assistant turn, so both the transcript and session-usage footer kept showing an indefinite spinner. The UI now treats the finished turn as the authoritative activity -boundary and projects any unresolved compaction inside it as failed, while -preserving the provider's durable history item unchanged. This repairs existing -affected histories as well as future failures without requiring a protocol -simulator, but it does not attempt to reproduce provider-specific wire output. +boundary and projects any unresolved compaction inside it as stopped, while +preserving the provider's durable history item unchanged. Updated renderers recover +the display of existing affected histories as well as future failures without a +protocol simulator, but older renderers still interpret the unchanged raw status. ## Decision @@ -25,9 +25,11 @@ turn without receiving the tool call's terminal update. Rendering now resolves one effective compaction status for both consumers. A `pending` or `in_progress` item remains active only while its assistant turn is -unfinished; once the turn is finished, it is displayed as failed and no longer -contributes to the session-level compacting state. Explicit `completed` and -`failed` provider states remain unchanged. +unfinished; once the turn is finished, it is displayed as stopped and no longer +contributes to the session-level compacting state. `stopped` is a display-only +inference because the turn boundary does not distinguish failure, cancellation, +disconnect, or interruption. Explicit `completed` and `failed` provider states +remain unchanged. This is a projection rule, not a history migration. Rewriting the persisted tool call would erase the distinction between provider evidence and Lody's recovery @@ -44,7 +46,10 @@ This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570) in ACP lifecycle or cancellation behavior. Unit coverage verifies unfinished active states, finished unresolved states, and -explicit terminal states. Component type checking covers propagation of the turn -boundary through both assistant tool-call render paths. No Model API Simulator or -end-to-end test was added; provider-specific protocol reproduction remains out of -scope for this UI state-recovery fix. +explicit terminal states. A deterministic lifecycle regression drives the production +ACP history writer and finalizer through an in-progress compaction, repeated +finalization, Loro document reopening, a late completed update, and a new compaction +in the next turn; it then checks the same projection used by both UI consumers. No +Model API Simulator or end-to-end test was added. Issue #267 still requires separate +runtime cancellation verification because a finished host turn does not prove that +the provider prompt has stopped. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index 8095dd8c6..7e8ecdf70 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -10,9 +10,9 @@ Translation: current 远端上下文压缩请求失败后,其 tool-call 条目可能仍停留在 `pending` 或 `in_progress`,即使 Lody 已经结束了承载它的 assistant turn;因此消息流和会话用量 底栏都会无限显示旋转状态。现在 UI 将已结束的 turn 作为活动边界,把其中未收敛的压缩 -状态投影为失败,同时保持 provider 写入的持久历史不变。这个处理既能修复已经受影响的 -历史,也覆盖后续失败,并不依赖协议模拟器;但它不会尝试复刻特定 provider 的线协议 -输出。 +状态投影为“已停止”,同时保持 provider 写入的持久历史不变。新版本 renderer 能恢复已 +受影响历史以及后续失败的显示,且不依赖协议模拟器;旧版本 renderer 仍会按未改写的原始 +状态渲染。 ## 决策 @@ -21,8 +21,9 @@ Translation: current 却收不到 tool call 的终态更新。 渲染层现在为两个消费方解析同一个有效压缩状态。`pending` 或 `in_progress` 仅在 -assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为失败,也不再计入会话级 -“正在压缩”状态。Provider 明确写入的 `completed` 和 `failed` 状态保持不变。 +assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为“已停止”,也不再计入会话级 +“正在压缩”状态。`stopped` 只是显示层推断,因为 turn 边界无法区分失败、取消、断连或 +中断。Provider 明确写入的 `completed` 和 `failed` 状态保持不变。 这是一条投影规则,不是历史迁移。重写持久化 tool call 会抹掉 provider 证据和 Lody 恢复推断之间的区别,而只修复未来的错误路径又会让已经受影响的会话继续卡住。两个渲染 @@ -35,6 +36,9 @@ assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为失 [issue #267](https://github.com/LodyAI/Lody/issues/267):后者是手动 `/compact` 被中断后, 后端 turn 可能确实仍处于活跃状态;本次改动不调整 ACP 生命周期或取消行为。 -单元测试覆盖未结束的活跃状态、已结束但未收敛的状态,以及显式终态。组件类型检查覆盖 -turn 边界在两条 assistant tool-call 渲染路径中的传递。本次没有增加 Model API -Simulator 或端到端测试;特定 provider 的协议复现不属于这次 UI 状态恢复的范围。 +单元测试覆盖未结束的活跃状态、已结束但未收敛的状态,以及显式终态。一条确定性的生命 +周期回归测试使用生产 ACP history writer 和 finalizer,依次覆盖进行中的压缩、重复 +finalization、Loro 文档重新打开、迟到的 completed update,以及下一 turn 的新压缩,并 +对两个 UI 消费方共用的 projection 做断言。本次没有增加 Model API Simulator 或端到端 +测试。Issue #267 仍需单独验证 runtime cancellation,因为 host turn 已结束不能证明 +provider prompt 已停止。 diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index cb4840f3c..3f7cfff49 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -18,6 +18,10 @@ import type { SessionManager } from '../src/session/session-manager'; import type { Logger } from '../src/utils/logger'; import { loadEnv } from '../src/utils/const'; import { createTestCloudPort } from './test-cloud-port'; +import { + isSessionContextCompacting, + resolveContextCompactionDisplayStatus, +} from '../../../packages/components/src/lib/session-context-compaction'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -363,6 +367,100 @@ describe('MessageHandler ACP batching', () => { } }); + it('recovers a persisted compaction after finalization and accepts later activity', async () => { + vi.useRealTimers(); + const sessionId = 'compaction-lifecycle' as SessionId; + const { repo, docs, handler } = await createHandlerHarness([sessionId]); + const doc = docs.get(sessionId); + if (!doc) throw new Error(`Missing session doc for ${sessionId}`); + const host = handler as unknown as { + beginConversationTurn(sessionId: SessionId): string; + enqueueACPUpdate(sessionId: SessionId, update: AcpSessionNotification): void; + flushACPUpdatesNow(sessionId: SessionId): Promise; + finalizeACPState(sessionId: SessionId, turnId?: string): Promise; + }; + const findCompaction = (history: SessionHistoryInput[], toolCallId: string) => + history + .flatMap((entry) => readItems(entry)) + .find( + (item) => + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + item.toolCallId === toolCallId + ); + + try { + const turnId = host.beginConversationTurn(sessionId); + host.enqueueACPUpdate(sessionId, { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'compact-1', + title: 'Context compacting', + status: 'in_progress', + _meta: { contextCompaction: true }, + }, + }); + await host.flushACPUpdatesNow(sessionId); + expect(isSessionContextCompacting(await doc.getHistory())).toBe(true); + + await host.finalizeACPState(sessionId, turnId); + await host.finalizeACPState(sessionId, turnId); + + const reopened = new SessionDocument(repo, sessionId, async () => {}); + await reopened.initOffline({ history: [] }); + const reloadedHistory = await reopened.getHistory(); + const reloadedTurn = reloadedHistory.find((entry) => entry.id === turnId); + const staleCompaction = findCompaction(reloadedHistory, 'compact-1'); + expect(reloadedTurn?.finished).toBe(true); + expect(staleCompaction).toMatchObject({ status: 'in_progress' }); + if (!staleCompaction || staleCompaction.type !== 'tool_call') { + throw new Error('Missing persisted context compaction'); + } + expect(resolveContextCompactionDisplayStatus(staleCompaction.status, true)).toBe('stopped'); + expect(isSessionContextCompacting(reloadedHistory)).toBe(false); + + host.enqueueACPUpdate(sessionId, { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'compact-1', + status: 'completed', + _meta: { contextCompaction: true }, + }, + }); + await host.flushACPUpdatesNow(sessionId); + const completedHistory = await doc.getHistory(); + const completedCompaction = findCompaction(completedHistory, 'compact-1'); + expect(completedCompaction).toMatchObject({ status: 'completed' }); + if (!completedCompaction || completedCompaction.type !== 'tool_call') { + throw new Error('Missing completed context compaction'); + } + expect(resolveContextCompactionDisplayStatus(completedCompaction.status, true)).toBe( + 'completed' + ); + + const nextTurnId = host.beginConversationTurn(sessionId); + host.enqueueACPUpdate(sessionId, { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'compact-2', + title: 'Context compacting', + status: 'in_progress', + _meta: { contextCompaction: true }, + }, + }); + await host.flushACPUpdatesNow(sessionId); + const nextHistory = await doc.getHistory(); + expect(nextTurnId).not.toBe(turnId); + expect(findCompaction(nextHistory, 'compact-2')).toMatchObject({ status: 'in_progress' }); + expect(isSessionContextCompacting(nextHistory)).toBe(true); + } finally { + await destroyRepoOnRealTimers(repo); + } + }); + it('persists updates buffered during the finalization tail instead of dropping them', async () => { const sessionId = 's-1' as SessionId; const { repo, docs, handler } = await createHandlerHarness([sessionId]); diff --git a/locales/en.json b/locales/en.json index 58206ec87..33a140338 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1214,6 +1214,7 @@ "sessions.activity.compactingContext": "Compacting context", "sessions.activity.contextCompacted": "Context compacted", "sessions.activity.contextCompactionFailed": "Context compaction failed", + "sessions.activity.contextCompactionStopped": "Context compaction stopped", "sessions.askQuestion.answered": "Answered", "sessions.askQuestion.autoContinueIn": "Continues in {{seconds}}s", "sessions.askQuestion.continuing": "Continuing", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 485944be0..91dccccbe 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1214,6 +1214,7 @@ "sessions.activity.compactingContext": "正在压缩上下文", "sessions.activity.contextCompacted": "上下文已压缩", "sessions.activity.contextCompactionFailed": "上下文压缩失败", + "sessions.activity.contextCompactionStopped": "上下文压缩已停止", "sessions.askQuestion.answered": "已回答", "sessions.askQuestion.autoContinueIn": "{{seconds}} 秒后自动继续", "sessions.askQuestion.continuing": "正在继续", diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index 117b0c9d5..b517e9a1b 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -99,6 +99,7 @@ import { Brain, BrushCleaning, Check, + CircleStop, X, CheckCircle2, ChevronRight, @@ -5738,7 +5739,13 @@ const ToolCallCard = memo(function ToolCallCard({ if (toolCall.activityKind === 'context_compaction') { const status = resolveContextCompactionDisplayStatus(toolCall.status, turnFinished); const isCompacting = status === 'pending' || status === 'in_progress'; - const StatusIcon = isCompacting ? Loader2 : status === 'failed' ? AlertCircle : Check; + const StatusIcon = isCompacting + ? Loader2 + : status === 'failed' + ? AlertCircle + : status === 'stopped' + ? CircleStop + : Check; return (
); diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index 4c16b3218..feaadcdf2 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,13 +1,14 @@ import type { MessageContent, SessionHistory } from '@lody/shared'; type ToolCallStatus = Extract['status']; +export type ContextCompactionDisplayStatus = ToolCallStatus | 'stopped'; export const resolveContextCompactionDisplayStatus = ( status: ToolCallStatus, isTurnFinished: boolean -): ToolCallStatus => { +): ContextCompactionDisplayStatus => { if (isTurnFinished && (status === 'pending' || status === 'in_progress')) { - return 'failed'; + return 'stopped'; } return status; }; diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index ffa00281e..afdf04499 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -27,8 +27,8 @@ const historyWithStatus = ( describe('resolveContextCompactionDisplayStatus', () => { it('ends an unresolved compaction when its owning turn has finished', () => { - expect(resolveContextCompactionDisplayStatus('pending', true)).toBe('failed'); - expect(resolveContextCompactionDisplayStatus('in_progress', true)).toBe('failed'); + expect(resolveContextCompactionDisplayStatus('pending', true)).toBe('stopped'); + expect(resolveContextCompactionDisplayStatus('in_progress', true)).toBe('stopped'); }); it('preserves active compactions and provider terminal states', () => { From 41783137c14ec2fb18d2fee8b139f5354fc27cb3 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:15:56 +0800 Subject: [PATCH 4/5] fix: settle compaction on provider failure Persist failed compaction state only when the ACP prompt returns an error or the provider disconnects. Keep interrupted turns active until provider state converges. Model: gpt-5.6-sol --- ...09-10-context-compaction-terminal-state.md | 68 ++++++++-------- ...10-context-compaction-terminal-state.zh.md | 47 ++++++----- .../src/lib/assistant-turn-finalize.test.ts | 51 ++++++++++++ apps/cli/src/lib/assistant-turn-finalize.ts | 19 ++++- apps/cli/src/lib/message-handler.ts | 17 +++- .../src/session/session-execution-service.ts | 20 +++-- .../message-handler-acp-batching.test.ts | 33 ++++---- .../tests/session-execution-service.test.ts | 77 +++++++++++++++++-- locales/en.json | 1 - locales/zh_CN.json | 1 - .../components/src/components/ai-gui/view.tsx | 29 +------ .../src/lib/session-context-compaction.ts | 23 +----- .../tests/session-context-compaction.test.ts | 25 +----- 13 files changed, 254 insertions(+), 157 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index 15899dfca..1851aa313 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -1,4 +1,4 @@ -# Stop stale context-compaction indicators at the turn boundary +# Settle context compaction after provider failure Status: implemented Translation: current @@ -8,48 +8,42 @@ Translation: current ## Abstract A failed remote context-compaction request could leave its tool-call item in -`pending` or `in_progress` even after Lody had finalized the containing assistant -turn, so both the transcript and session-usage footer kept showing an indefinite -spinner. The UI now treats the finished turn as the authoritative activity -boundary and projects any unresolved compaction inside it as stopped, while -preserving the provider's durable history item unchanged. Updated renderers recover -the display of existing affected histories as well as future failures without a -protocol simulator, but older renderers still interpret the unchanged raw status. +`pending` or `in_progress`, so both the transcript and session-usage footer kept +showing an indefinite spinner. When the provider prompt returns an ACP error or the +agent disconnects, Lody now persists unresolved compaction activities in that turn +as `failed`. Renderers continue to follow the durable tool-call status directly. ## Decision -The compaction activity item and its owning assistant turn have different -writers. Provider updates own the tool-call status, while Lody's turn finalizer -owns `SessionHistory.finished`. A transport failure can therefore finalize the -turn without receiving the tool call's terminal update. - -Rendering now resolves one effective compaction status for both consumers. A -`pending` or `in_progress` item remains active only while its assistant turn is -unfinished; once the turn is finished, it is displayed as stopped and no longer -contributes to the session-level compacting state. `stopped` is a display-only -inference because the turn boundary does not distinguish failure, cancellation, -disconnect, or interruption. Explicit `completed` and `failed` provider states -remain unchanged. - -This is a projection rule, not a history migration. Rewriting the persisted tool -call would erase the distinction between provider evidence and Lody's recovery -inference, and fixing only the future error path would leave already affected -sessions stuck. The turn boundary is available in both render paths and is the -narrowest reliable terminal signal. +The compaction activity and its owning assistant turn have different terminal +signals. `SessionHistory.finished` records host finalization, including interrupted +turn teardown, and therefore does not prove that the provider prompt stopped. The +UI must not infer a compaction terminal state from that field. + +The prompt error path has stronger evidence: the ACP prompt returned an error, or +the provider connection ended. Before finalization clears the turn state, it asks +the finalizer to change only `pending` or `in_progress` context-compaction items in +that exact assistant turn to `failed`. Ordinary completion and cancellation do not +request this settlement. Explicit provider terminal states remain unchanged, and a +late provider update for the same `toolCallId` can still replace `failed` with +`completed`. + +This fixes future error paths and histories that receive a later failure-aware +finalization. It does not migrate already persisted stale histories, because those +histories contain no durable evidence that distinguishes #570 from an interrupted +but still-active provider prompt. ## Scope and verification This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570) in [PR #573](https://github.com/LodyAI/Lody/pull/573). It is distinct from [issue #267](https://github.com/LodyAI/Lody/issues/267), where an interrupted manual -`/compact` may leave an actually active backend turn; this change does not alter -ACP lifecycle or cancellation behavior. - -Unit coverage verifies unfinished active states, finished unresolved states, and -explicit terminal states. A deterministic lifecycle regression drives the production -ACP history writer and finalizer through an in-progress compaction, repeated -finalization, Loro document reopening, a late completed update, and a new compaction -in the next turn; it then checks the same projection used by both UI consumers. No -Model API Simulator or end-to-end test was added. Issue #267 still requires separate -runtime cancellation verification because a finished host turn does not prove that -the provider prompt has stopped. +`/compact` may leave an actually active backend prompt. A normal cancellation +finalization intentionally leaves that compaction active instead of hiding it. + +Unit coverage verifies that ordinary finalization preserves an unresolved activity +and that ACP failures request settlement. A deterministic lifecycle regression +drives the production ACP history writer and finalizer through ordinary and +failure-aware finalization, Loro document reopening, a late completed update, and a +new compaction in the next turn. No Model API Simulator or end-to-end test was added; +issue #267 still requires a separate provider cancellation fix. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index 7e8ecdf70..fba5d7fc5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -1,4 +1,4 @@ -# 在 turn 边界终止陈旧的上下文压缩状态 +# Provider 失败后收敛上下文压缩状态 Status: implemented Translation: current @@ -8,37 +8,36 @@ Translation: current ## 摘要 远端上下文压缩请求失败后,其 tool-call 条目可能仍停留在 `pending` 或 -`in_progress`,即使 Lody 已经结束了承载它的 assistant turn;因此消息流和会话用量 -底栏都会无限显示旋转状态。现在 UI 将已结束的 turn 作为活动边界,把其中未收敛的压缩 -状态投影为“已停止”,同时保持 provider 写入的持久历史不变。新版本 renderer 能恢复已 -受影响历史以及后续失败的显示,且不依赖协议模拟器;旧版本 renderer 仍会按未改写的原始 -状态渲染。 +`in_progress`,导致消息流和会话用量底栏无限显示旋转状态。现在,当 provider prompt 返回 +ACP error 或 agent 断连时,Lody 会把该 turn 内未收敛的压缩活动持久化为 `failed`;渲染层 +继续直接遵循持久化 tool-call 状态。 ## 决策 -压缩活动条目和承载它的 assistant turn 由不同主体写入。Provider 更新负责 tool-call -状态,Lody 的 turn finalizer 负责 `SessionHistory.finished`。因此传输失败可能结束 turn, -却收不到 tool call 的终态更新。 +压缩活动与承载它的 assistant turn 具有不同的终止信号。`SessionHistory.finished` 记录 +host finalization,其中也包括被中断 turn 的 teardown,因此不能证明 provider prompt 已 +停止;UI 不应从该字段推断压缩终态。 -渲染层现在为两个消费方解析同一个有效压缩状态。`pending` 或 `in_progress` 仅在 -assistant turn 尚未结束时保持活跃;turn 结束后,它会显示为“已停止”,也不再计入会话级 -“正在压缩”状态。`stopped` 只是显示层推断,因为 turn 边界无法区分失败、取消、断连或 -中断。Provider 明确写入的 `completed` 和 `failed` 状态保持不变。 +Prompt error path 提供了更强的证据:ACP prompt 已返回错误,或者 provider connection 已 +结束。在 finalization 清理 turn state 前,这条路径要求 finalizer 仅把该 assistant turn +内 `pending` 或 `in_progress` 的 context-compaction 条目改为 `failed`。普通完成和取消不会 +请求该收敛。Provider 已写入的终态保持不变;相同 `toolCallId` 的迟到 provider update 仍可 +把 `failed` 覆盖为 `completed`。 -这是一条投影规则,不是历史迁移。重写持久化 tool call 会抹掉 provider 证据和 Lody -恢复推断之间的区别,而只修复未来的错误路径又会让已经受影响的会话继续卡住。两个渲染 -路径都能取得 turn 边界,它是最窄且可靠的终止信号。 +该设计修复未来的 error path,并处理之后再次经过 failure-aware finalization 的历史。 +它不会迁移已经持久化的陈旧历史,因为这些历史里没有 durable evidence 能区分 #570 和 +“已中断但 provider prompt 仍活跃”的情况。 ## 范围与验证 本修复通过 [PR #573](https://github.com/LodyAI/Lody/pull/573) 处理 [issue #570](https://github.com/LodyAI/Lody/issues/570)。它不同于 [issue #267](https://github.com/LodyAI/Lody/issues/267):后者是手动 `/compact` 被中断后, -后端 turn 可能确实仍处于活跃状态;本次改动不调整 ACP 生命周期或取消行为。 - -单元测试覆盖未结束的活跃状态、已结束但未收敛的状态,以及显式终态。一条确定性的生命 -周期回归测试使用生产 ACP history writer 和 finalizer,依次覆盖进行中的压缩、重复 -finalization、Loro 文档重新打开、迟到的 completed update,以及下一 turn 的新压缩,并 -对两个 UI 消费方共用的 projection 做断言。本次没有增加 Model API Simulator 或端到端 -测试。Issue #267 仍需单独验证 runtime cancellation,因为 host turn 已结束不能证明 -provider prompt 已停止。 +provider prompt 可能确实仍处于活跃状态。普通 cancellation finalization 会有意保留该压缩 +活动,而不是在 UI 中隐藏它。 + +单元测试验证普通 finalization 会保留未收敛活动,而 ACP failure 会请求收敛。一条确定性 +生命周期回归测试使用生产 ACP history writer 和 finalizer,覆盖普通及 failure-aware +finalization、Loro 文档重新打开、迟到的 completed update,以及下一 turn 的新压缩。本次 +没有增加 Model API Simulator 或端到端测试;issue #267 仍需要单独修复 provider +cancellation。 diff --git a/apps/cli/src/lib/assistant-turn-finalize.test.ts b/apps/cli/src/lib/assistant-turn-finalize.test.ts index fd199cace..59bee68a7 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.test.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.test.ts @@ -67,6 +67,57 @@ describe('markAssistantTurnFinished', () => { expect(history[0]?.permissionWaitMs).toBe(4_000); }); + it('settles an incomplete compaction only with provider failure evidence', () => { + const history = [ + assistantEntry({ + id: 'assistant:u1', + items: [ + { + type: 'tool_call', + toolCallId: 'compact-1', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }), + ]; + + markAssistantTurnFinished(history, { endedAt: TURN_ENDED_AT }); + expect(history[0]?.items?.[0]).toMatchObject({ status: 'in_progress' }); + + markAssistantTurnFinished(history, { + endedAt: APP_CLOSED_AT, + settleContextCompactionAsFailed: true, + }); + expect(history[0]).toMatchObject({ finished: true, endedAt: TURN_ENDED_AT }); + expect(history[0]?.items?.[0]).toMatchObject({ status: 'failed' }); + }); + + it('preserves a provider terminal compaction during failure finalization', () => { + const history = [ + assistantEntry({ + id: 'assistant:u1', + items: [ + { + type: 'tool_call', + toolCallId: 'compact-1', + title: 'Context compacting', + status: 'completed', + activityKind: 'context_compaction', + }, + ], + }), + ]; + + markAssistantTurnFinished(history, { + endedAt: TURN_ENDED_AT, + settleContextCompactionAsFailed: true, + }); + + expect(history[0]?.items?.[0]).toMatchObject({ status: 'completed' }); + }); + it('never stamps a user or system entry standing after the turn', () => { const history: SessionHistoryInput[] = [ assistantEntry({ id: 'assistant:u1' }), diff --git a/apps/cli/src/lib/assistant-turn-finalize.ts b/apps/cli/src/lib/assistant-turn-finalize.ts index ac92f6b72..e1c3e60f8 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.ts @@ -31,12 +31,29 @@ export const markAssistantTurnFinished = ( turnId?: string | undefined; endedAt: number; permissionWaitMs?: number | undefined; + /** The provider prompt returned an error before its compaction emitted a terminal update. */ + settleContextCompactionAsFailed?: boolean | undefined; } ): SessionHistoryInput[] => { - const { turnId, endedAt, permissionWaitMs } = options; + const { turnId, endedAt, permissionWaitMs, settleContextCompactionAsFailed } = options; for (let i = history.length - 1; i >= 0; i--) { const entry = history[i]; if (entry && entry.role === 'assistant' && (!turnId || entry.id === turnId)) { + if (settleContextCompactionAsFailed && entry.items) { + let changed = false; + const items = entry.items.map((item) => { + if ( + item.type !== 'tool_call' || + item.activityKind !== 'context_compaction' || + (item.status !== 'pending' && item.status !== 'in_progress') + ) { + return item; + } + changed = true; + return { ...item, status: 'failed' as const }; + }); + if (changed) entry.items = items; + } // Already finalized: its terminal timing is the truth, not this call's clock. if (entry.finished === true) break; entry.finished = true; diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index cec5d54d3..a4d19ef16 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -3118,8 +3118,8 @@ export class MessageHandler { userTurnId ), turnFinalization: { - finalizeACPState: async (sessionId, turnId) => - await this.finalizeACPState(sessionId, turnId), + finalizeACPState: async (sessionId, turnId, options) => + await this.finalizeACPState(sessionId, turnId, options), persistCodeCollabTurnDiffs: async (sessionId, turnId) => await this.persistCodeCollabTurnDiffs(sessionId, turnId), flushSessionUsage: async (sessionId) => await this.flushSessionUsage(sessionId), @@ -5822,7 +5822,11 @@ export class MessageHandler { } } - private async finalizeACPState(sessionId: SessionId, turnId?: string): Promise { + private async finalizeACPState( + sessionId: SessionId, + turnId?: string, + options?: { settleContextCompactionAsFailed?: boolean } + ): Promise { // Finalization marks the last assistant entry finished — that entry must // exist and be correctly ordered first, so wait for the turn history gate // (bounded; opens on user-turn sync or timeout). @@ -5849,7 +5853,12 @@ export class MessageHandler { // Mark the owning assistant entry as finished and record timing. const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); await sessionDoc.updateHistory((history) => - markAssistantTurnFinished(history, { turnId, endedAt, permissionWaitMs }) + markAssistantTurnFinished(history, { + turnId, + endedAt, + permissionWaitMs, + settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, + }) ); await sessionDoc.waitUntilSynced(); } catch (error) { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 154bb9dce..014d1ca60 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -178,7 +178,11 @@ const SILENT_TURN_FAILURE_MESSAGE = 'new session if this conversation has grown too long.'; type TurnFinalizationEffects = { - finalizeACPState: (sessionId: SessionId, turnId?: string) => Promise; + finalizeACPState: ( + sessionId: SessionId, + turnId?: string, + options?: { settleContextCompactionAsFailed?: boolean } + ) => Promise; persistCodeCollabTurnDiffs?: (sessionId: SessionId, turnId: string) => Promise; flushSessionUsage: (sessionId: SessionId) => Promise; syncSessionBranchName: (sessionId: SessionId, session: ISession) => Promise; @@ -2269,7 +2273,15 @@ export class SessionExecutionService { sessionDoc: SessionDocument, error?: unknown ): Promise { - await this.deps.turnFinalization.finalizeACPState(sessionId); + const acpError = error ? parseACPError(error) : null; + const providerDisconnected = error ? isAgentDisconnectedError(error) : false; + await this.deps.turnFinalization.finalizeACPState( + sessionId, + this.currentTurnBySession.get(sessionId), + { + settleContextCompactionAsFailed: acpError !== null || providerDisconnected, + } + ); await this.persistCodeCollabTurnDiffsAfterACPFinalization( sessionId, this.currentTurnBySession.get(sessionId) @@ -2277,8 +2289,6 @@ export class SessionExecutionService { await this.deps.turnFinalization.flushSessionUsage(sessionId); if (error) { - const acpError = parseACPError(error); - if (acpError) { const failureReason = mapACPErrorToFailureReason(acpError); const userMessage = getACPErrorUserMessage(acpError); @@ -2319,7 +2329,7 @@ export class SessionExecutionService { ); } } - } else if (isAgentDisconnectedError(error)) { + } else if (providerDisconnected) { this.deps.logger.warn( `[${sessionId}] Agent disconnected during chat, terminating session for clean restart` ); diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index 3f7cfff49..43dd637df 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -18,10 +18,7 @@ import type { SessionManager } from '../src/session/session-manager'; import type { Logger } from '../src/utils/logger'; import { loadEnv } from '../src/utils/const'; import { createTestCloudPort } from './test-cloud-port'; -import { - isSessionContextCompacting, - resolveContextCompactionDisplayStatus, -} from '../../../packages/components/src/lib/session-context-compaction'; +import { isSessionContextCompacting } from '../../../packages/components/src/lib/session-context-compaction'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -367,7 +364,7 @@ describe('MessageHandler ACP batching', () => { } }); - it('recovers a persisted compaction after finalization and accepts later activity', async () => { + it('settles only a failed compaction across finalization, reload, and later activity', async () => { vi.useRealTimers(); const sessionId = 'compaction-lifecycle' as SessionId; const { repo, docs, handler } = await createHandlerHarness([sessionId]); @@ -377,7 +374,11 @@ describe('MessageHandler ACP batching', () => { beginConversationTurn(sessionId: SessionId): string; enqueueACPUpdate(sessionId: SessionId, update: AcpSessionNotification): void; flushACPUpdatesNow(sessionId: SessionId): Promise; - finalizeACPState(sessionId: SessionId, turnId?: string): Promise; + finalizeACPState( + sessionId: SessionId, + turnId?: string, + options?: { settleContextCompactionAsFailed?: boolean } + ): Promise; }; const findCompaction = (history: SessionHistoryInput[], toolCallId: string) => history @@ -414,11 +415,17 @@ describe('MessageHandler ACP batching', () => { const staleCompaction = findCompaction(reloadedHistory, 'compact-1'); expect(reloadedTurn?.finished).toBe(true); expect(staleCompaction).toMatchObject({ status: 'in_progress' }); - if (!staleCompaction || staleCompaction.type !== 'tool_call') { - throw new Error('Missing persisted context compaction'); - } - expect(resolveContextCompactionDisplayStatus(staleCompaction.status, true)).toBe('stopped'); - expect(isSessionContextCompacting(reloadedHistory)).toBe(false); + expect(isSessionContextCompacting(reloadedHistory)).toBe(true); + + await host.finalizeACPState(sessionId, turnId, { + settleContextCompactionAsFailed: true, + }); + await host.finalizeACPState(sessionId, turnId, { + settleContextCompactionAsFailed: true, + }); + const failedHistory = await reopened.getHistory(); + expect(findCompaction(failedHistory, 'compact-1')).toMatchObject({ status: 'failed' }); + expect(isSessionContextCompacting(failedHistory)).toBe(false); host.enqueueACPUpdate(sessionId, { sessionId, @@ -436,9 +443,7 @@ describe('MessageHandler ACP batching', () => { if (!completedCompaction || completedCompaction.type !== 'tool_call') { throw new Error('Missing completed context compaction'); } - expect(resolveContextCompactionDisplayStatus(completedCompaction.status, true)).toBe( - 'completed' - ); + expect(isSessionContextCompacting(completedHistory)).toBe(false); const nextTurnId = host.beginConversationTurn(sessionId); host.enqueueACPUpdate(sessionId, { diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index fa4f9276c..776dbef8b 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -4840,12 +4840,33 @@ describe('SessionExecutionService', () => { it('records ACP string error data as the visible chat failure message', async () => { const upsertDocMeta = vi.fn(async () => {}); + let history: SessionHistoryInput[] = [ + { + id: 'turn-1', + role: 'assistant', + timestamp: '2026-09-10T00:00:00.000Z', + fileDiff: [], + items: [ + { + type: 'tool_call', + toolCallId: 'compact-1', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }, + ]; const sessionDoc = { getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), - updateHistory: vi.fn(async () => {}), + getHistory: vi.fn(async () => history), + updateHistory: vi.fn( + async (updater: (current: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = updater(history); + } + ), }; const acpError = Object.assign(new Error('Invalid params'), { code: -32602, @@ -4892,6 +4913,16 @@ describe('SessionExecutionService', () => { getOrOpenSessionCode: vi.fn(async () => null), updateAcpCapabilities: vi.fn(async () => {}), } as unknown as LoroDocumentManager, + turnFinalization: { + ...createBaseDeps({}).turnFinalization, + finalizeACPState: vi.fn(async (_sessionId, turnId, options) => { + history = markAssistantTurnFinished(history, { + turnId, + endedAt: 42, + settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, + }); + }), + }, }); const service = new SessionExecutionService(deps); @@ -4913,6 +4944,10 @@ describe('SessionExecutionService', () => { 'acp_invalid_params', 'No goal is currently set. Use `/goal ` to create one.' ); + expect(history[0]).toMatchObject({ + finished: true, + items: [expect.objectContaining({ toolCallId: 'compact-1', status: 'failed' })], + }); }); it('records a visible failure when a chat turn fails before prompt starts', async () => { @@ -5231,6 +5266,21 @@ describe('SessionExecutionService', () => { status: 'pending', read: false, }, + { + id: 'assistant-prompt-cancel', + role: 'assistant', + timestamp: '2026-09-10T00:00:00.000Z', + fileDiff: [], + items: [ + { + type: 'tool_call', + toolCallId: 'compact-cancelled-turn', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }, ]; const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; @@ -5309,9 +5359,16 @@ describe('SessionExecutionService', () => { buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hello' }] as any), processMessageQueue: vi.fn(async () => {}), }); - vi.mocked(deps.turnFinalization.finalizeACPState).mockImplementation(async () => { - expect(history[0]).toMatchObject({ id: 'turn-prompt-cancel', status: 'canceled' }); - }); + vi.mocked(deps.turnFinalization.finalizeACPState).mockImplementation( + async (_sessionId, turnId, options) => { + history = markAssistantTurnFinished(history as SessionHistoryInput[], { + turnId, + endedAt: 42, + settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, + }) as Array>; + expect(history[0]).toMatchObject({ id: 'turn-prompt-cancel', status: 'canceled' }); + } + ); const onTurnSettled = vi.fn(async () => {}); service = new SessionExecutionService(deps); @@ -5336,6 +5393,16 @@ describe('SessionExecutionService', () => { expect(deps.processMessageQueue).not.toHaveBeenCalled(); expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); expect(history[0]).toMatchObject({ id: 'turn-prompt-cancel', status: 'canceled' }); + expect(history[1]).toMatchObject({ + id: 'assistant-prompt-cancel', + finished: true, + items: [ + expect.objectContaining({ + toolCallId: 'compact-cancelled-turn', + status: 'in_progress', + }), + ], + }); expect(upsertDocMeta).toHaveBeenCalledWith('session-session-prompt-cancel', { lastHandledUserMsgId: 'turn-prompt-cancel', processingUserMsgId: undefined, diff --git a/locales/en.json b/locales/en.json index 33a140338..58206ec87 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1214,7 +1214,6 @@ "sessions.activity.compactingContext": "Compacting context", "sessions.activity.contextCompacted": "Context compacted", "sessions.activity.contextCompactionFailed": "Context compaction failed", - "sessions.activity.contextCompactionStopped": "Context compaction stopped", "sessions.askQuestion.answered": "Answered", "sessions.askQuestion.autoContinueIn": "Continues in {{seconds}}s", "sessions.askQuestion.continuing": "Continuing", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 91dccccbe..485944be0 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1214,7 +1214,6 @@ "sessions.activity.compactingContext": "正在压缩上下文", "sessions.activity.contextCompacted": "上下文已压缩", "sessions.activity.contextCompactionFailed": "上下文压缩失败", - "sessions.activity.contextCompactionStopped": "上下文压缩已停止", "sessions.askQuestion.answered": "已回答", "sessions.askQuestion.autoContinueIn": "{{seconds}} 秒后自动继续", "sessions.askQuestion.continuing": "正在继续", diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index b517e9a1b..1d967a6df 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -99,7 +99,6 @@ import { Brain, BrushCleaning, Check, - CircleStop, X, CheckCircle2, ChevronRight, @@ -168,7 +167,6 @@ import { TerminalComponent } from './terminal-component'; import { prepareTerminalOutputBlocksPreview } from './terminal-preview'; import { type DurationUnitLabels, formatDurationCompact } from '@/lib/format-duration'; import { resolveSessionHistoryDurationMs } from '@/lib/session-history-duration'; -import { resolveContextCompactionDisplayStatus } from '@/lib/session-context-compaction'; import { cn } from '@/lib/utils'; import { ConversationColumn } from '@/components/shared/conversation-column'; import { CreatedSessionOperationCard } from './created-session-operation-card'; @@ -3415,14 +3413,12 @@ const AssistantToolCallVirtualRow = memo( sessionId, messageId, entry, - turnFinished, onFilePathClick, fontSize, }: { sessionId: SessionId; messageId: string; entry: AssistantToolCallRenderItem; - turnFinished: boolean; onFilePathClick?: (filePath: string) => void; fontSize: ConversationFontSize; }) { @@ -3447,7 +3443,6 @@ const AssistantToolCallVirtualRow = memo( @@ -4353,7 +4345,6 @@ const renderAssistantContent = ( messageId: string; itemIndex: number; isStreaming?: boolean; - turnFinished?: boolean; onFilePathClick?: (filePath: string) => void; conversationFontSize?: ConversationFontSize; /** This turn's plan approval is still unanswered — see `PlanPanel`. */ @@ -4448,7 +4439,6 @@ const renderAssistantContent = ( @@ -5713,7 +5703,6 @@ const ToolCallCard = memo(function ToolCallCard({ onExpandedChange, onFilePathClick, inlineOutput = false, - turnFinished = false, }: { toolCall: ToolCallMessage; sessionId: SessionId; @@ -5722,7 +5711,6 @@ const ToolCallCard = memo(function ToolCallCard({ onExpandedChange?: (expanded: boolean) => void; onFilePathClick?: (filePath: string) => void; inlineOutput?: boolean; - turnFinished?: boolean; }) { const { t } = useTranslation(); if (toolCall.activityKind === 'codex_retry') { @@ -5737,15 +5725,8 @@ const ToolCallCard = memo(function ToolCallCard({ ); } if (toolCall.activityKind === 'context_compaction') { - const status = resolveContextCompactionDisplayStatus(toolCall.status, turnFinished); - const isCompacting = status === 'pending' || status === 'in_progress'; - const StatusIcon = isCompacting - ? Loader2 - : status === 'failed' - ? AlertCircle - : status === 'stopped' - ? CircleStop - : Check; + const isCompacting = toolCall.status === 'pending' || toolCall.status === 'in_progress'; + const StatusIcon = isCompacting ? Loader2 : toolCall.status === 'failed' ? AlertCircle : Check; return (
{isCompacting ? t('sessions.activity.compactingContext', 'Compacting context') - : status === 'failed' + : toolCall.status === 'failed' ? t('sessions.activity.contextCompactionFailed', 'Context compaction failed') - : status === 'stopped' - ? t('sessions.activity.contextCompactionStopped', 'Context compaction stopped') - : t('sessions.activity.contextCompacted', 'Context compacted')} + : t('sessions.activity.contextCompacted', 'Context compacted')}
); diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index feaadcdf2..e7d16dc21 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -1,29 +1,14 @@ -import type { MessageContent, SessionHistory } from '@lody/shared'; - -type ToolCallStatus = Extract['status']; -export type ContextCompactionDisplayStatus = ToolCallStatus | 'stopped'; - -export const resolveContextCompactionDisplayStatus = ( - status: ToolCallStatus, - isTurnFinished: boolean -): ContextCompactionDisplayStatus => { - if (isTurnFinished && (status === 'pending' || status === 'in_progress')) { - return 'stopped'; - } - return status; -}; +import type { SessionHistory } from '@lody/shared'; export const isSessionContextCompacting = ( - history: readonly Pick[] + history: readonly Pick[] ): boolean => { for (let entryIndex = history.length - 1; entryIndex >= 0; entryIndex -= 1) { - const entry = history[entryIndex]; - const items = entry?.items ?? []; + const items = history[entryIndex]?.items ?? []; for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = items[itemIndex]; if (item?.type !== 'tool_call' || item.activityKind !== 'context_compaction') continue; - const status = resolveContextCompactionDisplayStatus(item.status, entry?.finished === true); - return status === 'pending' || status === 'in_progress'; + return item.status === 'pending' || item.status === 'in_progress'; } } return false; diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index afdf04499..dac623cb4 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory } from '@lody/shared'; -import { - isSessionContextCompacting, - resolveContextCompactionDisplayStatus, -} from '../src/lib/session-context-compaction'; +import { isSessionContextCompacting } from '../src/lib/session-context-compaction'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -25,20 +22,6 @@ const historyWithStatus = ( }, ] as Pick[]; -describe('resolveContextCompactionDisplayStatus', () => { - it('ends an unresolved compaction when its owning turn has finished', () => { - expect(resolveContextCompactionDisplayStatus('pending', true)).toBe('stopped'); - expect(resolveContextCompactionDisplayStatus('in_progress', true)).toBe('stopped'); - }); - - it('preserves active compactions and provider terminal states', () => { - expect(resolveContextCompactionDisplayStatus('pending', false)).toBe('pending'); - expect(resolveContextCompactionDisplayStatus('in_progress', false)).toBe('in_progress'); - expect(resolveContextCompactionDisplayStatus('completed', true)).toBe('completed'); - expect(resolveContextCompactionDisplayStatus('failed', true)).toBe('failed'); - }); -}); - describe('isSessionContextCompacting', () => { it('tracks pending and in-progress compaction tool calls', () => { expect(isSessionContextCompacting(historyWithStatus('pending'))).toBe(true); @@ -50,8 +33,8 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('failed'))).toBe(false); }); - it('stops loading when the owning turn finishes without a terminal tool update', () => { - expect(isSessionContextCompacting(historyWithStatus('pending', true))).toBe(false); - expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(false); + it('does not treat host turn finalization as provider termination', () => { + expect(isSessionContextCompacting(historyWithStatus('pending', true))).toBe(true); + expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(true); }); }); From e0a035777ac4c3101f326bffbce5d1c110fb48fd Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:41:02 +0800 Subject: [PATCH 5/5] fix: settle compaction after cancelled prompt drain Model: gpt-5.6-sol --- ...09-10-context-compaction-terminal-state.md | 58 +++++++----- ...10-context-compaction-terminal-state.zh.md | 44 +++++---- .../src/session/session-execution-service.ts | 23 ++++- .../tests/session-execution-service.test.ts | 93 ++++++++++++++++--- 4 files changed, 160 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md index 1851aa313..d40aafd60 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -7,11 +7,12 @@ Translation: current ## Abstract -A failed remote context-compaction request could leave its tool-call item in -`pending` or `in_progress`, so both the transcript and session-usage footer kept -showing an indefinite spinner. When the provider prompt returns an ACP error or the -agent disconnects, Lody now persists unresolved compaction activities in that turn -as `failed`. Renderers continue to follow the durable tool-call status directly. +A failed or cancelled remote context-compaction request could leave its tool-call +item in `pending` or `in_progress`, so both the transcript and session-usage footer +kept showing an indefinite spinner after the provider was no longer active. Lody +now persists unresolved compaction activities in that turn as `failed` after a +failed provider prompt settles or the cancelled prompt is successfully terminated. +Renderers continue to follow the durable tool-call status directly. ## Decision @@ -20,13 +21,20 @@ signals. `SessionHistory.finished` records host finalization, including interrup turn teardown, and therefore does not prove that the provider prompt stopped. The UI must not infer a compaction terminal state from that field. -The prompt error path has stronger evidence: the ACP prompt returned an error, or -the provider connection ended. Before finalization clears the turn state, it asks -the finalizer to change only `pending` or `in_progress` context-compaction items in -that exact assistant turn to `failed`. Ordinary completion and cancellation do not -request this settlement. Explicit provider terminal states remain unchanged, and a -late provider update for the same `toolCallId` can still replace `failed` with -`completed`. +The prompt error path has stronger evidence: a prompt that started has returned +control with an error and is no longer in flight. Before finalization clears the +turn state, it asks the finalizer to change only `pending` or `in_progress` +context-compaction items in that exact assistant turn to `failed`. This lifecycle +signal covers transport failures such as `Connection failed: error sending request` +without depending on a numeric ACP error code or a message allowlist. + +Cancellation has two phases. Host teardown first records the cancelled turn while +leaving compaction active. The execution owner then waits for the raw ACP request; +after five seconds it may terminate the old session, and a failed termination keeps +waiting for raw completion. Only after that drain completes does the host settle +the unresolved compaction, flush the usage state, and release the owner. Explicit +provider terminal states remain unchanged, and a late provider update for the same +`toolCallId` can still replace `failed` with `completed`. This fixes future error paths and histories that receive a later failure-aware finalization. It does not migrate already persisted stale histories, because those @@ -36,14 +44,18 @@ but still-active provider prompt. ## Scope and verification This fixes [issue #570](https://github.com/LodyAI/Lody/issues/570) in -[PR #573](https://github.com/LodyAI/Lody/pull/573). It is distinct from -[issue #267](https://github.com/LodyAI/Lody/issues/267), where an interrupted manual -`/compact` may leave an actually active backend prompt. A normal cancellation -finalization intentionally leaves that compaction active instead of hiding it. - -Unit coverage verifies that ordinary finalization preserves an unresolved activity -and that ACP failures request settlement. A deterministic lifecycle regression -drives the production ACP history writer and finalizer through ordinary and -failure-aware finalization, Loro document reopening, a late completed update, and a -new compaction in the next turn. No Model API Simulator or end-to-end test was added; -issue #267 still requires a separate provider cancellation fix. +[PR #573](https://github.com/LodyAI/Lody/pull/573). Together with the provider +ownership recovery from [PR #571](https://github.com/LodyAI/Lody/pull/571), it also +completes the compaction activity cleanup required by +[issue #267](https://github.com/LodyAI/Lody/issues/267): an interrupted `/compact` +remains active while the provider prompt is active, then becomes terminal before +the next prompt can acquire the session. + +Unit coverage uses the exact #570 transport-error shape and verifies that a settled +provider prompt requests compaction settlement without an ACP code. Deterministic +cancellation coverage verifies that compaction remains active while raw provider +ownership is retained, becomes failed after raw completion or successful +termination, stays active after failed termination, and is settled before the next +prompt runs. The lifecycle regression also covers Loro document reopening, a late +completed update, and a new compaction in the next turn. No Model API Simulator or +end-to-end test was added. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md index fba5d7fc5..0fd2be839 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -7,10 +7,10 @@ Translation: current ## 摘要 -远端上下文压缩请求失败后,其 tool-call 条目可能仍停留在 `pending` 或 -`in_progress`,导致消息流和会话用量底栏无限显示旋转状态。现在,当 provider prompt 返回 -ACP error 或 agent 断连时,Lody 会把该 turn 内未收敛的压缩活动持久化为 `failed`;渲染层 -继续直接遵循持久化 tool-call 状态。 +远端上下文压缩请求失败或取消后,其 tool-call 条目可能仍停留在 `pending` 或 +`in_progress`,导致 provider 已不再活跃时,消息流和会话用量底栏仍无限显示旋转状态。 +现在,失败的 provider prompt 已结束或取消中的 prompt 被成功 terminate 后,Lody 会把该 +turn 内未收敛的压缩活动持久化为 `failed`;渲染层继续直接遵循持久化 tool-call 状态。 ## 决策 @@ -18,10 +18,16 @@ ACP error 或 agent 断连时,Lody 会把该 turn 内未收敛的压缩活动 host finalization,其中也包括被中断 turn 的 teardown,因此不能证明 provider prompt 已 停止;UI 不应从该字段推断压缩终态。 -Prompt error path 提供了更强的证据:ACP prompt 已返回错误,或者 provider connection 已 -结束。在 finalization 清理 turn state 前,这条路径要求 finalizer 仅把该 assistant turn -内 `pending` 或 `in_progress` 的 context-compaction 条目改为 `failed`。普通完成和取消不会 -请求该收敛。Provider 已写入的终态保持不变;相同 `toolCallId` 的迟到 provider update 仍可 +Prompt error path 提供了更强的证据:一个已经启动的 prompt 带错误返回了控制权,且已不再 +处于 in-flight 状态。在 finalization 清理 turn state 前,这条路径要求 finalizer 仅把该 +assistant turn 内 `pending` 或 `in_progress` 的 context-compaction 条目改为 `failed`。这个 +生命周期信号可以覆盖 `Connection failed: error sending request` 一类传输错误,不依赖 +numeric ACP error code 或错误文案白名单。 + +取消分为两个阶段。Host teardown 先记录 turn 已取消,但保持 compaction 活跃;随后执行 +owner 等待 raw ACP request,五秒后可以 terminate 旧 session,而 terminate 失败时继续等待 +raw completion。只有 drain 完成以后,host 才收敛未完成 compaction、flush usage state, +并释放 owner。Provider 已写入的终态保持不变;相同 `toolCallId` 的迟到 provider update 仍可 把 `failed` 覆盖为 `completed`。 该设计修复未来的 error path,并处理之后再次经过 failure-aware finalization 的历史。 @@ -31,13 +37,15 @@ Prompt error path 提供了更强的证据:ACP prompt 已返回错误,或者 ## 范围与验证 本修复通过 [PR #573](https://github.com/LodyAI/Lody/pull/573) 处理 -[issue #570](https://github.com/LodyAI/Lody/issues/570)。它不同于 -[issue #267](https://github.com/LodyAI/Lody/issues/267):后者是手动 `/compact` 被中断后, -provider prompt 可能确实仍处于活跃状态。普通 cancellation finalization 会有意保留该压缩 -活动,而不是在 UI 中隐藏它。 - -单元测试验证普通 finalization 会保留未收敛活动,而 ACP failure 会请求收敛。一条确定性 -生命周期回归测试使用生产 ACP history writer 和 finalizer,覆盖普通及 failure-aware -finalization、Loro 文档重新打开、迟到的 completed update,以及下一 turn 的新压缩。本次 -没有增加 Model API Simulator 或端到端测试;issue #267 仍需要单独修复 provider -cancellation。 +[issue #570](https://github.com/LodyAI/Lody/issues/570)。它与 +[PR #571](https://github.com/LodyAI/Lody/pull/571) 的 provider ownership recovery 组合后, +也补全了 [issue #267](https://github.com/LodyAI/Lody/issues/267) 所需的 compaction activity +清理:被中断的 `/compact` 在 provider prompt 仍活跃时保持 active,并在下一 prompt 可以 +取得 session 前进入终态。 + +单元测试直接使用 #570 的原始传输错误 shape,验证 prompt 已结束时即使没有 ACP code 也会 +请求 compaction settlement。确定性的 cancellation 测试验证:保留 raw provider ownership +期间 compaction 仍为 active;raw completion 或成功 terminate 后变为 failed;terminate +失败后继续 active;且下一 prompt 执行前已经完成收敛。生命周期回归还覆盖 Loro 文档重新 +打开、迟到的 completed update,以及下一 turn 的新压缩。本次没有增加 Model API +Simulator 或端到端测试。 diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 819346d1b..ac4e4221a 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1978,6 +1978,19 @@ export class SessionExecutionService { ) ); } + + if (runtime?.promptStarted) { + yield* self.ignoreWithWarning( + options.sessionId, + 'Failed to settle context compaction after cancelled ACP prompt stopped', + self.tryPromise(async () => { + await self.deps.turnFinalization.finalizeACPState(options.sessionId, options.turnId, { + settleContextCompactionAsFailed: true, + }); + await self.persistTurnDiffsAndFlushUsage(options.sessionId, options.turnId); + }) + ); + } }).pipe( Effect.ensuring( Effect.sync(() => { @@ -2195,7 +2208,9 @@ export class SessionExecutionService { if (options.userTurnId) { await this.markTurnFailed(options.sessionId, options.sessionDoc, options.userTurnId); } - await this.handleTurnError(options.sessionId, options.sessionDoc, options.error); + await this.handleTurnError(options.sessionId, options.sessionDoc, options.error, { + providerPromptSettled: options.runtime.promptStarted && !options.runtime.promptInFlight, + }); await options.onUnhandledError?.(options.error); } @@ -2298,7 +2313,8 @@ export class SessionExecutionService { private async handleTurnError( sessionId: SessionId, sessionDoc: SessionDocument, - error?: unknown + error?: unknown, + options?: { providerPromptSettled?: boolean } ): Promise { const acpError = error ? parseACPError(error) : null; const providerDisconnected = error ? isAgentDisconnectedError(error) : false; @@ -2306,7 +2322,8 @@ export class SessionExecutionService { sessionId, this.currentTurnBySession.get(sessionId), { - settleContextCompactionAsFailed: acpError !== null || providerDisconnected, + settleContextCompactionAsFailed: + options?.providerPromptSettled === true || acpError !== null || providerDisconnected, } ); await this.persistCodeCollabTurnDiffsAfterACPFinalization( diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 0cbfe3d50..7c6f80f90 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -4838,7 +4838,26 @@ describe('SessionExecutionService', () => { expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); }); - it('records ACP string error data as the visible chat failure message', async () => { + it.each([ + { + name: 'ACP string error data', + error: Object.assign(new Error('Invalid params'), { + code: -32602, + data: 'No goal is currently set. Use `/goal ` to create one.', + }), + expectedFailure: [ + 'acp_invalid_params', + 'No goal is currently set. Use `/goal ` to create one.', + ] as const, + }, + { + name: 'remote compact transport error', + error: new Error( + 'Error running remote compact task: Connection failed: error sending request' + ), + expectedFailure: null, + }, + ])('settles context compaction after a provider prompt rejects ($name)', async (testCase) => { const upsertDocMeta = vi.fn(async () => {}); let history: SessionHistoryInput[] = [ { @@ -4868,14 +4887,10 @@ describe('SessionExecutionService', () => { } ), }; - const acpError = Object.assign(new Error('Invalid params'), { - code: -32602, - data: 'No goal is currently set. Use `/goal ` to create one.', - }); const agentClient = { isCreated: vi.fn(() => true), prompt: vi.fn(async () => { - throw acpError; + throw testCase.error; }), currentModel: undefined, }; @@ -4939,11 +4954,11 @@ describe('SessionExecutionService', () => { userEmail: 'user@example.com', }); - expect(deps.recordChatFailure).toHaveBeenCalledWith( - sessionDoc, - 'acp_invalid_params', - 'No goal is currently set. Use `/goal ` to create one.' - ); + if (testCase.expectedFailure) { + expect(deps.recordChatFailure).toHaveBeenCalledWith(sessionDoc, ...testCase.expectedFailure); + } else { + expect(deps.recordChatFailure).not.toHaveBeenCalled(); + } expect(history[0]).toMatchObject({ finished: true, items: [expect.objectContaining({ toolCallId: 'compact-1', status: 'failed' })], @@ -5389,7 +5404,7 @@ describe('SessionExecutionService', () => { ); expect(agentClient.cancel).toHaveBeenCalledWith('acp-prompt-cancel'); - expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); + expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(2); expect(deps.processMessageQueue).not.toHaveBeenCalled(); expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); expect(history[0]).toMatchObject({ id: 'turn-prompt-cancel', status: 'canceled' }); @@ -5399,7 +5414,7 @@ describe('SessionExecutionService', () => { items: [ expect.objectContaining({ toolCallId: 'compact-cancelled-turn', - status: 'in_progress', + status: 'failed', }), ], }); @@ -5430,6 +5445,21 @@ describe('SessionExecutionService', () => { read: false, items: [{ type: 'text', text: 'old request' }], }, + { + id: `assistant:${userTurnId}`, + role: 'assistant', + timestamp: '2026-09-10T00:00:00.000Z', + fileDiff: [], + items: [ + { + type: 'tool_call', + toolCallId: 'compact-cancel-drain', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }, + ], + }, ]; let status: unknown; const sessionDoc = { @@ -5536,6 +5566,15 @@ describe('SessionExecutionService', () => { } as unknown as LoroDocumentManager, buildAcpPromptBlocks: async ({ inputBlocks }) => inputBlocks as ContentBlock[], }); + vi.mocked(deps.turnFinalization.finalizeACPState).mockImplementation( + async (_sessionId, turnId, options) => { + history = markAssistantTurnFinished(history as SessionHistoryInput[], { + turnId, + endedAt: 42, + settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, + }) as Array>; + } + ); const service = new SessionExecutionService(deps); const message: Parameters[0] = { type: 'session/chat', @@ -5575,6 +5614,16 @@ describe('SessionExecutionService', () => { processingUserMsgId: undefined, }); expect(service.getExecutionSnapshot(sessionId)).toMatchObject({ hasActiveTurn: true }); + expect(history[1]).toMatchObject({ + id: `assistant:${userTurnId}`, + finished: true, + items: [ + expect.objectContaining({ + toolCallId: 'compact-cancel-drain', + status: 'in_progress', + }), + ], + }); await service.continueSession(nextMessage); expect(delivered).toEqual([[{ type: 'text', text: 'old request' }]]); @@ -5601,6 +5650,17 @@ describe('SessionExecutionService', () => { expect(terminated).toBe(false); } expect(service.getExecutionSnapshot(sessionId)).toMatchObject({ hasActiveTurn: false }); + expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(2); + expect(history[1]).toMatchObject({ + id: `assistant:${userTurnId}`, + finished: true, + items: [ + expect.objectContaining({ + toolCallId: 'compact-cancel-drain', + status: 'failed', + }), + ], + }); if (completion !== 'timeout') { await service.continueSession(nextMessage); expect(delivered).toEqual([ @@ -5709,7 +5769,12 @@ describe('SessionExecutionService', () => { }); expect(agentClient.cancel).toHaveBeenCalledWith('acp-prompt-cancel-resolved'); - expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(1); + expect(deps.turnFinalization.finalizeACPState).toHaveBeenCalledTimes(2); + expect(deps.turnFinalization.finalizeACPState).toHaveBeenLastCalledWith( + 'session-prompt-cancel-resolved', + 'assistant-prompt-cancel-resolved', + { settleContextCompactionAsFailed: true } + ); expect(deps.turnFinalization.notifySessionCompleted).not.toHaveBeenCalled(); expect(deps.processMessageQueue).not.toHaveBeenCalled(); expect(upsertDocMeta).toHaveBeenCalledWith('session-session-prompt-cancel-resolved', {