@hncode/hncode 0.4.2 → 0.5.1

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.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
package/src/llm.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { llmTools } from './tools/index.js';
11
11
  import { effortWire } from './config.js';
12
12
  import { applyPromptCache } from './cache.js';
13
+ import { runPatch, runPatchSync } from './plugin.js';
13
14
 
14
15
  export function openAiToolDefs(tools) {
15
16
  // Guard: a caller that passes null/undefined (e.g. setToolsList(null)) used to
@@ -147,6 +148,75 @@ export function toAnthropic(messages) {
147
148
  return { system: sys.join('\n\n').trim(), messages: out };
148
149
  }
149
150
 
151
+ /** Responses: content parts use `input_text` / `input_image`. */
152
+ function toResponsesContent(c) {
153
+ const parts = [];
154
+ const text = mediaText(c);
155
+ if (text) parts.push({ type: 'input_text', text });
156
+ for (const m of c.media) {
157
+ if (m && m.type === 'image' && m.data) {
158
+ parts.push({ type: 'input_image', image_url: `data:${m.mimeType || 'image/png'};base64,${m.data}` });
159
+ }
160
+ }
161
+ if (!parts.length) return text || '';
162
+ return parts;
163
+ }
164
+
165
+ // Internal canonical messages -> OpenAI RESPONSES API request shape.
166
+ //
167
+ // The Responses API is NOT chat/completions with a different name: the message
168
+ // array becomes a flat `input` ITEM list, the system prompt moves to a top-level
169
+ // `instructions`, tools are FLAT (no nested `function` object), a tool call is its
170
+ // own item (`function_call`) and its result is another (`function_call_output`,
171
+ // keyed by `call_id` rather than `tool_call_id`). Returns { instructions, input }.
172
+ export function toResponsesInput(messages) {
173
+ const sys = [];
174
+ const input = [];
175
+ for (const m of messages) {
176
+ if (m.role === 'system') { sys.push(m.content); continue; }
177
+ if (m.role === 'user') {
178
+ input.push({ role: 'user', content: isMediaContent(m.content) ? toResponsesContent(m.content) : [{ type: 'input_text', text: String(m.content == null ? '' : m.content) }] });
179
+ continue;
180
+ }
181
+ if (m.role === 'tool') {
182
+ // Tool output is a top-level item, not a role:'tool' message.
183
+ const out = isMediaContent(m.content)
184
+ ? toResponsesContent(m.content)
185
+ : String(m.content == null ? '' : m.content);
186
+ input.push({ type: 'function_call_output', call_id: m.toolCallId, output: out });
187
+ continue;
188
+ }
189
+ if (m.role === 'assistant') {
190
+ const hasText = typeof m.content === 'string' && m.content.trim();
191
+ const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
192
+ if (!hasText && !hasCalls) continue;
193
+ if (hasText) {
194
+ input.push({ role: 'assistant', content: [{ type: 'output_text', text: m.content }] });
195
+ }
196
+ for (const tc of (m.toolCalls || [])) {
197
+ input.push({
198
+ type: 'function_call',
199
+ call_id: tc.id,
200
+ name: tc.name,
201
+ arguments: JSON.stringify(tc.args || {}),
202
+ });
203
+ }
204
+ }
205
+ }
206
+ return { instructions: sys.join('\n\n').trim(), input };
207
+ }
208
+
209
+ // Responses tools are FLAT: { type:'function', name, description, parameters }.
210
+ // (chat/completions nests all of that under a `function` key.)
211
+ export function responsesToolDefs(tools) {
212
+ return (Array.isArray(tools) ? tools : []).map((t) => ({
213
+ type: 'function',
214
+ name: t.name,
215
+ description: t.description,
216
+ parameters: t.parameters,
217
+ }));
218
+ }
219
+
150
220
  function authHeaders(cfg) {
151
221
  const h = { 'content-type': 'application/json' };
152
222
  if (cfg.apiKey) {
@@ -163,7 +233,12 @@ function authHeaders(cfg) {
163
233
  function buildBody(cfg, messages, streaming = true, opts = {}) {
164
234
  const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
165
235
  if (cfg.temperature != null) common.temperature = cfg.temperature;
166
- // Thinking / reasoning. The wire form depends on the protocol and the chosen
236
+ // Ask for the token accounting on the FINAL stream frame. Without this the
237
+ // OpenAI-compatible stream carries no `usage` at all, so the only token numbers
238
+ // available were our own estimates (see /usage, which divided the transcript's
239
+ // JSON length by 3.5). `includeUsage` is cleared after a 400 (see request):
240
+ // servers that predate the field reject the whole request.
241
+ if (streaming && opts.includeUsage !== false) common.stream_options = { include_usage: true };
167
242
  // effort (see config.effortWire): OpenAI-compatible sends `reasoning_effort`,
168
243
  // Anthropic sends a `thinking` budget. Models that think by default need no
169
244
  // flag, so nothing is sent when the effort is off/unset.
@@ -193,6 +268,30 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
193
268
  sessionKey: cfg.sessionId,
194
269
  }));
195
270
  }
271
+ if (cfg.protocol === 'responses') {
272
+ const { instructions, input } = toResponsesInput(messages);
273
+ const rbody = {
274
+ model: cfg.innerModel,
275
+ // `instructions` carries the system prompt; `input` is the flat item list.
276
+ input,
277
+ // NOTE: the cap is `max_output_tokens` here, NOT chat/completions'
278
+ // `max_tokens` — sending the wrong name is silently ignored by the API.
279
+ max_output_tokens: cfg.maxOutputTokens,
280
+ stream: streaming,
281
+ ...wire,
282
+ };
283
+ if (instructions) rbody.instructions = instructions;
284
+ if (cfg.temperature != null) rbody.temperature = cfg.temperature;
285
+ if (!opts.noTools) {
286
+ const defsR = Object.prototype.hasOwnProperty.call(opts, 'tools')
287
+ ? responsesToolDefs(opts.tools) : responsesToolDefs(llmToolsList);
288
+ if (defsR.length) rbody.tools = defsR;
289
+ }
290
+ return JSON.stringify(applyPromptCache('responses', rbody, {
291
+ enabled: cfg.promptCache !== false,
292
+ sessionKey: cfg.sessionId,
293
+ }));
294
+ }
196
295
  const body = {
197
296
  model: cfg.innerModel, messages: toOpenAi(messages),
198
297
  ...common, ...wire,
@@ -247,8 +346,8 @@ export class LLM {
247
346
  try {
248
347
  const res = await fetch(cfg.endpoint, {
249
348
  method: 'POST',
250
- headers: authHeaders(cfg),
251
- body: buildBody(cfg, messages, false, opts),
349
+ headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
350
+ body: (() => { let b = buildBody(cfg, messages, false, opts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
252
351
  signal: this.controller.signal,
253
352
  });
254
353
  if (!res || !res.ok) {
@@ -263,6 +362,17 @@ export class LLM {
263
362
  for (const cb of (json.content || [])) {
264
363
  if (cb.type === 'text' && cb.text) text += cb.text;
265
364
  }
365
+ } else if (cfg.protocol === 'responses') {
366
+ // Responses: text sits in `message` items' `output_text` parts. Some
367
+ // gateways also expose a top-level `output_text` convenience field.
368
+ if (typeof json.output_text === 'string') text = json.output_text;
369
+ else {
370
+ for (const item of (json.output || [])) {
371
+ if (item && item.type === 'message' && Array.isArray(item.content)) {
372
+ for (const c of item.content) if (c && c.type === 'output_text' && c.text) text += c.text;
373
+ }
374
+ }
375
+ }
266
376
  } else {
267
377
  const msg = json.choices && json.choices[0] && json.choices[0].message;
268
378
  if (msg && msg.content) text = msg.content;
@@ -275,7 +385,9 @@ export class LLM {
275
385
  if (!text) {
276
386
  const truncated = cfg.protocol === 'anthropic'
277
387
  ? (json.stop_reason === 'max_tokens')
278
- : !!(json.choices && json.choices[0] && json.choices[0].finish_reason === 'length');
388
+ : cfg.protocol === 'responses'
389
+ ? !!(json.incomplete_details && json.incomplete_details.reason === 'max_output_tokens')
390
+ : !!(json.choices && json.choices[0] && json.choices[0].finish_reason === 'length');
279
391
  return truncated ? '' : null;
280
392
  }
281
393
  return text;
@@ -286,17 +398,26 @@ export class LLM {
286
398
  }
287
399
 
288
400
  async request(messages, onEvent) {
401
+ // Mixin seam: let plugins mutate the messages sent to the model.
402
+ try { const out = await runPatch('llmRequest', { messages, cfg: this.cfg }, (c) => c); if (out && out.messages) messages = out.messages; } catch (e) { /* patch failed; proceed with original */ }
289
403
  const cfg = this.cfg;
290
404
  let res = null;
291
405
  let lastError = null;
292
406
  let lastStatus = 0;
293
407
  let attempts = 0;
294
408
 
295
- // Retry the HTTP round-trip up to 3 times. Any non-200 response is treated
296
- // as a problem and retried (5xx, 429, and unexpected 4xx alike), as is a
297
- // network/connection error. The only exceptions are aborts (Esc/Ctrl-C) —
298
- // those end the turn — and a response that is not ok on the final attempt.
299
- const RETRIES = 3;
409
+ // Retry the HTTP round-trip. Any non-200 response is treated as a problem and
410
+ // retried (5xx, 429, and unexpected 4xx alike), as is a network/connection
411
+ // error. The only exceptions are aborts (Esc/Ctrl-C) — those end the turn —
412
+ // and a response that is not ok on the final attempt. The count is
413
+ // `cfg.maxRetries` when set (the `--retries` flag), else 3.
414
+ const RETRIES = Number.isInteger(this.cfg && this.cfg.maxRetries) && this.cfg.maxRetries > 0
415
+ ? this.cfg.maxRetries : 3;
416
+ // Whether this attempt asks for streaming usage. A server that does not know
417
+ // `stream_options` rejects the whole request with a 400, so the first 400
418
+ // turns it off and the retry goes out without it — degrading to an estimate
419
+ // rather than failing the request over an accounting nicety.
420
+ const reqOpts = {};
300
421
 
301
422
  for (let attempt = 1; attempt <= RETRIES; attempt++) {
302
423
  attempts = attempt;
@@ -307,8 +428,8 @@ export class LLM {
307
428
  try {
308
429
  res = await fetch(cfg.endpoint, {
309
430
  method: 'POST',
310
- headers: authHeaders(cfg),
311
- body: buildBody(cfg, messages),
431
+ headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
432
+ body: (() => { let b = buildBody(cfg, messages, true, reqOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
312
433
  signal,
313
434
  });
314
435
  } catch (e) {
@@ -318,7 +439,12 @@ export class LLM {
318
439
  lastStatus = 0; // network error
319
440
  }
320
441
  if (res && res.ok) break;
321
- if (res) lastStatus = res.status;
442
+ if (res) {
443
+ lastStatus = res.status;
444
+ // Drop `stream_options` and try again: a 400 here is the one error this
445
+ // request can fix by itself, and the accounting is optional.
446
+ if (res.status === 400 && reqOpts.includeUsage !== false) reqOpts.includeUsage = false;
447
+ }
322
448
  if (attempt < RETRIES) {
323
449
  // Small exponential backoff (200ms, 400ms). If the user hit Esc during
324
450
  // the wait, stop retrying.
@@ -392,30 +518,40 @@ export class LLM {
392
518
  }
393
519
  }
394
520
 
395
- // ---- inline reasoning tags -------------------------------------------------
396
- // Some OpenAI-compatible servers do not send a separate `reasoning_content`
397
- // field: they put the chain of thought INLINE in `content`, wrapped in a tag.
398
- // Three spellings have to be supported, because they come from three places:
399
- // ` thinking…` — the DeepSeek-R1 token markers, as emitted by
400
- // vLLM / llama.cpp builds of that model
401
- // `<thinking>…</thinking>` — what several OpenAI-compatible servers wrap it in
402
- // `<think>…</think>` — what OUR OWN system prompt asks for (agent.js
403
- // and prompt-presets.js both instruct this)
404
- // The last one was missing, so a model that followed our prompt had its whole
405
- // reasoning trace rendered as the ANSWER, raw tags and all.
521
+ // A model that follows OUR prompt emits `<|thinking|>…<|/thinking|>` (agent.js and
522
+ // prompt-presets.js both instruct it). Three more spellings have to be accepted,
523
+ // because they are not ours to change:
524
+ // `<|think|>` / `<|/think|>` — the short form of the same convention, for models
525
+ // that abbreviate it, and for sessions recorded before
526
+ // the longer spelling became the prompt's
527
+ // `</|thinking|>` / `</|think|>`
528
+ // — the same two with the slash written before the bar.
529
+ // Accepted so the delimiter ORDER cannot be the thing
530
+ // that decides whether reasoning stays out of the answer
531
+ // `<|begin▁of▁thinking|>` / `<|end▁of▁thinking|>`
532
+ // — DeepSeek-R1's own token markers, as emitted by
533
+ // vLLM / llama.cpp builds. The bars are U+FF5C and the
534
+ // separators U+2581, so no prose can be mistaken for one.
535
+ // The OPENING marker was missing while the closing one
536
+ // was present, which left the format inside-out: the
537
+ // begin marker went out as the ANSWER, and the end marker
538
+ // then flipped everything after it into reasoning.
406
539
  //
407
- // The three are mutually distinguishable, which is what makes supporting all of
408
- // them safe: `</think>` does not occur inside `</thinking>`, `<think>` does not
409
- // occur inside `<thinking>`, and `' thinking'` does not occur inside `<think>`.
410
- // So an early match can never be caused by the longer tag.
540
+ // The BARE forms are deliberately GONE: `' thinking'`, `<thinking>`, `<think>`.
541
+ // feedContent locates a tag with `indexOf` over the whole buffer and then TOGGLES, so a
542
+ // tag that also occurs in ordinary writing reclassifies everything after it. On `'
543
+ // thinking'` that is a sentence like "I am thinking about this", "stop thinking and
544
+ // answer", "after thinking it over" — and because such a reply never reaches a closing
545
+ // tag, the remainder was emitted as REASONING and the answer disappeared into the
546
+ // thinking block. `<think>` / `<thinking>` are legal prose too (HTML, a tutorial, a
547
+ // quotation of this very file), which is what agent.js means when it warns that "a plain
548
+ // <think> is ordinary text that may appear in code or prose".
411
549
  //
412
- // Without splitting these out, the raw tags and the whole reasoning trace are
413
- // rendered as the assistant's answer (and counted as output tokens). This is a
414
- // streaming state machine: a tag can be split across chunks, so a trailing partial
415
- // tag is held back until the next chunk decides what it is.
416
- const THINK_OPEN_TAGS = [' thinking', '<thinking>', '<think>'];
550
+ // Mixing is intended and already works: the parser toggles on ANY opening tag and ANY
551
+ // closing one, so `<|thinking|>…<|/think|>` closes correctly.
552
+ const THINK_OPEN_TAGS = ['<|thinking|>', '<|think|>', '<|begin▁of▁thinking|>'];
417
553
  // Closing tags. If none of these appear, the block stays in think mode to EOI.
418
- const THINK_CLOSE_TAGS = ['<|end▁of▁thinking|>', '</thinking>', '</think>'];
554
+ const THINK_CLOSE_TAGS = ['<|/thinking|>', '</|thinking|>', '<|/think|>', '</|think|>', '<|end▁of▁thinking|>'];
419
555
 
420
556
  // Longest suffix of `buf` that is a proper prefix of one of `tags`.
421
557
  // A single trailing space is deliberately NOT a candidate: `' '` is a prefix of
@@ -489,10 +625,75 @@ function processEvent(protocol, raw, onEvent, state) {
489
625
  let d;
490
626
  try { d = JSON.parse(raw); } catch { return 'continue'; }
491
627
  if (protocol === 'anthropic') return processAnthropic(d, onEvent, state);
628
+ if (protocol === 'responses') return processResponses(d, onEvent, state);
492
629
  return processOpenAI(d, onEvent, state);
493
630
  }
494
631
 
632
+ // ---- token accounting -------------------------------------------------------
633
+ // Every protocol reports the same facts under different names, and the only
634
+ // numbers that are not estimates come from here. Normalised shape:
635
+ // { input, output, cached, cacheWrite, reasoning }
636
+ // `cached` is the part of `input` that was a cache READ (billed far cheaper), and
637
+ // `cacheWrite` the part written to the cache (billed dearer on Anthropic). Keeping
638
+ // them separate is what makes the cost estimate match a real invoice.
639
+ //
640
+ // A field left undefined means "not reported", which is different from zero: the
641
+ // caller adds what it gets and does not invent the rest.
642
+
643
+ /** OpenAI chat/completions `usage`. */
644
+ function normalizeOpenAIUsage(u) {
645
+ if (!u || typeof u !== 'object') return null;
646
+ const cached = u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens;
647
+ const reasoning = u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens;
648
+ return {
649
+ input: numOrUndef(u.prompt_tokens ?? u.input_tokens),
650
+ output: numOrUndef(u.completion_tokens ?? u.output_tokens),
651
+ cached: numOrUndef(cached),
652
+ reasoning: numOrUndef(reasoning),
653
+ };
654
+ }
655
+
656
+ /** Anthropic `usage`, which splits cache reads and writes out. */
657
+ function normalizeAnthropicUsage(u) {
658
+ if (!u || typeof u !== 'object') return null;
659
+ return {
660
+ input: numOrUndef(u.input_tokens),
661
+ output: numOrUndef(u.output_tokens),
662
+ cached: numOrUndef(u.cache_read_input_tokens),
663
+ cacheWrite: numOrUndef(u.cache_creation_input_tokens),
664
+ };
665
+ }
666
+
667
+ /** OpenAI Responses `usage`. */
668
+ function normalizeResponsesUsage(u) {
669
+ if (!u || typeof u !== 'object') return null;
670
+ const cached = u.input_tokens_details && u.input_tokens_details.cached_tokens;
671
+ const reasoning = u.output_tokens_details && u.output_tokens_details.reasoning_tokens;
672
+ return {
673
+ input: numOrUndef(u.input_tokens),
674
+ output: numOrUndef(u.output_tokens),
675
+ cached: numOrUndef(cached),
676
+ reasoning: numOrUndef(reasoning),
677
+ };
678
+ }
679
+
680
+ function numOrUndef(v) {
681
+ const n = Number(v);
682
+ return Number.isFinite(n) ? n : undefined;
683
+ }
684
+
685
+ /** Emit one `usage` event if the frame carried any numbers. */
686
+ function emitUsage(onEvent, u) {
687
+ if (!u) return;
688
+ if (u.input === undefined && u.output === undefined && u.cached === undefined && u.cacheWrite === undefined) return;
689
+ onEvent({ type: 'usage', usage: u });
690
+ }
691
+
495
692
  function processOpenAI(d, onEvent, state) {
693
+ // The accounting arrives on its own frame, with an EMPTY `choices` array, right
694
+ // before `[DONE]` — so it has to be read before the delta checks below, which
695
+ // would otherwise return early on it.
696
+ emitUsage(onEvent, normalizeOpenAIUsage(d.usage));
496
697
  const choice = d.choices && d.choices[0];
497
698
  const delta = choice && choice.delta;
498
699
  const finishReason = choice && choice.finish_reason;
@@ -561,6 +762,13 @@ function processAnthropic(d, onEvent, state) {
561
762
  const t = d.type;
562
763
  if (t === 'message_start' || t === 'message_delta' || t === 'message_stop') {
563
764
  if (t === 'message_stop') { onEvent({ type: 'end' }); return 'end'; }
765
+ // Anthropic splits the accounting in two: `message_start` carries the INPUT
766
+ // (including cache reads and writes) and `message_delta` the OUTPUT. Both are
767
+ // reported, and the accumulator adds them.
768
+ if (t === 'message_start' && d.message && d.message.usage) {
769
+ emitUsage(onEvent, normalizeAnthropicUsage(d.message.usage));
770
+ }
771
+ if (t === 'message_delta' && d.usage) emitUsage(onEvent, normalizeAnthropicUsage(d.usage));
564
772
  // `message_delta` carries the stop reason; `max_tokens` there means the reply
565
773
  // was cut off, not finished (see processOpenAI for the OpenAI spelling).
566
774
  if (t === 'message_delta' && d.delta && d.delta.stop_reason === 'max_tokens') {
@@ -579,8 +787,12 @@ function processAnthropic(d, onEvent, state) {
579
787
  }
580
788
  if (t === 'content_block_delta') {
581
789
  const delta = d.delta || {};
790
+ // A thinking delta can arrive EMPTY (the protocol sends a delta frame with no
791
+ // text for a block that produced none). Emitting it created an empty reasoning
792
+ // block, which renders as a bare `●` row. The renderer drops those as well — this
793
+ // is the cheaper place to stop it, and it keeps `state.chat` free of them.
582
794
  if (delta.type === 'text_delta') feedContent(delta.text || '', onEvent, state);
583
- else if (delta.type === 'thinking_delta') onEvent({ type: 'think', text: delta.thinking || '' });
795
+ else if (delta.type === 'thinking_delta' && delta.thinking) onEvent({ type: 'think', text: delta.thinking });
584
796
  else if (delta.type === 'input_json') {
585
797
  const blk = state.tools.get(d.index);
586
798
  if (blk) { blk.argsJson += delta.partial_json || ''; onEvent({ type: 'tool_args', id: blk.id, chunk: delta.partial_json || '' }); }
@@ -595,9 +807,91 @@ function processAnthropic(d, onEvent, state) {
595
807
  return 'continue';
596
808
  }
597
809
 
810
+ // OpenAI Responses API streaming. The wire is a TYPED event stream (much closer to
811
+ // Anthropic than to chat/completions' `choices[].delta`):
812
+ // response.output_text.delta -> answer text
813
+ // response.reasoning_summary_text.delta -> thinking summary
814
+ // response.output_item.added (function_call)-> tool_start
815
+ // response.function_call_arguments.delta -> tool_args (keyed by item_id)
816
+ // response.output_item.done (function_call) -> tool_end
817
+ // response.completed / response.incomplete -> end (incomplete = truncated)
818
+ // Tool calls are addressed by `item_id` (the item's own id), and the `call_id`
819
+ // that goes back on `function_call_output` is carried on the item — the agent keys
820
+ // its tools by the id we emit here, so we emit the call_id and remember it.
821
+ function processResponses(d, onEvent, state) {
822
+ const t = d.type;
823
+ if (t === 'response.output_text.delta') {
824
+ if (d.delta) feedContent(d.delta, onEvent, state);
825
+ return 'continue';
826
+ }
827
+ if (t === 'response.reasoning_summary_text.delta' || t === 'response.reasoning_text.delta') {
828
+ if (d.delta) onEvent({ type: 'think', text: d.delta });
829
+ return 'continue';
830
+ }
831
+ if (t === 'response.output_item.added' || t === 'response.output_item.done') {
832
+ const item = d.item || {};
833
+ if (item.type === 'function_call') {
834
+ const id = item.call_id || item.id;
835
+ if (t === 'response.output_item.added') {
836
+ if (id && !state.tools.has(id)) {
837
+ state.tools.set(id, { id, name: item.name, argsJson: '', done: false });
838
+ onEvent({ type: 'tool_start', id, name: item.name });
839
+ }
840
+ } else {
841
+ // done: the item carries the FULL arguments, so emit any that never
842
+ // streamed (some servers skip the argument deltas for short calls).
843
+ const entry = state.tools.get(id);
844
+ if (entry && !entry.done) {
845
+ if (typeof item.arguments === 'string' && item.arguments && !entry.argsJson) {
846
+ entry.argsJson = item.arguments;
847
+ onEvent({ type: 'tool_args', id, chunk: item.arguments });
848
+ }
849
+ entry.done = true;
850
+ onEvent({ type: 'tool_end', id });
851
+ }
852
+ }
853
+ }
854
+ return 'continue';
855
+ }
856
+ if (t === 'response.function_call_arguments.delta') {
857
+ const id = d.item_id || d.call_id;
858
+ const entry = id ? state.tools.get(id) : null;
859
+ if (entry) {
860
+ entry.argsJson += d.delta || '';
861
+ onEvent({ type: 'tool_args', id, chunk: d.delta || '' });
862
+ }
863
+ return 'continue';
864
+ }
865
+ if (t === 'response.completed') {
866
+ // The accounting rides on the terminal event's `response` object.
867
+ emitUsage(onEvent, normalizeResponsesUsage(d.response && d.response.usage));
868
+ onEvent({ type: 'end' });
869
+ return 'end';
870
+ }
871
+ if (t === 'response.incomplete') {
872
+ // `incomplete_details.reason === 'max_output_tokens'` means the same thing as
873
+ // chat/completions' finish_reason:'length' — the reply was cut, not finished.
874
+ emitUsage(onEvent, normalizeResponsesUsage(d.response && d.response.usage));
875
+ const reason = d.response && d.response.incomplete_details && d.response.incomplete_details.reason;
876
+ if (reason === 'max_output_tokens') onEvent({ type: 'truncated' });
877
+ onEvent({ type: 'end' });
878
+ return 'end';
879
+ }
880
+ if (t === 'response.failed' || t === 'error') {
881
+ const msg = (d.response && d.response.error && d.response.error.message) || d.message || 'response failed';
882
+ onEvent({ type: 'error', error: new Error(msg) });
883
+ return 'end';
884
+ }
885
+ return 'continue';
886
+ }
887
+
598
888
  // --- Non-streaming path ---
599
889
  export function consumeNonStreaming(protocol, json, onEvent) {
600
890
  const state = { tagPending: '', inThink: false };
891
+ // Same accounting, one frame. Everything below this line is unchanged.
892
+ emitUsage(onEvent, protocol === 'anthropic' ? normalizeAnthropicUsage(json.usage)
893
+ : protocol === 'responses' ? normalizeResponsesUsage(json.usage)
894
+ : normalizeOpenAIUsage(json.usage));
601
895
  if (protocol === 'anthropic') {
602
896
  const content = json.content || [];
603
897
  if (json.stop_reason === 'max_tokens') onEvent({ type: 'truncated' });
@@ -609,6 +903,33 @@ export function consumeNonStreaming(protocol, json, onEvent) {
609
903
  onEvent({ type: 'tool_end', id: cb.id });
610
904
  }
611
905
  }
906
+ } else if (protocol === 'responses') {
907
+ // Responses non-streaming: one object with an `output` ITEM list. Text lives in
908
+ // a `message` item's `content[]` (`output_text`), reasoning in a `reasoning`
909
+ // item's summary, and tool calls are their own `function_call` items.
910
+ const output = json.output || [];
911
+ if (json.incomplete_details && json.incomplete_details.reason === 'max_output_tokens') {
912
+ onEvent({ type: 'truncated' });
913
+ }
914
+ if (json.status === 'failed' && json.error) {
915
+ onEvent({ type: 'error', error: new Error(json.error.message || 'response failed') });
916
+ return;
917
+ }
918
+ for (const item of output) {
919
+ if (!item) continue;
920
+ if (item.type === 'reasoning' && Array.isArray(item.summary)) {
921
+ for (const s of item.summary) if (s && typeof s.text === 'string') onEvent({ type: 'think', text: s.text });
922
+ } else if (item.type === 'message' && Array.isArray(item.content)) {
923
+ for (const c of item.content) {
924
+ if (c && c.type === 'output_text' && c.text) feedContent(c.text, onEvent, state);
925
+ }
926
+ } else if (item.type === 'function_call') {
927
+ const id = item.call_id || item.id;
928
+ onEvent({ type: 'tool_start', id, name: item.name });
929
+ if (item.arguments) onEvent({ type: 'tool_args', id, chunk: item.arguments });
930
+ onEvent({ type: 'tool_end', id });
931
+ }
932
+ }
612
933
  } else {
613
934
  const choice = json.choices && json.choices[0];
614
935
  const msg = choice && choice.message;