@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/README.md +25 -2
- package/bundle/index.js +7304 -6333
- package/package.json +38 -3
- package/src/config.ts +67 -1
- package/src/convert.ts +77 -20
- package/src/index.ts +590 -56
- package/src/models.ts +1 -1
- package/src/query-state.ts +187 -3
- package/src/tool-pairing-audit.ts +61 -0
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.
|
package/src/query-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
//
|
|
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
|
+
}
|