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

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