实现 Codex 风格上下文压缩时间线交互
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
OpencodeCompactionSource,
|
||||
OpencodeCompactionTimelineEvent,
|
||||
OpencodeNativePartKind,
|
||||
OpencodeNativeToolState,
|
||||
OpencodeNormalizedSessionMessage,
|
||||
@@ -13,6 +15,31 @@ export interface OpenCodeEventEnvelope {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface OpencodeCompactionMatch {
|
||||
id?: string;
|
||||
nativePartID?: string;
|
||||
nativeEventID?: string;
|
||||
runID?: string;
|
||||
generation?: number;
|
||||
}
|
||||
|
||||
export interface OpencodePendingCompactionInput extends OpencodeCompactionMatch {
|
||||
source: OpencodeCompactionSource;
|
||||
anchorMessageID?: string;
|
||||
anchorPartID?: string;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
export interface OpencodeCompactionCompletion {
|
||||
nativePartID?: string;
|
||||
nativeEventID?: string;
|
||||
anchorMessageID?: string;
|
||||
anchorPartID?: string;
|
||||
runID?: string;
|
||||
generation?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
const PART_TYPES = new Set<OpencodeNativePartKind>([
|
||||
'text',
|
||||
'reasoning',
|
||||
@@ -79,6 +106,30 @@ function getPartId(value: unknown): string | undefined {
|
||||
?? nonEmptyString(asRecord(record?.part)?.partID);
|
||||
}
|
||||
|
||||
function getNativeEventId(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence)
|
||||
? String(record.sequence)
|
||||
: nonEmptyString(record?.sequence);
|
||||
return nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence;
|
||||
}
|
||||
|
||||
function getRunId(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
return nonEmptyString(record?.runID)
|
||||
?? nonEmptyString(record?.runId)
|
||||
?? nonEmptyString(asRecord(record?.part)?.runID)
|
||||
?? nonEmptyString(asRecord(record?.part)?.runId);
|
||||
}
|
||||
|
||||
function getGeneration(value: unknown): number | undefined {
|
||||
const record = asRecord(value);
|
||||
const generation = record?.generation ?? asRecord(record?.part)?.generation;
|
||||
return typeof generation === 'number' && Number.isFinite(generation)
|
||||
? generation
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toMilliseconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
||||
return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value;
|
||||
@@ -217,9 +268,197 @@ function createEmptyState(sessionID: string): OpencodeSessionTranscriptState {
|
||||
messageOrder: [],
|
||||
partsById: {},
|
||||
partOrderByMessageId: {},
|
||||
compactionsById: {},
|
||||
compactionOrder: [],
|
||||
};
|
||||
}
|
||||
|
||||
function nextCompactionOrder(state: OpencodeSessionTranscriptState): number {
|
||||
return state.compactionOrder.reduce((highest, id) => (
|
||||
Math.max(highest, state.compactionsById[id]?.order ?? -1)
|
||||
), -1) + 1;
|
||||
}
|
||||
|
||||
function matchesCompaction(
|
||||
event: OpencodeCompactionTimelineEvent,
|
||||
match: OpencodeCompactionMatch,
|
||||
): boolean {
|
||||
const entries = Object.entries(match).filter(([, value]) => value !== undefined);
|
||||
return entries.length > 0 && entries.every(([key, value]) => (
|
||||
event[key as keyof OpencodeCompactionMatch] === value
|
||||
));
|
||||
}
|
||||
|
||||
function findCompactionId(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
match: OpencodeCompactionMatch,
|
||||
runningOnly = false,
|
||||
): string | undefined {
|
||||
return [...state.compactionOrder].reverse().find((id) => {
|
||||
const event = state.compactionsById[id];
|
||||
return event
|
||||
&& (!runningOnly || event.status === 'running')
|
||||
&& matchesCompaction(event, match);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceCompaction(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
event: OpencodeCompactionTimelineEvent,
|
||||
): OpencodeSessionTranscriptState {
|
||||
return {
|
||||
...state,
|
||||
compactionsById: { ...state.compactionsById, [event.id]: event },
|
||||
compactionOrder: state.compactionOrder.includes(event.id)
|
||||
? state.compactionOrder
|
||||
: [...state.compactionOrder, event.id],
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a renderer-owned running event before its native Part is available. */
|
||||
export function createPendingCompaction(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
input: OpencodePendingCompactionInput,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const match: OpencodeCompactionMatch = {
|
||||
...(input.id ? { id: input.id } : {}),
|
||||
...(input.nativePartID ? { nativePartID: input.nativePartID } : {}),
|
||||
...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}),
|
||||
...(input.runID ? { runID: input.runID } : {}),
|
||||
...(input.generation !== undefined ? { generation: input.generation } : {}),
|
||||
};
|
||||
const existingID = findCompactionId(state, match, true);
|
||||
if (existingID) return state;
|
||||
const order = nextCompactionOrder(state);
|
||||
const id = input.id
|
||||
?? `compaction:${state.sessionID}:${input.runID ?? input.generation ?? 'pending'}:${order}`;
|
||||
if (state.compactionsById[id]) return state;
|
||||
return replaceCompaction(state, {
|
||||
id,
|
||||
sessionID: state.sessionID,
|
||||
source: input.source,
|
||||
status: 'running',
|
||||
order,
|
||||
...(input.anchorMessageID ? { anchorMessageID: input.anchorMessageID } : {}),
|
||||
...(input.anchorPartID ? { anchorPartID: input.anchorPartID } : {}),
|
||||
...(input.nativePartID ? { nativePartID: input.nativePartID } : {}),
|
||||
...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}),
|
||||
...(input.runID ? { runID: input.runID } : {}),
|
||||
...(input.generation !== undefined ? { generation: input.generation } : {}),
|
||||
...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete the precisely matched event, or the latest running event if no match is supplied. */
|
||||
export function completeCompaction(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
match: OpencodeCompactionMatch = {},
|
||||
completion: OpencodeCompactionCompletion = {},
|
||||
): OpencodeSessionTranscriptState {
|
||||
const hasMatch = Object.values(match).some((value) => value !== undefined);
|
||||
const id = hasMatch
|
||||
? findCompactionId(state, match, true)
|
||||
: [...state.compactionOrder].reverse().find((candidateID) => (
|
||||
state.compactionsById[candidateID]?.status === 'running'
|
||||
));
|
||||
if (!id) return state;
|
||||
const current = state.compactionsById[id];
|
||||
return replaceCompaction(state, {
|
||||
...current,
|
||||
status: 'completed',
|
||||
...(completion.nativePartID ? { nativePartID: completion.nativePartID } : {}),
|
||||
...(completion.nativeEventID ? { nativeEventID: completion.nativeEventID } : {}),
|
||||
...(completion.anchorMessageID ? { anchorMessageID: completion.anchorMessageID } : {}),
|
||||
...(completion.anchorPartID ? { anchorPartID: completion.anchorPartID } : {}),
|
||||
...(completion.runID ? { runID: completion.runID } : {}),
|
||||
...(completion.generation !== undefined ? { generation: completion.generation } : {}),
|
||||
...(completion.completedAt !== undefined ? { completedAt: completion.completedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a failed/cancelled event without relying on its mutable native correlation. */
|
||||
export function removeCompaction(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
match: OpencodeCompactionMatch,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const id = findCompactionId(state, match);
|
||||
if (!id) return state;
|
||||
const compactionsById = { ...state.compactionsById };
|
||||
delete compactionsById[id];
|
||||
return {
|
||||
...state,
|
||||
compactionsById,
|
||||
compactionOrder: state.compactionOrder.filter((candidateID) => candidateID !== id),
|
||||
};
|
||||
}
|
||||
|
||||
function compactionSource(part: OpencodeNormalizedSessionPart): OpencodeCompactionSource {
|
||||
const raw = part.raw ?? {};
|
||||
return raw.source === 'manual' || raw.auto === false ? 'manual' : 'automatic';
|
||||
}
|
||||
|
||||
function upsertCompactionPart(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
part: OpencodeNormalizedSessionPart,
|
||||
nativeEventID?: string,
|
||||
): OpencodeSessionTranscriptState {
|
||||
if (part.type !== 'compaction') return state;
|
||||
const raw = part.raw ?? {};
|
||||
const runID = getRunId(raw);
|
||||
const generation = getGeneration(raw);
|
||||
const source = compactionSource(part);
|
||||
const correlatedID = findCompactionId(state, { nativePartID: part.id });
|
||||
let id = correlatedID;
|
||||
if (!id && source === 'manual') {
|
||||
id = [...state.compactionOrder].reverse().find((candidateID) => {
|
||||
const candidate = state.compactionsById[candidateID];
|
||||
return candidate?.source === 'manual'
|
||||
&& candidate.status === 'running'
|
||||
&& !candidate.nativePartID
|
||||
&& (runID === undefined || candidate.runID === undefined || candidate.runID === runID)
|
||||
&& (generation === undefined
|
||||
|| candidate.generation === undefined
|
||||
|| candidate.generation === generation);
|
||||
});
|
||||
}
|
||||
if (!id) {
|
||||
id = `compaction:${state.sessionID}:part:${part.id}`;
|
||||
}
|
||||
let next = state;
|
||||
if (!correlatedID) {
|
||||
for (const candidateID of state.compactionOrder) {
|
||||
if (candidateID === id) continue;
|
||||
if (state.compactionsById[candidateID]?.status !== 'running') continue;
|
||||
next = completeCompaction(next, { id: candidateID });
|
||||
}
|
||||
}
|
||||
const existing = next.compactionsById[id];
|
||||
const endedAt = part.time?.end;
|
||||
return replaceCompaction(next, {
|
||||
id,
|
||||
sessionID: state.sessionID,
|
||||
source: existing?.source ?? source,
|
||||
status: endedAt !== undefined ? 'completed' : existing?.status ?? 'running',
|
||||
order: existing?.order ?? nextCompactionOrder(next),
|
||||
anchorMessageID: part.messageID,
|
||||
anchorPartID: part.id,
|
||||
nativePartID: part.id,
|
||||
...(nativeEventID ? { nativeEventID } : existing?.nativeEventID
|
||||
? { nativeEventID: existing.nativeEventID }
|
||||
: {}),
|
||||
...(runID ? { runID } : existing?.runID ? { runID: existing.runID } : {}),
|
||||
...(generation !== undefined ? { generation } : existing?.generation !== undefined
|
||||
? { generation: existing.generation }
|
||||
: {}),
|
||||
...(part.time?.start !== undefined ? { startedAt: part.time.start } : existing?.startedAt !== undefined
|
||||
? { startedAt: existing.startedAt }
|
||||
: {}),
|
||||
...(endedAt !== undefined ? { completedAt: endedAt } : existing?.completedAt !== undefined
|
||||
? { completedAt: existing.completedAt }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMessage(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
input: unknown,
|
||||
@@ -255,10 +494,11 @@ function ensureMessage(
|
||||
function upsertPart(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
part: OpencodeNormalizedSessionPart,
|
||||
nativeEventID?: string,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const message = state.messagesById[part.messageID];
|
||||
const currentOrder = state.partOrderByMessageId[part.messageID] ?? [];
|
||||
return {
|
||||
const next = {
|
||||
...state,
|
||||
partsById: {
|
||||
...state.partsById,
|
||||
@@ -282,6 +522,7 @@ function upsertPart(
|
||||
: [...currentOrder, part.id],
|
||||
},
|
||||
};
|
||||
return upsertCompactionPart(next, part, nativeEventID);
|
||||
}
|
||||
|
||||
function removePart(
|
||||
@@ -293,7 +534,7 @@ function removePart(
|
||||
const message = state.messagesById[part.messageID];
|
||||
const nextParts = { ...state.partsById };
|
||||
delete nextParts[partID];
|
||||
return {
|
||||
const next = {
|
||||
...state,
|
||||
partsById: nextParts,
|
||||
messagesById: message
|
||||
@@ -311,6 +552,9 @@ function removePart(
|
||||
.filter((id) => id !== partID),
|
||||
},
|
||||
};
|
||||
return part.type === 'compaction'
|
||||
? removeCompaction(next, { nativePartID: partID })
|
||||
: next;
|
||||
}
|
||||
|
||||
function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined {
|
||||
@@ -411,6 +655,7 @@ export function hydrateOpenCodeSession(
|
||||
sessionID: string,
|
||||
messages: unknown,
|
||||
status: OpencodeSessionExecutionState = 'idle',
|
||||
current?: OpencodeSessionTranscriptState,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const state = createEmptyState(sessionID);
|
||||
if (!Array.isArray(messages)) return { ...state, status };
|
||||
@@ -426,6 +671,69 @@ export function hydrateOpenCodeSession(
|
||||
if (part) next = upsertPart(next, part);
|
||||
}
|
||||
}
|
||||
const snapshotOrder = [...next.compactionOrder];
|
||||
snapshotOrder.forEach((id, index) => {
|
||||
const snapshotEvent = next.compactionsById[id];
|
||||
const existingID = snapshotEvent.nativePartID
|
||||
? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID })
|
||||
: undefined;
|
||||
const pendingID = !existingID && snapshotEvent.source === 'manual'
|
||||
? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => {
|
||||
const candidate = current?.compactionsById[candidateID];
|
||||
return candidate?.source === 'manual'
|
||||
&& candidate.status === 'running'
|
||||
&& !candidate.nativePartID
|
||||
&& (snapshotEvent.runID === undefined
|
||||
|| candidate.runID === undefined
|
||||
|| candidate.runID === snapshotEvent.runID)
|
||||
&& (snapshotEvent.generation === undefined
|
||||
|| candidate.generation === undefined
|
||||
|| candidate.generation === snapshotEvent.generation);
|
||||
})
|
||||
: undefined;
|
||||
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;
|
||||
if (idToUse !== id) {
|
||||
const compactionsById = { ...next.compactionsById };
|
||||
delete compactionsById[id];
|
||||
next = {
|
||||
...next,
|
||||
compactionsById,
|
||||
compactionOrder: next.compactionOrder.map((candidateID) => (
|
||||
candidateID === id ? idToUse : candidateID
|
||||
)),
|
||||
};
|
||||
}
|
||||
next = replaceCompaction(next, {
|
||||
...snapshotEvent,
|
||||
...existing,
|
||||
id: idToUse,
|
||||
status: completed ? 'completed' : 'running',
|
||||
order: index,
|
||||
anchorMessageID: snapshotEvent.anchorMessageID,
|
||||
anchorPartID: snapshotEvent.anchorPartID,
|
||||
nativePartID: snapshotEvent.nativePartID,
|
||||
...(snapshotEvent.nativeEventID ? { nativeEventID: snapshotEvent.nativeEventID } : {}),
|
||||
...(snapshotEvent.runID ? { runID: snapshotEvent.runID } : {}),
|
||||
...(snapshotEvent.generation !== undefined ? { generation: snapshotEvent.generation } : {}),
|
||||
...(snapshotEvent.startedAt !== undefined ? { startedAt: snapshotEvent.startedAt } : {}),
|
||||
...(snapshotEvent.completedAt !== undefined ? { completedAt: snapshotEvent.completedAt } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
if (status !== 'idle' && current) {
|
||||
for (const id of current.compactionOrder) {
|
||||
const pending = current.compactionsById[id];
|
||||
if (!pending || pending.status !== 'running' || pending.nativePartID) continue;
|
||||
if (next.compactionsById[id]) continue;
|
||||
next = replaceCompaction(next, { ...pending, order: nextCompactionOrder(next) });
|
||||
}
|
||||
}
|
||||
return { ...next, status };
|
||||
}
|
||||
|
||||
@@ -434,8 +742,9 @@ export function hydrateSessionSnapshot(
|
||||
sessionID: string,
|
||||
messages: unknown,
|
||||
status: OpencodeSessionExecutionState = 'idle',
|
||||
current?: OpencodeSessionTranscriptState,
|
||||
): OpencodeSessionTranscriptState {
|
||||
return hydrateOpenCodeSession(sessionID, messages, status);
|
||||
return hydrateOpenCodeSession(sessionID, messages, status, current);
|
||||
}
|
||||
|
||||
export function reduceOpenCodeEvent(
|
||||
@@ -480,7 +789,7 @@ export function reduceOpenCodeEvent(
|
||||
const messageID = part?.messageID || getMessageId(event.payload);
|
||||
if (!part || !messageID) return state;
|
||||
const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID);
|
||||
return upsertPart(withMessage, { ...part, messageID });
|
||||
return upsertPart(withMessage, { ...part, messageID }, getNativeEventId(event.payload));
|
||||
}
|
||||
|
||||
if (event.type === 'session.status') {
|
||||
@@ -491,14 +800,42 @@ export function reduceOpenCodeEvent(
|
||||
status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status,
|
||||
};
|
||||
}
|
||||
if (event.type === 'session.idle' || event.type === 'session.compacted') {
|
||||
return { ...state, status: 'idle' };
|
||||
if (event.type === 'session.idle') {
|
||||
return { ...completeCompaction(state), status: 'idle' };
|
||||
}
|
||||
if (event.type === 'session.compacted') {
|
||||
const payload = asRecord(event.payload);
|
||||
const match: OpencodeCompactionMatch = {
|
||||
...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}),
|
||||
...(getGeneration(event.payload) !== undefined
|
||||
? { generation: getGeneration(event.payload) }
|
||||
: {}),
|
||||
};
|
||||
const completedAt = toMilliseconds(
|
||||
asRecord(payload?.time)?.completed
|
||||
?? asRecord(payload?.time)?.end
|
||||
?? payload?.completedAt
|
||||
?? payload?.timestamp,
|
||||
);
|
||||
return completeCompaction(state, match, {
|
||||
...(getNativeEventId(event.payload) ? { nativeEventID: getNativeEventId(event.payload) } : {}),
|
||||
...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}),
|
||||
...(getGeneration(event.payload) !== undefined
|
||||
? { generation: getGeneration(event.payload) }
|
||||
: {}),
|
||||
...(completedAt !== undefined ? { completedAt } : {}),
|
||||
});
|
||||
}
|
||||
if (event.type === 'session.error') {
|
||||
const payload = asRecord(event.payload);
|
||||
const error = payload?.error;
|
||||
let next = state;
|
||||
for (const id of state.compactionOrder) {
|
||||
if (next.compactionsById[id]?.status !== 'running') continue;
|
||||
next = removeCompaction(next, { id });
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
...next,
|
||||
status: 'idle',
|
||||
lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000),
|
||||
};
|
||||
@@ -519,3 +856,13 @@ export function getOrderedSessionParts(
|
||||
export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number {
|
||||
return state ? Object.keys(state.partsById).length : 0;
|
||||
}
|
||||
|
||||
export function getOrderedSessionCompactions(
|
||||
state: OpencodeSessionTranscriptState | undefined,
|
||||
): OpencodeCompactionTimelineEvent[] {
|
||||
if (!state) return [];
|
||||
return state.compactionOrder
|
||||
.map((id) => state.compactionsById[id])
|
||||
.filter((event): event is OpencodeCompactionTimelineEvent => Boolean(event))
|
||||
.sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user