436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
completeCompaction,
|
|
createPendingCompaction,
|
|
getOrderedSessionParts,
|
|
getOrderedSessionCompactions,
|
|
hydrateOpenCodeSession,
|
|
reduceOpenCodeEvent,
|
|
removeCompaction,
|
|
} from '@/lib/opencode-session-state';
|
|
|
|
describe('OpenCode native session state', () => {
|
|
it('hydrates native parts without dropping execution metadata', () => {
|
|
const state = hydrateOpenCodeSession('ses_1', [{
|
|
info: {
|
|
id: 'msg_1',
|
|
role: 'assistant',
|
|
sessionID: 'ses_1',
|
|
time: { created: 1_700_000_000_000 },
|
|
},
|
|
parts: [
|
|
{ id: 'text_1', type: 'text', text: 'Final answer' },
|
|
{ id: 'reason_1', type: 'reasoning', text: 'Checking the repository' },
|
|
{
|
|
id: 'tool_1',
|
|
type: 'tool',
|
|
tool: 'read',
|
|
callID: 'call_1',
|
|
state: {
|
|
status: 'completed',
|
|
input: { path: 'README.md' },
|
|
output: 'ok',
|
|
time: { start: 1_700_000_000_010, end: 1_700_000_000_110 },
|
|
},
|
|
},
|
|
{ id: 'step_1', type: 'step-finish', reason: 'stop', cost: 0.01 },
|
|
{ id: 'future_1', type: 'future-native-part', detail: 'preserve me' },
|
|
],
|
|
}]);
|
|
|
|
const parts = getOrderedSessionParts(state, 'msg_1');
|
|
expect(parts.map((part) => part.type)).toEqual([
|
|
'text',
|
|
'reasoning',
|
|
'tool',
|
|
'step-finish',
|
|
'unknown',
|
|
]);
|
|
expect(parts[2]).toEqual(expect.objectContaining({
|
|
tool: 'read',
|
|
callID: 'call_1',
|
|
state: expect.objectContaining({ status: 'completed' }),
|
|
}));
|
|
expect(parts.at(-1)).toEqual(expect.objectContaining({
|
|
rawType: 'future-native-part',
|
|
detail: expect.stringContaining('preserve me'),
|
|
}));
|
|
});
|
|
|
|
it('applies full updates, deltas, and removals by stable part id', () => {
|
|
const base = hydrateOpenCodeSession('ses_1', []);
|
|
const updated = reduceOpenCodeEvent(base, {
|
|
type: 'message.part.updated',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
part: { id: 'part_1', type: 'text', text: 'Hello' },
|
|
},
|
|
});
|
|
const withDelta = reduceOpenCodeEvent(updated, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
field: 'text',
|
|
delta: ' world',
|
|
},
|
|
});
|
|
const duplicateDelta = reduceOpenCodeEvent(withDelta, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
field: 'text',
|
|
delta: ' world',
|
|
},
|
|
});
|
|
|
|
expect(getOrderedSessionParts(duplicateDelta, 'msg_1')[0]?.text).toBe('Hello world');
|
|
|
|
const removed = reduceOpenCodeEvent(duplicateDelta, {
|
|
type: 'message.part.removed',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
},
|
|
});
|
|
expect(getOrderedSessionParts(removed, 'msg_1')).toEqual([]);
|
|
});
|
|
|
|
it('ignores events from another session and tracks native lifecycle status', () => {
|
|
const state = hydrateOpenCodeSession('ses_1', []);
|
|
const untouched = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_2',
|
|
messageID: 'msg_2',
|
|
partID: 'part_2',
|
|
delta: 'wrong session',
|
|
},
|
|
});
|
|
expect(untouched).toBe(state);
|
|
|
|
const busy = reduceOpenCodeEvent(state, {
|
|
type: 'session.status',
|
|
payload: { sessionID: 'ses_1', status: { type: 'retry' } },
|
|
});
|
|
expect(busy?.status).toBe('retry');
|
|
|
|
const idle = reduceOpenCodeEvent(busy, {
|
|
type: 'session.idle',
|
|
payload: { sessionID: 'ses_1' },
|
|
});
|
|
expect(idle?.status).toBe('idle');
|
|
});
|
|
|
|
it('deduplicates replayed delta events by event identity without dropping equal real chunks', () => {
|
|
let state = hydrateOpenCodeSession('ses_1', []);
|
|
state = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.updated',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
part: { id: 'part_1', type: 'text', text: '' },
|
|
},
|
|
});
|
|
state = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
delta: 'x',
|
|
eventID: 'evt_1',
|
|
},
|
|
});
|
|
state = reduceOpenCodeEvent(state, {
|
|
type: 'session.status',
|
|
payload: { sessionID: 'ses_1', status: { type: 'busy' } },
|
|
});
|
|
state = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
delta: 'x',
|
|
eventID: 'evt_1',
|
|
},
|
|
});
|
|
expect(getOrderedSessionParts(state, 'msg_1')[0]?.text).toBe('x');
|
|
|
|
const secondRealChunk = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.delta',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID: 'msg_1',
|
|
partID: 'part_1',
|
|
delta: 'x',
|
|
eventID: 'evt_2',
|
|
},
|
|
});
|
|
expect(getOrderedSessionParts(secondRealChunk, 'msg_1')[0]?.text).toBe('xx');
|
|
});
|
|
|
|
it('hydrates historical compaction Parts as completed timeline events in transcript order', () => {
|
|
const state = hydrateOpenCodeSession('ses_1', [{
|
|
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
|
parts: [
|
|
{ id: 'compact_1', type: 'compaction', auto: false },
|
|
{ id: 'compact_2', type: 'compaction', auto: true },
|
|
],
|
|
}]);
|
|
|
|
expect(getOrderedSessionCompactions(state)).toEqual([
|
|
expect.objectContaining({
|
|
id: 'compaction:ses_1:part:compact_1',
|
|
source: 'manual',
|
|
status: 'completed',
|
|
order: 0,
|
|
anchorMessageID: 'msg_1',
|
|
anchorPartID: 'compact_1',
|
|
nativePartID: 'compact_1',
|
|
}),
|
|
expect.objectContaining({
|
|
source: 'automatic',
|
|
status: 'completed',
|
|
order: 1,
|
|
nativePartID: 'compact_2',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('allows only the latest compaction in an active snapshot to remain running', () => {
|
|
const state = hydrateOpenCodeSession('ses_1', [{
|
|
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
|
parts: [
|
|
{ id: 'compact_1', type: 'compaction', auto: true },
|
|
{ id: 'compact_2', type: 'compaction', auto: true },
|
|
],
|
|
}], 'busy');
|
|
|
|
expect(getOrderedSessionCompactions(state).map((event) => event.status))
|
|
.toEqual(['completed', 'running']);
|
|
});
|
|
|
|
it('does not downgrade a completed compaction during busy history hydration', () => {
|
|
const snapshot = [{
|
|
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
|
parts: [{ id: 'compact_1', type: 'compaction', auto: true }],
|
|
}];
|
|
const running = hydrateOpenCodeSession('ses_1', snapshot, 'busy');
|
|
const completed = reduceOpenCodeEvent(running, {
|
|
type: 'session.compacted',
|
|
payload: { sessionID: 'ses_1', eventID: 'evt_compacted_1' },
|
|
});
|
|
|
|
const rehydrated = hydrateOpenCodeSession('ses_1', snapshot, 'busy', completed);
|
|
|
|
expect(getOrderedSessionCompactions(rehydrated)).toEqual([
|
|
expect.objectContaining({
|
|
nativePartID: 'compact_1',
|
|
status: 'completed',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('merges a manual pending event with its native Part without changing the UI id', () => {
|
|
const empty = hydrateOpenCodeSession('ses_1', [], 'busy');
|
|
const pending = createPendingCompaction(empty, {
|
|
id: 'ui_compact_1',
|
|
source: 'manual',
|
|
runID: 'run_1',
|
|
generation: 3,
|
|
startedAt: 1_700_000_000_000,
|
|
});
|
|
const withNativePart = reduceOpenCodeEvent(pending, {
|
|
type: 'message.part.updated',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
eventID: 'evt_part_1',
|
|
messageID: 'msg_1',
|
|
part: {
|
|
id: 'native_compact_1',
|
|
type: 'compaction',
|
|
auto: false,
|
|
runID: 'run_1',
|
|
generation: 3,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(getOrderedSessionCompactions(withNativePart)).toEqual([
|
|
expect.objectContaining({
|
|
id: 'ui_compact_1',
|
|
nativePartID: 'native_compact_1',
|
|
nativeEventID: 'evt_part_1',
|
|
anchorMessageID: 'msg_1',
|
|
status: 'running',
|
|
}),
|
|
]);
|
|
|
|
const replayed = reduceOpenCodeEvent(withNativePart, {
|
|
type: 'message.part.updated',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
eventID: 'evt_part_2',
|
|
messageID: 'msg_1',
|
|
part: { id: 'native_compact_1', type: 'compaction', auto: false },
|
|
},
|
|
});
|
|
expect(getOrderedSessionCompactions(replayed)).toHaveLength(1);
|
|
expect(getOrderedSessionCompactions(replayed)[0]?.id).toBe('ui_compact_1');
|
|
});
|
|
|
|
it('preserves unmatched running pending events across active hydration only', () => {
|
|
const current = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 },
|
|
);
|
|
|
|
const active = hydrateOpenCodeSession('ses_1', [], 'busy', current);
|
|
expect(getOrderedSessionCompactions(active).map((event) => event.id)).toEqual(['ui_pending']);
|
|
|
|
const historical = hydrateOpenCodeSession('ses_1', [], 'idle', current);
|
|
expect(getOrderedSessionCompactions(historical)).toEqual([]);
|
|
});
|
|
|
|
it('merges an active snapshot Part into the current manual pending UI identity', () => {
|
|
const current = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 },
|
|
);
|
|
const hydrated = hydrateOpenCodeSession('ses_1', [{
|
|
info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' },
|
|
parts: [{ id: 'native_compact_1', type: 'compaction', auto: false }],
|
|
}], 'busy', current);
|
|
|
|
expect(getOrderedSessionCompactions(hydrated)).toEqual([
|
|
expect.objectContaining({
|
|
id: 'ui_pending',
|
|
nativePartID: 'native_compact_1',
|
|
status: 'running',
|
|
runID: 'run_1',
|
|
generation: 1,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('closes an older running native compaction when a later one arrives', () => {
|
|
let state = hydrateOpenCodeSession('ses_1', [], 'busy');
|
|
for (const [messageID, partID] of [['msg_1', 'compact_1'], ['msg_2', 'compact_2']]) {
|
|
state = reduceOpenCodeEvent(state, {
|
|
type: 'message.part.updated',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
messageID,
|
|
part: { id: partID, type: 'compaction', auto: true },
|
|
},
|
|
})!;
|
|
}
|
|
|
|
expect(getOrderedSessionCompactions(state).map((event) => event.status))
|
|
.toEqual(['completed', 'running']);
|
|
});
|
|
|
|
it('completes and removes only the compaction matching all supplied correlation keys', () => {
|
|
let state = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_1', source: 'manual', runID: 'run_1', generation: 1 },
|
|
);
|
|
state = createPendingCompaction(state, {
|
|
id: 'ui_2',
|
|
source: 'manual',
|
|
runID: 'run_2',
|
|
generation: 2,
|
|
nativePartID: 'native_2',
|
|
});
|
|
|
|
const wrongGeneration = completeCompaction(
|
|
state,
|
|
{ runID: 'run_2', generation: 1, nativePartID: 'native_2' },
|
|
);
|
|
expect(wrongGeneration).toBe(state);
|
|
|
|
state = completeCompaction(
|
|
state,
|
|
{ runID: 'run_2', generation: 2, nativePartID: 'native_2' },
|
|
{ nativeEventID: 'evt_complete_2', completedAt: 1_700_000_000_100 },
|
|
);
|
|
expect(state.compactionsById.ui_2).toEqual(expect.objectContaining({
|
|
status: 'completed',
|
|
nativeEventID: 'evt_complete_2',
|
|
completedAt: 1_700_000_000_100,
|
|
}));
|
|
expect(state.compactionsById.ui_1?.status).toBe('running');
|
|
|
|
const untouched = removeCompaction(state, { runID: 'run_1', generation: 2 });
|
|
expect(untouched).toBe(state);
|
|
state = removeCompaction(state, { runID: 'run_1', generation: 1 });
|
|
expect(getOrderedSessionCompactions(state).map((event) => event.id)).toEqual(['ui_2']);
|
|
});
|
|
|
|
it('completes compaction on session.compacted without marking the transcript idle', () => {
|
|
const busy = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_compact_1', source: 'automatic', runID: 'run_1', generation: 4 },
|
|
);
|
|
const compacted = reduceOpenCodeEvent(busy, {
|
|
type: 'session.compacted',
|
|
payload: {
|
|
sessionID: 'ses_1',
|
|
eventID: 'evt_complete_1',
|
|
runID: 'run_1',
|
|
generation: 4,
|
|
},
|
|
});
|
|
|
|
expect(compacted?.status).toBe('busy');
|
|
expect(compacted?.compactionsById.ui_compact_1).toEqual(expect.objectContaining({
|
|
status: 'completed',
|
|
nativeEventID: 'evt_complete_1',
|
|
}));
|
|
|
|
const idle = reduceOpenCodeEvent(compacted, {
|
|
type: 'session.idle',
|
|
payload: { sessionID: 'ses_1' },
|
|
});
|
|
expect(idle?.status).toBe('idle');
|
|
});
|
|
|
|
it('uses session.idle as a successful completion fallback', () => {
|
|
const busy = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_compact_idle', source: 'manual', runID: 'run_idle', generation: 5 },
|
|
);
|
|
|
|
const idle = reduceOpenCodeEvent(busy, {
|
|
type: 'session.idle',
|
|
payload: { sessionID: 'ses_1' },
|
|
});
|
|
|
|
expect(idle?.status).toBe('idle');
|
|
expect(idle?.compactionsById.ui_compact_idle?.status).toBe('completed');
|
|
});
|
|
|
|
it('removes unfinished compactions on a fatal session error', () => {
|
|
const busy = createPendingCompaction(
|
|
hydrateOpenCodeSession('ses_1', [], 'busy'),
|
|
{ id: 'ui_compact_error', source: 'automatic', runID: 'run_error', generation: 6 },
|
|
);
|
|
|
|
const failed = reduceOpenCodeEvent(busy, {
|
|
type: 'session.error',
|
|
payload: { sessionID: 'ses_1', error: 'provider failed' },
|
|
});
|
|
|
|
expect(failed?.status).toBe('idle');
|
|
expect(failed?.compactionOrder).toEqual([]);
|
|
expect(failed?.compactionsById.ui_compact_error).toBeUndefined();
|
|
});
|
|
});
|