fix: 收口上下文压缩生命周期边界
This commit is contained in:
@@ -674,8 +674,18 @@ export function hydrateOpenCodeSession(
|
|||||||
const snapshotOrder = [...next.compactionOrder];
|
const snapshotOrder = [...next.compactionOrder];
|
||||||
snapshotOrder.forEach((id, index) => {
|
snapshotOrder.forEach((id, index) => {
|
||||||
const snapshotEvent = next.compactionsById[id];
|
const snapshotEvent = next.compactionsById[id];
|
||||||
const existingID = snapshotEvent.nativePartID
|
const existingID = current
|
||||||
? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID })
|
? 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;
|
: undefined;
|
||||||
const pendingID = !existingID && snapshotEvent.source === 'manual'
|
const pendingID = !existingID && snapshotEvent.source === 'manual'
|
||||||
? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => {
|
? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => {
|
||||||
@@ -694,10 +704,10 @@ export function hydrateOpenCodeSession(
|
|||||||
const preservedID = existingID ?? pendingID;
|
const preservedID = existingID ?? pendingID;
|
||||||
const existing = preservedID ? current?.compactionsById[preservedID] : undefined;
|
const existing = preservedID ? current?.compactionsById[preservedID] : undefined;
|
||||||
const idToUse = preservedID ?? id;
|
const idToUse = preservedID ?? id;
|
||||||
const isLatestActive = status !== 'idle' && index === snapshotOrder.length - 1;
|
|
||||||
const completed = existing?.status === 'completed'
|
const completed = existing?.status === 'completed'
|
||||||
|| snapshotEvent.completedAt !== undefined
|
|| snapshotEvent.completedAt !== undefined
|
||||||
|| !isLatestActive;
|
|| status === 'idle'
|
||||||
|
|| existing?.status !== 'running';
|
||||||
if (idToUse !== id) {
|
if (idToUse !== id) {
|
||||||
const compactionsById = { ...next.compactionsById };
|
const compactionsById = { ...next.compactionsById };
|
||||||
delete compactionsById[id];
|
delete compactionsById[id];
|
||||||
|
|||||||
@@ -2390,6 +2390,7 @@ function finishSessionRunSuccessfully(
|
|||||||
runToken: number,
|
runToken: number,
|
||||||
messages: RawMessage[],
|
messages: RawMessage[],
|
||||||
statuses: Record<string, OpencodeSessionStatus> = {},
|
statuses: Record<string, OpencodeSessionStatus> = {},
|
||||||
|
compactionGeneration?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isSessionRunCurrent(sessionId, runToken)) return false;
|
if (!isSessionRunCurrent(sessionId, runToken)) return false;
|
||||||
flushSessionRunStreamBatch(set, sessionId, runToken);
|
flushSessionRunStreamBatch(set, sessionId, runToken);
|
||||||
@@ -2397,6 +2398,19 @@ function finishSessionRunSuccessfully(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId);
|
const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId);
|
||||||
const nextState = { ...state, sendingSessionIds };
|
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 {
|
return {
|
||||||
...getSessionMessagePatch(state, sessionId, messages),
|
...getSessionMessagePatch(state, sessionId, messages),
|
||||||
...dispatchSessionRunEvent(state, sessionId, {
|
...dispatchSessionRunEvent(state, sessionId, {
|
||||||
@@ -2413,6 +2427,14 @@ function finishSessionRunSuccessfully(
|
|||||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||||
loading: false,
|
loading: false,
|
||||||
...errorState(null),
|
...errorState(null),
|
||||||
|
...(completedTranscript && completedTranscript !== currentTranscript
|
||||||
|
? {
|
||||||
|
sessionTranscriptBySessionId: {
|
||||||
|
...state.sessionTranscriptBySessionId,
|
||||||
|
[sessionId]: completedTranscript,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
invalidateSessionRun(sessionId);
|
invalidateSessionRun(sessionId);
|
||||||
@@ -3051,6 +3073,7 @@ async function runSessionSubmission(
|
|||||||
runToken,
|
runToken,
|
||||||
messages,
|
messages,
|
||||||
statuses,
|
statuses,
|
||||||
|
submission.kind === 'compact' ? runGeneration : undefined,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
completed
|
completed
|
||||||
|
|||||||
@@ -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', [{
|
const state = hydrateOpenCodeSession('ses_1', [{
|
||||||
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
||||||
parts: [
|
parts: [
|
||||||
@@ -214,7 +214,50 @@ describe('OpenCode native session state', () => {
|
|||||||
}], 'busy');
|
}], 'busy');
|
||||||
|
|
||||||
expect(getOrderedSessionCompactions(state).map((event) => event.status))
|
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', () => {
|
it('does not downgrade a completed compaction during busy history hydration', () => {
|
||||||
|
|||||||
@@ -4995,6 +4995,90 @@ describe('opencode store', () => {
|
|||||||
expect(compactOutcome).toBe('resolved');
|
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 () => {
|
it('removes an unfinished manual compaction when its POST fails', async () => {
|
||||||
const post = createDeferred<{ success: boolean }>();
|
const post = createDeferred<{ success: boolean }>();
|
||||||
createHostEventSourceMock.mockReturnValue(
|
createHostEventSourceMock.mockReturnValue(
|
||||||
|
|||||||
Reference in New Issue
Block a user