@giovannijecha/jecode 0.7.4 → 0.8.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.
package/README.md CHANGED
@@ -22,18 +22,21 @@
22
22
 
23
23
  <p align="center">
24
24
  <a href="https://github.com/giovannijecha/jecode/blob/main/CHANGELOG.md">Changelog</a> &middot;
25
+ <a href="https://github.com/giovannijecha/jecode/blob/main/docs/compatibility.md">Compatibility</a> &middot;
25
26
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
27
  </p>
27
28
 
28
29
  > Jecode is currently pre-1.0. The core loop is usable today, but commands and
29
- > terminal interactions may still evolve before 1.0.
30
+ > terminal interactions may still evolve before 1.0. The release-candidate
31
+ > surface is now frozen in the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/compatibility.md).
30
32
 
31
33
  ## Why Jecode
32
34
 
33
35
  - **One visible loop.** One controller talks to the model, runs tools, and
34
- returns control to you. Independent reads may overlap inside a step; writes
35
- and commands stay ordered. There are no delegated agents or hidden model
36
- workers.
36
+ returns control to you. Independent reads may overlap within one model
37
+ response; writes and commands stay ordered. You can steer an active turn
38
+ without starting a second loop. There are no delegated agents or hidden
39
+ model workers.
37
40
  - **Terminal-native today.** The transcript, composer, searchable menus, tool
38
41
  output, diffs, approvals, reasoning, and status share one full-screen TUI.
39
42
  - **Permission-aware.** Tool use is visible. Dangerous actions ask first, and
@@ -245,7 +248,8 @@ Useful controls:
245
248
  - **Backspace/Delete** removes one character;
246
249
  **Ctrl+Backspace/Delete** removes one word.
247
250
  - **Home/End** moves to the beginning or end of the composer.
248
- - **Tab** completes a slash command without running it; **Enter** sends.
251
+ - **Tab** completes a slash command without running it; **Enter** sends. During
252
+ an active model turn, **Enter** queues guidance for its next safe boundary.
249
253
  - **Alt+Enter** inserts a newline.
250
254
  - **Esc** closes the active menu or interrupts foreground work.
251
255
  - **Ctrl+C** interrupts, or exits while idle. **Ctrl+D** requests a clean exit.
@@ -253,9 +257,11 @@ Useful controls:
253
257
  - **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
254
258
 
255
259
  The footer keeps the active model, effort, and workspace visible. During work,
256
- it adds the current state, elapsed time, and interrupt hint. Operational
257
- feedback uses the same replaceable status area instead of adding noise to the
258
- conversation or its Markdown export.
260
+ it adds the current state, elapsed time, steering availability or queue count,
261
+ and interrupt hint. Queued guidance joins the same conversation turn after the
262
+ provider response or complete tool batch already in progress; `Esc` remains an
263
+ immediate interruption. Operational feedback uses the same replaceable status
264
+ area instead of adding noise to the conversation or its Markdown export.
259
265
 
260
266
  ## Sessions and context
261
267
 
@@ -316,19 +322,23 @@ settings, then built-in defaults.
316
322
  | `--root` | - | Current directory |
317
323
  | `--effort` | `JECODE_EFFORT` | `high` |
318
324
  | `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
319
- | `--max-steps` | `JECODE_MAX_STEPS` | `40` |
325
+ | `--max-steps` | `JECODE_MAX_STEPS` | No limit |
320
326
  | `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
321
327
  | `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
322
328
  | `--auto-approve` | `JECODE_AUTO_APPROVE=1` | Off |
323
329
  | `--ephemeral` | `JECODE_EPHEMERAL=1` | Off |
324
330
 
331
+ `--max-steps` is an opt-in, process-only budget for deterministic automation
332
+ and diagnostics. Interactive work has no arbitrary model-loop ceiling, and the
333
+ budget is neither shown nor saved by `/settings`.
334
+
325
335
  Non-secret preferences live in `~/.jecode/settings.json`. Explicitly saved API
326
336
  keys live in `~/.jecode/credentials.json`, while ChatGPT OAuth accounts live in
327
337
  `~/.jecode/accounts.json`. Environment credentials always take precedence.
328
338
  Secret stores use owner-only permissions where the operating system supports
329
339
  them.
330
340
 
331
- Jecode has one current interface theme, Dark Steel. `NO_COLOR` is supported for
341
+ Jecode has one current interface theme, Slate. `NO_COLOR` is supported for
332
342
  terminals and pipelines that disable colour.
333
343
 
334
344
  ## Safety model
@@ -409,15 +419,17 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
409
419
  probes for long-session rendering and workspace search. Architecture and
410
420
  security boundaries are documented in
411
421
  [docs/architecture.md](docs/architecture.md); brand assets and usage rules live
412
- in [docs/brand.md](docs/brand.md). The maintainer release procedure lives in
413
- [docs/releasing.md](docs/releasing.md).
422
+ in [docs/brand.md](docs/brand.md). The public 1.0 contract lives in
423
+ [docs/compatibility.md](docs/compatibility.md), and the maintainer release
424
+ procedure lives in [docs/releasing.md](docs/releasing.md).
414
425
 
415
426
  ## Community
416
427
 
417
428
  - Ask questions, share workflows, and explore early ideas in
418
429
  [GitHub Discussions](https://github.com/giovannijecha/jecode/discussions).
419
430
  - Report reproducible bugs and focused feature requests through
420
- [GitHub Issues](https://github.com/giovannijecha/jecode/issues).
431
+ [GitHub Issues](https://github.com/giovannijecha/jecode/issues). New
432
+ capabilities are deferred until the 1.0 stabilization cycle is complete.
421
433
  - Report security concerns privately through the repository Security tab.
422
434
 
423
435
  Public pull requests are not accepted at this stage. Code changes remain a
package/dist/batch.js CHANGED
@@ -126,7 +126,7 @@ function options(session, contextPolicy) {
126
126
  maxTokens: session.config.maxTokens,
127
127
  contextPolicy,
128
128
  effort: session.config.effort,
129
- maxSteps: session.config.maxSteps,
129
+ maxModelRequests: session.config.maxModelRequests,
130
130
  toolContext: { root: session.config.root },
131
131
  };
132
132
  }
package/dist/cli-info.js CHANGED
@@ -15,7 +15,7 @@ Options:
15
15
  --ollama-host <url> Ollama Cloud, local, or custom endpoint
16
16
  --effort <level> low, medium, high, xhigh, or max
17
17
  --max-tokens <number> output-token ceiling
18
- --max-steps <number> tool-loop ceiling
18
+ --max-steps <number> optional per-turn model-request budget
19
19
  --compaction-percent <${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT}>
20
20
  context usage that triggers compaction (default: ${DEFAULT_COMPACTION_PERCENT})
21
21
  --reduced-motion use a steady terminal cursor
package/dist/config.js CHANGED
@@ -27,6 +27,7 @@ export function loadConfig(argv, saved = readSettings()) {
27
27
  if (!EFFORTS.includes(effort)) {
28
28
  throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
29
29
  }
30
+ const maxModelRequests = optional(flags["max-steps"], process.env.JECODE_MAX_STEPS, undefined);
30
31
  return {
31
32
  providerId,
32
33
  model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
@@ -37,7 +38,9 @@ export function loadConfig(argv, saved = readSettings()) {
37
38
  effort,
38
39
  // Every request streams, so a large ceiling costs nothing in timeout risk.
39
40
  maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
40
- maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
41
+ ...(maxModelRequests === undefined
42
+ ? {}
43
+ : { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
41
44
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
42
45
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
43
46
  autoApprove: flags["auto-approve"] === "true" ||
@@ -6,7 +6,7 @@
6
6
  import { isToolCall } from "./types.js";
7
7
  import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
8
  import { requestAssistant } from "./controller-request.js";
9
- export const MAX_TOOL_CALLS_PER_STEP = 32;
9
+ export const MAX_TOOL_CALLS_PER_RESPONSE = 32;
10
10
  /** Independent read calls share one bounded execution wave. */
11
11
  export const MAX_CONCURRENT_TOOL_CALLS = 4;
12
12
  /**
@@ -28,9 +28,25 @@ export async function runTurn(history, options, events, signal, modelHistory = h
28
28
  if (projected !== undefined)
29
29
  context = clone([...projected]);
30
30
  };
31
- for (let step = 0; step < options.maxSteps; step++) {
31
+ const appendSteering = (messages) => {
32
+ for (const text of messages) {
33
+ append({ role: "user", content: [{ kind: "text", text }] });
34
+ }
35
+ for (const text of messages) {
36
+ events.onSteering?.(text);
37
+ }
38
+ return messages.length > 0;
39
+ };
40
+ let requests = 0;
41
+ while (true) {
32
42
  throwIfAborted(signal);
33
- events.onStep?.(step + 1, options.maxSteps);
43
+ if (options.maxModelRequests !== undefined &&
44
+ requests >= options.maxModelRequests) {
45
+ const requestLabel = options.maxModelRequests === 1 ? "request" : "requests";
46
+ throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (--max-steps limit reached)`);
47
+ }
48
+ appendSteering(options.steering?.drain() ?? []);
49
+ requests++;
34
50
  // The message is displayed as it streams; what comes back here is the
35
51
  // assembled version, which exists to be appended to the history.
36
52
  const response = await requestAssistant(history, context, specs, options, events, signal);
@@ -41,8 +57,8 @@ export async function runTurn(history, options, events, signal, modelHistory = h
41
57
  if (assistant.content.length === 0) {
42
58
  throw new Error(`${options.provider.id} completed without an answer or tool call`);
43
59
  }
44
- if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
45
- throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
60
+ if (calls.length > MAX_TOOL_CALLS_PER_RESPONSE) {
61
+ throw new Error(`provider returned ${calls.length} tool calls in one response (maximum ${MAX_TOOL_CALLS_PER_RESPONSE})`);
46
62
  }
47
63
  assertToolCallIds(calls);
48
64
  if (assistant.usage !== undefined)
@@ -52,6 +68,11 @@ export async function runTurn(history, options, events, signal, modelHistory = h
52
68
  : response.inputTokens);
53
69
  append(assistant);
54
70
  if (calls.length === 0) {
71
+ const steering = options.steering?.drainOrClose();
72
+ if (steering !== undefined && appendSteering(steering.messages)) {
73
+ await checkpoint("checkpointed");
74
+ continue;
75
+ }
55
76
  await checkpoint("completed");
56
77
  return; // the model is done — hand back to the user
57
78
  }
@@ -68,14 +89,15 @@ export async function runTurn(history, options, events, signal, modelHistory = h
68
89
  for (let offset = 0; offset < batch.length; offset++) {
69
90
  throwIfAborted(signal);
70
91
  const call = batch[offset];
71
- events.onToolProgress?.(start + offset + 1, calls.length);
92
+ const current = start + offset + 1;
93
+ events.onToolPreparing?.(call, current, calls.length);
72
94
  const preview = await look(call, options, signal);
73
95
  throwIfAborted(signal);
74
96
  announced.add(call.id);
75
97
  events.onToolCall(call, preview);
76
- prepared.push({ call, preview });
98
+ prepared.push({ call, current, preview });
77
99
  }
78
- const runs = await Promise.all(prepared.map(({ call, preview }) => settle(call, options, events, signal, preview)));
100
+ const runs = await Promise.all(prepared.map(({ call, current, preview }) => settle(call, current, calls.length, options, events, signal, preview)));
79
101
  for (let offset = 0; offset < runs.length; offset++) {
80
102
  const call = prepared[offset]?.call;
81
103
  const run = runs[offset];
@@ -113,9 +135,9 @@ export async function runTurn(history, options, events, signal, modelHistory = h
113
135
  throw error;
114
136
  }
115
137
  append({ role: "user", content: results });
138
+ appendSteering(options.steering?.drain() ?? []);
116
139
  await checkpoint("checkpointed");
117
140
  }
118
- throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
119
141
  }
120
142
  function nextBatch(calls, start, tools) {
121
143
  const first = calls[start];
@@ -141,12 +163,12 @@ function assertToolCallIds(calls) {
141
163
  if (call.id.trim() === "")
142
164
  throw new Error("provider returned a tool call without an id");
143
165
  if (seen.has(call.id)) {
144
- throw new Error("provider returned duplicate tool call ids in one step");
166
+ throw new Error("provider returned duplicate tool call ids in one response");
145
167
  }
146
168
  seen.add(call.id);
147
169
  }
148
170
  }
149
- async function settle(call, options, events, signal, preview) {
171
+ async function settle(call, current, total, options, events, signal, preview) {
150
172
  const tool = findTool(options.tools, call.name);
151
173
  if (tool === undefined) {
152
174
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
@@ -158,6 +180,7 @@ async function settle(call, options, events, signal, preview) {
158
180
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
159
181
  }
160
182
  throwIfAborted(signal);
183
+ events.onToolStart?.(call, current, total);
161
184
  return runTool(tool, call, {
162
185
  ...options.toolContext,
163
186
  signal,
@@ -0,0 +1,8 @@
1
+ // One compact duration grammar shared by terminal and exported tool evidence.
2
+ export function toolDuration(durationMs, live = false) {
3
+ const bounded = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0;
4
+ if (!live && bounded < 1_000)
5
+ return `${Math.round(bounded)}ms`;
6
+ const seconds = bounded / 1_000;
7
+ return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
8
+ }
@@ -30,29 +30,27 @@ export async function removeOpenAIAccount(signal) {
30
30
  }
31
31
  return { removed: true, revokeFailed };
32
32
  }
33
- export async function openAIAuthorization(forceToken, signal, onStatus) {
33
+ export async function openAIAuthorization(rejectedToken, signal, onStatus) {
34
34
  const account = openAICodexAccount();
35
35
  if (account === undefined)
36
36
  throw new Error("ChatGPT account is not connected");
37
- const mustRefresh = forceToken !== undefined || expiresSoon(account);
37
+ const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
38
38
  const ready = mustRefresh
39
- ? await refreshAccount(forceToken, signal, onStatus)
39
+ ? await refreshAccount(rejectedToken, signal, onStatus)
40
40
  : account;
41
41
  return { accessToken: ready.accessToken, accountId: ready.accountId };
42
42
  }
43
- async function refreshAccount(forceToken, signal, onStatus) {
43
+ async function refreshAccount(rejectedToken, signal, onStatus) {
44
44
  if (refreshing !== undefined)
45
45
  return abortable(refreshing, signal);
46
46
  if (signal?.aborted === true)
47
47
  throw abortReason(signal);
48
- onStatus?.("Refreshing ChatGPT sign-in");
49
48
  const task = updateOpenAICodexAccount(async (current) => {
50
49
  if (current === undefined)
51
50
  throw new Error("ChatGPT account is not connected");
52
- if (forceToken !== undefined && current.accessToken !== forceToken)
53
- return current;
54
- if (forceToken === undefined && !expiresSoon(current))
51
+ if (!requiresAuthorityRefresh(current, rejectedToken))
55
52
  return current;
53
+ onStatus?.("Refreshing ChatGPT sign-in");
56
54
  return refreshOpenAITokens(current);
57
55
  }).then((account) => {
58
56
  if (account === undefined)
@@ -72,6 +70,12 @@ async function refreshAccount(forceToken, signal, onStatus) {
72
70
  function expiresSoon(account) {
73
71
  return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
74
72
  }
73
+ function requiresAuthorityRefresh(account, rejectedToken) {
74
+ // A different fresh token means another process already recovered. An
75
+ // expiring replacement still needs rotation, while a rejected current token
76
+ // must be rotated regardless of its advertised expiry.
77
+ return expiresSoon(account) || account.accessToken === rejectedToken;
78
+ }
75
79
  function abortable(promise, signal) {
76
80
  if (signal === undefined)
77
81
  return promise;
@@ -9,6 +9,7 @@ import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
9
9
  export async function assembleAnthropic(events, onStream) {
10
10
  const blocks = new Map();
11
11
  const partialJson = new Map();
12
+ const announcedTools = new Set();
12
13
  const sizes = { toolArguments: 0 };
13
14
  let stopReason;
14
15
  let stopDetails;
@@ -23,6 +24,13 @@ export async function assembleAnthropic(events, onStream) {
23
24
  case "content_block_start": {
24
25
  if (typeof event.index === "number" && event.content_block !== undefined) {
25
26
  blocks.set(event.index, { ...event.content_block });
27
+ if (event.content_block["type"] === "tool_use" &&
28
+ !announcedTools.has(event.index)) {
29
+ announcedTools.add(event.index);
30
+ const rawName = event.content_block["name"];
31
+ const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
32
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
33
+ }
26
34
  }
27
35
  break;
28
36
  }
@@ -44,27 +44,39 @@ export function stopNotice(data) {
44
44
  export function fromWireResponse(data) {
45
45
  const raw = Array.isArray(data.content) ? data.content : [];
46
46
  const content = [];
47
+ let suppressedToolCall = false;
47
48
  for (const item of raw) {
48
49
  const block = item;
49
50
  if (block.type === "text" && typeof block.text === "string") {
50
51
  content.push({ kind: "text", text: block.text });
51
52
  }
52
- else if (block.type === "tool_use" &&
53
- typeof block.id === "string" &&
54
- typeof block.name === "string") {
55
- content.push({
56
- kind: "tool_call",
57
- id: block.id,
58
- name: block.name,
59
- input: (block.input ?? {}),
60
- });
53
+ else if (block.type === "tool_use") {
54
+ if (data.stop_reason === "tool_use" &&
55
+ typeof block.id === "string" &&
56
+ typeof block.name === "string") {
57
+ content.push({
58
+ kind: "tool_call",
59
+ id: block.id,
60
+ name: block.name,
61
+ input: (block.input ?? {}),
62
+ });
63
+ }
64
+ else {
65
+ suppressedToolCall = true;
66
+ }
61
67
  }
62
68
  // thinking / redacted_thinking and anything future: carried in `raw` only.
63
69
  }
64
70
  const notice = stopNotice(data);
65
71
  if (notice !== undefined)
66
72
  content.push({ kind: "text", text: notice });
67
- return { role: "assistant", content, raw, rawFrom: "anthropic", usage: normalizeUsage(data) };
73
+ const retainRaw = data.stop_reason !== "max_tokens" && !suppressedToolCall;
74
+ return {
75
+ role: "assistant",
76
+ content,
77
+ ...(retainRaw ? { raw, rawFrom: "anthropic" } : {}),
78
+ usage: normalizeUsage(data),
79
+ };
68
80
  }
69
81
  function normalizeUsage(data) {
70
82
  const usage = data.usage;
@@ -6,6 +6,7 @@
6
6
  import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
7
7
  export async function assembleOllama(events, onStream) {
8
8
  const calls = new Map();
9
+ const announcedTools = new Map();
9
10
  let toolArgumentChars = 0;
10
11
  let content = "";
11
12
  let reasoning = "";
@@ -53,6 +54,12 @@ export async function assembleOllama(events, onStream) {
53
54
  call.args += part.function.arguments;
54
55
  }
55
56
  calls.set(index, call);
57
+ const name = call.name === "" ? undefined : call.name;
58
+ if (!announcedTools.has(index) ||
59
+ (announcedTools.get(index) === undefined && name !== undefined)) {
60
+ announcedTools.set(index, name);
61
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
62
+ }
56
63
  }
57
64
  }
58
65
  if (finishReason === undefined) {
@@ -60,10 +60,18 @@ export function fromWireReply(reply) {
60
60
  const content = [];
61
61
  if (reply.content !== "")
62
62
  content.push({ kind: "text", text: reply.content });
63
- for (const call of reply.toolCalls) {
64
- content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
63
+ const acceptsToolCalls = reply.finishReason === "tool_calls";
64
+ if (acceptsToolCalls) {
65
+ for (const call of reply.toolCalls) {
66
+ content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
67
+ }
65
68
  }
66
- const raw = reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
69
+ const notice = stopNotice(reply);
70
+ if (notice !== undefined)
71
+ content.push({ kind: "text", text: notice });
72
+ const suppressedToolCall = reply.toolCalls.length > 0 && !acceptsToolCalls;
73
+ const retainRaw = reply.finishReason !== "length" && !suppressedToolCall;
74
+ const raw = !retainRaw || reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
67
75
  return {
68
76
  role: "assistant",
69
77
  content,
@@ -6,6 +6,7 @@
6
6
  // those streamed items remain the fallback when the final envelope is empty.
7
7
  export async function assembleOpenAI(events, onStream) {
8
8
  const items = [];
9
+ const announcedTools = { identities: new Set(), anonymous: false };
9
10
  let refusal = false;
10
11
  for await (const raw of events) {
11
12
  const event = raw;
@@ -24,14 +25,31 @@ export async function assembleOpenAI(events, onStream) {
24
25
  if (typeof event.delta === "string")
25
26
  onStream?.({ kind: "thinking", text: event.delta });
26
27
  break;
28
+ case "response.output_item.added":
29
+ if (isFunctionCall(event.item)) {
30
+ announceTool(event, event.item, announcedTools, onStream);
31
+ }
32
+ break;
33
+ case "response.function_call_arguments.delta":
34
+ case "response.function_call_arguments.done":
35
+ announceTool(event, undefined, announcedTools, onStream);
36
+ break;
27
37
  case "response.output_item.done":
28
- if (event.item !== undefined)
38
+ if (event.item !== undefined) {
39
+ if (isFunctionCall(event.item)) {
40
+ announceTool(event, event.item, announcedTools, onStream);
41
+ }
29
42
  items.push(event.item);
43
+ }
30
44
  break;
31
45
  case "response.done":
32
46
  case "response.completed":
47
+ return withDefaultStatus(reconcileOutput(event.response, items), "completed");
33
48
  case "response.incomplete":
34
- return reconcileOutput(event.response, items);
49
+ return {
50
+ ...reconcileOutput(event.response, items),
51
+ status: "incomplete",
52
+ };
35
53
  case "response.failed": {
36
54
  const response = event.response;
37
55
  throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
@@ -44,9 +62,46 @@ export async function assembleOpenAI(events, onStream) {
44
62
  }
45
63
  throw new Error("openai stream ended before a terminal response event");
46
64
  }
65
+ function isFunctionCall(item) {
66
+ return typeof item === "object" && item !== null &&
67
+ item["type"] === "function_call";
68
+ }
69
+ function announceTool(event, item, announced, onStream) {
70
+ const identities = toolIdentities(event, item);
71
+ if (identities.length === 0) {
72
+ if (announced.anonymous)
73
+ return;
74
+ announced.anonymous = true;
75
+ }
76
+ else {
77
+ const duplicate = identities.some((identity) => announced.identities.has(identity));
78
+ for (const identity of identities)
79
+ announced.identities.add(identity);
80
+ if (duplicate)
81
+ return;
82
+ }
83
+ const rawName = item?.name ?? event.name;
84
+ const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
85
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
86
+ }
87
+ function toolIdentities(event, item) {
88
+ const identities = [];
89
+ const itemId = event.item_id ?? item?.id;
90
+ if (typeof itemId === "string" && itemId !== "")
91
+ identities.push(`item:${itemId}`);
92
+ if (typeof event.output_index === "number")
93
+ identities.push(`output:${event.output_index}`);
94
+ if (typeof item?.call_id === "string" && item.call_id !== "") {
95
+ identities.push(`call:${item.call_id}`);
96
+ }
97
+ return identities;
98
+ }
47
99
  function reconcileOutput(completed, items) {
48
100
  if (completed === undefined)
49
101
  return { output: items };
50
102
  const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
51
103
  return items.length > finalCount ? { ...completed, output: items } : completed;
52
104
  }
105
+ function withDefaultStatus(response, status) {
106
+ return response.status === undefined ? { ...response, status } : response;
107
+ }
@@ -40,8 +40,9 @@ export function toWireItems(message, providerId = "openai") {
40
40
  }
41
41
  export function stopNotice(data) {
42
42
  const reason = data.incomplete_details?.reason;
43
- if (reason === undefined)
44
- return undefined;
43
+ if (reason === undefined) {
44
+ return data.status === "incomplete" ? "[incomplete response]" : undefined;
45
+ }
45
46
  return reason === "max_output_tokens"
46
47
  ? "[truncated: hit max_output_tokens — raise --max-tokens]"
47
48
  : `[incomplete: ${reason}]`;
@@ -49,6 +50,7 @@ export function stopNotice(data) {
49
50
  export function fromWireResponse(data, providerId = "openai") {
50
51
  const raw = Array.isArray(data.output) ? data.output : [];
51
52
  const content = [];
53
+ let suppressedFunctionCall = false;
52
54
  for (const entry of raw) {
53
55
  const item = entry;
54
56
  if (item.type === "message" && Array.isArray(item.content)) {
@@ -62,22 +64,35 @@ export function fromWireResponse(data, providerId = "openai") {
62
64
  }
63
65
  }
64
66
  }
65
- else if (item.type === "function_call" &&
66
- typeof item.call_id === "string" &&
67
- typeof item.name === "string") {
68
- content.push({
69
- kind: "tool_call",
70
- id: item.call_id,
71
- name: item.name,
72
- input: parseArguments(item.arguments),
73
- });
67
+ else if (item.type === "function_call") {
68
+ if (data.status === "completed" &&
69
+ (item.status === undefined || item.status === "completed") &&
70
+ typeof item.call_id === "string" &&
71
+ typeof item.name === "string") {
72
+ content.push({
73
+ kind: "tool_call",
74
+ id: item.call_id,
75
+ name: item.name,
76
+ input: parseArguments(item.arguments),
77
+ });
78
+ }
79
+ else {
80
+ suppressedFunctionCall = true;
81
+ }
74
82
  }
75
83
  // reasoning items and anything future: carried in `raw` only.
76
84
  }
77
85
  const notice = stopNotice(data);
78
86
  if (notice !== undefined)
79
87
  content.push({ kind: "text", text: notice });
80
- return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
88
+ return {
89
+ role: "assistant",
90
+ content,
91
+ ...(data.status === "completed" && !suppressedFunctionCall
92
+ ? { raw, rawFrom: providerId }
93
+ : {}),
94
+ usage: normalizeUsage(data),
95
+ };
81
96
  }
82
97
  function normalizeUsage(data) {
83
98
  const usage = data.usage;
@@ -3,7 +3,7 @@
3
3
  // can become conversation or provider input.
4
4
  import { Buffer } from "node:buffer";
5
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
- export const SESSION_SCHEMA = 3;
6
+ export const SESSION_SCHEMA = 4;
7
7
  export const SESSION_FILE_LIMITS = Object.freeze({
8
8
  text: 1_048_576,
9
9
  metadataBytes: 64 * 1_024,
@@ -135,7 +135,7 @@ export function decodeNode(value) {
135
135
  effort: identity["effort"],
136
136
  }),
137
137
  messages: Object.freeze(messages.map(messageFromRecord)),
138
- blocks: Object.freeze(blocks.map(blockFromRecord)),
138
+ blocks: Object.freeze(blocks.map((block) => blockFromRecord(block, version))),
139
139
  ...(context === undefined ? {} : { context: Object.freeze(context) }),
140
140
  ...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
141
141
  });
@@ -266,18 +266,23 @@ function blockRecord(block) {
266
266
  right: block.right,
267
267
  tone: block.tone,
268
268
  body: block.body?.map(detailRecord) ?? null,
269
+ durationMs: block.durationMs ?? null,
269
270
  }];
270
271
  }
271
- function blockFromRecord(value) {
272
+ function blockFromRecord(value, version) {
272
273
  if (!record(value) || typeof value["kind"] !== "string")
273
274
  throw invalid();
274
275
  if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
275
276
  keys(value, "kind,text") && boundedText(value["text"]))
276
277
  return { kind: value["kind"], text: value["text"] };
277
- if (value["kind"] === "tool" && keys(value, "body,kind,name,right,target,tone") &&
278
+ const toolKeys = version >= 4
279
+ ? "body,durationMs,kind,name,right,target,tone"
280
+ : "body,kind,name,right,target,tone";
281
+ if (value["kind"] === "tool" && keys(value, toolKeys) &&
278
282
  bounded(value["name"], 256) && boundedText(value["target"]) &&
279
283
  boundedText(value["right"], 1_024) &&
280
284
  (value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
285
+ (version < 4 || nullableInteger(value["durationMs"], 0)) &&
281
286
  (value["body"] === null ||
282
287
  (Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
283
288
  const body = value["body"] === null
@@ -290,6 +295,9 @@ function blockFromRecord(value) {
290
295
  right: value["right"],
291
296
  tone: value["tone"],
292
297
  ...(body === undefined ? {} : { body }),
298
+ ...(version < 4 || value["durationMs"] === null
299
+ ? {}
300
+ : { durationMs: value["durationMs"] }),
293
301
  };
294
302
  }
295
303
  throw invalid();
@@ -398,7 +406,7 @@ function integer(value, minimum) {
398
406
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
399
407
  }
400
408
  function schema(value) {
401
- return value === 1 || value === 2 || value === SESSION_SCHEMA;
409
+ return value === 1 || value === 2 || value === 3 || value === SESSION_SCHEMA;
402
410
  }
403
411
  function settlement(value, version) {
404
412
  return value === "checkpointed" || value === "completed" ||