2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from "@opencode-ai/sdk/v2";
|
2026-03-12 23:45:45 +02:00
|
|
|
import { isFinalToolStatus } from "@/lib/toolStatus";
|
|
|
|
|
|
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
|
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readTimestamp = (value: unknown): number | undefined => {
|
|
|
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mergeTimeRange = (existing: unknown, incoming: unknown): Record<string, number> | undefined => {
|
|
|
|
|
const existingTime = isRecord(existing) ? existing : undefined;
|
|
|
|
|
const incomingTime = isRecord(incoming) ? incoming : undefined;
|
|
|
|
|
|
|
|
|
|
const startCandidates = [
|
|
|
|
|
readTimestamp(existingTime?.start),
|
|
|
|
|
readTimestamp(incomingTime?.start),
|
|
|
|
|
].filter((value): value is number => typeof value === 'number');
|
|
|
|
|
const endCandidates = [
|
|
|
|
|
readTimestamp(existingTime?.end),
|
|
|
|
|
readTimestamp(incomingTime?.end),
|
|
|
|
|
].filter((value): value is number => typeof value === 'number');
|
|
|
|
|
|
|
|
|
|
if (startCandidates.length === 0 && endCandidates.length === 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const merged: Record<string, number> = {};
|
|
|
|
|
if (startCandidates.length > 0) {
|
|
|
|
|
merged.start = Math.min(...startCandidates);
|
|
|
|
|
}
|
|
|
|
|
if (endCandidates.length > 0) {
|
|
|
|
|
merged.end = Math.max(...endCandidates);
|
|
|
|
|
}
|
|
|
|
|
return merged;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mergeToolState = (existing: unknown, incoming: unknown): Record<string, unknown> | undefined => {
|
|
|
|
|
const existingState = isRecord(existing) ? existing : undefined;
|
|
|
|
|
const incomingState = isRecord(incoming) ? incoming : undefined;
|
|
|
|
|
|
|
|
|
|
if (!existingState && !incomingState) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Never downgrade a terminal status. Once a tool reaches completed/error/etc,
|
|
|
|
|
// a late-arriving "running" SSE must not overwrite it.
|
|
|
|
|
const existingStatus = existingState?.status;
|
|
|
|
|
const incomingStatus = incomingState?.status;
|
|
|
|
|
const existingIsTerminal = typeof existingStatus === 'string' && isFinalToolStatus(existingStatus);
|
|
|
|
|
const incomingIsTerminal = typeof incomingStatus === 'string' && isFinalToolStatus(incomingStatus);
|
|
|
|
|
|
|
|
|
|
const merged: Record<string, unknown> = {
|
|
|
|
|
...(existingState ?? {}),
|
|
|
|
|
...(incomingState ?? {}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// If existing was terminal but incoming is not, keep the terminal status.
|
|
|
|
|
if (existingIsTerminal && !incomingIsTerminal && typeof incomingStatus === 'string') {
|
|
|
|
|
merged.status = existingStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mergedTime = mergeTimeRange(existingState?.time, incomingState?.time);
|
|
|
|
|
if (mergedTime) {
|
|
|
|
|
merged.time = mergedTime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRecord(existingState?.metadata) || isRecord(incomingState?.metadata)) {
|
|
|
|
|
merged.metadata = {
|
|
|
|
|
...(isRecord(existingState?.metadata) ? existingState.metadata : {}),
|
|
|
|
|
...(isRecord(incomingState?.metadata) ? incomingState.metadata : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRecord(existingState?.input) || isRecord(incomingState?.input)) {
|
|
|
|
|
merged.input = {
|
|
|
|
|
...(isRecord(existingState?.input) ? existingState.input : {}),
|
|
|
|
|
...(isRecord(incomingState?.input) ? incomingState.input : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return merged;
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const extractTextFromDelta = (delta: unknown): string => {
|
|
|
|
|
if (!delta) return '';
|
|
|
|
|
if (typeof delta === 'string') return delta;
|
|
|
|
|
if (Array.isArray(delta)) {
|
|
|
|
|
return delta.map((item) => extractTextFromDelta(item)).join('');
|
|
|
|
|
}
|
|
|
|
|
if (typeof delta === 'object') {
|
|
|
|
|
if (typeof (delta as { text?: unknown }).text === 'string') {
|
|
|
|
|
return (delta as { text: string }).text;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray((delta as { content?: unknown[] }).content)) {
|
|
|
|
|
return (delta as { content: unknown[] }).content.map((item: unknown) => extractTextFromDelta(item)).join('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const extractTextFromPart = (part: unknown): string => {
|
|
|
|
|
if (!part) return '';
|
2026-03-12 23:45:45 +02:00
|
|
|
const typedPart = part as { text?: string | unknown[]; content?: string | unknown[]; value?: string | unknown[]; delta?: unknown };
|
|
|
|
|
|
|
|
|
|
const toText = (value: unknown): string => {
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
return value
|
|
|
|
|
.map((item: unknown) => {
|
|
|
|
|
if (typeof item === 'string') return item;
|
|
|
|
|
if (item && typeof item === 'object') {
|
|
|
|
|
const typedItem = item as { text?: unknown; content?: unknown; value?: unknown; delta?: unknown };
|
|
|
|
|
return toText(typedItem.text) || toText(typedItem.content) || toText(typedItem.value) || extractTextFromDelta(typedItem.delta);
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})
|
|
|
|
|
.join('');
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const candidates = [
|
|
|
|
|
toText(typedPart.text),
|
|
|
|
|
toText(typedPart.content),
|
|
|
|
|
toText(typedPart.value),
|
|
|
|
|
extractTextFromDelta(typedPart.delta),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let best = '';
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
if (candidate.length > best.length) {
|
|
|
|
|
best = candidate;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
return best;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const normalized: { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown } = {
|
|
|
|
|
...(existing as Record<string, unknown> | undefined),
|
|
|
|
|
...incoming,
|
|
|
|
|
} as { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown };
|
|
|
|
|
const existingType = typeof (existing as { type?: unknown } | undefined)?.type === 'string'
|
|
|
|
|
? (existing as { type: string }).type
|
|
|
|
|
: undefined;
|
|
|
|
|
normalized.type = normalized.type || existingType || 'text';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const isStreamingTextLikePart = normalized.type === 'text' || normalized.type === 'reasoning';
|
|
|
|
|
|
|
|
|
|
if (isStreamingTextLikePart) {
|
|
|
|
|
const existingRecord = (existing ?? {}) as { text?: unknown; content?: unknown; value?: unknown };
|
|
|
|
|
const existingText = extractTextFromPart(existing);
|
|
|
|
|
const directText = extractTextFromPart(incoming);
|
2025-12-07 19:32:53 +02:00
|
|
|
const deltaText = extractTextFromDelta((incoming as { delta?: unknown }).delta);
|
2026-03-12 23:45:45 +02:00
|
|
|
let mergedText = '';
|
|
|
|
|
|
|
|
|
|
const incomingField =
|
|
|
|
|
typeof normalized.text === 'string'
|
|
|
|
|
? 'text'
|
|
|
|
|
: typeof normalized.content === 'string'
|
|
|
|
|
? 'content'
|
|
|
|
|
: typeof normalized.value === 'string'
|
|
|
|
|
? 'value'
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const targetField = incomingField ?? (
|
|
|
|
|
typeof existingRecord.text === 'string'
|
|
|
|
|
? 'text'
|
|
|
|
|
: typeof existingRecord.content === 'string'
|
|
|
|
|
? 'content'
|
|
|
|
|
: typeof existingRecord.value === 'string'
|
|
|
|
|
? 'value'
|
|
|
|
|
: 'text'
|
|
|
|
|
);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
if (deltaText) {
|
|
|
|
|
mergedText = existingText ? `${existingText}${deltaText}` : deltaText;
|
|
|
|
|
} else if (directText) {
|
|
|
|
|
mergedText = directText;
|
2025-12-07 19:32:53 +02:00
|
|
|
} else {
|
2026-03-12 23:45:45 +02:00
|
|
|
mergedText = existingText;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
normalized[targetField] = mergedText;
|
|
|
|
|
if (targetField !== 'text') {
|
|
|
|
|
normalized.text = mergedText;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
delete normalized.delta;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const mergedTime = mergeTimeRange(
|
|
|
|
|
(existing as { time?: unknown } | undefined)?.time,
|
|
|
|
|
(incoming as { time?: unknown } | undefined)?.time,
|
|
|
|
|
);
|
|
|
|
|
if (mergedTime) {
|
|
|
|
|
normalized.time = mergedTime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (normalized.type === 'tool') {
|
|
|
|
|
const mergedState = mergeToolState(
|
|
|
|
|
(existing as { state?: unknown } | undefined)?.state,
|
|
|
|
|
(incoming as { state?: unknown } | undefined)?.state,
|
|
|
|
|
);
|
|
|
|
|
if (mergedState) {
|
|
|
|
|
normalized.state = mergedState;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return normalized as Part;
|
2026-03-12 23:45:45 +02:00
|
|
|
};
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const deepEqualRecord = (left: Record<string, unknown>, right: Record<string, unknown>): boolean => {
|
|
|
|
|
const keys = new Set<string>([
|
|
|
|
|
...Object.keys(left),
|
|
|
|
|
...Object.keys(right),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
const leftValue = left[key];
|
|
|
|
|
const rightValue = right[key];
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
|
|
|
|
|
if (!Array.isArray(leftValue) || !Array.isArray(rightValue) || leftValue.length !== rightValue.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (let index = 0; index < leftValue.length; index += 1) {
|
|
|
|
|
if (!deepEqualUnknown(leftValue[index], rightValue[index])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!deepEqualUnknown(leftValue, rightValue)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deepEqualUnknown = (left: unknown, right: unknown): boolean => {
|
|
|
|
|
if (left === right) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!left || !right) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof left !== typeof right) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof left === 'object' && typeof right === 'object') {
|
|
|
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
|
|
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
|
|
|
if (!deepEqualUnknown(left[index], right[index])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return deepEqualRecord(left as Record<string, unknown>, right as Record<string, unknown>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const arePartsEquivalent = (left: Part | undefined, right: Part | undefined): boolean => {
|
|
|
|
|
if (!left || !right) {
|
|
|
|
|
return left === right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return deepEqualUnknown(left, right);
|
|
|
|
|
};
|