879 lines
31 KiB
TypeScript
879 lines
31 KiB
TypeScript
import type {
|
|
OpencodeCompactionSource,
|
|
OpencodeCompactionTimelineEvent,
|
|
OpencodeNativePartKind,
|
|
OpencodeNativeToolState,
|
|
OpencodeNormalizedSessionMessage,
|
|
OpencodeNormalizedSessionPart,
|
|
OpencodeNormalizedToolState,
|
|
OpencodeSessionExecutionState,
|
|
OpencodeSessionTranscriptState,
|
|
} from '@/types/opencode';
|
|
|
|
export interface OpenCodeEventEnvelope {
|
|
type: string;
|
|
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',
|
|
'tool',
|
|
'step-start',
|
|
'step-finish',
|
|
'retry',
|
|
'subtask',
|
|
'agent',
|
|
'patch',
|
|
'file',
|
|
'snapshot',
|
|
'compaction',
|
|
]);
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function getSessionId(value: unknown): string | undefined {
|
|
const record = asRecord(value);
|
|
if (!record) return undefined;
|
|
return nonEmptyString(record?.sessionID)
|
|
?? nonEmptyString(record?.sessionId)
|
|
?? getSessionId(record?.part)
|
|
?? getSessionId(record?.info)
|
|
?? getSessionId(record?.message);
|
|
}
|
|
|
|
function getDeltaText(value: unknown): string {
|
|
if (typeof value === 'string') return value;
|
|
|
|
const record = asRecord(value);
|
|
if (!record) return '';
|
|
|
|
for (const key of ['text', 'reasoning', 'reasoning_content', 'reasoning_text']) {
|
|
const text = nonEmptyString(record[key]);
|
|
if (text) return text;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getMessageId(value: unknown): string | undefined {
|
|
const record = asRecord(value);
|
|
return nonEmptyString(record?.messageID)
|
|
?? nonEmptyString(record?.messageId)
|
|
?? nonEmptyString(asRecord(record?.info)?.id)
|
|
?? nonEmptyString(asRecord(record?.message)?.id)
|
|
?? nonEmptyString(asRecord(record?.part)?.messageID);
|
|
}
|
|
|
|
function getPartId(value: unknown): string | undefined {
|
|
const record = asRecord(value);
|
|
return nonEmptyString(record?.partID)
|
|
?? nonEmptyString(record?.partId)
|
|
?? nonEmptyString(asRecord(record?.part)?.id)
|
|
?? 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;
|
|
}
|
|
|
|
function getTime(value: unknown): { start?: number; end?: number } | undefined {
|
|
const record = asRecord(value);
|
|
if (!record) return undefined;
|
|
const start = toMilliseconds(record.start);
|
|
const end = toMilliseconds(record.end);
|
|
return start === undefined && end === undefined ? undefined : { start, end };
|
|
}
|
|
|
|
function stringifyDetail(value: unknown, limit = 8_000): string | undefined {
|
|
if (value === undefined || value === null) return undefined;
|
|
const text = typeof value === 'string' ? value : (() => {
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
})();
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return undefined;
|
|
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n…` : trimmed;
|
|
}
|
|
|
|
function normalizeToolState(value: unknown): OpencodeNormalizedToolState | undefined {
|
|
const record = asRecord(value);
|
|
if (!record) return undefined;
|
|
const rawStatus = nonEmptyString(record.status);
|
|
const status: OpencodeNativeToolState = rawStatus === 'pending'
|
|
|| rawStatus === 'running'
|
|
|| rawStatus === 'completed'
|
|
|| rawStatus === 'error'
|
|
? rawStatus
|
|
: 'running';
|
|
const time = getTime(record.time);
|
|
return {
|
|
status,
|
|
...(record.input !== undefined ? { input: record.input } : {}),
|
|
...(record.output !== undefined ? { output: record.output } : {}),
|
|
...(record.error !== undefined ? { error: record.error } : {}),
|
|
...(asRecord(record.metadata) ? { metadata: asRecord(record.metadata)! } : {}),
|
|
...(time ? { time } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizePartType(rawType: string): OpencodeNativePartKind {
|
|
return PART_TYPES.has(rawType as OpencodeNativePartKind)
|
|
? rawType as OpencodeNativePartKind
|
|
: 'unknown';
|
|
}
|
|
|
|
function pickPartRecord(value: unknown): Record<string, unknown> | null {
|
|
const record = asRecord(value);
|
|
return asRecord(record?.part) ?? record;
|
|
}
|
|
|
|
function pickMessageInfo(value: unknown): Record<string, unknown> | null {
|
|
const record = asRecord(value);
|
|
return asRecord(record?.info) ?? asRecord(record?.message) ?? record;
|
|
}
|
|
|
|
function normalizeMessageRole(value: unknown): string {
|
|
const role = nonEmptyString(value);
|
|
return role === 'user' || role === 'assistant' || role === 'system' || role === 'tool'
|
|
? role
|
|
: 'assistant';
|
|
}
|
|
|
|
function normalizePart(
|
|
input: unknown,
|
|
sessionID: string,
|
|
messageID: string,
|
|
fallbackId?: string,
|
|
): OpencodeNormalizedSessionPart | null {
|
|
const record = pickPartRecord(input);
|
|
if (!record) return null;
|
|
const id = nonEmptyString(record.id) ?? nonEmptyString(record.partID) ?? fallbackId;
|
|
const rawType = nonEmptyString(record.type) ?? 'unknown';
|
|
if (!id) return null;
|
|
|
|
const type = normalizePartType(rawType);
|
|
const state = normalizeToolState(record.state);
|
|
const time = getTime(record.time) ?? state?.time;
|
|
const text = typeof record.text === 'string'
|
|
? record.text
|
|
: typeof record.reasoning === 'string'
|
|
? record.reasoning
|
|
: typeof record.reasoning_content === 'string'
|
|
? record.reasoning_content
|
|
: typeof record.reasoning_text === 'string'
|
|
? record.reasoning_text
|
|
: undefined;
|
|
const detail = stringifyDetail(
|
|
type === 'tool'
|
|
? state?.output ?? state?.error ?? state?.input
|
|
: type === 'retry'
|
|
? record.message ?? record.error ?? record
|
|
: type === 'step-finish'
|
|
? {
|
|
reason: record.reason,
|
|
cost: record.cost,
|
|
tokens: record.tokens,
|
|
}
|
|
: type === 'text' || type === 'reasoning'
|
|
? undefined
|
|
: record,
|
|
);
|
|
|
|
return {
|
|
id,
|
|
sessionID,
|
|
messageID,
|
|
type,
|
|
rawType,
|
|
...(text !== undefined ? { text } : {}),
|
|
...(record.synthetic === true ? { synthetic: true } : {}),
|
|
...(typeof record.tool === 'string' ? { tool: record.tool } : {}),
|
|
...(typeof record.callID === 'string' ? { callID: record.callID } : {}),
|
|
...(state ? { state } : {}),
|
|
...(detail ? { detail } : {}),
|
|
...(typeof record.parentID === 'string' ? { parentID: record.parentID } : {}),
|
|
...(typeof record.sessionID === 'string' ? { childSessionID: record.sessionID } : {}),
|
|
...(time ? { time } : {}),
|
|
raw: record,
|
|
};
|
|
}
|
|
|
|
function createEmptyState(sessionID: string): OpencodeSessionTranscriptState {
|
|
return {
|
|
sessionID,
|
|
status: 'idle',
|
|
messagesById: {},
|
|
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,
|
|
sessionID: string,
|
|
messageID: string,
|
|
): OpencodeSessionTranscriptState {
|
|
const info = pickMessageInfo(input) ?? {};
|
|
const existing = state.messagesById[messageID];
|
|
const next: OpencodeNormalizedSessionMessage = {
|
|
id: messageID,
|
|
sessionID,
|
|
role: normalizeMessageRole(info.role),
|
|
...(toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) !== undefined
|
|
? { createdAt: toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) }
|
|
: existing?.createdAt !== undefined ? { createdAt: existing.createdAt } : {}),
|
|
...(toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) !== undefined
|
|
? { completedAt: toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) }
|
|
: existing?.completedAt !== undefined ? { completedAt: existing.completedAt } : {}),
|
|
partIDs: existing?.partIDs ?? [],
|
|
};
|
|
return {
|
|
...state,
|
|
messagesById: { ...state.messagesById, [messageID]: next },
|
|
messageOrder: state.messageOrder.includes(messageID)
|
|
? state.messageOrder
|
|
: [...state.messageOrder, messageID],
|
|
partOrderByMessageId: state.partOrderByMessageId[messageID]
|
|
? state.partOrderByMessageId
|
|
: { ...state.partOrderByMessageId, [messageID]: [] },
|
|
};
|
|
}
|
|
|
|
function upsertPart(
|
|
state: OpencodeSessionTranscriptState,
|
|
part: OpencodeNormalizedSessionPart,
|
|
nativeEventID?: string,
|
|
): OpencodeSessionTranscriptState {
|
|
const message = state.messagesById[part.messageID];
|
|
const currentOrder = state.partOrderByMessageId[part.messageID] ?? [];
|
|
const next = {
|
|
...state,
|
|
partsById: {
|
|
...state.partsById,
|
|
[part.id]: part,
|
|
},
|
|
messagesById: message
|
|
? {
|
|
...state.messagesById,
|
|
[part.messageID]: {
|
|
...message,
|
|
partIDs: message.partIDs.includes(part.id)
|
|
? message.partIDs
|
|
: [...message.partIDs, part.id],
|
|
},
|
|
}
|
|
: state.messagesById,
|
|
partOrderByMessageId: {
|
|
...state.partOrderByMessageId,
|
|
[part.messageID]: currentOrder.includes(part.id)
|
|
? currentOrder
|
|
: [...currentOrder, part.id],
|
|
},
|
|
};
|
|
return upsertCompactionPart(next, part, nativeEventID);
|
|
}
|
|
|
|
function removePart(
|
|
state: OpencodeSessionTranscriptState,
|
|
partID: string,
|
|
): OpencodeSessionTranscriptState {
|
|
const part = state.partsById[partID];
|
|
if (!part) return state;
|
|
const message = state.messagesById[part.messageID];
|
|
const nextParts = { ...state.partsById };
|
|
delete nextParts[partID];
|
|
const next = {
|
|
...state,
|
|
partsById: nextParts,
|
|
messagesById: message
|
|
? {
|
|
...state.messagesById,
|
|
[part.messageID]: {
|
|
...message,
|
|
partIDs: message.partIDs.filter((id) => id !== partID),
|
|
},
|
|
}
|
|
: state.messagesById,
|
|
partOrderByMessageId: {
|
|
...state.partOrderByMessageId,
|
|
[part.messageID]: (state.partOrderByMessageId[part.messageID] ?? [])
|
|
.filter((id) => id !== partID),
|
|
},
|
|
};
|
|
return part.type === 'compaction'
|
|
? removeCompaction(next, { nativePartID: partID })
|
|
: next;
|
|
}
|
|
|
|
function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined {
|
|
const record = asRecord(event.payload);
|
|
const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence)
|
|
? String(record.sequence)
|
|
: nonEmptyString(record?.sequence);
|
|
const id = nonEmptyString(record?.eventID)
|
|
?? nonEmptyString(record?.eventId)
|
|
?? sequence
|
|
// Message/Part ids identify the resource, not the event. They must not be
|
|
// treated as a delta event id or every later chunk would be discarded.
|
|
?? (event.type === 'message.part.delta' ? undefined : nonEmptyString(record?.id));
|
|
return id ? `${sessionID ?? getSessionId(event.payload) ?? ''}:${event.type}:${id}` : undefined;
|
|
}
|
|
|
|
function rememberEventKey(
|
|
state: OpencodeSessionTranscriptState,
|
|
key: string | undefined,
|
|
): OpencodeSessionTranscriptState {
|
|
if (!key) return state;
|
|
const previous = state.seenEventKeys ?? [];
|
|
if (previous.includes(key)) return state;
|
|
return {
|
|
...state,
|
|
lastEventKey: key,
|
|
seenEventKeys: [...previous, key].slice(-512),
|
|
};
|
|
}
|
|
|
|
function applyDelta(
|
|
state: OpencodeSessionTranscriptState,
|
|
event: OpenCodeEventEnvelope,
|
|
): OpencodeSessionTranscriptState {
|
|
const record = asRecord(event.payload);
|
|
const delta = getDeltaText(record?.delta);
|
|
const sessionID = getSessionId(event.payload) ?? state.sessionID;
|
|
const messageID = getMessageId(event.payload);
|
|
const partID = getPartId(event.payload);
|
|
if (!delta || !messageID || !partID) return state;
|
|
const field = nonEmptyString(record?.field) ?? nonEmptyString(asRecord(record?.part)?.field);
|
|
const signature = `${messageID}:${partID}:${field ?? 'text'}:${delta}`;
|
|
// Without a runtime event identity, only suppress an immediately repeated
|
|
// signature. With an identity, the reducer-level event cache is the source
|
|
// of truth and equal consecutive chunks remain valid output.
|
|
if (!eventKey(event, sessionID) && state.lastDeltaSignature === signature) return state;
|
|
|
|
const existing = state.partsById[partID];
|
|
const isReasoning = field === 'reasoning'
|
|
|| field === 'thinking'
|
|
|| asRecord(record?.part)?.type === 'reasoning'
|
|
|| existing?.type === 'reasoning';
|
|
const nextPart: OpencodeNormalizedSessionPart = {
|
|
...(existing ?? {
|
|
id: partID,
|
|
sessionID,
|
|
messageID,
|
|
type: isReasoning ? 'reasoning' : 'text',
|
|
rawType: isReasoning ? 'reasoning' : 'text',
|
|
}),
|
|
text: `${existing?.text ?? ''}${delta}`,
|
|
};
|
|
return {
|
|
...upsertPart(ensureMessage(state, record?.part ?? record, sessionID, messageID), nextPart),
|
|
lastDeltaSignature: signature,
|
|
};
|
|
}
|
|
|
|
export function normalizeOpenCodeMessage(
|
|
input: unknown,
|
|
fallbackSessionID?: string,
|
|
): OpencodeNormalizedSessionMessage | null {
|
|
const record = asRecord(input);
|
|
if (!record) return null;
|
|
const info = pickMessageInfo(record);
|
|
const sessionID = getSessionId(record) ?? fallbackSessionID;
|
|
const id = nonEmptyString(info?.id) ?? nonEmptyString(record.id);
|
|
if (!sessionID || !id) return null;
|
|
const parts = Array.isArray(record.parts) ? record.parts : [];
|
|
const normalizedParts = parts
|
|
.map((part) => normalizePart(part, sessionID, id))
|
|
.filter((part): part is OpencodeNormalizedSessionPart => Boolean(part));
|
|
return {
|
|
id,
|
|
sessionID,
|
|
role: normalizeMessageRole(info?.role),
|
|
...(toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) !== undefined
|
|
? { createdAt: toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) }
|
|
: {}),
|
|
...(toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) !== undefined
|
|
? { completedAt: toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) }
|
|
: {}),
|
|
partIDs: normalizedParts.map((part) => part.id),
|
|
};
|
|
}
|
|
|
|
export function hydrateOpenCodeSession(
|
|
sessionID: string,
|
|
messages: unknown,
|
|
status: OpencodeSessionExecutionState = 'idle',
|
|
current?: OpencodeSessionTranscriptState,
|
|
): OpencodeSessionTranscriptState {
|
|
const state = createEmptyState(sessionID);
|
|
if (!Array.isArray(messages)) return { ...state, status };
|
|
let next = state;
|
|
for (const messageInput of messages) {
|
|
const message = normalizeOpenCodeMessage(messageInput, sessionID);
|
|
if (!message) continue;
|
|
next = ensureMessage(next, messageInput, sessionID, message.id);
|
|
const record = asRecord(messageInput);
|
|
const parts = Array.isArray(record?.parts) ? record.parts : [];
|
|
for (const partInput of parts) {
|
|
const part = normalizePart(partInput, sessionID, message.id);
|
|
if (part) next = upsertPart(next, part);
|
|
}
|
|
}
|
|
const snapshotOrder = [...next.compactionOrder];
|
|
snapshotOrder.forEach((id, index) => {
|
|
const snapshotEvent = next.compactionsById[id];
|
|
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) => {
|
|
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 completed = existing?.status === 'completed'
|
|
|| snapshotEvent.completedAt !== undefined
|
|
|| status === 'idle'
|
|
|| existing?.status !== 'running';
|
|
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 };
|
|
}
|
|
|
|
/** Rebuild the canonical transcript from the server's message/Part snapshot. */
|
|
export function hydrateSessionSnapshot(
|
|
sessionID: string,
|
|
messages: unknown,
|
|
status: OpencodeSessionExecutionState = 'idle',
|
|
current?: OpencodeSessionTranscriptState,
|
|
): OpencodeSessionTranscriptState {
|
|
return hydrateOpenCodeSession(sessionID, messages, status, current);
|
|
}
|
|
|
|
export function reduceOpenCodeEvent(
|
|
current: OpencodeSessionTranscriptState | undefined,
|
|
event: OpenCodeEventEnvelope,
|
|
): OpencodeSessionTranscriptState | undefined {
|
|
const sessionID = getSessionId(event.payload) ?? current?.sessionID;
|
|
if (!sessionID) return current;
|
|
if (current && current.sessionID !== sessionID) return current;
|
|
let state = current ?? createEmptyState(sessionID);
|
|
const key = eventKey(event, sessionID);
|
|
if (key && state.seenEventKeys?.includes(key)) return state;
|
|
state = rememberEventKey(state, key);
|
|
|
|
if (event.type === 'message.part.delta') {
|
|
return applyDelta(state, event);
|
|
}
|
|
|
|
if (event.type === 'message.part.removed') {
|
|
const partID = getPartId(event.payload);
|
|
return partID ? removePart(state, partID) : state;
|
|
}
|
|
|
|
if (event.type === 'message.updated') {
|
|
const messageInput = asRecord(event.payload)?.message ?? event.payload;
|
|
const message = normalizeOpenCodeMessage(messageInput, sessionID);
|
|
if (!message) return state;
|
|
let next = ensureMessage(state, messageInput, sessionID, message.id);
|
|
const parts = Array.isArray(asRecord(messageInput)?.parts)
|
|
? asRecord(messageInput)?.parts as unknown[]
|
|
: [];
|
|
for (const partInput of parts) {
|
|
const part = normalizePart(partInput, sessionID, message.id);
|
|
if (part) next = upsertPart(next, part);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
if (event.type === 'message.part.updated') {
|
|
const record = asRecord(event.payload);
|
|
const part = normalizePart(record?.part ?? event.payload, sessionID, getMessageId(event.payload) ?? '');
|
|
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 }, getNativeEventId(event.payload));
|
|
}
|
|
|
|
if (event.type === 'session.status') {
|
|
const type = nonEmptyString(asRecord(event.payload)?.status && asRecord(asRecord(event.payload)?.status)?.type)
|
|
?? nonEmptyString(asRecord(event.payload)?.type);
|
|
return {
|
|
...state,
|
|
status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status,
|
|
};
|
|
}
|
|
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 {
|
|
...next,
|
|
status: 'idle',
|
|
lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000),
|
|
};
|
|
}
|
|
return state;
|
|
}
|
|
|
|
export function getOrderedSessionParts(
|
|
state: OpencodeSessionTranscriptState | undefined,
|
|
messageID: string,
|
|
): OpencodeNormalizedSessionPart[] {
|
|
if (!state) return [];
|
|
return (state.partOrderByMessageId[messageID] ?? [])
|
|
.map((partID) => state.partsById[partID])
|
|
.filter((part): part is OpencodeNormalizedSessionPart => Boolean(part));
|
|
}
|
|
|
|
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);
|
|
}
|