diff --git a/src/lib/opencode-session-state.ts b/src/lib/opencode-session-state.ts index 09e5765..9422b14 100644 --- a/src/lib/opencode-session-state.ts +++ b/src/lib/opencode-session-state.ts @@ -674,8 +674,18 @@ export function hydrateOpenCodeSession( const snapshotOrder = [...next.compactionOrder]; snapshotOrder.forEach((id, index) => { const snapshotEvent = next.compactionsById[id]; - const existingID = snapshotEvent.nativePartID - ? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID }) + const existingID = current + ? findCompactionId(current, { id: snapshotEvent.id }) + ?? (snapshotEvent.nativePartID + ? findCompactionId(current, { nativePartID: snapshotEvent.nativePartID }) + : undefined) + ?? (snapshotEvent.anchorPartID + ? [...current.compactionOrder].reverse().find((candidateID) => { + const candidate = current.compactionsById[candidateID]; + return candidate?.anchorPartID === snapshotEvent.anchorPartID + && candidate.anchorMessageID === snapshotEvent.anchorMessageID; + }) + : undefined) : undefined; const pendingID = !existingID && snapshotEvent.source === 'manual' ? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => { @@ -694,10 +704,10 @@ export function hydrateOpenCodeSession( const preservedID = existingID ?? pendingID; const existing = preservedID ? current?.compactionsById[preservedID] : undefined; const idToUse = preservedID ?? id; - const isLatestActive = status !== 'idle' && index === snapshotOrder.length - 1; const completed = existing?.status === 'completed' || snapshotEvent.completedAt !== undefined - || !isLatestActive; + || status === 'idle' + || existing?.status !== 'running'; if (idToUse !== id) { const compactionsById = { ...next.compactionsById }; delete compactionsById[id]; diff --git a/src/stores/opencode.ts b/src/stores/opencode.ts index affbb1c..8a7dc0d 100644 --- a/src/stores/opencode.ts +++ b/src/stores/opencode.ts @@ -2390,6 +2390,7 @@ function finishSessionRunSuccessfully( runToken: number, messages: RawMessage[], statuses: Record = {}, + compactionGeneration?: number, ): boolean { if (!isSessionRunCurrent(sessionId, runToken)) return false; flushSessionRunStreamBatch(set, sessionId, runToken); @@ -2397,6 +2398,19 @@ function finishSessionRunSuccessfully( set((state) => { const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId); const nextState = { ...state, sendingSessionIds }; + const currentTranscript = state.sessionTranscriptBySessionId[sessionId]; + const completedTranscript = compactionGeneration !== undefined + && state.runtimeGeneration === compactionGeneration + ? completeCompaction( + getOrCreateSessionTranscript(state, sessionId), + { runID: String(runToken), generation: compactionGeneration }, + { + runID: String(runToken), + generation: compactionGeneration, + completedAt: Date.now(), + }, + ) + : currentTranscript; return { ...getSessionMessagePatch(state, sessionId, messages), ...dispatchSessionRunEvent(state, sessionId, { @@ -2413,6 +2427,14 @@ function finishSessionRunSuccessfully( sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, ...errorState(null), + ...(completedTranscript && completedTranscript !== currentTranscript + ? { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: completedTranscript, + }, + } + : {}), }; }); invalidateSessionRun(sessionId); @@ -3051,6 +3073,7 @@ async function runSessionSubmission( runToken, messages, statuses, + submission.kind === 'compact' ? runGeneration : undefined, ); if ( completed diff --git a/tests/unit/opencode-session-state.test.ts b/tests/unit/opencode-session-state.test.ts index 9724294..5214ac9 100644 --- a/tests/unit/opencode-session-state.test.ts +++ b/tests/unit/opencode-session-state.test.ts @@ -204,7 +204,7 @@ describe('OpenCode native session state', () => { ]); }); - it('allows only the latest compaction in an active snapshot to remain running', () => { + it('hydrates cold compaction snapshots as completed even while the session is busy', () => { const state = hydrateOpenCodeSession('ses_1', [{ info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, parts: [ @@ -214,7 +214,50 @@ describe('OpenCode native session state', () => { }], 'busy'); expect(getOrderedSessionCompactions(state).map((event) => event.status)) - .toEqual(['completed', 'running']); + .toEqual(['completed', 'completed']); + }); + + it('does not attach a historical compaction to a later ordinary busy run', () => { + const current = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_2', role: 'user', sessionID: 'ses_1' }, + parts: [{ id: 'text_2', type: 'text', text: 'continue normally' }], + }], 'busy'); + const state = hydrateOpenCodeSession('ses_1', [ + { + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'compact_1', type: 'compaction', auto: true }], + }, + { + info: { id: 'msg_2', role: 'user', sessionID: 'ses_1' }, + parts: [{ id: 'text_2', type: 'text', text: 'continue normally' }], + }, + ], 'busy', current); + + expect(getOrderedSessionCompactions(state)).toEqual([ + expect.objectContaining({ nativePartID: 'compact_1', status: 'completed' }), + ]); + }); + + it('keeps a matching real-time native compaction running during active hydration', () => { + const current = reduceOpenCodeEvent( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + messageID: 'msg_1', + part: { id: 'compact_1', type: 'compaction', auto: true }, + }, + }, + ); + const state = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'compact_1', type: 'compaction', auto: true }], + }], 'busy', current); + + expect(getOrderedSessionCompactions(state)).toEqual([ + expect.objectContaining({ nativePartID: 'compact_1', status: 'running' }), + ]); }); it('does not downgrade a completed compaction during busy history hydration', () => { diff --git a/tests/unit/opencode-store.test.ts b/tests/unit/opencode-store.test.ts index 8ec16a9..cf6dd77 100644 --- a/tests/unit/opencode-store.test.ts +++ b/tests/unit/opencode-store.test.ts @@ -4995,6 +4995,90 @@ describe('opencode store', () => { expect(compactOutcome).toBe('resolved'); }); + it('completes compact and releases its queued prompt from HTTP idle without lifecycle SSE', async () => { + vi.useFakeTimers(); + try { + createHostEventSourceMock + .mockReturnValueOnce(new MockEventSource('/api/opencode/events?sessionId=ses_compact_http_idle')) + .mockReturnValueOnce(new MockEventSource('/api/opencode/events?sessionId=ses_compact_http_idle')); + let statusPolls = 0; + let promptPosted = false; + useOpencodeStore.setState({ + runtimeGeneration: 11, + selectedSessionId: 'ses_compact_http_idle', + sessions: [{ id: 'ses_compact_http_idle' }], + sessionStatuses: { ses_compact_http_idle: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return { success: true }; + if (path.endsWith('/messages') && init?.method === 'POST') { + promptPosted = true; + return { success: true }; + } + if (path === '/api/opencode/sessions/status') { + statusPolls += 1; + return { + statuses: { + ses_compact_http_idle: { + type: statusPolls === 1 ? 'busy' : 'idle', + }, + }, + }; + } + if (path.endsWith('/messages')) { + return { + messages: promptPosted + ? [ + { id: 'msg_http_user', role: 'user', content: 'After HTTP idle' }, + { id: 'msg_http_assistant', role: 'assistant', content: 'Queued prompt done' }, + ] + : [], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_http_idle'); + await vi.waitFor(() => { + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_http_idle; + expect(transcript?.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + status: 'running', + source: 'manual', + generation: 11, + }); + }); + await useOpencodeStore.getState().sendSessionMessage( + 'ses_compact_http_idle', + 'After HTTP idle', + ); + expect(promptPosted).toBe(false); + expect(useOpencodeStore.getState().queuedSessionPrompts.ses_compact_http_idle) + .toHaveLength(1); + + await vi.advanceTimersByTimeAsync(750); + await compacting; + await vi.waitFor(() => expect(promptPosted).toBe(true)); + + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_http_idle!; + expect(transcript.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + status: 'completed', + source: 'manual', + generation: 11, + completedAt: expect.any(Number), + }); + expect(useOpencodeStore.getState().queuedSessionPrompts.ses_compact_http_idle) + .toBeUndefined(); + await vi.waitFor(() => { + expect(useOpencodeStore.getState().sendingSessionIds.ses_compact_http_idle) + .toBeUndefined(); + }); + } finally { + vi.useRealTimers(); + } + }); + it('removes an unfinished manual compaction when its POST fails', async () => { const post = createDeferred<{ success: boolean }>(); createHostEventSourceMock.mockReturnValue(