@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,13 @@
2
2
  // repairs them with synthetic "[no tool result recorded]" blocks.
3
3
  // Kept pure so tests can exercise the exact audit without activating Pi.
4
4
 
5
+ export interface RecoveredToolResult {
6
+ id: string;
7
+ assistantIndex: number;
8
+ sourceUserIndex: number;
9
+ targetUserIndex: number;
10
+ }
11
+
5
12
  export interface MissingToolResult {
6
13
  id: string;
7
14
  toolName: string;
@@ -27,6 +34,68 @@ function toolResultIds(content: unknown): Set<string> {
27
34
  return ids;
28
35
  }
29
36
 
37
+ /**
38
+ * Pi can split a parallel Claude tool batch into several visible turns when a
39
+ * steer is drained after the first tool result. The first assistant message
40
+ * still contains every tool_use, while later sibling results sit behind small
41
+ * duplicate assistant/tool-result pairs. Anthropic history requires every
42
+ * result immediately after the original batch, so copy those already-recorded
43
+ * later results into that first user result message before generic repair adds
44
+ * a false "[no tool result recorded]" placeholder.
45
+ *
46
+ * The later pair stays intact because Pi also recorded its duplicate tool_use;
47
+ * copying is therefore required to keep both assistant messages valid.
48
+ */
49
+ export function recoverLaterToolResults(
50
+ messages: Array<{ role?: string; content?: unknown }>,
51
+ ): RecoveredToolResult[] {
52
+ const recovered: RecoveredToolResult[] = [];
53
+ for (let i = 0; i < messages.length; i++) {
54
+ const assistant = messages[i];
55
+ if (assistant?.role !== "assistant") continue;
56
+ const uses = toolUses(assistant.content);
57
+ if (uses.length === 0) continue;
58
+
59
+ const target = messages[i + 1];
60
+ if (target?.role !== "user") continue;
61
+ const present = toolResultIds(target.content);
62
+ const missing = uses.filter((use) => !present.has(use.id));
63
+ if (missing.length === 0) continue;
64
+
65
+ for (const use of missing) {
66
+ let sourceBlock: Record<string, any> | undefined;
67
+ let sourceUserIndex = -1;
68
+ for (let j = i + 2; j < messages.length; j++) {
69
+ const candidate = messages[j];
70
+ if (candidate?.role !== "user") continue;
71
+ sourceBlock = contentBlocks(candidate.content).find(
72
+ (block) => block.type === "tool_result" && block.tool_use_id === use.id,
73
+ );
74
+ if (sourceBlock) {
75
+ sourceUserIndex = j;
76
+ break;
77
+ }
78
+ }
79
+ if (!sourceBlock) continue;
80
+
81
+ const targetBlocks = Array.isArray(target.content)
82
+ ? target.content as Array<Record<string, any>>
83
+ : typeof target.content === "string" && target.content
84
+ ? [{ type: "text", text: target.content }]
85
+ : [];
86
+ // tool_result blocks must lead the user message; insert the recovered
87
+ // result after the existing tool_results, never after trailing text.
88
+ let insertAt = 0;
89
+ while (insertAt < targetBlocks.length && targetBlocks[insertAt]?.type === "tool_result") insertAt++;
90
+ targetBlocks.splice(insertAt, 0, { ...sourceBlock });
91
+ target.content = targetBlocks;
92
+ present.add(use.id);
93
+ recovered.push({ id: use.id, assistantIndex: i, sourceUserIndex, targetUserIndex: i + 1 });
94
+ }
95
+ }
96
+ return recovered;
97
+ }
98
+
30
99
  /**
31
100
  * Anthropic history requires an assistant message containing tool_use blocks to
32
101
  * be followed by a user message containing matching tool_result blocks. Return
@@ -52,6 +121,54 @@ export function findUnpairedToolUses(messages: Array<{ role?: string; content?:
52
121
  return missing;
53
122
  }
54
123
 
124
+ export const LOST_TOOL_RESULT_TEXT =
125
+ "Claude bridge: the result of this tool call was lost before the session was rebuilt "
126
+ + "(the turn was interrupted). Treat the call as failed — it may or may not have executed. "
127
+ + "Re-run the tool if its output is still needed.";
128
+
129
+ /**
130
+ * Insert explicit, bridge-authored error results for every unpaired tool_use,
131
+ * IN PLACE, before cc-session-io's repairToolPairing runs.
132
+ *
133
+ * repairToolPairing backfills with a bare "[no tool result recorded]" — a
134
+ * placeholder the model reads as tool OUTPUT and keeps reasoning on (observed:
135
+ * two bash calls in the 2026-07-28 token test, silently treated as if they had
136
+ * returned). An is_error result that says what happened and what to do turns a
137
+ * silent correctness hazard into a recoverable failure.
138
+ *
139
+ * Results are prepended to the immediately following user message (tool_result
140
+ * blocks must lead a user message), or a new user message is inserted when none
141
+ * follows. `missing` must come from findUnpairedToolUses on the same array.
142
+ */
143
+ export function insertLostToolResultPlaceholders(
144
+ messages: Array<{ role?: string; content?: unknown }>,
145
+ missing: MissingToolResult[],
146
+ ): void {
147
+ const block = (id: string) => ({ type: "tool_result", tool_use_id: id, content: LOST_TOOL_RESULT_TEXT, is_error: true });
148
+ const byAssistant = new Map<number, MissingToolResult[]>();
149
+ for (const item of missing) {
150
+ const group = byAssistant.get(item.assistantIndex) ?? [];
151
+ group.push(item);
152
+ byAssistant.set(item.assistantIndex, group);
153
+ }
154
+ // Descending order so inserting a new user message never shifts an index a
155
+ // later (earlier-in-array) group still needs.
156
+ for (const assistantIndex of [...byAssistant.keys()].sort((a, b) => b - a)) {
157
+ const group = byAssistant.get(assistantIndex)!;
158
+ const blocks = group.map((item) => block(item.id));
159
+ const userIndex = group[0].userIndex;
160
+ if (userIndex != null && messages[userIndex]?.role === "user") {
161
+ const user = messages[userIndex] as { role: string; content: unknown };
162
+ const existing = typeof user.content === "string"
163
+ ? (user.content ? [{ type: "text", text: user.content }] : [])
164
+ : Array.isArray(user.content) ? user.content : [];
165
+ user.content = [...blocks, ...existing];
166
+ } else {
167
+ messages.splice(assistantIndex + 1, 0, { role: "user", content: blocks });
168
+ }
169
+ }
170
+ }
171
+
55
172
  export function summarizeMissingToolNames(missing: MissingToolResult[]): Array<{ name: string; count: number }> {
56
173
  const counts = new Map<string, number>();
57
174
  for (const item of missing) counts.set(item.toolName, (counts.get(item.toolName) ?? 0) + 1);
@@ -28,9 +28,15 @@ export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTyp
28
28
  }
29
29
  case "object": {
30
30
  if (prop.properties && typeof prop.properties === "object" && !Array.isArray(prop.properties)) {
31
- base = z.object(jsonSchemaToZodShape(prop));
32
- if (prop.additionalProperties === false) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).strict();
33
- else if (prop.additionalProperties === true) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).passthrough();
31
+ const obj = z.object(jsonSchemaToZodShape(prop));
32
+ // JSON Schema's default is PERMISSIVE (additionalProperties omitted
33
+ // means allowed); zod's default is to silently STRIP unknown keys.
34
+ // Stripping matters here: the MCP handler compares its validated
35
+ // input against the raw streamed tool_use input to claim a call id,
36
+ // so a silently dropped key made the two diverge and the claim fail
37
+ // (stranding the call — see claimToolCall). Only an explicit
38
+ // additionalProperties:false may reject/strip.
39
+ base = prop.additionalProperties === false ? obj.strict() : obj.passthrough();
34
40
  } else {
35
41
  base = z.record(z.string(), z.unknown());
36
42
  }