@vanillagreen/pi-claude-bridge 1.2.0 → 1.4.0

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.
package/src/models.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `resolveModelId` returns the first partial match, so `opus` resolves to the first-listed opus entry.
3
3
  // Extracted from index.ts so tests can import without activating the extension.
4
4
 
5
- export const MODEL_IDS_IN_ORDER = ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
5
+ export const MODEL_IDS_IN_ORDER = ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
6
6
 
7
7
  // Project pi-ai's model entries down to the fields pi's registerProvider expects,
8
8
  // and keep MODEL_IDS_IN_ORDER ordering. IDs missing from pi-ai are silently dropped.
@@ -14,6 +14,73 @@ export interface PendingToolCall {
14
14
  resolve: (result: McpResult) => void;
15
15
  }
16
16
 
17
+ export interface TurnToolCallRecord {
18
+ id: string;
19
+ toolName: string;
20
+ arguments: Record<string, unknown>;
21
+ }
22
+
23
+ export interface ClaimedToolCall {
24
+ toolCallId?: string;
25
+ match: "tool-args" | "tool-name" | "none";
26
+ ambiguous: boolean;
27
+ available: number;
28
+ }
29
+
30
+ export interface ToolResultProgress {
31
+ expectedIds: string[];
32
+ deliveredIds: string[];
33
+ resolvedIds: string[];
34
+ waitingIds: string[];
35
+ queuedIds: string[];
36
+ unmatchedResultIds: string[];
37
+ missingDeliveredIds: string[];
38
+ unresolvedIds: string[];
39
+ toolNames: Array<{ name: string; count: number }>;
40
+ expectedCount: number;
41
+ deliveredCount: number;
42
+ resolvedCount: number;
43
+ waitingCount: number;
44
+ queuedCount: number;
45
+ unmatchedResultCount: number;
46
+ }
47
+
48
+ function normalizeForCompare(value: unknown): unknown {
49
+ if (Array.isArray(value)) return value.map(normalizeForCompare);
50
+ if (value && typeof value === "object") {
51
+ const out: Record<string, unknown> = {};
52
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
53
+ const child = (value as Record<string, unknown>)[key];
54
+ if (child !== undefined) out[key] = normalizeForCompare(child);
55
+ }
56
+ return out;
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function argsKey(value: unknown): string {
62
+ return JSON.stringify(normalizeForCompare(value ?? {}));
63
+ }
64
+
65
+ function sameArgs(left: unknown, right: unknown): boolean {
66
+ return argsKey(left) === argsKey(right);
67
+ }
68
+
69
+ function hasRecordedArgs(args: Record<string, unknown> | undefined): boolean {
70
+ return Object.keys(args ?? {}).length > 0;
71
+ }
72
+
73
+ function unique(values: Iterable<string | undefined>): string[] {
74
+ const out: string[] = [];
75
+ const seen = new Set<string>();
76
+ for (const value of values) {
77
+ if (!value || seen.has(value)) continue;
78
+ seen.add(value);
79
+ out.push(value);
80
+ }
81
+ return out;
82
+ }
83
+
17
84
  export class QueryContext {
18
85
  // Query-scoped (fully isolated per query)
19
86
  activeQuery: unknown | null = null;
@@ -22,8 +89,14 @@ export class QueryContext {
22
89
  pendingToolCalls = new Map<string, PendingToolCall>();
23
90
  pendingResults = new Map<string, McpResult>();
24
91
  turnToolCallIds: string[] = [];
25
- nextHandlerIdx = 0;
92
+ turnToolCalls: TurnToolCallRecord[] = [];
93
+ claimedToolCallIds = new Set<string>();
94
+ deliveredToolResultIds = new Set<string>();
95
+ resolvedToolResultIds = new Set<string>();
96
+ unmatchedToolResultIds = new Set<string>();
97
+ reportedToolResultMismatch = false;
26
98
  deferredUserMessages: string[] = [];
99
+ handledTerminalError = false;
27
100
 
28
101
  // Per-turn (reset together)
29
102
  turnOutput: AssistantMessage | null = null;
@@ -47,8 +120,119 @@ export class QueryContext {
47
120
  this.turnStarted = false;
48
121
  this.turnSawStreamEvent = false;
49
122
  this.turnSawToolCall = false;
50
- // turnToolCallIds and nextHandlerIdx are NOT reset — they persist across
51
- // tool-result delivery callbacks within the same assistant message.
123
+ this.handledTerminalError = false;
124
+ // Tool-call tracking is NOT reset here it persists across the
125
+ // tool-result delivery callback for the same assistant message. New
126
+ // assistant messages call resetToolTracking() explicitly.
127
+ }
128
+
129
+ resetToolTracking(): void {
130
+ this.turnToolCallIds = [];
131
+ this.turnToolCalls = [];
132
+ this.claimedToolCallIds.clear();
133
+ this.deliveredToolResultIds.clear();
134
+ this.resolvedToolResultIds.clear();
135
+ this.unmatchedToolResultIds.clear();
136
+ this.reportedToolResultMismatch = false;
137
+ }
138
+
139
+ recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
140
+ if (!id) return;
141
+ if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
142
+ const existing = this.turnToolCalls.find((call) => call.id === id);
143
+ if (existing) {
144
+ existing.toolName = toolName;
145
+ existing.arguments = args;
146
+ return;
147
+ }
148
+ this.turnToolCalls.push({ id, toolName, arguments: args });
149
+ }
150
+
151
+ updateToolCallArgs(id: string | undefined, args: Record<string, unknown>): void {
152
+ if (!id) return;
153
+ const existing = this.turnToolCalls.find((call) => call.id === id);
154
+ if (existing) existing.arguments = args;
155
+ }
156
+
157
+ hasRecordedToolCall(id: string | undefined): boolean {
158
+ return Boolean(id && (this.turnToolCallIds.includes(id) || this.turnToolCalls.some((call) => call.id === id)));
159
+ }
160
+
161
+ claimToolCall(toolName: string, args: Record<string, unknown> = {}): ClaimedToolCall {
162
+ const unclaimed = this.turnToolCalls.filter((call) => !this.claimedToolCallIds.has(call.id));
163
+ const byName = unclaimed.filter((call) => call.toolName === toolName);
164
+ const exact = byName.filter((call) => sameArgs(call.arguments, args));
165
+ let chosen: TurnToolCallRecord | undefined;
166
+ let match: ClaimedToolCall["match"] = "none";
167
+ let ambiguous = false;
168
+
169
+ if (exact.length > 0) {
170
+ chosen = exact[0];
171
+ match = "tool-args";
172
+ ambiguous = exact.length > 1;
173
+ } else if (byName.length === 1 && !hasRecordedArgs(byName[0].arguments)) {
174
+ // The SDK can invoke the MCP handler after content_block_start but
175
+ // before input_json_delta/content_block_stop finalizes arguments.
176
+ // Falling back to the sole same-name, argument-less call preserves that
177
+ // race without ever claiming a different tool type.
178
+ chosen = byName[0];
179
+ match = "tool-name";
180
+ }
181
+
182
+ if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
183
+ this.claimedToolCallIds.add(chosen.id);
184
+ return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length };
185
+ }
186
+
187
+ markToolResultDelivered(id: string | undefined): void {
188
+ if (id) this.deliveredToolResultIds.add(id);
189
+ }
190
+
191
+ markToolResultResolved(id: string | undefined): void {
192
+ if (id) this.resolvedToolResultIds.add(id);
193
+ }
194
+
195
+ markToolResultUnmatched(id: string | undefined): void {
196
+ if (id) this.unmatchedToolResultIds.add(id);
197
+ }
198
+
199
+ toolResultProgress(): ToolResultProgress {
200
+ const expectedIds = unique([
201
+ ...this.turnToolCalls.map((call) => call.id),
202
+ ...this.turnToolCallIds,
203
+ ]);
204
+ const deliveredIds = unique(this.deliveredToolResultIds);
205
+ const resolvedIds = unique(this.resolvedToolResultIds);
206
+ const waitingIds = unique(this.pendingToolCalls.keys());
207
+ const queuedIds = unique(this.pendingResults.keys());
208
+ const unmatchedResultIds = unique(this.unmatchedToolResultIds);
209
+ const missingDeliveredIds = expectedIds.filter((id) => !this.deliveredToolResultIds.has(id));
210
+ const unresolvedIds = expectedIds.filter((id) => !this.resolvedToolResultIds.has(id));
211
+ const affectedIds = new Set([...missingDeliveredIds, ...unresolvedIds, ...waitingIds, ...queuedIds, ...unmatchedResultIds]);
212
+ const counts = new Map<string, number>();
213
+ for (const call of this.turnToolCalls) {
214
+ if (affectedIds.size > 0 && !affectedIds.has(call.id)) continue;
215
+ counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
216
+ }
217
+ return {
218
+ expectedIds,
219
+ deliveredIds,
220
+ resolvedIds,
221
+ waitingIds,
222
+ queuedIds,
223
+ unmatchedResultIds,
224
+ missingDeliveredIds,
225
+ unresolvedIds,
226
+ toolNames: [...counts.entries()]
227
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
228
+ .map(([name, count]) => ({ name, count })),
229
+ expectedCount: expectedIds.length,
230
+ deliveredCount: deliveredIds.length,
231
+ resolvedCount: resolvedIds.length,
232
+ waitingCount: waitingIds.length,
233
+ queuedCount: queuedIds.length,
234
+ unmatchedResultCount: unmatchedResultIds.length,
235
+ };
52
236
  }
53
237
  }
54
238
 
@@ -0,0 +1,61 @@
1
+ // Detect missing assistant tool_use ↔ user tool_result pairs before cc-session-io
2
+ // repairs them with synthetic "[no tool result recorded]" blocks.
3
+ // Kept pure so tests can exercise the exact audit without activating Pi.
4
+
5
+ export interface MissingToolResult {
6
+ id: string;
7
+ toolName: string;
8
+ assistantIndex: number;
9
+ userIndex: number | null;
10
+ }
11
+
12
+ function contentBlocks(content: unknown): Array<Record<string, any>> {
13
+ return Array.isArray(content) ? content.filter((block): block is Record<string, any> => Boolean(block && typeof block === "object")) : [];
14
+ }
15
+
16
+ function toolUses(content: unknown): Array<{ id: string; name: string }> {
17
+ return contentBlocks(content)
18
+ .filter((block) => block.type === "tool_use" && typeof block.id === "string")
19
+ .map((block) => ({ id: block.id, name: typeof block.name === "string" && block.name ? block.name : "unknown" }));
20
+ }
21
+
22
+ function toolResultIds(content: unknown): Set<string> {
23
+ const ids = new Set<string>();
24
+ for (const block of contentBlocks(content)) {
25
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") ids.add(block.tool_use_id);
26
+ }
27
+ return ids;
28
+ }
29
+
30
+ /**
31
+ * Anthropic history requires an assistant message containing tool_use blocks to
32
+ * be followed by a user message containing matching tool_result blocks. Return
33
+ * every tool_use that would force repairToolPairing to synthesize a result.
34
+ */
35
+ export function findUnpairedToolUses(messages: Array<{ role?: string; content?: unknown }>): MissingToolResult[] {
36
+ const missing: MissingToolResult[] = [];
37
+ for (let i = 0; i < messages.length; i++) {
38
+ const msg = messages[i];
39
+ if (msg?.role !== "assistant") continue;
40
+ const uses = toolUses(msg.content);
41
+ if (uses.length === 0) continue;
42
+
43
+ const next = messages[i + 1];
44
+ const nextUserIndex = next?.role === "user" ? i + 1 : null;
45
+ const resultIds = nextUserIndex == null ? new Set<string>() : toolResultIds(next.content);
46
+ for (const use of uses) {
47
+ if (!resultIds.has(use.id)) {
48
+ missing.push({ id: use.id, toolName: use.name, assistantIndex: i, userIndex: nextUserIndex });
49
+ }
50
+ }
51
+ }
52
+ return missing;
53
+ }
54
+
55
+ export function summarizeMissingToolNames(missing: MissingToolResult[]): Array<{ name: string; count: number }> {
56
+ const counts = new Map<string, number>();
57
+ for (const item of missing) counts.set(item.toolName, (counts.get(item.toolName) ?? 0) + 1);
58
+ return [...counts.entries()]
59
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
60
+ .map(([name, count]) => ({ name, count }));
61
+ }