pi-codebuddy-sdk 0.1.18 → 0.3.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 ADDED
@@ -0,0 +1,72 @@
1
+ // Dynamic model list from CodeBuddy SDK supportedModels().
2
+
3
+ import type { ModelInfo } from "@tencent-ai/agent-sdk";
4
+
5
+ export type PiModel = {
6
+ id: string;
7
+ name: string;
8
+ reasoning: boolean;
9
+ input: ("text" | "image")[];
10
+ contextWindow: number;
11
+ maxTokens: number;
12
+ thinkingLevelMap?: Record<string, string>;
13
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
14
+ };
15
+
16
+ const DEFAULT_CONTEXT = 131_072;
17
+ const DEFAULT_MAX_TOKENS = 8192;
18
+
19
+ function detectThinking(id: string): boolean {
20
+ return /claude|gemini|gpt-5|hy3|deepseek|glm/i.test(id);
21
+ }
22
+
23
+ function detectImages(id: string): boolean {
24
+ return /claude|gemini|gpt/i.test(id);
25
+ }
26
+
27
+ function estimateContext(id: string): number {
28
+ const lower = id.toLowerCase();
29
+ if (lower.includes("gemini")) return 1_048_576;
30
+ if (lower.includes("claude") || lower.includes("gpt")) return 200_000;
31
+ return DEFAULT_CONTEXT;
32
+ }
33
+
34
+ function estimateMaxTokens(id: string): number {
35
+ if (id.toLowerCase().includes("gpt")) return 16_384;
36
+ return DEFAULT_MAX_TOKENS;
37
+ }
38
+
39
+ export function rawModelsFromSdk(supported: Array<ModelInfo & { id?: string; name?: string }>): PiModel[] {
40
+ return supported
41
+ .map((m) => ({ id: m.id ?? m.value, name: m.name ?? m.displayName ?? m.id ?? m.value }))
42
+ .filter((m) => m.id)
43
+ .map((m) => ({
44
+ id: m.id!,
45
+ name: m.name || m.id!,
46
+ reasoning: detectThinking(m.id!),
47
+ input: detectImages(m.id!) ? ["text", "image"] as const : ["text"] as const,
48
+ contextWindow: estimateContext(m.id!),
49
+ maxTokens: estimateMaxTokens(m.id!),
50
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
51
+ }));
52
+ }
53
+
54
+ export const FALLBACK_MODELS: PiModel[] = [
55
+ { id: "hy3-preview-agent-ioa", name: "Hunyuan 3 Preview", reasoning: true, input: ["text"], contextWindow: DEFAULT_CONTEXT, maxTokens: DEFAULT_MAX_TOKENS, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
56
+ ];
57
+
58
+ export function buildModels(models: PiModel[]): PiModel[] {
59
+ return models.map((m) => ({
60
+ ...m,
61
+ cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
62
+ }));
63
+ }
64
+
65
+ export function codebuddyModelId(model: { id: string }): string {
66
+ return model.id;
67
+ }
68
+
69
+ export function resolveModel<T extends { id: string }>(models: T[], input: string): T | undefined {
70
+ const lower = input.toLowerCase();
71
+ return models.find((m) => m.id === lower || m.id.toLowerCase().includes(lower));
72
+ }
@@ -0,0 +1,80 @@
1
+ // Query state: QueryContext class + context stack.
2
+ //
3
+ // All per-query and per-turn mutable state lives here. Reentrant queries
4
+ // (subagents) push the parent context onto a stack and get a fresh instance.
5
+ // Adding a new field = one property on the class.
6
+ //
7
+ // Extracted from index.ts so tests can import without activating the extension.
8
+
9
+ import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
10
+ import type { McpResult } from "./extract-tool-results.js";
11
+
12
+ export interface PendingToolCall {
13
+ toolName: string;
14
+ resolve: (result: McpResult) => void;
15
+ }
16
+
17
+ export class QueryContext {
18
+ // Query-scoped (fully isolated per query)
19
+ activeQuery: unknown | null = null;
20
+ currentPiStream: AssistantMessageEventStream | null = null;
21
+ latestCursor = 0;
22
+ pendingToolCalls = new Map<string, PendingToolCall>();
23
+ pendingResults = new Map<string, McpResult>();
24
+ turnToolCallIds: string[] = [];
25
+ nextHandlerIdx = 0;
26
+ deferredUserMessages: string[] = [];
27
+
28
+ // Per-turn (reset together)
29
+ turnOutput: AssistantMessage | null = null;
30
+ turnStarted = false;
31
+ turnSawStreamEvent = false;
32
+ turnSawToolCall = false;
33
+
34
+ get turnBlocks(): Array<any> {
35
+ if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
36
+ return this.turnOutput.content;
37
+ }
38
+
39
+ resetTurnState(model: Model<any>): void {
40
+ this.turnOutput = {
41
+ role: "assistant", content: [],
42
+ api: model.api, provider: model.provider, model: model.id,
43
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
44
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
45
+ stopReason: "stop", timestamp: Date.now(),
46
+ };
47
+ this.turnStarted = false;
48
+ this.turnSawStreamEvent = false;
49
+ this.turnSawToolCall = false;
50
+ // turnToolCallIds and nextHandlerIdx are NOT reset — they persist across
51
+ // tool-result delivery callbacks within the same assistant message.
52
+ }
53
+ }
54
+
55
+ let _ctx = new QueryContext();
56
+ const contextStack: QueryContext[] = [];
57
+
58
+ export function ctx(): QueryContext { return _ctx; }
59
+
60
+ export function stackDepth(): number { return contextStack.length; }
61
+
62
+ export function pushContext(): void {
63
+ if (!_ctx.activeQuery) throw new Error("pushContext() called with no active query");
64
+ contextStack.push(_ctx);
65
+ _ctx = new QueryContext();
66
+ }
67
+
68
+ export function popContext(): void {
69
+ if (contextStack.length === 0) throw new Error("popContext() called with empty stack");
70
+ const parent = contextStack[contextStack.length - 1];
71
+ parent.deferredUserMessages.push(..._ctx.deferredUserMessages);
72
+ _ctx = contextStack.pop()!;
73
+ }
74
+
75
+ // Test-only: drop all state so test files can start from a clean module.
76
+ // Not called from production.
77
+ export function resetStack(): void {
78
+ _ctx = new QueryContext();
79
+ contextStack.length = 0;
80
+ }
@@ -0,0 +1,17 @@
1
+ // Serialize CodeBuddy SDK query() subprocesses — only one CLI at a time.
2
+
3
+ let chain: Promise<void> = Promise.resolve();
4
+
5
+ export function withSdkGate<T>(fn: () => Promise<T>): Promise<T> {
6
+ const run = chain.then(fn);
7
+ chain = run.then(() => undefined, () => undefined);
8
+ return run;
9
+ }
10
+
11
+ export async function drainQuery(q: AsyncIterable<unknown>): Promise<void> {
12
+ const timer = new Promise<void>((r) => setTimeout(r, 5000));
13
+ await Promise.race([
14
+ (async () => { for await (const _ of q) { /* drain */ } })(),
15
+ timer,
16
+ ]);
17
+ }
@@ -0,0 +1,38 @@
1
+ // Pure session-file integrity check. Returns an array of warning strings;
2
+ // callers decide how to surface them (debug log, piUI, diagDump, etc.).
3
+ // Extracted from index.ts so tests can import without activating the extension.
4
+
5
+ import { statSync, readFileSync } from "fs";
6
+
7
+ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
8
+ const warnings = [];
9
+ let st;
10
+ try {
11
+ st = statSync(jsonlPath);
12
+ } catch (e) {
13
+ warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
14
+ return warnings;
15
+ }
16
+ let content;
17
+ try {
18
+ content = readFileSync(jsonlPath, "utf8");
19
+ } catch (e) {
20
+ warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
21
+ return warnings;
22
+ }
23
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
24
+ if (lines.length !== expectedRecordCount) {
25
+ warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
26
+ return warnings;
27
+ }
28
+ try {
29
+ const firstRec = JSON.parse(lines[0]);
30
+ const lastRec = JSON.parse(lines[lines.length - 1]);
31
+ if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
32
+ warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
33
+ }
34
+ } catch (e) {
35
+ warnings.push(`malformed JSONL — path=${jsonlPath} err=${e.message}`);
36
+ }
37
+ return warnings;
38
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Skills block extraction + MCP naming constants.
2
+ // Extracted from index.ts so tests can import without activating the extension.
3
+
4
+ import { extractAgentsAppend } from "./agents-md.js";
5
+
6
+ export const MCP_SERVER_NAME = "custom_tools";
7
+ export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
8
+
9
+ const SKILLS_START_MARKER = "The following skills provide specialized instructions for specific tasks.";
10
+ const SKILLS_END_MARKER = "</available_skills>";
11
+
12
+ // Extract skills block from pi's system prompt for forwarding to CodeBuddy.
13
+ export function extractSkillsBlock(systemPrompt?: string): string | undefined {
14
+ if (!systemPrompt) return undefined;
15
+ const startMarker = SKILLS_START_MARKER;
16
+ const endMarker = SKILLS_END_MARKER;
17
+ const start = systemPrompt.indexOf(startMarker);
18
+ if (start === -1) return undefined;
19
+ const end = systemPrompt.indexOf(endMarker, start);
20
+ if (end === -1) return undefined;
21
+ return rewriteSkillsBlock(systemPrompt.slice(start, end + endMarker.length).trim());
22
+ }
23
+
24
+ export function rewriteSkillsBlock(skillsBlock: string): string {
25
+ return skillsBlock.replace(
26
+ "Use the read tool to load a skill's file",
27
+ `Use the read tool (mcp__${MCP_SERVER_NAME}__read) to load a skill's file`,
28
+ );
29
+ }
30
+
31
+ function stripSkillsBlock(systemPrompt: string): string {
32
+ const start = systemPrompt.indexOf(SKILLS_START_MARKER);
33
+ if (start === -1) return systemPrompt;
34
+ const end = systemPrompt.indexOf(SKILLS_END_MARKER, start);
35
+ if (end === -1) return systemPrompt;
36
+ return (systemPrompt.slice(0, start) + systemPrompt.slice(end + SKILLS_END_MARKER.length))
37
+ .replace(/\n{3,}/g, "\n\n")
38
+ .trim();
39
+ }
40
+
41
+ function applySkillsRewrite(systemPrompt: string): string {
42
+ const rewritten = extractSkillsBlock(systemPrompt);
43
+ if (!rewritten) return systemPrompt;
44
+ const start = systemPrompt.indexOf(SKILLS_START_MARKER);
45
+ const end = systemPrompt.indexOf(SKILLS_END_MARKER, start);
46
+ if (start === -1 || end === -1) return systemPrompt;
47
+ return systemPrompt.slice(0, start) + rewritten + systemPrompt.slice(end + SKILLS_END_MARKER.length);
48
+ }
49
+
50
+ /** Pi system prompt as CodeBuddy override (replaces default "CodeBuddy Code" identity). */
51
+ export function buildCodebuddySystemPrompt(
52
+ piSystemPrompt: string | undefined,
53
+ options?: { includeAgents?: boolean; includeSkills?: boolean },
54
+ ): string | undefined {
55
+ const parts: string[] = [];
56
+
57
+ if (piSystemPrompt) {
58
+ let prompt = piSystemPrompt;
59
+ if (options?.includeSkills === false) {
60
+ prompt = stripSkillsBlock(prompt);
61
+ } else {
62
+ prompt = applySkillsRewrite(prompt);
63
+ }
64
+ if (prompt) parts.push(prompt);
65
+ }
66
+
67
+ if (options?.includeAgents !== false) {
68
+ const agents = extractAgentsAppend();
69
+ if (agents) parts.push(agents);
70
+ }
71
+
72
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
73
+ }
@@ -0,0 +1,42 @@
1
+ // TypeBox (JSON Schema) → Zod conversion used by buildMcpServers.
2
+ //
3
+ // Pi tools declare their parameters as TypeBox objects (i.e. JSON Schema at
4
+ // runtime). The Agent SDK's createSdkMcpServer requires Zod — its internal
5
+ // `Z0()` detects Zod via the `~standard` marker or `_def`/`_zod` properties
6
+ // and silently downgrades unrecognized schemas to
7
+ // `{type: "object", properties: {}}`, which leaves the model with no
8
+ // parameter info. This module bridges the two so MCP-exposed pi tools retain
9
+ // their schemas. If this breaks after an SDK update, check whether `Z0()`
10
+ // detection changed or createSdkMcpServer now accepts raw JSON Schema.
11
+
12
+ import { z } from "zod";
13
+
14
+ export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
15
+ let base: z.ZodTypeAny;
16
+ if (Array.isArray(prop.enum)) base = z.enum(prop.enum as [string, ...string[]]);
17
+ else switch (prop.type) {
18
+ case "string": base = z.string(); break;
19
+ case "number": case "integer": base = z.number(); break;
20
+ case "boolean": base = z.boolean(); break;
21
+ case "array": base = prop.items
22
+ ? z.array(jsonSchemaPropertyToZod(prop.items as Record<string, unknown>))
23
+ : z.array(z.unknown()); break;
24
+ case "object": base = z.record(z.string(), z.unknown()); break;
25
+ default: base = z.unknown();
26
+ }
27
+ if (typeof prop.description === "string") base = base.describe(prop.description);
28
+ return base;
29
+ }
30
+
31
+ export function jsonSchemaToZodShape(schema: unknown): Record<string, z.ZodTypeAny> {
32
+ const s = schema as Record<string, unknown>;
33
+ if (!s || s.type !== "object" || !s.properties) return {};
34
+ const props = s.properties as Record<string, Record<string, unknown>>;
35
+ const required = new Set(Array.isArray(s.required) ? s.required as string[] : []);
36
+ const shape: Record<string, z.ZodTypeAny> = {};
37
+ for (const [key, prop] of Object.entries(props)) {
38
+ const zodProp = jsonSchemaPropertyToZod(prop);
39
+ shape[key] = required.has(key) ? zodProp : zodProp.optional();
40
+ }
41
+ return shape;
42
+ }
package/src/provider.ts DELETED
@@ -1,289 +0,0 @@
1
- /**
2
- * Provider entry point — pi-claude-bridge pattern.
3
- *
4
- * For each Pi turn:
5
- * 1. Extract the latest user message
6
- * 2. Call sdk.query() — stateless, CodeBuddy uses its own system prompt
7
- * 3. Stream events back to Pi (text, thinking, toolcalls)
8
- */
9
- import {
10
- createAssistantMessageEventStream,
11
- type AssistantMessage,
12
- type AssistantMessageEventStream,
13
- type Context,
14
- type Model,
15
- type SimpleStreamOptions,
16
- type TextContent,
17
- type ThinkingContent,
18
- type ToolCall,
19
- } from "@earendil-works/pi-ai/compat";
20
- import type {
21
- Effort,
22
- Message as CbMessage,
23
- ThinkingConfig,
24
- } from "@tencent-ai/agent-sdk";
25
-
26
- type CodebuddySdk = typeof import("@tencent-ai/agent-sdk");
27
-
28
- async function loadSdk(): Promise<CodebuddySdk> {
29
- return import("@tencent-ai/agent-sdk");
30
- }
31
-
32
- // ── helpers ──
33
-
34
- function makePartial(model: Model<any>): AssistantMessage {
35
- return {
36
- role: "assistant",
37
- content: [],
38
- api: model.api,
39
- provider: model.provider,
40
- model: model.id,
41
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
42
- stopReason: "stop",
43
- timestamp: Date.now(),
44
- };
45
- }
46
-
47
- function makeErrorPartial(model: Model<any>, error: unknown): AssistantMessage {
48
- return {
49
- ...makePartial(model),
50
- stopReason: "error",
51
- errorMessage: error instanceof Error ? error.message : String(error),
52
- };
53
- }
54
-
55
- // ── extract user message ──
56
-
57
- function extractUserMessage(context: Context): string {
58
- const msgs = context.messages;
59
- for (let i = msgs.length - 1; i >= 0; i--) {
60
- if (msgs[i].role !== "user") continue;
61
- const raw = contentToText((msgs[i] as any).content);
62
- if (raw) return raw.trim();
63
- }
64
- return "Continue.";
65
- }
66
-
67
- function contentToText(content: unknown): string {
68
- if (typeof content === "string") return content;
69
- if (!Array.isArray(content)) return "";
70
- return content
71
- .filter((c: any) => c?.type === "text")
72
- .map((c: any) => c.text as string)
73
- .join("\n");
74
- }
75
-
76
- // ── thinking config ──
77
-
78
- const THINKING_BUDGETS: Record<string, number> = {
79
- minimal: 1600, low: 4000, medium: 0, high: 0, xhigh: 32000,
80
- };
81
- const EFFORT_MAP: Record<string, Effort | undefined> = {
82
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh",
83
- };
84
-
85
- function buildThinking(reasoning?: SimpleStreamOptions["reasoning"], budgets?: SimpleStreamOptions["thinkingBudgets"]): { thinking?: ThinkingConfig; effort?: Effort } {
86
- if (!reasoning) return {};
87
- if (reasoning === "off") return { thinking: { type: "disabled" } };
88
- const budget = budgets?.[reasoning as keyof typeof budgets] ?? THINKING_BUDGETS[reasoning] ?? 0;
89
- const effort = EFFORT_MAP[reasoning];
90
- if (budget > 0) return { thinking: { type: "enabled", budgetTokens: budget }, effort };
91
- return { thinking: { type: "adaptive" }, effort };
92
- }
93
-
94
- // ── usage extraction ──
95
-
96
- function extractUsage(msg: CbMessage): Partial<AssistantMessage> | null {
97
- if (msg.type !== "result" || !msg.usage) return null;
98
- return {
99
- usage: {
100
- input: msg.usage.input_tokens ?? 0,
101
- output: msg.usage.output_tokens ?? 0,
102
- cacheRead: msg.usage.cache_read_input_tokens ?? 0,
103
- cacheWrite: msg.usage.cache_creation_input_tokens ?? 0,
104
- totalTokens:
105
- (msg.usage.input_tokens ?? 0) +
106
- (msg.usage.output_tokens ?? 0) +
107
- (msg.usage.cache_read_input_tokens ?? 0) +
108
- (msg.usage.cache_creation_input_tokens ?? 0),
109
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: msg.total_cost_usd ?? 0 },
110
- },
111
- };
112
- }
113
-
114
- // ── main ──
115
-
116
- export function streamCodebuddy(
117
- model: Model<any>,
118
- context: Context,
119
- options?: SimpleStreamOptions,
120
- ): AssistantMessageEventStream {
121
- const stream = createAssistantMessageEventStream();
122
- const partial = makePartial(model);
123
-
124
- queueMicrotask(async () => {
125
- try {
126
- stream.push({ type: "start", partial });
127
-
128
- const sdk = await loadSdk();
129
- const userText = extractUserMessage(context);
130
-
131
- const { thinking, effort } = buildThinking(options?.reasoning, options?.thinkingBudgets);
132
-
133
- const q = sdk.query({
134
- prompt: userText,
135
- options: {
136
- model: model.id,
137
- permissionMode: "bypassPermissions",
138
- maxTurns: 100,
139
- thinking,
140
- effort,
141
- includePartialMessages: true,
142
- },
143
- });
144
-
145
- // ── abort ──
146
- let aborted = false;
147
- const onAbort = async () => {
148
- if (aborted) return;
149
- aborted = true;
150
- try { await q.interrupt(); } catch { /* best-effort */ }
151
- partial.stopReason = "aborted";
152
- stream.push({ type: "error", reason: "aborted", error: partial });
153
- };
154
- if (options?.signal) {
155
- options.signal.addEventListener("abort", onAbort, { once: true });
156
- if (options.signal.aborted) { await onAbort(); return; }
157
- }
158
-
159
- // ── per-block tracking ──
160
- type BlockKind = "text" | "thinking" | "tool_use" | "redacted_thinking";
161
- const blockKind = new Map<number, BlockKind>();
162
- const blockAccum = new Map<number, string>();
163
- const blockMeta = new Map<number, { id: string; name: string; signature?: string }>();
164
-
165
- let hasStreamEvents = false;
166
-
167
- function onStart(idx: number, kind: BlockKind, init: string, meta?: { id: string; name: string; signature?: string }) {
168
- blockKind.set(idx, kind);
169
- blockAccum.set(idx, init);
170
- if (meta) blockMeta.set(idx, meta);
171
-
172
- if (kind === "redacted_thinking") {
173
- const tc: ThinkingContent = { type: "thinking", thinking: "[redacted]", redacted: true, thinkingSignature: meta?.signature };
174
- partial.content = [...partial.content, tc];
175
- stream.push({ type: "thinking_start", contentIndex: idx, partial });
176
- stream.push({ type: "thinking_end", contentIndex: idx, content: "[redacted]", partial });
177
- } else if (kind === "thinking") {
178
- const tc: ThinkingContent = { type: "thinking", thinking: init, thinkingSignature: meta?.signature };
179
- partial.content = [...partial.content, tc];
180
- stream.push({ type: "thinking_start", contentIndex: idx, partial });
181
- } else if (kind === "text") {
182
- partial.content = [...partial.content, { type: "text", text: init }];
183
- stream.push({ type: "text_start", contentIndex: idx, partial });
184
- } else if (kind === "tool_use" && meta) {
185
- partial.content = [...partial.content, { type: "toolCall", id: meta.id, name: meta.name, arguments: {} }];
186
- stream.push({ type: "toolcall_start", contentIndex: idx, partial });
187
- }
188
- }
189
-
190
- function onDelta(idx: number, deltaType: string, value: string) {
191
- const kind = blockKind.get(idx);
192
- if (!kind) return;
193
-
194
- if (deltaType === "text_delta" && kind === "text") {
195
- const next = (blockAccum.get(idx) || "") + value;
196
- blockAccum.set(idx, next);
197
- const blocks = [...partial.content];
198
- blocks[idx] = { type: "text", text: next };
199
- partial.content = blocks;
200
- stream.push({ type: "text_delta", contentIndex: idx, delta: value, partial });
201
- } else if (deltaType === "thinking_delta" && kind === "thinking") {
202
- const next = (blockAccum.get(idx) || "") + value;
203
- blockAccum.set(idx, next);
204
- const blocks = [...partial.content];
205
- blocks[idx] = { type: "thinking", thinking: next, thinkingSignature: blockMeta.get(idx)?.signature };
206
- partial.content = blocks;
207
- stream.push({ type: "thinking_delta", contentIndex: idx, delta: value, partial });
208
- } else if (deltaType === "input_json_delta" && kind === "tool_use") {
209
- const json = (blockAccum.get(idx) || "") + value;
210
- blockAccum.set(idx, json);
211
- const blocks = [...partial.content];
212
- try { blocks[idx] = { ...blocks[idx], arguments: JSON.parse(json) }; } catch { /* partial */ }
213
- partial.content = blocks;
214
- stream.push({ type: "toolcall_delta", contentIndex: idx, delta: value, partial } as any);
215
- }
216
- }
217
-
218
- function onStop(idx: number) {
219
- const kind = blockKind.get(idx);
220
- const accum = blockAccum.get(idx) || "";
221
- if (kind === "text") stream.push({ type: "text_end", contentIndex: idx, content: accum, partial });
222
- else if (kind === "thinking") stream.push({ type: "thinking_end", contentIndex: idx, content: accum, partial });
223
- else if (kind === "tool_use") stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: partial.content[idx] as ToolCall, partial });
224
- }
225
-
226
- // ── iterate ──
227
- for await (const msg of q) {
228
- if (aborted) return;
229
-
230
- if (msg.type === "system") {
231
- if (msg.model) partial.responseModel = msg.model;
232
- continue;
233
- }
234
-
235
- if (msg.type === "stream_event") {
236
- hasStreamEvents = true;
237
- const evt = msg.event;
238
-
239
- if (evt.type === "content_block_start") {
240
- const b = evt.content_block;
241
- if (b.type === "text") onStart(evt.index, "text", b.text || "");
242
- else if (b.type === "thinking") onStart(evt.index, "thinking", b.thinking || "", { id: "", name: "", signature: b.signature });
243
- else if (b.type === "redacted_thinking") onStart(evt.index, "redacted_thinking", "[redacted]", { id: "", name: "", signature: b.data });
244
- else if (b.type === "tool_use") onStart(evt.index, "tool_use", JSON.stringify(b.input || {}), { id: b.id, name: b.name });
245
- } else if (evt.type === "content_block_delta") {
246
- const d = evt.delta;
247
- if (d.type === "text_delta") onDelta(evt.index, "text_delta", d.text);
248
- else if (d.type === "thinking_delta") onDelta(evt.index, "thinking_delta", d.thinking);
249
- else if (d.type === "input_json_delta") onDelta(evt.index, "input_json_delta", d.partial_json);
250
- } else if (evt.type === "content_block_stop") {
251
- onStop(evt.index);
252
- }
253
- continue;
254
- }
255
-
256
- // Fallback: full assistant messages (when model doesn't support streaming)
257
- if (msg.type === "assistant" && !hasStreamEvents) {
258
- let ci = 0;
259
- for (const block of msg.message.content) {
260
- const idx = ci++;
261
- if (block.type === "thinking") { onStart(idx, "thinking", block.thinking); onStop(idx); }
262
- else if (block.type === "redacted_thinking") onStart(idx, "redacted_thinking", "[redacted]", { id: "", name: "", signature: block.data });
263
- else if (block.type === "text") { onStart(idx, "text", block.text); onStop(idx); }
264
- else if (block.type === "tool_use") { onStart(idx, "tool_use", JSON.stringify(block.input ?? {}), { id: block.id, name: block.name }); onStop(idx); }
265
- }
266
- }
267
-
268
- if (msg.type === "result") {
269
- const u = extractUsage(msg);
270
- if (u) Object.assign(partial, u);
271
-
272
- if (msg.subtype === "success") {
273
- partial.stopReason = "stop";
274
- stream.push({ type: "done", reason: "stop", message: { ...partial } });
275
- } else {
276
- partial.stopReason = "error";
277
- partial.errorMessage = msg.errors?.join("; ") ?? `CodeBuddy error: ${msg.subtype}`;
278
- stream.push({ type: "error", reason: "error", error: { ...partial } });
279
- }
280
- break;
281
- }
282
- }
283
- } catch (error) {
284
- stream.push({ type: "error", reason: "error", error: makeErrorPartial(model, error) });
285
- }
286
- });
287
-
288
- return stream;
289
- }