summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src/reconcile.ts
blob: 2d2b68dd3c5016bf3a0ff3e23d051f93dcc7f782 (plain)
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;
}