From 7a811590c4943b7b1b7ea5f3b4d3ce3ce05622a5 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 15 Aug 2026 09:17:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E5=8F=A3=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=8E=8B=E7=BC=A9=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/opencode-session-state.ts | 18 +++-- src/stores/opencode.ts | 23 +++++++ tests/unit/opencode-session-state.test.ts | 47 ++++++++++++- tests/unit/opencode-store.test.ts | 84 +++++++++++++++++++++++ 4 files changed, 166 insertions(+), 6 deletions(-) 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(