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', {