u-foo 2.5.6 → 2.5.8

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.
@@ -102,104 +102,6 @@ function clipText(value = "", maxChars = 6000) {
102
102
  return `${text.slice(0, maxChars)}\n...[truncated]`;
103
103
  }
104
104
 
105
- function summarizeFileSnippet(file = "", content = "") {
106
- const target = String(file || "").trim();
107
- const body = String(content || "").trim();
108
- if (!body) return `${target}: empty`;
109
-
110
- if (target.toLowerCase().endsWith("package.json")) {
111
- try {
112
- const parsed = JSON.parse(body);
113
- const name = String(parsed.name || "").trim() || "(unknown)";
114
- const version = String(parsed.version || "").trim() || "(unknown)";
115
- const scripts = parsed.scripts && typeof parsed.scripts === "object"
116
- ? Object.keys(parsed.scripts).slice(0, 4)
117
- : [];
118
- const scriptText = scripts.length > 0 ? ` scripts=${scripts.join(",")}` : "";
119
- return `${target}: name=${name} version=${version}${scriptText}`;
120
- } catch {
121
- // fall through
122
- }
123
- }
124
-
125
- const lines = body
126
- .split(/\r?\n/)
127
- .map((line) => line.trim())
128
- .filter(Boolean)
129
- .slice(0, 3)
130
- .map((line) => (line.length > 120 ? `${line.slice(0, 120)}...` : line));
131
-
132
- if (lines.length === 0) return `${target}: empty`;
133
- return `${target}: ${lines.join(" | ")}`;
134
- }
135
-
136
- function extractPreflightEvidence(systemPrompt = "") {
137
- const source = String(systemPrompt || "");
138
- if (!source) return [];
139
-
140
- const results = [];
141
- const fileRegex = /File:\s*([^\n]+)\n([\s\S]*?)(?=\n---\n(?:File|Command):|$)/g;
142
- let match = fileRegex.exec(source);
143
- while (match) {
144
- const file = String(match[1] || "").trim();
145
- const content = String(match[2] || "").trim();
146
- if (file) {
147
- results.push({ kind: "file", label: file, summary: summarizeFileSnippet(file, content) });
148
- }
149
- match = fileRegex.exec(source);
150
- }
151
-
152
- const cmdRegex = /Command:\s*([^\n]+)\n([\s\S]*?)(?=\n---\n(?:File|Command):|$)/g;
153
- match = cmdRegex.exec(source);
154
- while (match) {
155
- const command = String(match[1] || "").trim();
156
- const output = String(match[2] || "").trim();
157
- if (command) {
158
- const clipped = clipText(output, 300).replace(/\s+/g, " ").trim();
159
- results.push({ kind: "command", label: command, summary: `${command}: ${clipped || "(no output)"}` });
160
- }
161
- match = cmdRegex.exec(source);
162
- }
163
-
164
- return results;
165
- }
166
-
167
- function isAnalysisPrompt(text = "") {
168
- return /(?:analy[sz]e|analysis|review|audit|status|architecture|codebase|repo|project|现状|架构|审查|分析|项目|代码库)/i.test(text);
169
- }
170
-
171
- function parseReadIntent(prompt = "") {
172
- const text = String(prompt || "");
173
- const patterns = [
174
- /(?:\bread\b|\bcat\b|查看|读取)\s+([A-Za-z0-9_./\\-]+(?:\.[A-Za-z0-9._-]+)?)/i,
175
- /([A-Za-z0-9_./\\-]+\.(?:md|txt|json|js|ts|jsx|tsx|yml|yaml|toml|sh))/i,
176
- ];
177
- for (const re of patterns) {
178
- const match = text.match(re);
179
- if (!match || !match[1]) continue;
180
- const candidate = String(match[1]).trim().replace(/[),.;:]+$/, "");
181
- if (!candidate) continue;
182
- if (candidate.length > 260) continue;
183
- return candidate;
184
- }
185
- return "";
186
- }
187
-
188
- function parseBashIntent(prompt = "") {
189
- const text = String(prompt || "").trim();
190
- if (!text) return "";
191
- if (
192
- /\b(ls|dir|tree)\b/i.test(text)
193
- || /\b(list|show)\s+(files|dirs|directories|folders)\b/i.test(text)
194
- || /列出|目录|文件列表/.test(text)
195
- ) {
196
- return "ls -la";
197
- }
198
- const cmdMatch = text.match(/(?:运行|执行|run|exec(?:ute)?)\s+`([^`]+)`/i);
199
- if (cmdMatch && cmdMatch[1]) return String(cmdMatch[1]).trim();
200
- return "";
201
- }
202
-
203
105
  function normalizeProvider(value = "") {
204
106
  const text = String(value || "").trim().toLowerCase();
205
107
  if (!text) return "";
@@ -317,6 +219,11 @@ function buildCoreToolSpecs() {
317
219
  properties: {
318
220
  path: { type: "string" },
319
221
  content: { type: "string" },
222
+ mode: {
223
+ type: "string",
224
+ enum: ["overwrite", "append"],
225
+ description: 'Write mode: "overwrite" replaces the file (default), "append" adds to its end.',
226
+ },
320
227
  append: { type: "boolean" },
321
228
  },
322
229
  required: ["path", "content"],
@@ -530,34 +437,22 @@ function emitPhase(callback, event = {}) {
530
437
  }
531
438
  }
532
439
 
533
- async function runOpenAiLikeTurn({
440
+ // Shared SSE transport skeleton: POST the payload, then read the stream as
441
+ // SSE blocks, dispatch each non-[DONE] block to onEvent, and stop after the
442
+ // batch that carried [DONE]. Timeout/cancel translation and request cleanup
443
+ // live here so each protocol turn only declares its event handling.
444
+ async function runSseRequest({
534
445
  url = "",
535
- apiKey = "",
536
- model = "",
537
- messages = [],
538
- onTextDelta = null,
539
- onThinkingDelta = null,
540
- onPhase = null,
446
+ headers = {},
447
+ payload = {},
541
448
  signal = null,
542
449
  timeoutMs = 300000,
450
+ onPhase = null,
451
+ onNonStream,
452
+ onEvent,
453
+ onTail = null,
454
+ buildResult,
543
455
  } = {}) {
544
- const payload = {
545
- model,
546
- max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
547
- messages,
548
- tools: buildCoreToolSpecs(),
549
- tool_choice: "auto",
550
- stream: true,
551
- temperature: 0,
552
- };
553
-
554
- const headers = {
555
- "content-type": "application/json",
556
- };
557
- if (apiKey) {
558
- headers.authorization = `Bearer ${apiKey}`;
559
- }
560
-
561
456
  const request = createRequestController({ signal, timeoutMs });
562
457
 
563
458
  emitPhase(onPhase, { type: "request_start" });
@@ -577,28 +472,13 @@ async function runOpenAiLikeTurn({
577
472
 
578
473
  if (!response.body || typeof response.body.getReader !== "function") {
579
474
  const data = await response.json();
580
- const message = data && data.choices && data.choices[0] && data.choices[0].message
581
- ? data.choices[0].message
582
- : {};
583
- const text = typeof message.content === "string" ? message.content : "";
584
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
585
- if (text && typeof onTextDelta === "function") {
586
- onTextDelta(text);
587
- }
588
- return {
589
- text,
590
- toolCalls,
591
- };
475
+ return onNonStream(data);
592
476
  }
593
477
 
594
478
  const reader = response.body.getReader();
595
479
  const decoder = new TextDecoder();
596
- const toolCallMap = new Map();
597
480
  let rawBuffer = "";
598
- let responseText = "";
599
- const announcedToolNames = new Set();
600
- let nextSyntheticIndex = 0;
601
- let lastSyntheticIndex = -1;
481
+ let sawDone = false;
602
482
 
603
483
  while (true) {
604
484
  const { done, value } = await reader.read();
@@ -609,91 +489,178 @@ async function runOpenAiLikeTurn({
609
489
  rawBuffer = parsed.rest;
610
490
 
611
491
  for (const block of parsed.blocks) {
612
- const payloadText = parseSseDataBlock(block);
613
- if (!payloadText) continue;
614
- if (payloadText === "[DONE]") {
615
- rawBuffer = "";
616
- break;
492
+ const { event, data } = parseSseEventBlock(block);
493
+ if (!data) continue;
494
+ if (data === "[DONE]") {
495
+ // Stop reading after this batch instead of waiting for the server
496
+ // to close the connection, but keep the buffered tail and finish
497
+ // the blocks already parsed alongside [DONE] instead of silently
498
+ // dropping them.
499
+ sawDone = true;
500
+ continue;
617
501
  }
618
502
 
619
- const chunk = parseJsonSafe(payloadText, null);
620
- if (!chunk || typeof chunk !== "object") continue;
503
+ onEvent({ event, data });
504
+ }
621
505
 
622
- const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
623
- if (!choice || typeof choice !== "object") continue;
506
+ if (sawDone) break;
507
+ }
624
508
 
625
- const delta = choice.delta && typeof choice.delta === "object" ? choice.delta : {};
509
+ if (typeof onTail === "function") {
510
+ onTail(rawBuffer);
511
+ }
626
512
 
627
- const reasoningChunk = typeof delta.reasoning_content === "string"
628
- ? delta.reasoning_content
629
- : (typeof delta.reasoning === "string" ? delta.reasoning : "");
630
- if (reasoningChunk) {
631
- emitPhase(onPhase, { type: "thinking_delta", text: reasoningChunk });
632
- if (typeof onThinkingDelta === "function") {
633
- onThinkingDelta(reasoningChunk);
634
- }
513
+ return buildResult();
514
+ } catch (err) {
515
+ if (request.timedOut()) {
516
+ const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
517
+ timeoutError.code = "timeout";
518
+ throw timeoutError;
519
+ }
520
+ if (signal && typeof signal === "object" && signal.aborted) {
521
+ const cancelError = new Error("CLI cancelled");
522
+ cancelError.code = "cancelled";
523
+ throw cancelError;
524
+ }
525
+ throw err;
526
+ } finally {
527
+ request.cleanup();
528
+ }
529
+ }
530
+
531
+ async function runOpenAiLikeTurn({
532
+ url = "",
533
+ apiKey = "",
534
+ model = "",
535
+ messages = [],
536
+ onTextDelta = null,
537
+ onThinkingDelta = null,
538
+ onPhase = null,
539
+ signal = null,
540
+ timeoutMs = 300000,
541
+ } = {}) {
542
+ const payload = {
543
+ model,
544
+ max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
545
+ messages,
546
+ tools: buildCoreToolSpecs(),
547
+ tool_choice: "auto",
548
+ stream: true,
549
+ temperature: 0,
550
+ };
551
+
552
+ const headers = {
553
+ "content-type": "application/json",
554
+ };
555
+ if (apiKey) {
556
+ headers.authorization = `Bearer ${apiKey}`;
557
+ }
558
+
559
+ const toolCallMap = new Map();
560
+ const announcedToolNames = new Set();
561
+ let responseText = "";
562
+ let nextSyntheticIndex = 0;
563
+ let lastSyntheticIndex = -1;
564
+
565
+ return runSseRequest({
566
+ url,
567
+ headers,
568
+ payload,
569
+ signal,
570
+ timeoutMs,
571
+ onPhase,
572
+ onNonStream: (data) => {
573
+ const message = data && data.choices && data.choices[0] && data.choices[0].message
574
+ ? data.choices[0].message
575
+ : {};
576
+ const text = typeof message.content === "string" ? message.content : "";
577
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
578
+ if (text && typeof onTextDelta === "function") {
579
+ onTextDelta(text);
580
+ }
581
+ return {
582
+ text,
583
+ toolCalls,
584
+ };
585
+ },
586
+ onEvent: ({ data }) => {
587
+ const chunk = parseJsonSafe(data, null);
588
+ if (!chunk || typeof chunk !== "object") return;
589
+
590
+ const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
591
+ if (!choice || typeof choice !== "object") return;
592
+
593
+ const delta = choice.delta && typeof choice.delta === "object" ? choice.delta : {};
594
+
595
+ const reasoningChunk = typeof delta.reasoning_content === "string"
596
+ ? delta.reasoning_content
597
+ : (typeof delta.reasoning === "string" ? delta.reasoning : "");
598
+ if (reasoningChunk) {
599
+ emitPhase(onPhase, { type: "thinking_delta", text: reasoningChunk });
600
+ if (typeof onThinkingDelta === "function") {
601
+ onThinkingDelta(reasoningChunk);
635
602
  }
603
+ }
636
604
 
637
- if (typeof delta.content === "string" && delta.content) {
638
- responseText += delta.content;
639
- emitPhase(onPhase, { type: "text_delta", text: delta.content });
640
- if (typeof onTextDelta === "function") {
641
- onTextDelta(delta.content);
642
- }
605
+ if (typeof delta.content === "string" && delta.content) {
606
+ responseText += delta.content;
607
+ emitPhase(onPhase, { type: "text_delta", text: delta.content });
608
+ if (typeof onTextDelta === "function") {
609
+ onTextDelta(delta.content);
643
610
  }
611
+ }
644
612
 
645
- if (Array.isArray(delta.tool_calls)) {
646
- for (const callPart of delta.tool_calls) {
647
- let index;
648
- if (Number.isFinite(callPart.index)) {
649
- index = callPart.index;
650
- } else if (typeof callPart.id === "string" && callPart.id) {
651
- // Provider omitted index: a chunk carrying an id starts a new
652
- // call, so give it its own synthetic index instead of
653
- // collapsing every call into slot 0.
654
- while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
655
- index = nextSyntheticIndex;
656
- nextSyntheticIndex += 1;
657
- lastSyntheticIndex = index;
658
- } else if (lastSyntheticIndex >= 0) {
659
- // No index and no id: continuation of the latest synthetic call.
660
- index = lastSyntheticIndex;
661
- } else {
662
- index = 0;
663
- }
664
- const previous = toolCallMap.get(index) || {
665
- id: "",
666
- type: "function",
667
- function: {
668
- name: "",
669
- arguments: "",
670
- },
671
- };
613
+ if (Array.isArray(delta.tool_calls)) {
614
+ for (const callPart of delta.tool_calls) {
615
+ let index;
616
+ if (Number.isFinite(callPart.index)) {
617
+ index = callPart.index;
618
+ } else if (typeof callPart.id === "string" && callPart.id) {
619
+ // Provider omitted index: a chunk carrying an id starts a new
620
+ // call, so give it its own synthetic index instead of
621
+ // collapsing every call into slot 0.
622
+ while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
623
+ index = nextSyntheticIndex;
624
+ nextSyntheticIndex += 1;
625
+ lastSyntheticIndex = index;
626
+ } else if (lastSyntheticIndex >= 0) {
627
+ // No index and no id: continuation of the latest synthetic call.
628
+ index = lastSyntheticIndex;
629
+ } else {
630
+ index = 0;
631
+ }
632
+ const previous = toolCallMap.get(index) || {
633
+ id: "",
634
+ type: "function",
635
+ function: {
636
+ name: "",
637
+ arguments: "",
638
+ },
639
+ };
672
640
 
673
- if (typeof callPart.id === "string" && callPart.id) previous.id = callPart.id;
674
- if (callPart.function && typeof callPart.function === "object") {
675
- if (typeof callPart.function.name === "string" && callPart.function.name) {
676
- previous.function.name = callPart.function.name;
677
- }
678
- if (typeof callPart.function.arguments === "string" && callPart.function.arguments) {
679
- previous.function.arguments += callPart.function.arguments;
680
- }
641
+ if (typeof callPart.id === "string" && callPart.id) previous.id = callPart.id;
642
+ if (callPart.function && typeof callPart.function === "object") {
643
+ if (typeof callPart.function.name === "string" && callPart.function.name) {
644
+ previous.function.name = callPart.function.name;
645
+ }
646
+ if (typeof callPart.function.arguments === "string" && callPart.function.arguments) {
647
+ previous.function.arguments += callPart.function.arguments;
681
648
  }
649
+ }
682
650
 
683
- toolCallMap.set(index, previous);
651
+ toolCallMap.set(index, previous);
684
652
 
685
- const toolName = previous.function.name;
686
- const announceKey = `${index}:${toolName}`;
687
- if (toolName && !announcedToolNames.has(announceKey)) {
688
- announcedToolNames.add(announceKey);
689
- emitPhase(onPhase, { type: "tool_request", name: toolName });
690
- }
653
+ const toolName = previous.function.name;
654
+ const announceKey = `${index}:${toolName}`;
655
+ if (toolName && !announcedToolNames.has(announceKey)) {
656
+ announcedToolNames.add(announceKey);
657
+ emitPhase(onPhase, { type: "tool_request", name: toolName });
691
658
  }
692
659
  }
693
660
  }
694
- }
695
-
696
- if (rawBuffer.trim()) {
661
+ },
662
+ onTail: (rawBuffer) => {
663
+ if (!rawBuffer.trim()) return;
697
664
  const fallbackBlock = parseSseDataBlock(rawBuffer);
698
665
  if (fallbackBlock && fallbackBlock !== "[DONE]") {
699
666
  const chunk = parseJsonSafe(fallbackBlock, null);
@@ -705,29 +672,14 @@ async function runOpenAiLikeTurn({
705
672
  }
706
673
  }
707
674
  }
708
- }
709
-
710
- return {
675
+ },
676
+ buildResult: () => ({
711
677
  text: responseText,
712
678
  toolCalls: Array.from(toolCallMap.entries())
713
679
  .sort((a, b) => a[0] - b[0])
714
680
  .map((entry) => entry[1]),
715
- };
716
- } catch (err) {
717
- if (request.timedOut()) {
718
- const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
719
- timeoutError.code = "timeout";
720
- throw timeoutError;
721
- }
722
- if (signal && typeof signal === "object" && signal.aborted) {
723
- const cancelError = new Error("CLI cancelled");
724
- cancelError.code = "cancelled";
725
- throw cancelError;
726
- }
727
- throw err;
728
- } finally {
729
- request.cleanup();
730
- }
681
+ }),
682
+ });
731
683
  }
732
684
 
733
685
  function normalizeAnthropicMessageContent(raw = []) {
@@ -800,25 +752,19 @@ async function runAnthropicTurn({
800
752
  headers["x-api-key"] = apiKey;
801
753
  }
802
754
 
803
- const request = createRequestController({ signal, timeoutMs });
804
-
805
- emitPhase(onPhase, { type: "request_start" });
806
-
807
- try {
808
- const response = await fetch(url, {
809
- method: "POST",
810
- headers,
811
- body: JSON.stringify(payload),
812
- signal: request.signal,
813
- });
814
-
815
- if (!response.ok) {
816
- const body = await response.text().catch(() => "");
817
- throw new Error(`provider request failed (${response.status}): ${clipText(body, 500)}`);
818
- }
819
-
820
- if (!response.body || typeof response.body.getReader !== "function") {
821
- const data = await response.json();
755
+ const blockMap = new Map();
756
+ let responseText = "";
757
+ let nextSyntheticBlockIndex = 0;
758
+ let lastBlockIndex = -1;
759
+
760
+ return runSseRequest({
761
+ url,
762
+ headers,
763
+ payload,
764
+ signal,
765
+ timeoutMs,
766
+ onPhase,
767
+ onNonStream: (data) => {
822
768
  const content = normalizeAnthropicMessageContent(data && data.content);
823
769
  const text = content
824
770
  .filter((item) => item.type === "text")
@@ -832,240 +778,181 @@ async function runAnthropicTurn({
832
778
  assistantContent: content,
833
779
  toolCalls: extractAnthropicToolCalls(content),
834
780
  };
835
- }
836
-
837
- const reader = response.body.getReader();
838
- const decoder = new TextDecoder();
839
- const blockMap = new Map();
840
- let rawBuffer = "";
841
- let responseText = "";
842
-
843
- while (true) {
844
- const { done, value } = await reader.read();
845
- if (done) break;
846
-
847
- rawBuffer += decoder.decode(value, { stream: true });
848
- const parsed = parseSseBlocks(rawBuffer);
849
- rawBuffer = parsed.rest;
850
-
851
- for (const rawBlock of parsed.blocks) {
852
- const { event, data } = parseSseEventBlock(rawBlock);
853
- if (!data || data === "[DONE]") continue;
854
-
855
- const payloadChunk = parseJsonSafe(data, null);
856
- if (!payloadChunk || typeof payloadChunk !== "object") continue;
781
+ },
782
+ onEvent: ({ event, data }) => {
783
+ const payloadChunk = parseJsonSafe(data, null);
784
+ if (!payloadChunk || typeof payloadChunk !== "object") return;
785
+
786
+ if (event === "error") {
787
+ const errMsg = payloadChunk.error && payloadChunk.error.message
788
+ ? String(payloadChunk.error.message)
789
+ : "anthropic stream error";
790
+ throw new Error(errMsg);
791
+ }
857
792
 
858
- if (event === "error") {
859
- const errMsg = payloadChunk.error && payloadChunk.error.message
860
- ? String(payloadChunk.error.message)
861
- : "anthropic stream error";
862
- throw new Error(errMsg);
793
+ if (event === "content_block_start") {
794
+ let index;
795
+ if (Number.isFinite(payloadChunk.index)) {
796
+ index = payloadChunk.index;
797
+ } else {
798
+ // Provider omitted index: each start opens a new block, so give
799
+ // it its own synthetic index instead of collapsing every block
800
+ // into slot 0.
801
+ while (blockMap.has(nextSyntheticBlockIndex)) nextSyntheticBlockIndex += 1;
802
+ index = nextSyntheticBlockIndex;
803
+ nextSyntheticBlockIndex += 1;
863
804
  }
864
-
865
- if (event === "content_block_start") {
866
- const index = Number.isFinite(payloadChunk.index) ? payloadChunk.index : 0;
867
- const contentBlock = payloadChunk.content_block && typeof payloadChunk.content_block === "object"
868
- ? payloadChunk.content_block
869
- : {};
870
-
871
- if (contentBlock.type === "text") {
872
- blockMap.set(index, {
873
- order: index,
874
- type: "text",
875
- text: String(contentBlock.text || ""),
876
- });
877
- } else if (contentBlock.type === "thinking") {
878
- blockMap.set(index, {
879
- order: index,
880
- type: "thinking",
881
- text: String(contentBlock.thinking || ""),
882
- });
883
- } else if (contentBlock.type === "tool_use") {
884
- blockMap.set(index, {
885
- order: index,
886
- type: "tool_use",
887
- id: String(contentBlock.id || ""),
888
- name: String(contentBlock.name || ""),
889
- input: contentBlock.input && typeof contentBlock.input === "object" && !Array.isArray(contentBlock.input)
890
- ? { ...contentBlock.input }
891
- : {},
892
- inputJson: "",
893
- });
894
- const toolName = String(contentBlock.name || "");
895
- if (toolName) {
896
- emitPhase(onPhase, { type: "tool_request", name: toolName });
897
- }
805
+ lastBlockIndex = index;
806
+ const contentBlock = payloadChunk.content_block && typeof payloadChunk.content_block === "object"
807
+ ? payloadChunk.content_block
808
+ : {};
809
+
810
+ if (contentBlock.type === "text") {
811
+ blockMap.set(index, {
812
+ order: index,
813
+ type: "text",
814
+ text: String(contentBlock.text || ""),
815
+ });
816
+ } else if (contentBlock.type === "thinking") {
817
+ blockMap.set(index, {
818
+ order: index,
819
+ type: "thinking",
820
+ text: String(contentBlock.thinking || ""),
821
+ });
822
+ } else if (contentBlock.type === "tool_use") {
823
+ blockMap.set(index, {
824
+ order: index,
825
+ type: "tool_use",
826
+ id: String(contentBlock.id || ""),
827
+ name: String(contentBlock.name || ""),
828
+ input: contentBlock.input && typeof contentBlock.input === "object" && !Array.isArray(contentBlock.input)
829
+ ? { ...contentBlock.input }
830
+ : {},
831
+ inputJson: "",
832
+ });
833
+ const toolName = String(contentBlock.name || "");
834
+ if (toolName) {
835
+ emitPhase(onPhase, { type: "tool_request", name: toolName });
898
836
  }
899
- continue;
900
837
  }
838
+ return;
839
+ }
901
840
 
902
- if (event === "content_block_delta") {
903
- const index = Number.isFinite(payloadChunk.index) ? payloadChunk.index : 0;
904
- const delta = payloadChunk.delta && typeof payloadChunk.delta === "object"
905
- ? payloadChunk.delta
906
- : {};
907
- const current = blockMap.get(index) || { order: index, type: "text", text: "" };
908
-
909
- if (delta.type === "text_delta") {
910
- const deltaText = String(delta.text || "");
911
- current.type = "text";
912
- current.text = `${String(current.text || "")}${deltaText}`;
913
- blockMap.set(index, current);
914
- if (deltaText) {
915
- responseText += deltaText;
916
- emitPhase(onPhase, { type: "text_delta", text: deltaText });
917
- if (typeof onTextDelta === "function") {
918
- onTextDelta(deltaText);
919
- }
841
+ if (event === "content_block_delta") {
842
+ let index;
843
+ if (Number.isFinite(payloadChunk.index)) {
844
+ index = payloadChunk.index;
845
+ } else if (lastBlockIndex >= 0) {
846
+ // No index: continuation of the most recently started block.
847
+ index = lastBlockIndex;
848
+ } else {
849
+ index = 0;
850
+ }
851
+ const delta = payloadChunk.delta && typeof payloadChunk.delta === "object"
852
+ ? payloadChunk.delta
853
+ : {};
854
+ const current = blockMap.get(index) || { order: index, type: "text", text: "" };
855
+
856
+ if (delta.type === "text_delta") {
857
+ const deltaText = String(delta.text || "");
858
+ current.type = "text";
859
+ current.text = `${String(current.text || "")}${deltaText}`;
860
+ blockMap.set(index, current);
861
+ if (deltaText) {
862
+ responseText += deltaText;
863
+ emitPhase(onPhase, { type: "text_delta", text: deltaText });
864
+ if (typeof onTextDelta === "function") {
865
+ onTextDelta(deltaText);
920
866
  }
921
- continue;
922
867
  }
868
+ return;
869
+ }
923
870
 
924
- if (delta.type === "thinking_delta") {
925
- const deltaText = String(delta.thinking || "");
926
- current.type = "thinking";
927
- current.text = `${String(current.text || "")}${deltaText}`;
928
- blockMap.set(index, current);
929
- if (deltaText) {
930
- emitPhase(onPhase, { type: "thinking_delta", text: deltaText });
931
- if (typeof onThinkingDelta === "function") {
932
- onThinkingDelta(deltaText);
933
- }
871
+ if (delta.type === "thinking_delta") {
872
+ const deltaText = String(delta.thinking || "");
873
+ current.type = "thinking";
874
+ current.text = `${String(current.text || "")}${deltaText}`;
875
+ blockMap.set(index, current);
876
+ if (deltaText) {
877
+ emitPhase(onPhase, { type: "thinking_delta", text: deltaText });
878
+ if (typeof onThinkingDelta === "function") {
879
+ onThinkingDelta(deltaText);
934
880
  }
935
- continue;
936
881
  }
882
+ return;
883
+ }
937
884
 
938
- if (delta.type === "input_json_delta") {
939
- current.type = "tool_use";
940
- current.inputJson = `${String(current.inputJson || "")}${String(delta.partial_json || "")}`;
941
- blockMap.set(index, current);
942
- continue;
943
- }
885
+ if (delta.type === "input_json_delta") {
886
+ current.type = "tool_use";
887
+ current.inputJson = `${String(current.inputJson || "")}${String(delta.partial_json || "")}`;
888
+ blockMap.set(index, current);
889
+ return;
944
890
  }
945
891
  }
946
- }
892
+ },
893
+ buildResult: () => {
894
+ const assistantContent = Array.from(blockMap.values())
895
+ .sort((a, b) => a.order - b.order)
896
+ .filter((item) => item.type !== "thinking")
897
+ .map((item) => {
898
+ if (item.type === "text") {
899
+ return {
900
+ type: "text",
901
+ text: String(item.text || ""),
902
+ };
903
+ }
947
904
 
948
- const assistantContent = Array.from(blockMap.values())
949
- .sort((a, b) => a.order - b.order)
950
- .filter((item) => item.type !== "thinking")
951
- .map((item) => {
952
- if (item.type === "text") {
905
+ const inputFromDelta = normalizeToolCallArgs(item.inputJson || "");
906
+ const mergedInput = {
907
+ ...(item.input && typeof item.input === "object" ? item.input : {}),
908
+ ...(inputFromDelta && typeof inputFromDelta === "object" ? inputFromDelta : {}),
909
+ };
953
910
  return {
954
- type: "text",
955
- text: String(item.text || ""),
911
+ type: "tool_use",
912
+ id: String(item.id || `tool_${randomUUID()}`),
913
+ name: String(item.name || ""),
914
+ input: mergedInput,
956
915
  };
957
- }
958
-
959
- const inputFromDelta = normalizeToolCallArgs(item.inputJson || "");
960
- const mergedInput = {
961
- ...(item.input && typeof item.input === "object" ? item.input : {}),
962
- ...(inputFromDelta && typeof inputFromDelta === "object" ? inputFromDelta : {}),
963
- };
964
- return {
965
- type: "tool_use",
966
- id: String(item.id || `tool_${randomUUID()}`),
967
- name: String(item.name || ""),
968
- input: mergedInput,
969
- };
970
- });
916
+ });
971
917
 
972
- if (!responseText) {
973
- responseText = assistantContent
974
- .filter((item) => item.type === "text")
975
- .map((item) => item.text)
976
- .join("");
977
- }
918
+ if (!responseText) {
919
+ responseText = assistantContent
920
+ .filter((item) => item.type === "text")
921
+ .map((item) => item.text)
922
+ .join("");
923
+ }
978
924
 
979
- return {
980
- text: responseText,
981
- assistantContent,
982
- toolCalls: extractAnthropicToolCalls(assistantContent),
983
- };
984
- } catch (err) {
985
- if (request.timedOut()) {
986
- const timeoutError = new Error(`CLI timeout (${normalizeTimeoutMs(timeoutMs)}ms)`);
987
- timeoutError.code = "timeout";
988
- throw timeoutError;
989
- }
990
- if (signal && typeof signal === "object" && signal.aborted) {
991
- const cancelError = new Error("CLI cancelled");
992
- cancelError.code = "cancelled";
993
- throw cancelError;
994
- }
995
- throw err;
996
- } finally {
997
- request.cleanup();
998
- }
925
+ return {
926
+ text: responseText,
927
+ assistantContent,
928
+ toolCalls: extractAnthropicToolCalls(assistantContent),
929
+ };
930
+ },
931
+ });
999
932
  }
1000
933
 
1001
- async function runNativeLoopOpenAi({
1002
- workspaceRoot = process.cwd(),
1003
- prompt = "",
1004
- systemPrompt = "",
1005
- historyMessages = [],
1006
- model = "",
1007
- baseUrl = "",
1008
- apiKey = "",
1009
- timeoutMs = 300000,
1010
- onStreamDelta = null,
1011
- onThinkingDelta = null,
1012
- onPhase = null,
1013
- onToolEvent = null,
1014
- signal = null,
1015
- guards,
1016
- } = {}) {
1017
- const requestModel = String(model || "").trim();
1018
- if (!requestModel) {
1019
- throw new Error("ucode model is not configured");
1020
- }
1021
-
1022
- const requestUrl = resolveCompletionUrl(baseUrl);
1023
- if (!requestUrl) {
1024
- throw new Error("ucode baseUrl is not configured");
1025
- }
1026
-
1027
- const messages = cloneMessageList(historyMessages);
1028
- const systemText = String(systemPrompt || "").trim();
1029
- const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
1030
- if (systemText && !hasSystem) {
1031
- messages.unshift({ role: "system", content: systemText });
1032
- }
1033
- messages.push({ role: "user", content: String(prompt || "") });
1034
-
1035
- let aggregated = "";
1036
- let streamed = false;
1037
- let toolCallsExecuted = 0;
1038
- let toolErrors = 0;
1039
- const toolBudget = resolveNativeToolBudget();
1040
-
1041
- while (true) {
1042
- guards.ensureActive();
1043
-
1044
- const turnResult = await runOpenAiLikeTurn({
1045
- url: requestUrl,
1046
- apiKey,
1047
- model: requestModel,
1048
- messages,
1049
- signal,
1050
- timeoutMs,
1051
- onPhase,
1052
- onThinkingDelta,
1053
- onTextDelta: (chunk) => {
1054
- const text = String(chunk || "");
1055
- if (!text) return;
1056
- aggregated += text;
1057
- if (typeof onStreamDelta === "function") {
1058
- streamed = true;
1059
- onStreamDelta(text);
1060
- }
1061
- },
1062
- });
1063
-
1064
- const toolCalls = Array.isArray(turnResult.toolCalls)
1065
- ? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
1066
- : [];
1067
-
1068
- if (toolCalls.length === 0) {
934
+ // Transport descriptors: everything the shared native loop needs that differs
935
+ // between the OpenAI chat-completions and Anthropic messages protocols —
936
+ // request URL resolution, initial message shaping, turn execution, and
937
+ // assistant/tool-result message formatting.
938
+ const TRANSPORTS = {
939
+ "openai-chat": {
940
+ resolveUrl: resolveCompletionUrl,
941
+ prepareMessages({ messages, systemPrompt, prompt }) {
942
+ const systemText = String(systemPrompt || "").trim();
943
+ const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
944
+ if (systemText && !hasSystem) {
945
+ messages.unshift({ role: "system", content: systemText });
946
+ }
947
+ messages.push({ role: "user", content: String(prompt || "") });
948
+ },
949
+ runTurn: runOpenAiLikeTurn,
950
+ getToolCalls(turnResult) {
951
+ return Array.isArray(turnResult.toolCalls)
952
+ ? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
953
+ : [];
954
+ },
955
+ appendFinalAssistantMessage({ messages, turnResult }) {
1069
956
  const text = String(turnResult.text || "").trim();
1070
957
  if (text) {
1071
958
  messages.push({
@@ -1073,78 +960,114 @@ async function runNativeLoopOpenAi({
1073
960
  content: text,
1074
961
  });
1075
962
  }
1076
- if (!aggregated.trim() && text) {
1077
- aggregated = text;
963
+ },
964
+ prepareToolCalls({ messages, toolCalls }) {
965
+ const assistantToolCalls = [];
966
+ for (const call of toolCalls) {
967
+ const callId = String(call.id || `call_${randomUUID()}`);
968
+ const name = normalizeToolName(call.function.name || "");
969
+ const args = normalizeToolCallArgs(call.function.arguments || "");
970
+
971
+ assistantToolCalls.push({
972
+ id: callId,
973
+ type: "function",
974
+ function: {
975
+ name: name || String(call.function.name || ""),
976
+ arguments: toJsonString(args),
977
+ },
978
+ });
1078
979
  }
1079
- return {
1080
- text: aggregated,
1081
- streamed,
1082
- toolCallsExecuted,
1083
- messages,
1084
- };
1085
- }
1086
980
 
1087
- const assistantToolCalls = [];
1088
- for (const call of toolCalls) {
1089
- const callId = String(call.id || `call_${randomUUID()}`);
1090
- const name = normalizeToolName(call.function.name || "");
1091
- const args = normalizeToolCallArgs(call.function.arguments || "");
1092
-
1093
- assistantToolCalls.push({
1094
- id: callId,
1095
- type: "function",
1096
- function: {
1097
- name: name || String(call.function.name || ""),
1098
- arguments: toJsonString(args),
1099
- },
1100
- });
1101
- }
981
+ if (assistantToolCalls.length === 0) return null;
1102
982
 
1103
- if (assistantToolCalls.length === 0) {
1104
- return {
1105
- text: aggregated,
1106
- streamed,
1107
- toolCallsExecuted,
1108
- messages,
1109
- };
1110
- }
1111
-
1112
- messages.push({
1113
- role: "assistant",
1114
- content: null,
1115
- tool_calls: assistantToolCalls,
1116
- });
983
+ messages.push({
984
+ role: "assistant",
985
+ content: null,
986
+ tool_calls: assistantToolCalls,
987
+ });
1117
988
 
1118
- for (const toolCall of assistantToolCalls) {
1119
- const toolResult = runCoreTool({
1120
- tool: toolCall.function.name,
989
+ return assistantToolCalls.map((toolCall) => ({
990
+ name: toolCall.function.name,
1121
991
  args: normalizeToolCallArgs(toolCall.function.arguments),
1122
- workspaceRoot,
1123
- onToolEvent,
1124
- });
1125
- toolCallsExecuted += 1;
1126
- if (!toolResult || toolResult.ok === false) {
1127
- toolErrors += 1;
1128
- }
1129
- enforceNativeToolBudget({
1130
- toolCallsExecuted,
1131
- toolErrors,
1132
- maxToolCalls: toolBudget.maxToolCalls,
1133
- maxToolErrors: toolBudget.maxToolErrors,
1134
- lastTool: toolCall.function.name,
1135
- lastError: toolResult && toolResult.error ? String(toolResult.error) : "",
1136
- });
992
+ source: toolCall,
993
+ }));
994
+ },
995
+ appendToolResult({ messages, call, toolResult }) {
1137
996
  messages.push({
1138
997
  role: "tool",
1139
- tool_call_id: toolCall.id,
998
+ tool_call_id: call.source.id,
1140
999
  content: clipText(toJsonString(toolResult), 12000),
1141
1000
  });
1142
- }
1143
- }
1001
+ },
1002
+ },
1003
+ "anthropic-messages": {
1004
+ resolveUrl: resolveAnthropicMessagesUrl,
1005
+ prepareMessages({ messages, prompt }) {
1006
+ messages.push({
1007
+ role: "user",
1008
+ content: String(prompt || ""),
1009
+ });
1010
+ },
1011
+ runTurn: runAnthropicTurn,
1012
+ getToolCalls(turnResult) {
1013
+ return Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
1014
+ },
1015
+ appendFinalAssistantMessage({ messages, turnResult }) {
1016
+ const assistantContent = Array.isArray(turnResult.assistantContent)
1017
+ ? turnResult.assistantContent
1018
+ : [];
1019
+ if (assistantContent.length > 0) {
1020
+ messages.push({
1021
+ role: "assistant",
1022
+ content: assistantContent,
1023
+ });
1024
+ } else if (String(turnResult.text || "").trim()) {
1025
+ messages.push({
1026
+ role: "assistant",
1027
+ content: [
1028
+ {
1029
+ type: "text",
1030
+ text: String(turnResult.text || ""),
1031
+ },
1032
+ ],
1033
+ });
1034
+ }
1035
+ },
1036
+ prepareToolCalls({ messages, turnResult, toolCalls }) {
1037
+ const assistantContent = Array.isArray(turnResult.assistantContent)
1038
+ ? turnResult.assistantContent
1039
+ : [];
1144
1040
 
1145
- }
1041
+ messages.push({
1042
+ role: "assistant",
1043
+ content: assistantContent,
1044
+ });
1045
+
1046
+ return toolCalls.map((call) => ({
1047
+ name: call.name,
1048
+ args: call.args,
1049
+ source: call,
1050
+ }));
1051
+ },
1052
+ appendToolResult({ collected, call, toolResult }) {
1053
+ collected.push({
1054
+ type: "tool_result",
1055
+ tool_use_id: String(call.source.id || ""),
1056
+ content: clipText(toJsonString(toolResult), 12000),
1057
+ is_error: Boolean(!toolResult || toolResult.ok === false),
1058
+ });
1059
+ },
1060
+ flushToolResults({ messages, collected }) {
1061
+ messages.push({
1062
+ role: "user",
1063
+ content: collected,
1064
+ });
1065
+ },
1066
+ },
1067
+ };
1146
1068
 
1147
- async function runNativeLoopAnthropic({
1069
+ async function runNativeLoop({
1070
+ transport,
1148
1071
  workspaceRoot = process.cwd(),
1149
1072
  prompt = "",
1150
1073
  systemPrompt = "",
@@ -1165,16 +1088,13 @@ async function runNativeLoopAnthropic({
1165
1088
  throw new Error("ucode model is not configured");
1166
1089
  }
1167
1090
 
1168
- const requestUrl = resolveAnthropicMessagesUrl(baseUrl);
1091
+ const requestUrl = transport.resolveUrl(baseUrl);
1169
1092
  if (!requestUrl) {
1170
1093
  throw new Error("ucode baseUrl is not configured");
1171
1094
  }
1172
1095
 
1173
1096
  const messages = cloneMessageList(historyMessages);
1174
- messages.push({
1175
- role: "user",
1176
- content: String(prompt || ""),
1177
- });
1097
+ transport.prepareMessages({ messages, systemPrompt, prompt });
1178
1098
 
1179
1099
  let aggregated = "";
1180
1100
  let streamed = false;
@@ -1185,7 +1105,7 @@ async function runNativeLoopAnthropic({
1185
1105
  while (true) {
1186
1106
  guards.ensureActive();
1187
1107
 
1188
- const turnResult = await runAnthropicTurn({
1108
+ const turnResult = await transport.runTurn({
1189
1109
  url: requestUrl,
1190
1110
  apiKey,
1191
1111
  model: requestModel,
@@ -1206,28 +1126,10 @@ async function runNativeLoopAnthropic({
1206
1126
  },
1207
1127
  });
1208
1128
 
1209
- const toolCalls = Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
1129
+ const toolCalls = transport.getToolCalls(turnResult);
1210
1130
 
1211
1131
  if (toolCalls.length === 0) {
1212
- const assistantContent = Array.isArray(turnResult.assistantContent)
1213
- ? turnResult.assistantContent
1214
- : [];
1215
- if (assistantContent.length > 0) {
1216
- messages.push({
1217
- role: "assistant",
1218
- content: assistantContent,
1219
- });
1220
- } else if (String(turnResult.text || "").trim()) {
1221
- messages.push({
1222
- role: "assistant",
1223
- content: [
1224
- {
1225
- type: "text",
1226
- text: String(turnResult.text || ""),
1227
- },
1228
- ],
1229
- });
1230
- }
1132
+ transport.appendFinalAssistantMessage({ messages, turnResult });
1231
1133
  const text = String(turnResult.text || "").trim();
1232
1134
  if (!aggregated.trim() && text) {
1233
1135
  aggregated = text;
@@ -1240,20 +1142,21 @@ async function runNativeLoopAnthropic({
1240
1142
  };
1241
1143
  }
1242
1144
 
1243
- const assistantContent = Array.isArray(turnResult.assistantContent)
1244
- ? turnResult.assistantContent
1245
- : [];
1246
-
1247
- messages.push({
1248
- role: "assistant",
1249
- content: assistantContent,
1250
- });
1145
+ const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
1146
+ if (!pendingCalls) {
1147
+ return {
1148
+ text: aggregated,
1149
+ streamed,
1150
+ toolCallsExecuted,
1151
+ messages,
1152
+ };
1153
+ }
1251
1154
 
1252
- const toolResults = [];
1253
- for (const call of toolCalls) {
1155
+ const collectedResults = [];
1156
+ for (const pending of pendingCalls) {
1254
1157
  const toolResult = runCoreTool({
1255
- tool: call.name,
1256
- args: call.args,
1158
+ tool: pending.name,
1159
+ args: pending.args,
1257
1160
  workspaceRoot,
1258
1161
  onToolEvent,
1259
1162
  });
@@ -1266,23 +1169,21 @@ async function runNativeLoopAnthropic({
1266
1169
  toolErrors,
1267
1170
  maxToolCalls: toolBudget.maxToolCalls,
1268
1171
  maxToolErrors: toolBudget.maxToolErrors,
1269
- lastTool: call.name,
1172
+ lastTool: pending.name,
1270
1173
  lastError: toolResult && toolResult.error ? String(toolResult.error) : "",
1271
1174
  });
1272
- toolResults.push({
1273
- type: "tool_result",
1274
- tool_use_id: String(call.id || ""),
1275
- content: clipText(toJsonString(toolResult), 12000),
1276
- is_error: Boolean(!toolResult || toolResult.ok === false),
1175
+ transport.appendToolResult({
1176
+ messages,
1177
+ collected: collectedResults,
1178
+ call: pending,
1179
+ toolResult,
1277
1180
  });
1278
1181
  }
1279
1182
 
1280
- messages.push({
1281
- role: "user",
1282
- content: toolResults,
1283
- });
1183
+ if (typeof transport.flushToolResults === "function") {
1184
+ transport.flushToolResults({ messages, collected: collectedResults });
1185
+ }
1284
1186
  }
1285
-
1286
1187
  }
1287
1188
 
1288
1189
  async function runNativeAgentTask({
@@ -1303,6 +1204,17 @@ async function runNativeAgentTask({
1303
1204
  const guards = createGuards({ signal, timeoutMs });
1304
1205
  const nextSessionId = String(sessionId || "").trim() || `native-${randomUUID()}`;
1305
1206
  const promptText = String(prompt || "").trim();
1207
+ // Track every text delta so the error path can return the partial output
1208
+ // the model already produced instead of discarding it.
1209
+ let partialOutput = "";
1210
+ const trackingStreamDelta = (chunk) => {
1211
+ const text = String(chunk || "");
1212
+ if (!text) return;
1213
+ partialOutput += text;
1214
+ if (typeof onStreamDelta === "function") {
1215
+ onStreamDelta(chunk);
1216
+ }
1217
+ };
1306
1218
 
1307
1219
  try {
1308
1220
  guards.ensureActive();
@@ -1323,11 +1235,10 @@ async function runNativeAgentTask({
1323
1235
  model,
1324
1236
  });
1325
1237
 
1326
- const loopRunner = runtime.transport === "anthropic-messages"
1327
- ? runNativeLoopAnthropic
1328
- : runNativeLoopOpenAi;
1238
+ const transport = TRANSPORTS[runtime.transport] || TRANSPORTS["openai-chat"];
1329
1239
 
1330
- const runResult = await loopRunner({
1240
+ const runResult = await runNativeLoop({
1241
+ transport,
1331
1242
  workspaceRoot,
1332
1243
  prompt: promptText,
1333
1244
  systemPrompt,
@@ -1336,7 +1247,7 @@ async function runNativeAgentTask({
1336
1247
  baseUrl: runtime.baseUrl,
1337
1248
  apiKey: runtime.apiKey,
1338
1249
  timeoutMs,
1339
- onStreamDelta,
1250
+ onStreamDelta: trackingStreamDelta,
1340
1251
  onThinkingDelta,
1341
1252
  onPhase,
1342
1253
  onToolEvent,
@@ -1356,14 +1267,16 @@ async function runNativeAgentTask({
1356
1267
  output: outputText,
1357
1268
  messages: cloneMessageList(runResult.messages),
1358
1269
  sessionId: nextSessionId,
1359
- streamed: Boolean(runResult.streamed),
1270
+ // The loop marks streamed=true whenever it receives a stream callback;
1271
+ // only report it when the caller actually registered one.
1272
+ streamed: Boolean(runResult.streamed) && typeof onStreamDelta === "function",
1360
1273
  };
1361
1274
  } catch (err) {
1362
1275
  const message = err && err.message ? err.message : "native runner failed";
1363
1276
  return {
1364
1277
  ok: false,
1365
1278
  error: message,
1366
- output: "",
1279
+ output: partialOutput.trim(),
1367
1280
  sessionId: nextSessionId,
1368
1281
  streamed: false,
1369
1282
  };
@@ -1372,9 +1285,6 @@ async function runNativeAgentTask({
1372
1285
 
1373
1286
  module.exports = {
1374
1287
  runNativeAgentTask,
1375
- parseReadIntent,
1376
- parseBashIntent,
1377
- extractPreflightEvidence,
1378
1288
  resolveRuntimeConfig,
1379
1289
  resolveCompletionUrl,
1380
1290
  resolveAnthropicMessagesUrl,