1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
|
import type { ChatMessage, ToolCallChunk, ToolResultChunk } from "@dispatch/kernel";
export interface ReconcileReport {
readonly repairedCount: number;
readonly repairedToolCallIds: readonly string[];
/** Number of `error` chunks stripped from assistant messages. */
readonly strippedErrorChunks: number;
/** Number of assistant messages dropped after stripping left them empty. */
readonly droppedEmptyMessages: number;
}
export interface ReconcileResult {
readonly messages: ChatMessage[];
readonly report: ReconcileReport;
}
export function reconcileWithReport(messages: readonly ChatMessage[]): ReconcileResult {
// Phase 1: strip `error` chunks from assistant messages. An error chunk is a
// failed-generation marker, never valid provider content — removing it here
// (on load, before any provider sees the messages) auto-repairs broken chats
// with no DB surgery (append-only storage untouched).
let strippedErrorChunks = 0;
const stripped: ChatMessage[] = [];
for (const msg of messages) {
if (msg.role === "assistant" && msg.chunks.some((c) => c.type === "error")) {
const filtered = msg.chunks.filter((chunk) => {
if (chunk.type === "error") {
strippedErrorChunks++;
return false;
}
return true;
});
stripped.push({ role: msg.role, chunks: filtered });
} else {
stripped.push(msg);
}
}
// Phase 2: drop assistant messages left with neither `text` nor `tool-call`
// chunks after stripping (the now-empty error-only message). This is what
// unblocks continuation: such a message serializes to nothing a provider
// understands. Safe: it ends with no tool-call, so it is NEVER followed by a
// `tool` message — no "tool-without-preceding-assistant-tool_calls" 400.
let droppedEmptyMessages = 0;
const pruned: ChatMessage[] = [];
for (const msg of stripped) {
if (msg.role === "assistant") {
const hasContent = msg.chunks.some(
(chunk) => chunk.type === "text" || chunk.type === "tool-call" || chunk.type === "thinking",
);
if (!hasContent) {
droppedEmptyMessages++;
continue;
}
}
pruned.push(msg);
}
// Phase 3: orphaned-tool-call synthesis (unchanged) on what remains.
const resolvedIds = new Set<string>();
for (const msg of pruned) {
for (const chunk of msg.chunks) {
if (chunk.type === "tool-result") {
resolvedIds.add(chunk.toolCallId);
}
}
}
const orphaned: ToolCallChunk[] = [];
for (const msg of pruned) {
if (msg.role !== "assistant") continue;
for (const chunk of msg.chunks) {
if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) {
orphaned.push(chunk);
}
}
}
const result: ChatMessage[] = [...pruned];
for (const call of orphaned) {
const base = {
type: "tool-result" as const,
toolCallId: call.toolCallId,
toolName: call.toolName,
content: "interrupted: tool execution did not complete",
isError: true,
};
const synthesized: ToolResultChunk =
call.stepId !== undefined ? { ...base, stepId: call.stepId } : base;
result.push({ role: "tool", chunks: [synthesized] });
}
return {
messages: result,
report: {
repairedCount: orphaned.length,
repairedToolCallIds: orphaned.map((c) => c.toolCallId),
strippedErrorChunks,
droppedEmptyMessages,
},
};
}
export function reconcile(messages: readonly ChatMessage[]): ChatMessage[] {
return reconcileWithReport(messages).messages;
}
|