diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 7783c8433..227fa4ed0 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -5298,11 +5298,78 @@ export class SessionExecutionService { const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const activeTurnId = this.deps.getActiveTurnId(sessionId); const executionTurnId = this.currentTurnBySession.get(sessionId); + const runtimeTurnId = this.turnRuntimeBySession.get(sessionId)?.turnId; const isPrompting = activeTurnId === turnId; - const isCurrentExecutionTurn = executionTurnId === turnId; - const currentTurnId = activeTurnId ?? this.currentTurnBySession.get(sessionId); + const isCurrentExecutionTurn = executionTurnId === turnId || runtimeTurnId === turnId; + const currentTurnId = activeTurnId ?? executionTurnId ?? runtimeTurnId; // Cancel is exact-match only: a stale stop request must not interrupt a newer assistant turn. if (!isPrompting && !isCurrentExecutionTurn) { + // Stale repair mutates session-wide presence and history, so it must not + // overlap a newer turn or another durable rewrite. Hold the conflict lease + // across the awaited history read and recheck live ownership before + // cleaning up: a turn that starts while getHistory() is awaited must keep + // its presence and dispatch metadata. + if (currentTurnId == null) { + const releaseConflict = this.tryAcquireSessionRewriteConflictLease(sessionId); + if (releaseConflict) { + try { + const liveTurnId = + this.deps.getActiveTurnId(sessionId) ?? + this.currentTurnBySession.get(sessionId) ?? + this.turnRuntimeBySession.get(sessionId)?.turnId; + if (liveTurnId == null) { + const history = await sessionDoc.getHistory(); + const hasUnfinishedRequestedTurn = history.some( + (entry) => + entry.id === turnId && + entry.role === 'assistant' && + entry.finished !== true && + typeof entry.endedAt !== 'number' && + entry.items?.some( + (item) => + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) === true + ); + if (hasUnfinishedRequestedTurn) { + this.deps.logger.debug( + `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` + ); + this.deps.clearSessionActivePresence(sessionId); + await sessionDoc.updateHistory((nextHistory) => { + for (const entry of nextHistory) { + if (entry.id !== turnId) continue; + entry.finished = true; + entry.endedAt = getServerNow(); + if (!entry.items) continue; + for (const item of entry.items) { + if ( + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) { + item.status = 'failed'; + } + } + } + return nextHistory; + }); + + await this.finalizeCancelledTurn({ + sessionId, + sessionDoc, + turnId, + reportTurnError: false, + }); + return { success: true }; + } + } + } finally { + releaseConflict(); + } + } + } this.deps.logger.debug( `[${sessionId}] Ignoring stop request for stale turn ${turnId} (current=${currentTurnId ?? 'none'})` ); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index b8f1b9111..9e5efb924 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -6276,6 +6276,69 @@ describe('SessionExecutionService', () => { }); }); + it('finalizes a stale unfinished compaction turn when no live runtime owns it', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const compactionItem = { + type: 'tool_call', + toolCallId: 'context-compaction-stale', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }; + const history = [ + { + id: 'assistant-stale-compaction', + role: 'assistant', + items: [compactionItem], + finished: false, + }, + ]; + const sessionDoc = { + getHistory: vi.fn(async () => history), + setStatus: vi.fn(async () => {}), + updateHistory: vi.fn(async (update: (value: typeof history) => typeof history) => { + update(history); + }), + }; + const sessionManager = { + getSession: vi.fn(() => null), + getPendingSession: vi.fn(() => null), + createSession: vi.fn(), + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager; + const deps = createBaseDeps({ + sessionManager, + getActiveTurnId: vi.fn(() => undefined), + workspaceDocument: { + repo: { + upsertDocMeta, + getDocMeta: vi.fn(async () => ({ meta: {} })), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + updateAcpCapabilities: vi.fn(async () => {}), + } as unknown as LoroDocumentManager, + }); + + const service = new SessionExecutionService(deps); + const result = await service.cancelSession({ + type: 'session/cancel', + sessionId: 'session-stale-compaction' as SessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + turnId: 'assistant-stale-compaction', + }); + + expect(result).toEqual({ success: true }); + expect(compactionItem.status).toBe('failed'); + expect(sessionDoc.updateHistory).toHaveBeenCalled(); + expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); + expect(upsertDocMeta).toHaveBeenCalledWith('session-session-stale-compaction', { + lastCanceledTurn: undefined, + }); + }); + it('keeps a newer queued turn pending when cancelling the currently running turn', async () => { const upsertDocMeta = vi.fn(async () => {}); const sessionDoc = { diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 5f0aab0f6..314b38237 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 5f0aab0f6614ef50dc57c2b137cdc0d4b864359e +Subproject commit 314b38237273f3b71335d5193498b60755665769 diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index e6629465c..f89ca5839 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -55,7 +55,7 @@ import { Button } from '@/ui/button'; import { isMacOSElectronRenderer, useElectronFullscreen } from '@/lib/electron'; import { getIpcServices } from '@/lib/electron-ipc-client'; import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; -import { isSessionContextCompacting } from '@/lib/session-context-compaction'; +import { isSessionContextCompacting, canStopAgentEnabled } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; import { mergeDropZoneHandlers, useDropZone } from '@/hooks/use-drop-zone'; @@ -3493,8 +3493,13 @@ export const SessionChatInterface = memo( isSessionWorking, isGoalActive, }); - const canStopAgent = - (isSessionActive && activeAssistantTurnId != null) || (isGoalActive && canPauseGoal); + const canStopAgent = canStopAgentEnabled({ + isContextCompacting, + isSessionActive, + activeAssistantTurnId: activeAssistantTurnId ?? null, + isGoalActive, + canPauseGoal, + }); const latestCompletedProposedPlan = useMemo( () => findLatestCompletedCodexProposedPlan(sessionDoc?.history), [sessionDoc?.history] diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index e7d16dc21..b191c5362 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -13,3 +13,34 @@ export const isSessionContextCompacting = ( } return false; }; + +export type CanStopAgentOptions = { + isContextCompacting: boolean; + isSessionActive: boolean; + activeAssistantTurnId: string | null; + isGoalActive: boolean; + canPauseGoal: boolean; +}; + +/** + * Whether the session Stop control should be exposed. + * + * The compaction branch is gated on a cancellable assistant turn: when a + * pending/in-progress compaction marker remains in history but its assistant + * entry is already finished (restart, interrupted notification stream), + * `isContextCompacting` is true while `activeAssistantTurnId` is null. In that + * state Stop would be shown but `handleStop` rejects the click as + * `missing_active_turn`, leaving an idle session with a permanently + * nonfunctional Stop button. Only expose Stop during compaction when there is + * a turn to cancel. + */ +export const canStopAgentEnabled = ({ + isContextCompacting, + isSessionActive, + activeAssistantTurnId, + isGoalActive, + canPauseGoal, +}: CanStopAgentOptions): boolean => + (isContextCompacting && activeAssistantTurnId != null) || + (isSessionActive && activeAssistantTurnId != null) || + (isGoalActive && canPauseGoal); diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index dac623cb4..398cf4ccc 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory } from '@lody/shared'; -import { isSessionContextCompacting } from '../src/lib/session-context-compaction'; +import { canStopAgentEnabled, isSessionContextCompacting } from '../src/lib/session-context-compaction'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -38,3 +38,73 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(true); }); }); + +describe('canStopAgentEnabled', () => { + const base = { + isContextCompacting: false, + isSessionActive: false, + activeAssistantTurnId: null, + isGoalActive: false, + canPauseGoal: false, + }; + + it('does not expose Stop during compaction without a cancellable turn', () => { + // A pending/in-progress compaction marker remains in history but its + // assistant entry is already finished (restart, interrupted notification + // stream). Stop must NOT be shown — clicking it would be rejected as + // missing_active_turn, leaving a permanently nonfunctional button. + expect( + canStopAgentEnabled({ ...base, isContextCompacting: true, activeAssistantTurnId: null }) + ).toBe(false); + }); + + it('exposes Stop during compaction when a cancellable turn exists', () => { + expect( + canStopAgentEnabled({ + ...base, + isContextCompacting: true, + activeAssistantTurnId: 'turn-1', + }) + ).toBe(true); + }); + + it('exposes Stop for an active assistant turn regardless of compaction', () => { + expect( + canStopAgentEnabled({ + ...base, + isSessionActive: true, + activeAssistantTurnId: 'turn-2', + }) + ).toBe(true); + }); + + it('does not expose Stop for an active session without a turn', () => { + expect( + canStopAgentEnabled({ + ...base, + isSessionActive: true, + activeAssistantTurnId: null, + }) + ).toBe(false); + }); + + it('exposes Stop for a pausable goal', () => { + expect( + canStopAgentEnabled({ + ...base, + isGoalActive: true, + canPauseGoal: true, + }) + ).toBe(true); + }); + + it('does not expose Stop for an unpausable goal', () => { + expect( + canStopAgentEnabled({ + ...base, + isGoalActive: true, + canPauseGoal: false, + }) + ).toBe(false); + }); +});