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..d40aafd60 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.md @@ -0,0 +1,61 @@ +# Settle context compaction after provider failure + +Status: implemented +Translation: current + +[中文](2026-09-10-context-compaction-terminal-state.zh.md) + +## Abstract + +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 + +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: 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 +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). 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 new file mode 100644 index 000000000..0fd2be839 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-context-compaction-terminal-state.zh.md @@ -0,0 +1,51 @@ +# Provider 失败后收敛上下文压缩状态 + +Status: implemented +Translation: current + +[English](2026-09-10-context-compaction-terminal-state.md) + +## 摘要 + +远端上下文压缩请求失败或取消后,其 tool-call 条目可能仍停留在 `pending` 或 +`in_progress`,导致 provider 已不再活跃时,消息流和会话用量底栏仍无限显示旋转状态。 +现在,失败的 provider prompt 已结束或取消中的 prompt 被成功 terminate 后,Lody 会把该 +turn 内未收敛的压缩活动持久化为 `failed`;渲染层继续直接遵循持久化 tool-call 状态。 + +## 决策 + +压缩活动与承载它的 assistant turn 具有不同的终止信号。`SessionHistory.finished` 记录 +host finalization,其中也包括被中断 turn 的 teardown,因此不能证明 provider prompt 已 +停止;UI 不应从该字段推断压缩终态。 + +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 的历史。 +它不会迁移已经持久化的陈旧历史,因为这些历史里没有 durable evidence 能区分 #570 和 +“已中断但 provider prompt 仍活跃”的情况。 + +## 范围与验证 + +本修复通过 [PR #573](https://github.com/LodyAI/Lody/pull/573) 处理 +[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/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 355f26bdf..ac4e4221a 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; @@ -1974,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(() => { @@ -2191,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); } @@ -2294,9 +2313,19 @@ export class SessionExecutionService { private async handleTurnError( sessionId: SessionId, sessionDoc: SessionDocument, - error?: unknown + error?: unknown, + options?: { providerPromptSettled?: boolean } ): 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: + options?.providerPromptSettled === true || acpError !== null || providerDisconnected, + } + ); await this.persistCodeCollabTurnDiffsAfterACPFinalization( sessionId, this.currentTurnBySession.get(sessionId) @@ -2304,8 +2333,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); @@ -2346,7 +2373,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 cb4840f3c..43dd637df 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -18,6 +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 } from '../../../packages/components/src/lib/session-context-compaction'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -363,6 +364,108 @@ describe('MessageHandler ACP batching', () => { } }); + 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]); + 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, + options?: { settleContextCompactionAsFailed?: boolean } + ): 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' }); + 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, + 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(isSessionContextCompacting(completedHistory)).toBe(false); + + 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/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 1d11b4671..7c6f80f90 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -4838,23 +4838,59 @@ 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[] = [ + { + 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, - 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, }; @@ -4892,6 +4928,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); @@ -4908,11 +4954,15 @@ 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' })], + }); }); it('records a visible failure when a chat turn fails before prompt starts', async () => { @@ -5231,6 +5281,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 +5374,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); @@ -5332,10 +5404,20 @@ 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' }); + expect(history[1]).toMatchObject({ + id: 'assistant-prompt-cancel', + finished: true, + items: [ + expect.objectContaining({ + toolCallId: 'compact-cancelled-turn', + status: 'failed', + }), + ], + }); expect(upsertDocMeta).toHaveBeenCalledWith('session-session-prompt-cancel', { lastHandledUserMsgId: 'turn-prompt-cancel', processingUserMsgId: undefined, @@ -5363,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 = { @@ -5469,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', @@ -5508,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' }]]); @@ -5534,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([ @@ -5642,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', { diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index 44b63072d..dac623cb4 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -3,9 +3,13 @@ import type { SessionHistory } from '@lody/shared'; import { isSessionContextCompacting } 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 +20,7 @@ const historyWithStatus = (status: 'pending' | 'in_progress' | 'completed' | 'fa }, ], }, - ] as Pick[]; + ] as Pick[]; describe('isSessionContextCompacting', () => { it('tracks pending and in-progress compaction tool calls', () => { @@ -28,4 +32,9 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('completed'))).toBe(false); expect(isSessionContextCompacting(historyWithStatus('failed'))).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); + }); });