923 lines
30 KiB
TypeScript
923 lines
30 KiB
TypeScript
import type {
|
|
AttachedFileMeta,
|
|
ContentBlock,
|
|
RawMessage,
|
|
RuntimeToolEvidence,
|
|
ToolStatus,
|
|
} from '@/types/chat';
|
|
|
|
const EMPTY_ASSISTANT_RESPONSE_MESSAGE = '模型没有返回任何内容。请重试,或切换到支持图片输入的模型。';
|
|
|
|
function toMs(value: unknown): number | undefined {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value < 1e12 ? value * 1000 : value;
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const numeric = Number(value);
|
|
if (Number.isFinite(numeric)) {
|
|
return numeric < 1e12 ? numeric * 1000 : numeric;
|
|
}
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
return date.getTime();
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getRecord(input: unknown): Record<string, unknown> | null {
|
|
return input && typeof input === 'object' && !Array.isArray(input)
|
|
? input as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function getErrorMessage(input: unknown): string | undefined {
|
|
if (typeof input === 'string') {
|
|
const trimmed = input.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
const record = getRecord(input);
|
|
if (!record) return undefined;
|
|
|
|
const data = getRecord(record.data);
|
|
const nestedError = getRecord(record.error);
|
|
const metadata = getRecord(record.metadata);
|
|
const candidates = [
|
|
data?.message,
|
|
data?.error,
|
|
nestedError?.message,
|
|
record.message,
|
|
record.errorMessage,
|
|
record.error_message,
|
|
record.detail,
|
|
metadata?.message,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const message = getErrorMessage(candidate);
|
|
if (message) return message;
|
|
}
|
|
|
|
if (typeof record.data === 'string') {
|
|
const rawData = record.data.trim();
|
|
if (!rawData) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(rawData) as unknown;
|
|
return getErrorMessage(parsed) ?? rawData;
|
|
} catch {
|
|
return rawData;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeRole(input: unknown): RawMessage['role'] {
|
|
switch (input) {
|
|
case 'user':
|
|
case 'assistant':
|
|
case 'system':
|
|
case 'toolresult':
|
|
return input;
|
|
default:
|
|
return 'assistant';
|
|
}
|
|
}
|
|
|
|
function basenameFromPath(path: string): string {
|
|
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
return parts.at(-1) ?? path;
|
|
}
|
|
|
|
function normalizeTimestamp(record: Record<string, unknown>): number | undefined {
|
|
const time = getRecord(record.time);
|
|
return toMs(time?.created)
|
|
?? toMs(record.createdAt)
|
|
?? toMs(record.updatedAt)
|
|
?? toMs(record.timestamp);
|
|
}
|
|
|
|
function hasValue(value: unknown): boolean {
|
|
if (value === true) return true;
|
|
if (typeof value === 'number' && Number.isFinite(value)) return true;
|
|
if (typeof value === 'string' && value.trim()) return true;
|
|
return false;
|
|
}
|
|
|
|
function hasCompletedAssistantSignal(
|
|
info: Record<string, unknown>,
|
|
input: Record<string, unknown>,
|
|
parts: unknown[],
|
|
): boolean {
|
|
const time = getRecord(info.time);
|
|
if (
|
|
hasValue(time?.completed)
|
|
|| hasValue(info.completed)
|
|
|| hasValue(info.completedAt)
|
|
|| hasValue(input.completed)
|
|
|| hasValue(input.completedAt)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return parts.some((part) => getRecord(part)?.type === 'step-finish');
|
|
}
|
|
|
|
function normalizeToolStatus(input: unknown): ToolStatus['status'] {
|
|
switch (input) {
|
|
case 'completed':
|
|
return 'completed';
|
|
case 'error':
|
|
case 'errored':
|
|
return 'error';
|
|
default:
|
|
return 'running';
|
|
}
|
|
}
|
|
|
|
function sanitizeToolEvidenceText(value: unknown, limit = 600): string | undefined {
|
|
const redact = (text: string) => text
|
|
.replace(/(authorization|bearer|token|secret|password|api[_-]?key)(\s*[:=]\s*|\s+)[^\s,;]+/gi, '$1=[REDACTED]')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
const visit = (candidate: unknown, depth = 0): string => {
|
|
if (depth > 3 || candidate === null || candidate === undefined) return '';
|
|
if (typeof candidate === 'string') return candidate;
|
|
if (typeof candidate === 'number' || typeof candidate === 'boolean') return String(candidate);
|
|
if (Array.isArray(candidate)) {
|
|
return candidate.map((item) => visit(item, depth + 1)).filter(Boolean).join(' ');
|
|
}
|
|
const record = getRecord(candidate);
|
|
if (!record) return '';
|
|
return Object.entries(record)
|
|
.filter(([key]) => !/(authorization|bearer|token|secret|password|api[_-]?key|cookie)/i.test(key))
|
|
.map(([key, item]) => `${key}: ${visit(item, depth + 1)}`)
|
|
.filter(Boolean)
|
|
.join('; ');
|
|
};
|
|
const text = redact(visit(value));
|
|
return text ? text.slice(0, limit) : undefined;
|
|
}
|
|
|
|
function getToolExitCode(state: Record<string, unknown> | null): number | undefined {
|
|
const metadata = getRecord(state?.metadata);
|
|
const candidates = [
|
|
state?.exitCode,
|
|
state?.exit_code,
|
|
state?.exit,
|
|
metadata?.exitCode,
|
|
metadata?.exit_code,
|
|
metadata?.exit,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const numeric = typeof candidate === 'number'
|
|
? candidate
|
|
: typeof candidate === 'string' && candidate.trim()
|
|
? Number(candidate)
|
|
: Number.NaN;
|
|
if (Number.isInteger(numeric)) return numeric;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeToolEvidence(part: Record<string, unknown>): RuntimeToolEvidence | null {
|
|
if (part.type !== 'tool' || typeof part.tool !== 'string' || !part.tool.trim()) return null;
|
|
const state = getRecord(part.state);
|
|
const time = getRecord(state?.time);
|
|
const startedAt = toMs(time?.start);
|
|
const endedAt = toMs(time?.end);
|
|
return {
|
|
id: typeof part.id === 'string' ? part.id : undefined,
|
|
toolCallId: typeof part.callID === 'string' ? part.callID : undefined,
|
|
name: part.tool,
|
|
status: normalizeToolStatus(state?.status),
|
|
inputSummary: sanitizeToolEvidenceText(state?.input),
|
|
outputExcerpt: sanitizeToolEvidenceText(state?.output ?? state?.error),
|
|
exitCode: getToolExitCode(state),
|
|
startedAt,
|
|
endedAt,
|
|
};
|
|
}
|
|
|
|
function mergeToolEvidence(
|
|
current: RuntimeToolEvidence[] | undefined,
|
|
next: RuntimeToolEvidence | null,
|
|
): RuntimeToolEvidence[] | undefined {
|
|
if (!next) return current;
|
|
const combined = [...(current ?? [])];
|
|
const key = next.toolCallId ?? next.id ?? `${next.name}:${next.startedAt ?? ''}`;
|
|
const index = combined.findIndex((item) => (
|
|
(item.toolCallId ?? item.id ?? `${item.name}:${item.startedAt ?? ''}`) === key
|
|
));
|
|
if (index >= 0) {
|
|
combined[index] = { ...combined[index], ...next };
|
|
} else {
|
|
combined.push(next);
|
|
}
|
|
return combined;
|
|
}
|
|
|
|
function normalizeLegacyBlocks(content: unknown): ContentBlock[] {
|
|
if (typeof content === 'string') {
|
|
return [{ type: 'text', text: content }];
|
|
}
|
|
if (!Array.isArray(content)) return [];
|
|
|
|
const blocks: ContentBlock[] = [];
|
|
for (const block of content) {
|
|
const record = getRecord(block);
|
|
if (!record) continue;
|
|
|
|
const type = record.type;
|
|
if (type === 'text' && typeof record.text === 'string') {
|
|
blocks.push({ type: 'text', text: record.text, id: typeof record.id === 'string' ? record.id : undefined });
|
|
continue;
|
|
}
|
|
if (type === 'thinking' && typeof record.thinking === 'string') {
|
|
blocks.push({ type: 'thinking', thinking: record.thinking, id: typeof record.id === 'string' ? record.id : undefined });
|
|
continue;
|
|
}
|
|
if (type === 'reasoning') {
|
|
const reasoning = 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;
|
|
if (reasoning) {
|
|
blocks.push({ type: 'thinking', thinking: reasoning, id: typeof record.id === 'string' ? record.id : undefined });
|
|
}
|
|
continue;
|
|
}
|
|
if ((type === 'tool_use' || type === 'toolCall') && typeof record.name === 'string') {
|
|
blocks.push({
|
|
type: 'tool_use',
|
|
id: typeof record.id === 'string' ? record.id : undefined,
|
|
name: record.name,
|
|
input: record.input ?? record.arguments ?? {},
|
|
});
|
|
continue;
|
|
}
|
|
if ((type === 'image' || type === 'file') && typeof record.url === 'string' && typeof record.mimeType === 'string') {
|
|
blocks.push({
|
|
type: 'image',
|
|
url: record.url,
|
|
mimeType: record.mimeType,
|
|
alt: typeof record.alt === 'string' ? record.alt : undefined,
|
|
id: typeof record.id === 'string' ? record.id : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
function normalizeLegacyMessage(input: Record<string, unknown>): RawMessage {
|
|
const contentBlocks = normalizeLegacyBlocks(input.content);
|
|
const topLevelReasoning = typeof input.reasoning === 'string'
|
|
? input.reasoning
|
|
: typeof input.reasoning_content === 'string'
|
|
? input.reasoning_content
|
|
: typeof input.reasoning_text === 'string'
|
|
? input.reasoning_text
|
|
: undefined;
|
|
if (topLevelReasoning?.trim()) {
|
|
contentBlocks.unshift({ type: 'thinking', thinking: topLevelReasoning });
|
|
}
|
|
if (contentBlocks.length > 0) {
|
|
return {
|
|
id: typeof input.id === 'string' ? input.id : undefined,
|
|
role: normalizeRole(input.role),
|
|
timestamp: normalizeTimestamp(input),
|
|
content: contentBlocks,
|
|
};
|
|
}
|
|
|
|
if (typeof input.content === 'string') {
|
|
return {
|
|
id: typeof input.id === 'string' ? input.id : undefined,
|
|
role: normalizeRole(input.role),
|
|
timestamp: normalizeTimestamp(input),
|
|
content: [{ type: 'text', text: input.content }],
|
|
};
|
|
}
|
|
|
|
const fallbackText = typeof input.text === 'string'
|
|
? input.text
|
|
: typeof input.message === 'string'
|
|
? input.message
|
|
: JSON.stringify(input);
|
|
return {
|
|
id: typeof input.id === 'string' ? input.id : undefined,
|
|
role: normalizeRole(input.role),
|
|
timestamp: normalizeTimestamp(input),
|
|
content: [{ type: 'text', text: fallbackText }],
|
|
};
|
|
}
|
|
|
|
function normalizeFilePart(part: Record<string, unknown>): { blocks: ContentBlock[]; attachments: AttachedFileMeta[] } {
|
|
const url = typeof part.url === 'string' ? part.url : undefined;
|
|
const mimeType = typeof part.mime === 'string' && part.mime.trim() ? part.mime : 'application/octet-stream';
|
|
const source = getRecord(part.source);
|
|
const filePath = source?.type === 'file' && typeof source.path === 'string' ? source.path : undefined;
|
|
const fileName = typeof part.filename === 'string' && part.filename.trim()
|
|
? part.filename
|
|
: filePath
|
|
? basenameFromPath(filePath)
|
|
: url
|
|
? basenameFromPath(url)
|
|
: 'file';
|
|
const attachments: AttachedFileMeta[] = [{
|
|
fileName,
|
|
mimeType,
|
|
fileSize: 0,
|
|
preview: null,
|
|
filePath,
|
|
source: 'message-ref',
|
|
}];
|
|
|
|
if (mimeType.trim().toLowerCase().startsWith('image/') && url) {
|
|
return {
|
|
blocks: [{
|
|
type: 'image',
|
|
id: typeof part.id === 'string' ? part.id : undefined,
|
|
url,
|
|
mimeType,
|
|
alt: fileName,
|
|
}],
|
|
attachments,
|
|
};
|
|
}
|
|
|
|
return { blocks: [], attachments };
|
|
}
|
|
|
|
function normalizePart(part: unknown): { blocks: ContentBlock[]; attachments: AttachedFileMeta[] } {
|
|
const record = getRecord(part);
|
|
if (!record || typeof record.type !== 'string') {
|
|
return { blocks: [], attachments: [] };
|
|
}
|
|
|
|
if (record.type === 'text' && typeof record.text === 'string') {
|
|
return {
|
|
blocks: [{
|
|
type: 'text',
|
|
id: typeof record.id === 'string' ? record.id : undefined,
|
|
text: record.text,
|
|
...(record.synthetic === true ? { synthetic: true as const } : {}),
|
|
}],
|
|
attachments: [],
|
|
};
|
|
}
|
|
|
|
if (record.type === 'reasoning' && typeof record.text === 'string') {
|
|
return {
|
|
blocks: [{
|
|
type: 'thinking',
|
|
id: typeof record.id === 'string' ? record.id : undefined,
|
|
thinking: record.text,
|
|
}],
|
|
attachments: [],
|
|
};
|
|
}
|
|
|
|
if (record.type === 'tool' && typeof record.tool === 'string') {
|
|
const state = getRecord(record.state);
|
|
return {
|
|
blocks: [{
|
|
type: 'tool_use',
|
|
id: typeof record.callID === 'string' ? record.callID : typeof record.id === 'string' ? record.id : undefined,
|
|
name: record.tool,
|
|
input: state?.input ?? {},
|
|
}],
|
|
attachments: [],
|
|
};
|
|
}
|
|
|
|
if (record.type === 'file') {
|
|
return normalizeFilePart(record);
|
|
}
|
|
|
|
return { blocks: [], attachments: [] };
|
|
}
|
|
|
|
const COMMAND_HISTORY_PART_ID = /^prt[A-Za-z0-9_-]+$/u;
|
|
const COMMAND_HISTORY_NAME = /^[^\s/][^\s]*$/u;
|
|
const COMMAND_HISTORY_CONTROL = /\p{Cc}/u;
|
|
|
|
interface CommandHistorySelection {
|
|
invocationBlock?: ContentBlock;
|
|
parts: unknown[];
|
|
}
|
|
|
|
function isSafeCommandHistoryContextPart(input: unknown): boolean {
|
|
const record = getRecord(input);
|
|
return record?.type === 'text' || record?.type === 'file';
|
|
}
|
|
|
|
function selectCommandHistoryParts(parts: unknown[]): CommandHistorySelection {
|
|
const markers = parts
|
|
.map(getRecord)
|
|
.filter((part) => part?.type === 'command-invocation');
|
|
if (markers.length === 0) return { parts };
|
|
|
|
const marker = markers.length === 1 ? markers[0] : null;
|
|
const command = typeof marker?.command === 'string' ? marker.command : '';
|
|
const args = typeof marker?.arguments === 'string' ? marker.arguments : '';
|
|
const ids = Array.isArray(marker?.contextPartIDs) ? marker.contextPartIDs : null;
|
|
const valid = Boolean(
|
|
marker
|
|
&& typeof marker.id === 'string'
|
|
&& COMMAND_HISTORY_PART_ID.test(marker.id)
|
|
&& command.length > 0
|
|
&& command.length <= 256
|
|
&& COMMAND_HISTORY_NAME.test(command)
|
|
&& !COMMAND_HISTORY_CONTROL.test(command)
|
|
&& typeof marker.arguments === 'string'
|
|
&& args.length <= 8_192
|
|
&& ids
|
|
&& ids.length <= 64
|
|
&& ids.every((id) => typeof id === 'string' && COMMAND_HISTORY_PART_ID.test(id)),
|
|
);
|
|
if (!valid) {
|
|
return {
|
|
invocationBlock: { type: 'text', text: '[Project command metadata unavailable]' },
|
|
parts: [],
|
|
};
|
|
}
|
|
|
|
const allowed = new Set(ids as string[]);
|
|
return {
|
|
invocationBlock: {
|
|
id: marker!.id as string,
|
|
type: 'text',
|
|
text: `/${command}${args ? ` ${args}` : ''}`,
|
|
},
|
|
parts: parts.filter((part) => {
|
|
const record = getRecord(part);
|
|
return typeof record?.id === 'string'
|
|
&& allowed.has(record.id)
|
|
&& isSafeCommandHistoryContextPart(record);
|
|
}),
|
|
};
|
|
}
|
|
|
|
const RUNTIME_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
|
|
function boundedRuntimeIdentifier(value: unknown, maxLength: number): string | undefined {
|
|
if (typeof value !== 'string') return undefined;
|
|
const candidate = value.trim();
|
|
return candidate.length > 0
|
|
&& candidate.length <= maxLength
|
|
&& RUNTIME_IDENTIFIER_PATTERN.test(candidate)
|
|
? candidate
|
|
: undefined;
|
|
}
|
|
|
|
function getRuntimeMessageContext(
|
|
role: RawMessage['role'],
|
|
info: Record<string, unknown>,
|
|
): RawMessage['_runtimeContext'] {
|
|
if (role !== 'user') return undefined;
|
|
const model = getRecord(info.model);
|
|
const agent = boundedRuntimeIdentifier(info.agent, 64);
|
|
const providerID = boundedRuntimeIdentifier(model?.providerID, 128);
|
|
const modelID = boundedRuntimeIdentifier(model?.modelID, 128);
|
|
const variant = boundedRuntimeIdentifier(model?.variant, 64);
|
|
const context = {
|
|
...(agent ? { agent } : {}),
|
|
...(providerID && modelID ? { model: `${providerID}/${modelID}` } : {}),
|
|
...(variant ? { variant } : {}),
|
|
};
|
|
return Object.keys(context).length > 0 ? context : undefined;
|
|
}
|
|
|
|
function mergeAttachments(
|
|
current: AttachedFileMeta[] | undefined,
|
|
next: AttachedFileMeta[],
|
|
): AttachedFileMeta[] | undefined {
|
|
if (next.length === 0) return current;
|
|
const combined = [...(current ?? [])];
|
|
const seen = new Set(combined.map((file) => `${file.filePath ?? file.fileName}:${file.mimeType}`));
|
|
for (const file of next) {
|
|
const key = `${file.filePath ?? file.fileName}:${file.mimeType}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
combined.push(file);
|
|
}
|
|
return combined;
|
|
}
|
|
|
|
function appendAttachments(
|
|
current: AttachedFileMeta[] | undefined,
|
|
next: AttachedFileMeta[],
|
|
): AttachedFileMeta[] | undefined {
|
|
if (next.length === 0) return current;
|
|
return [...(current ?? []), ...next];
|
|
}
|
|
|
|
function isCommandInvocationMarker(input: unknown): boolean {
|
|
return getRecord(input)?.type === 'command-invocation';
|
|
}
|
|
|
|
function hasDirectCommandInvocationMarker(input: unknown): boolean {
|
|
if (isCommandInvocationMarker(input)) return true;
|
|
if (Array.isArray(input)) return input.some(isCommandInvocationMarker);
|
|
const record = getRecord(input);
|
|
return record
|
|
? Object.values(record).some(isCommandInvocationMarker)
|
|
: false;
|
|
}
|
|
|
|
function hasLegacyCommandInvocationMarker(input: Record<string, unknown>): boolean {
|
|
if (isCommandInvocationMarker(input)) return true;
|
|
if (Array.isArray(input.content)) {
|
|
return input.content.some(isCommandInvocationMarker);
|
|
}
|
|
return isCommandInvocationMarker(input.content);
|
|
}
|
|
|
|
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
return Object.prototype.hasOwnProperty.call(record, key);
|
|
}
|
|
|
|
function commandHistoryUnavailableMessage(input: Record<string, unknown>): RawMessage {
|
|
const info = getRecord(input.info);
|
|
return {
|
|
id: typeof info?.id === 'string'
|
|
? info.id
|
|
: typeof input.id === 'string'
|
|
? input.id
|
|
: undefined,
|
|
role: normalizeRole(info?.role ?? input.role),
|
|
timestamp: normalizeTimestamp(info ?? input),
|
|
content: [{
|
|
type: 'text',
|
|
text: '[Project command metadata unavailable]',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function getStreamingDelta(record: Record<string, unknown> | null, part: Record<string, unknown>): string {
|
|
const rawDelta = record?.delta ?? part.delta;
|
|
if (typeof rawDelta === 'string') return rawDelta;
|
|
const delta = getRecord(rawDelta);
|
|
if (typeof delta?.text === 'string') return delta.text;
|
|
if (typeof delta?.thinking === 'string') return delta.thinking;
|
|
if (typeof delta?.reasoning === 'string') return delta.reasoning;
|
|
if (typeof delta?.reasoning_content === 'string') return delta.reasoning_content;
|
|
if (typeof delta?.reasoning_text === 'string') return delta.reasoning_text;
|
|
if (typeof part.reasoning === 'string') return part.reasoning;
|
|
if (typeof part.reasoning_content === 'string') return part.reasoning_content;
|
|
if (typeof part.reasoning_text === 'string') return part.reasoning_text;
|
|
return '';
|
|
}
|
|
|
|
function mergeStreamingText(existingText: string, incomingText: string, delta: string): string {
|
|
if (!delta) return incomingText;
|
|
if (incomingText && incomingText.startsWith(existingText) && incomingText.length > existingText.length) return incomingText;
|
|
return `${existingText}${delta}`;
|
|
}
|
|
|
|
function applyStreamingDeltaToBlock(
|
|
candidate: ContentBlock,
|
|
existingBlock: ContentBlock | undefined,
|
|
delta: string,
|
|
): ContentBlock {
|
|
if (!delta) return candidate;
|
|
|
|
if (candidate.type === 'text') {
|
|
const existingText = existingBlock?.type === 'text' && typeof existingBlock.text === 'string'
|
|
? existingBlock.text
|
|
: '';
|
|
return {
|
|
...candidate,
|
|
text: mergeStreamingText(existingText, candidate.text ?? '', delta),
|
|
};
|
|
}
|
|
|
|
if (candidate.type === 'thinking') {
|
|
const existingThinking = existingBlock?.type === 'thinking' && typeof existingBlock.thinking === 'string'
|
|
? existingBlock.thinking
|
|
: '';
|
|
return {
|
|
...candidate,
|
|
thinking: mergeStreamingText(existingThinking, candidate.thinking ?? '', delta),
|
|
};
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
function normalizeStructuredMessage(input: Record<string, unknown>): RawMessage | null {
|
|
const info = getRecord(input.info);
|
|
const parts = Array.isArray(input.parts) ? input.parts : null;
|
|
if (!info || !parts) return null;
|
|
const errorMessage = getErrorMessage(info.error ?? input.error ?? input.errorMessage ?? input.error_message);
|
|
const role = normalizeRole(info.role);
|
|
const commandHistory: CommandHistorySelection = role === 'user'
|
|
? selectCommandHistoryParts(parts)
|
|
: { parts };
|
|
|
|
const blocks: ContentBlock[] = [];
|
|
let attachedFiles: AttachedFileMeta[] | undefined;
|
|
let toolEvidence: RuntimeToolEvidence[] | undefined;
|
|
if (commandHistory.invocationBlock) blocks.push(commandHistory.invocationBlock);
|
|
|
|
for (const part of commandHistory.parts) {
|
|
const record = getRecord(part);
|
|
const normalized = normalizePart(part);
|
|
blocks.push(...normalized.blocks);
|
|
attachedFiles = appendAttachments(attachedFiles, normalized.attachments);
|
|
toolEvidence = mergeToolEvidence(toolEvidence, record ? normalizeToolEvidence(record) : null);
|
|
}
|
|
|
|
const finalErrorMessage = errorMessage
|
|
?? (role === 'assistant' && blocks.length === 0 && hasCompletedAssistantSignal(info, input, parts)
|
|
? EMPTY_ASSISTANT_RESPONSE_MESSAGE
|
|
: undefined);
|
|
const runtimeContext = getRuntimeMessageContext(role, info);
|
|
|
|
return {
|
|
id: typeof info.id === 'string' ? info.id : undefined,
|
|
role,
|
|
timestamp: normalizeTimestamp(info),
|
|
content: blocks.length > 0 || !finalErrorMessage
|
|
? blocks
|
|
: [{ type: 'text', text: finalErrorMessage }],
|
|
...(attachedFiles?.length ? { _attachedFiles: attachedFiles } : {}),
|
|
...(toolEvidence?.length ? { _toolEvidence: toolEvidence } : {}),
|
|
...(runtimeContext ? { _runtimeContext: runtimeContext } : {}),
|
|
...(finalErrorMessage ? { isError: true, errorMessage: finalErrorMessage } : {}),
|
|
};
|
|
}
|
|
|
|
export function normalizeOpencodeSessionMessages(input: unknown): RawMessage[] {
|
|
if (!Array.isArray(input)) return [];
|
|
return input.flatMap((entry) => {
|
|
const record = getRecord(entry);
|
|
if (!record) return [];
|
|
const structuredInfo = getRecord(record.info);
|
|
const structuredParts = Array.isArray(record.parts) ? record.parts : null;
|
|
const structuredLike = hasOwn(record, 'info') || hasOwn(record, 'parts');
|
|
if (structuredLike && (!structuredInfo || !structuredParts)) {
|
|
return hasDirectCommandInvocationMarker(record.parts)
|
|
|| hasLegacyCommandInvocationMarker(record)
|
|
? [commandHistoryUnavailableMessage(record)]
|
|
: [normalizeLegacyMessage(record)];
|
|
}
|
|
if (structuredInfo && structuredParts) {
|
|
const containsCommandMarker = structuredParts.some(
|
|
isCommandInvocationMarker,
|
|
);
|
|
if (
|
|
containsCommandMarker
|
|
&& normalizeRole(structuredInfo.role) !== 'user'
|
|
) {
|
|
return [commandHistoryUnavailableMessage(record)];
|
|
}
|
|
const structured = normalizeStructuredMessage(record);
|
|
return structured
|
|
? [structured]
|
|
: [commandHistoryUnavailableMessage(record)];
|
|
}
|
|
if (hasLegacyCommandInvocationMarker(record)) {
|
|
return [commandHistoryUnavailableMessage(record)];
|
|
}
|
|
return [normalizeLegacyMessage(record)];
|
|
});
|
|
}
|
|
|
|
export function createOptimisticUserMessage(
|
|
text: string,
|
|
timestamp = Date.now(),
|
|
deliveryStatus?: RawMessage['_deliveryStatus'],
|
|
attachedFiles?: AttachedFileMeta[],
|
|
): RawMessage {
|
|
return {
|
|
id: `local-user-${timestamp}`,
|
|
role: 'user',
|
|
timestamp,
|
|
content: [{ type: 'text', text }],
|
|
...(attachedFiles?.length ? { _attachedFiles: attachedFiles } : {}),
|
|
...(deliveryStatus ? { _deliveryStatus: deliveryStatus } : {}),
|
|
};
|
|
}
|
|
|
|
export function createStreamingMessageFromEvent(input: unknown): RawMessage | null {
|
|
const record = getRecord(input);
|
|
const info = getRecord(record?.info ?? input);
|
|
if (!info) return null;
|
|
return {
|
|
id: typeof info.id === 'string' ? info.id : undefined,
|
|
role: normalizeRole(info.role),
|
|
timestamp: normalizeTimestamp(info),
|
|
content: [],
|
|
};
|
|
}
|
|
|
|
export function getOpencodeEventSessionId(input: unknown): string | null {
|
|
const record = getRecord(input);
|
|
if (!record) return null;
|
|
if (typeof record.sessionID === 'string' && record.sessionID.trim()) return record.sessionID;
|
|
if (typeof record.sessionId === 'string' && record.sessionId.trim()) return record.sessionId;
|
|
return getOpencodeEventSessionId(record.part)
|
|
?? getOpencodeEventSessionId(record.info)
|
|
?? getOpencodeEventSessionId(record.message)
|
|
?? null;
|
|
}
|
|
|
|
export function applyStreamingPartToMessage(message: RawMessage | null, input: unknown): RawMessage | null {
|
|
const record = getRecord(input);
|
|
const part = getRecord(record?.part ?? input);
|
|
if (!part) return message;
|
|
|
|
const current = message ?? {
|
|
role: 'assistant',
|
|
content: [],
|
|
};
|
|
const nextContent = Array.isArray(current.content) ? [...current.content] : [];
|
|
const normalized = normalizePart(part);
|
|
const toolEvidence = normalizeToolEvidence(part);
|
|
const rawCandidate = normalized.blocks.at(0);
|
|
const delta = getStreamingDelta(record, part);
|
|
const messageId = typeof record?.messageID === 'string'
|
|
? record.messageID
|
|
: typeof record?.messageId === 'string'
|
|
? record.messageId
|
|
: typeof part.messageID === 'string'
|
|
? part.messageID
|
|
: undefined;
|
|
const blockId = rawCandidate?.id ?? (typeof part.id === 'string' ? part.id : undefined);
|
|
const index = blockId
|
|
? nextContent.findIndex((block) => ('id' in block ? block.id : undefined) === blockId)
|
|
: -1;
|
|
const existingBlock = index >= 0 ? nextContent[index] : undefined;
|
|
const candidate = rawCandidate
|
|
? applyStreamingDeltaToBlock(rawCandidate, existingBlock, delta)
|
|
: delta
|
|
? { type: 'text', id: blockId, text: delta } satisfies ContentBlock
|
|
: undefined;
|
|
if (candidate) {
|
|
const nextBlock = blockId ? { ...candidate, id: blockId } : candidate;
|
|
if (index >= 0) {
|
|
nextContent[index] = nextBlock;
|
|
} else {
|
|
nextContent.push(nextBlock);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
...(current.id || !messageId ? {} : { id: messageId }),
|
|
content: nextContent,
|
|
_attachedFiles: mergeAttachments(current._attachedFiles, normalized.attachments),
|
|
_toolEvidence: mergeToolEvidence(current._toolEvidence, toolEvidence),
|
|
};
|
|
}
|
|
|
|
export function applyStreamingPartDeltaToMessage(message: RawMessage | null, input: unknown): RawMessage | null {
|
|
const record = getRecord(input);
|
|
if (!record) return message;
|
|
|
|
const part = getRecord(record.part) ?? record;
|
|
const delta = getStreamingDelta(record, part);
|
|
if (!delta) return message;
|
|
|
|
const messageID = typeof record.messageID === 'string'
|
|
? record.messageID
|
|
: typeof record.messageId === 'string'
|
|
? record.messageId
|
|
: undefined;
|
|
const partID = typeof record.partID === 'string'
|
|
? record.partID
|
|
: typeof record.partId === 'string'
|
|
? record.partId
|
|
: typeof part?.id === 'string'
|
|
? part.id
|
|
: undefined;
|
|
const field = typeof record.field === 'string'
|
|
? record.field
|
|
: typeof part?.field === 'string'
|
|
? part.field
|
|
: undefined;
|
|
const isThinkingDelta = part?.type === 'reasoning'
|
|
|| part?.type === 'thinking'
|
|
|| field === 'reasoning'
|
|
|| field === 'thinking'
|
|
|| field === 'reasoning_content'
|
|
|| field === 'reasoning_text';
|
|
|
|
if (message?.id && messageID && message.id !== messageID) return message;
|
|
|
|
const current = message ?? {
|
|
id: messageID,
|
|
role: 'assistant',
|
|
content: [],
|
|
} satisfies RawMessage;
|
|
const nextContent = Array.isArray(current.content) ? [...current.content] : [];
|
|
const index = partID
|
|
? nextContent.findIndex((block) => ('id' in block ? block.id : undefined) === partID)
|
|
: -1;
|
|
const existingBlock = index >= 0 ? nextContent[index] : undefined;
|
|
|
|
if (existingBlock?.type === 'thinking' || isThinkingDelta) {
|
|
const existingThinking = existingBlock?.type === 'thinking'
|
|
? existingBlock.thinking ?? ''
|
|
: existingBlock?.type === 'text'
|
|
? existingBlock.text ?? ''
|
|
: '';
|
|
const nextBlock = {
|
|
type: 'thinking',
|
|
id: partID,
|
|
thinking: `${existingThinking}${delta}`,
|
|
} satisfies ContentBlock;
|
|
if (index >= 0) {
|
|
nextContent[index] = nextBlock;
|
|
} else {
|
|
nextContent.push(nextBlock);
|
|
}
|
|
} else if (existingBlock?.type === 'text') {
|
|
nextContent[index] = {
|
|
...existingBlock,
|
|
text: `${existingBlock.text ?? ''}${delta}`,
|
|
};
|
|
} else {
|
|
nextContent.push({
|
|
type: 'text',
|
|
id: partID,
|
|
text: delta,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
content: nextContent,
|
|
};
|
|
}
|
|
|
|
export function removeStreamingPartFromMessage(
|
|
message: RawMessage | null,
|
|
input: unknown,
|
|
): RawMessage | null {
|
|
if (!message || !input || typeof input !== 'object') return message;
|
|
const record = input as Record<string, unknown>;
|
|
const messageID = typeof record.messageID === 'string'
|
|
? record.messageID
|
|
: typeof record.messageId === 'string'
|
|
? record.messageId
|
|
: undefined;
|
|
if (message.id && messageID && message.id !== messageID) return message;
|
|
const part = record.part && typeof record.part === 'object' && !Array.isArray(record.part)
|
|
? record.part as Record<string, unknown>
|
|
: null;
|
|
const partID = typeof record.partID === 'string'
|
|
? record.partID
|
|
: typeof record.partId === 'string'
|
|
? record.partId
|
|
: typeof part?.id === 'string'
|
|
? part.id
|
|
: undefined;
|
|
if (!partID || !Array.isArray(message.content)) return message;
|
|
|
|
const content = message.content.filter((block) => (
|
|
!('id' in block) || block.id !== partID
|
|
));
|
|
const toolEvidence = message._toolEvidence?.filter((evidence) => (
|
|
evidence.id !== partID && evidence.toolCallId !== partID
|
|
));
|
|
return {
|
|
...message,
|
|
content,
|
|
...(toolEvidence?.length ? { _toolEvidence: toolEvidence } : { _toolEvidence: undefined }),
|
|
};
|
|
}
|
|
|
|
export function getStreamingToolStatus(input: unknown): ToolStatus | null {
|
|
const record = getRecord(input);
|
|
const part = getRecord(record?.part ?? input);
|
|
if (!part || part.type !== 'tool' || typeof part.tool !== 'string') return null;
|
|
|
|
const state = getRecord(part.state);
|
|
const time = getRecord(state?.time);
|
|
const startedAt = toMs(time?.start);
|
|
const endedAt = toMs(time?.end);
|
|
return {
|
|
id: typeof part.id === 'string' ? part.id : undefined,
|
|
toolCallId: typeof part.callID === 'string' ? part.callID : undefined,
|
|
name: part.tool,
|
|
status: normalizeToolStatus(state?.status),
|
|
durationMs: startedAt !== undefined && endedAt !== undefined ? Math.max(0, endedAt - startedAt) : undefined,
|
|
summary: typeof state?.title === 'string' && state.title.trim()
|
|
? state.title
|
|
: typeof state?.error === 'string' && state.error.trim()
|
|
? state.error
|
|
: undefined,
|
|
updatedAt: endedAt ?? startedAt ?? Date.now(),
|
|
};
|
|
}
|