fix: 收口上下文压缩生命周期边界

This commit is contained in:
2026-08-15 09:17:07 +08:00
parent 1d0878bb5b
commit 7a811590c4
4 changed files with 166 additions and 6 deletions

View File

@@ -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];

View File

@@ -2390,6 +2390,7 @@ function finishSessionRunSuccessfully(
runToken: number,
messages: RawMessage[],
statuses: Record<string, OpencodeSessionStatus> = {},
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

View File

@@ -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', () => {

View File

@@ -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(