@mars-sea/dsh-commandcode-provider 0.10.5 → 0.10.6

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.10.6] - 2026-09-12
8
+
9
+ ### Fixed
10
+ - **Thinking models no longer fail every tool-loop turn with `The reasoning_content in the thinking mode must be passed back to the API`** ([#34](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/34)). On the `/alpha/generate` transport the adapter ported the pi plugin's behavior of dropping historical reasoning blocks, on the theory that private reasoning must not leak into later turns — but the official CLI does the opposite (`toWireMessages`, command-code@1.53.1, converts every `thinking` block into a `{ type: 'reasoning', text }` part of the assistant message), and the provider rebuilds its DeepSeek request from those parts: an assistant turn whose tool calls arrive without their reasoning is rejected outright. The failure was the whole turn, on every iteration after the first tool call, so a Go-plan or fallback-transport session was unusable with a thinking model. Reasoning is now replayed in content order on the CLI transport, and the Provider API transport keeps passing it back as `reasoning_content` as before.
11
+
12
+ - **A tool whose parameter schema has no object root no longer kills the turn** ([#35](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/35)). The gateway validates the root of every function schema and refuses the entire request otherwise (`Invalid schema for function 'x': schema must be a JSON Schema of 'type: "object"', got 'type: null'`). The harness's own tool builder always declares that root, so the offending schema comes from a tool registered outside it — a plugin's or MCP bridge's hand-written schema (a type-less `{ properties, required }`), an empty one, or a generator's root `$ref`. Choosing a strict model (e.g. `deepseek/deepseek-v4.1-flash`) then failed the turn no matter which tool the model wanted to call: one badly declared tool poisoned the request for all of them. Both bodies now pass every schema through `toolParametersSchema()`: an object-rooted schema is untouched, a type-less object-shaped one gains `type: 'object'`, a `["object","null"]` union is collapsed, a root `$ref` is inlined from its local `$defs`/`definitions`, an `allOf`/`anyOf`/`oneOf` root is flattened into one object (branch properties unioned; `required` kept only where every alternative demands it), and a non-object root degrades to a permissive free-form object rather than failing the request. Only the root is rewritten and every path returns a copy, so the caller's (possibly deep-frozen) schema is never mutated.
13
+
14
+ - **Reading several images in one turn no longer makes the conversation unusable** ([#33](https://github.com/Mars-Sea/dsh-commandcode-provider/pull/33)). When one assistant turn issued parallel tool calls whose results each carried an image (two `read_image` calls, say), both transports emitted each image carrier as a user message directly after its own tool result, so a user message sat between the two tool messages. The gateway requires an assistant's tool blocks to be answered consecutively and rejected the request with HTTP 400 (`An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'`); because that history is replayed on every later turn, the conversation could not continue on this provider at all — a retry did not help and only switching provider or model worked. The carriers are now buffered and flushed once the whole tool group has been emitted, so parallel results stay adjacent (`assistant → tool → tool → user → user`) on both the CLI and the Provider API transport. A single-call turn keeps the shape from [#30](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/30). Because those carriers now travel as a group, each one names the tool call it came out of (`Attached image(s) from tool result (call-a):`) — every `read_image` result renders the same envelope text, so without that the notes would be identical and the only thing tying an image to its own result would be position.
15
+
16
+ - **A user message that converts to nothing is now dropped by both transports.** The CLI transport emitted it as `{ role: 'user', content: [] }` while the Provider API transport skipped it, so the same conversation could be assembled two different ways — and an empty content array is a shape the gateway has no reason to accept. Both now skip a user message that produced no text and no image (including one whose only blocks are ones this adapter does not put on the wire). An image carried out of a tool result is still flushed before the skip, so a pending image is never lost with the message.
17
+
18
+ ### Changed
19
+ - **Synced with the official command-code@1.53.1 CLI** (2026-09-12, four CLI-local items: default compaction model set to DeepSeek V4.1 Flash in `/config`, a BYOK reasoning-effort fix, and two `/usage` summary-line changes — Extra Credits shown separately, the `$X left` field removed). `COMMAND_CODE_CLI_VERSION` is now `1.53.1`. Static inspection of both bundles finds no data or protocol drift: the `/alpha/generate` request body, both endpoints, the per-model effort map, the subscription plan maps, every plan tier, every deal, and the peak/off-peak membership and schedule are unchanged, and the catalog still serves the same 69 models (`gpt-6-astra` remains a CLI/pricing/docs-only model, absent from the public Provider catalog, as before). The one bundle difference is upstream-internal: `gpt-5.6-terra` and `gpt-5.6-luna` are now routed through vercel-ai-gateway instead of openrouter, with the same efforts, modalities and 1.05M context. The BYOK fix renames the CLI's own `providers.json` option key from `reasoning_effort` to `reasoningEffort` — a CLI-side BYOK channel, not the Command Code API contract this plugin speaks.
20
+ - **`npm run typecheck` now checks the test suite too.** `tsconfig.json` included only `src`, so `tests/` was never type-checked — `tsx` strips types without checking them, which is why a test file could reference an undeclared type for months and still pass. Every test file type-checks now, and CI's existing `typecheck` step covers them from here on. No shipped behavior changed: this is the quality gate catching up with the suite.
21
+
7
22
  ## [0.10.5] - 2026-09-11
8
23
 
9
24
  ### Added
package/README.md CHANGED
@@ -52,7 +52,7 @@ Pick the release line that matches your DeepSeek Harness version:
52
52
  **pnpm 11 holds back new releases.** Its `minimumReleaseAge` defaults to 1440 minutes, so a version published less than a day ago is skipped and `@latest` resolves to the *previous* release — silently, with a success exit code. To install a release from the last 24 hours, name it exactly:
53
53
 
54
54
  ```sh
55
- dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.5
55
+ dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.6
56
56
  ```
57
57
 
58
58
  The same applies to every profile you install into, including the terminal UI below.
@@ -71,7 +71,7 @@ dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@0.9.1 #
71
71
  Each profile updates separately — the terminal UI owns its own plugin list (see below):
72
72
 
73
73
  ```sh
74
- dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.5
74
+ dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.6
75
75
  ```
76
76
 
77
77
  To move to a version published less than 24 hours ago, name it exactly as in Install above; pnpm 11's age gate resolves `@latest` to the previous release instead.
@@ -100,7 +100,7 @@ After restart, enter your API key in **Settings → Command Code** and save; **S
100
100
  The plugin also works under a terminal front door. **Each dsh profile owns its own plugin list**, so the web install above does not reach the terminal — add the plugin to the `dsh-tui` profile as well:
101
101
 
102
102
  ```sh
103
- dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.5
103
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.6
104
104
  ```
105
105
 
106
106
  Pin the exact version here. For the first 24 hours after a release, a bare package name (or `@latest`) is silently resolved to the previous one: the install succeeds, but the profile gets the older build — which is how a fresh terminal install ends up with no **`/settings` → Command Code** page and no `commandcode` models at all.
@@ -217,7 +217,8 @@ When your deployment's dsh shell mounts the web capability (`@deepseek-ai/dsh-we
217
217
  - **Image input is model-gated** — only Vision models accept images; text-only models refuse them.
218
218
  - Switching to a text-only model in an image-bearing session is rejected by dsh — pick a model marked *`Image`* or remove the images first.
219
219
  - **No `stop` sequences** — requests carrying one fail.
220
- - On the legacy `/alpha/generate` transport, reasoning blocks are not replayed into later turns; on the `/provider/v1/chat/completions` transport, historical reasoning is passed back as `reasoning_content` so tool-calling loops can keep their chain of thought. Only tool calls with a paired tool result are replayed on both transports.
220
+ - Historical reasoning is replayed on both transports so a tool-calling loop keeps its chain of thought: `/provider/v1/chat/completions` passes it back as `reasoning_content`, and `/alpha/generate` carries it as a `reasoning` block inside the rebuilt assistant turn (the shape the official CLI sends) — a DeepSeek thinking-mode tool loop is rejected without it. Only tool calls with a paired tool result are replayed on either transport.
221
+ - Every tool's parameter schema is normalized to the object root the provider requires, so a tool declared without one (a third-party plugin's hand-written schema, an MCP server's own `inputSchema`, a generated root `$ref`) no longer fails the whole turn with `schema must be a JSON Schema of 'type: "object"'`.
221
222
  - The model catalog is browsable without a key; chat requests need one.
222
223
 
223
224
  ## Permissions & privacy
package/README.zh-CN.md CHANGED
@@ -50,7 +50,7 @@
50
50
  **pnpm 11 会拦下刚发布的新版本。** 它的 `minimumReleaseAge` 默认为 1440 分钟,发布不足一天的版本会被跳过,`@latest` 解析到**上一个**版本 —— 而且是静默的,命令照样以成功退出。想装 24 小时内发布的版本,必须写精确版本号:
51
51
 
52
52
  ```sh
53
- dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.5
53
+ dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.6
54
54
  ```
55
55
 
56
56
  这一点对每个 profile 都成立,包括下面的终端界面。
@@ -69,7 +69,7 @@ dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@0.9.1 #
69
69
  每个 profile 各自更新 —— 终端界面有独立的插件列表(见下文):
70
70
 
71
71
  ```sh
72
- dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.5
72
+ dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.6
73
73
  ```
74
74
 
75
75
  要更新到发布不足 24 小时的版本,和上面的安装一样写精确版本号;pnpm 11 的年龄门禁会把 `@latest` 解析成上一个版本。
@@ -98,7 +98,7 @@ cmd login # macOS/Linux;Windows 原生版:cmdc login
98
98
  插件同样支持终端前端。**每个 dsh profile 有独立的插件列表**,所以上面那条 Web 安装命令不会装到终端里 —— 还要把插件装进 `dsh-tui` profile:
99
99
 
100
100
  ```sh
101
- dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.5
101
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.6
102
102
  ```
103
103
 
104
104
  这里请写精确版本号。新版本发布后的 24 小时内,只写包名(或 `@latest`)会被静默解析到上一个版本 —— 安装命令照样成功,但 profile 里拿到的是旧版本,结果就是全新的终端安装里既没有 **`/settings` → Command Code** 页面,也看不到任何 `commandcode` 模型。
package/lib/client.js CHANGED
@@ -2051,7 +2051,7 @@ window.__ModuleLoader__.load({
2051
2051
  }
2052
2052
  //#endregion
2053
2053
  //#region package.json
2054
- var version = "0.10.5";
2054
+ var version = "0.10.6";
2055
2055
  var repository = {
2056
2056
  "type": "git",
2057
2057
  "url": "git+https://github.com/Mars-Sea/dsh-commandcode-provider.git"
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@ import { Context } from "@deepseek-ai/cordis";
7
7
  import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
8
8
  import { CommandDefinition } from "@deepseek-ai/dsh-commands";
9
9
  //#region src/adapter.d.ts
10
- declare const COMMAND_CODE_CLI_VERSION = "1.53.0";
10
+ declare const COMMAND_CODE_CLI_VERSION = "1.53.1";
11
11
  declare const DEFAULT_API_BASE = "https://api.commandcode.ai";
12
12
  declare const DEFAULT_GENERATE_MAX_TOKENS = 64000;
13
13
  declare const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
package/lib/index.js CHANGED
@@ -1024,7 +1024,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1024
1024
  * and API key or subscription, and Command Code's terms apply.
1025
1025
  *
1026
1026
  * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
1027
- * re-verified against command-code@1.53.0 — endpoints, request shape, and
1027
+ * re-verified against command-code@1.53.1 — endpoints, request shape, and
1028
1028
  * stream events unchanged):
1029
1029
  * POST {apiBase}/alpha/generate
1030
1030
  * body: { config, memory, taste, skills, params: { model, messages, tools,
@@ -1037,7 +1037,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1037
1037
  * per-request options thunk and an API-key resolver from the plugin entry
1038
1038
  * (src/index.ts), so a settings change reaches the very next request.
1039
1039
  */
1040
- const COMMAND_CODE_CLI_VERSION = "1.53.0";
1040
+ const COMMAND_CODE_CLI_VERSION = "1.53.1";
1041
1041
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
1042
1042
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
1043
1043
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -1105,6 +1105,124 @@ function recordOrEmpty(value) {
1105
1105
  } catch {}
1106
1106
  return {};
1107
1107
  }
1108
+ /** How deep a root `$ref` / combinator chain is followed while normalizing. */
1109
+ const SCHEMA_NORMALIZE_MAX_DEPTH = 4;
1110
+ /**
1111
+ * Whether a type-less node is object-shaped enough to be one with the type
1112
+ * declared: `properties`/`required`/`additionalProperties` only make sense on
1113
+ * an object, and a schema carrying them was meant to be one.
1114
+ */
1115
+ function isObjectShaped(node) {
1116
+ return isRecord(node.properties) || isRecord(node.patternProperties) || Array.isArray(node.required) || node.additionalProperties !== void 0;
1117
+ }
1118
+ /** The `required` names of one schema node, ignoring malformed entries. */
1119
+ function requiredNames(node) {
1120
+ return Array.isArray(node.required) ? node.required.filter((name) => typeof name === "string") : [];
1121
+ }
1122
+ /** Resolve a local `#/...` pointer inside the schema that carried it. */
1123
+ function resolveLocalRef(root, ref) {
1124
+ if (!ref.startsWith("#/")) return void 0;
1125
+ let node = root;
1126
+ for (const rawSegment of ref.slice(2).split("/")) {
1127
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
1128
+ if (!isRecord(node)) return void 0;
1129
+ node = node[segment];
1130
+ }
1131
+ return isRecord(node) ? node : void 0;
1132
+ }
1133
+ /**
1134
+ * Flatten a type-less combinator node into one object schema, so the model
1135
+ * still sees the branch fields instead of an argument-free tool. `allOf`
1136
+ * branches must all hold, so their `required` entries are all kept; `anyOf` /
1137
+ * `oneOf` branches are alternatives, so only a name required by every branch
1138
+ * survives. Returns undefined when no branch describes an object.
1139
+ */
1140
+ function mergeCombinatorBranches(node, depth) {
1141
+ if (depth >= SCHEMA_NORMALIZE_MAX_DEPTH) return void 0;
1142
+ const properties = {};
1143
+ const required = /* @__PURE__ */ new Set();
1144
+ const alternatives = [];
1145
+ let sawBranch = false;
1146
+ for (const key of [
1147
+ "allOf",
1148
+ "anyOf",
1149
+ "oneOf"
1150
+ ]) {
1151
+ const branches = node[key];
1152
+ if (!Array.isArray(branches)) continue;
1153
+ const objectBranches = [];
1154
+ for (const branch of branches) {
1155
+ if (!isRecord(branch)) continue;
1156
+ objectBranches.push(toolParametersSchema(branch, depth + 1));
1157
+ }
1158
+ if (objectBranches.length === 0) continue;
1159
+ sawBranch = true;
1160
+ const names = objectBranches.map(requiredNames);
1161
+ if (key === "allOf") for (const name of names.flat()) required.add(name);
1162
+ else if (names.length > 0) alternatives.push(names[0].filter((name) => names.every((list) => list.includes(name))));
1163
+ for (const branch of objectBranches) {
1164
+ const branchProperties = isRecord(branch.properties) ? branch.properties : {};
1165
+ for (const [name, schema] of Object.entries(branchProperties)) if (!(name in properties)) properties[name] = schema;
1166
+ }
1167
+ }
1168
+ if (!sawBranch) return void 0;
1169
+ for (const group of alternatives) for (const name of group) required.add(name);
1170
+ const merged = {
1171
+ type: "object",
1172
+ properties
1173
+ };
1174
+ if (required.size > 0) merged.required = [...required];
1175
+ if (node.additionalProperties !== void 0) merged.additionalProperties = node.additionalProperties;
1176
+ for (const key of ["description", "title"]) if (node[key] !== void 0) merged[key] = node[key];
1177
+ return merged;
1178
+ }
1179
+ /**
1180
+ * Normalize one tool's parameters schema to an object-rooted JSON Schema.
1181
+ * A schema that already declares an object root is passed through untouched;
1182
+ * a type-less object-shaped one gains the type; a root `$ref` or combinator is
1183
+ * resolved/merged; anything else (an array/scalar root, or no schema at all)
1184
+ * degrades to a permissive free-form object, because a request the provider
1185
+ * refuses helps no one and the tool's own description is still in the prompt.
1186
+ */
1187
+ function toolParametersSchema(parameters, depth = 0) {
1188
+ if (!isRecord(parameters)) return {
1189
+ type: "object",
1190
+ properties: {},
1191
+ additionalProperties: true
1192
+ };
1193
+ if (parameters.type === "object") return parameters;
1194
+ if (Array.isArray(parameters.type) && parameters.type.includes("object")) return {
1195
+ ...parameters,
1196
+ type: "object"
1197
+ };
1198
+ if (parameters.type === void 0 || parameters.type === null) {
1199
+ if (typeof parameters.$ref === "string" && depth < SCHEMA_NORMALIZE_MAX_DEPTH) {
1200
+ const target = resolveLocalRef(parameters, parameters.$ref);
1201
+ if (target !== void 0) {
1202
+ const { $ref: _ref, ...rest } = parameters;
1203
+ return toolParametersSchema({
1204
+ ...target,
1205
+ ...rest
1206
+ }, depth + 1);
1207
+ }
1208
+ }
1209
+ if (isObjectShaped(parameters)) return {
1210
+ ...parameters,
1211
+ type: "object"
1212
+ };
1213
+ const merged = mergeCombinatorBranches(parameters, depth);
1214
+ if (merged !== void 0) return merged;
1215
+ if (typeof parameters.$ref === "string") return {
1216
+ ...parameters,
1217
+ type: "object"
1218
+ };
1219
+ }
1220
+ return {
1221
+ type: "object",
1222
+ properties: {},
1223
+ additionalProperties: true
1224
+ };
1225
+ }
1108
1226
  function projectSlugFromPath(pathName) {
1109
1227
  return pathName.toLowerCase().replace(/^[a-z]:/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|(?<!-)-+$/g, "") || "project";
1110
1228
  }
@@ -1281,23 +1399,33 @@ function toolResultTextForWire(media) {
1281
1399
  return media.text || (media.images.length > 0 ? "(image returned; see the attached image)" : "");
1282
1400
  }
1283
1401
  /** Leading line of the user message that carries a tool result's images. */
1284
- const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result:";
1402
+ const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result";
1285
1403
  /**
1286
1404
  * The model-visible note introducing the images carried out of one tool
1287
1405
  * result. Neither transport merges them back into the tool result, so this
1288
1406
  * line is what tells the model the image it is about to see belongs to the
1289
1407
  * tool it just ran rather than to the user; it also leads the part list,
1290
- * because an image-first content array is what some gateways reject. Count and
1291
- * pixel dimensions are appended when the tool's own text does not already
1292
- * state them (a result that returns the image and nothing else).
1408
+ * because an image-first content array is what some gateways reject.
1409
+ *
1410
+ * The note names the tool call the image came out of, because a turn's
1411
+ * carriers are emitted as a group after the whole tool group (issue #33):
1412
+ * position alone would have to carry the association, and every `read_image`
1413
+ * result renders the same envelope text, so two parallel calls would produce
1414
+ * two identical notes. The id is the WIRE id — the one the model saw on the
1415
+ * `tool-call` it issued and on the tool message just above — so an overlong
1416
+ * cross-provider id is named by the alias that replaced it, not by the
1417
+ * harness-side id the model never saw.
1418
+ *
1419
+ * Count and pixel dimensions are appended when the tool's own text does not
1420
+ * already state them (a result that returns the image and nothing else).
1293
1421
  */
1294
- function toolResultImageNote(media) {
1422
+ function toolResultImageNote(media, toolCallId) {
1423
+ const lead = `${TOOL_RESULT_IMAGE_TEXT} (${toolCallId}):`;
1295
1424
  const first = media.images[0];
1296
- if (first === void 0) return TOOL_RESULT_IMAGE_TEXT;
1425
+ if (first === void 0) return lead;
1297
1426
  const dimensions = `${first.width}x${first.height} px`;
1298
- if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return TOOL_RESULT_IMAGE_TEXT;
1299
- const count = media.images.length > 1 ? `${media.images.length} images, ` : "";
1300
- return `${TOOL_RESULT_IMAGE_TEXT} ${count}${dimensions}`;
1427
+ if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return lead;
1428
+ return `${lead} ${media.images.length > 1 ? `${media.images.length} images, ` : ""}${dimensions}`;
1301
1429
  }
1302
1430
  function hasImageContent(message) {
1303
1431
  const check = (blocks) => blocks.some((b) => b.type === "image" || b.type === "tool-result" && check(b.content));
@@ -1324,9 +1452,15 @@ async function messagesToCC(messages, readImage) {
1324
1452
  const out = [];
1325
1453
  const { ids: paired, names: toolNames } = pairedToolCalls(messages);
1326
1454
  const wireIds = wireToolCallIds(paired);
1455
+ const pendingImages = [];
1456
+ const flushPendingImages = () => {
1457
+ for (const message of pendingImages) out.push(message);
1458
+ pendingImages.length = 0;
1459
+ };
1327
1460
  for (const message of messages) {
1328
1461
  if (message.role === "system") continue;
1329
1462
  if (message.role === "user" && message.source.kind !== "tool") {
1463
+ flushPendingImages();
1330
1464
  const parts = [];
1331
1465
  for (const block of message.content) {
1332
1466
  if (block.type === "text") parts.push({
@@ -1338,6 +1472,7 @@ async function messagesToCC(messages, readImage) {
1338
1472
  parts.push(await imageToCommandCode(block.attachment, readImage));
1339
1473
  }
1340
1474
  }
1475
+ if (parts.length === 0) continue;
1341
1476
  out.push({
1342
1477
  role: "user",
1343
1478
  content: parts
@@ -1345,11 +1480,16 @@ async function messagesToCC(messages, readImage) {
1345
1480
  continue;
1346
1481
  }
1347
1482
  if (message.role === "assistant") {
1483
+ flushPendingImages();
1348
1484
  const parts = [];
1349
1485
  for (const block of message.content) if (block.type === "text") parts.push({
1350
1486
  type: "text",
1351
1487
  text: block.text
1352
1488
  });
1489
+ else if (block.type === "reasoning") parts.push({
1490
+ type: "reasoning",
1491
+ text: block.text
1492
+ });
1353
1493
  else if (block.type === "tool-call" && paired.has(block.id)) parts.push({
1354
1494
  type: "tool-call",
1355
1495
  toolCallId: wireIds.get(block.id) ?? block.id,
@@ -1366,11 +1506,12 @@ async function messagesToCC(messages, readImage) {
1366
1506
  const block = message.content[0];
1367
1507
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1368
1508
  const media = toolResultMedia(block);
1509
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1369
1510
  out.push({
1370
1511
  role: "tool",
1371
1512
  content: [{
1372
1513
  type: "tool-result",
1373
- toolCallId: wireIds.get(block.toolCallId) ?? block.toolCallId,
1514
+ toolCallId: wireToolCallId,
1374
1515
  toolName: toolNames.get(block.toolCallId) || "unknown",
1375
1516
  output: block.isError ? {
1376
1517
  type: "error-text",
@@ -1385,16 +1526,17 @@ async function messagesToCC(messages, readImage) {
1385
1526
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1386
1527
  const carried = [{
1387
1528
  type: "text",
1388
- text: toolResultImageNote(media)
1529
+ text: toolResultImageNote(media, wireToolCallId)
1389
1530
  }];
1390
1531
  for (const attachment of media.images) carried.push(await imageToCommandCode(attachment, readImage));
1391
- out.push({
1532
+ pendingImages.push({
1392
1533
  role: "user",
1393
1534
  content: carried
1394
1535
  });
1395
1536
  }
1396
1537
  }
1397
1538
  }
1539
+ flushPendingImages();
1398
1540
  return out;
1399
1541
  }
1400
1542
  /**
@@ -1422,9 +1564,15 @@ async function messagesToOpenAI(messages, readImage) {
1422
1564
  const out = [];
1423
1565
  const { ids: paired } = pairedToolCalls(messages);
1424
1566
  const wireIds = wireToolCallIds(paired);
1567
+ const pendingImages = [];
1568
+ const flushPendingImages = () => {
1569
+ for (const message of pendingImages) out.push(message);
1570
+ pendingImages.length = 0;
1571
+ };
1425
1572
  for (const message of messages) {
1426
1573
  if (message.role === "system") continue;
1427
1574
  if (message.role === "user" && message.source.kind !== "tool") {
1575
+ flushPendingImages();
1428
1576
  const parts = [];
1429
1577
  for (const block of message.content) {
1430
1578
  if (block.type === "text") parts.push({
@@ -1448,6 +1596,7 @@ async function messagesToOpenAI(messages, readImage) {
1448
1596
  continue;
1449
1597
  }
1450
1598
  if (message.role === "assistant") {
1599
+ flushPendingImages();
1451
1600
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1452
1601
  const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
1453
1602
  const toolCalls = message.content.filter((block) => block.type === "tool-call" && paired.has(block.id)).map((block) => ({
@@ -1472,25 +1621,27 @@ async function messagesToOpenAI(messages, readImage) {
1472
1621
  const block = message.content[0];
1473
1622
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1474
1623
  const media = toolResultMedia(block);
1624
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1475
1625
  out.push({
1476
1626
  role: "tool",
1477
- tool_call_id: wireIds.get(block.toolCallId) ?? block.toolCallId,
1627
+ tool_call_id: wireToolCallId,
1478
1628
  content: toolResultTextForWire(media)
1479
1629
  });
1480
1630
  if (media.images.length > 0) {
1481
1631
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1482
1632
  const carried = [{
1483
1633
  type: "text",
1484
- text: toolResultImageNote(media)
1634
+ text: toolResultImageNote(media, wireToolCallId)
1485
1635
  }];
1486
1636
  for (const attachment of media.images) carried.push(await imageToOpenAI(attachment, readImage));
1487
- out.push({
1637
+ pendingImages.push({
1488
1638
  role: "user",
1489
1639
  content: carried
1490
1640
  });
1491
1641
  }
1492
1642
  }
1493
1643
  }
1644
+ flushPendingImages();
1494
1645
  return out;
1495
1646
  }
1496
1647
  /** Account endpoints fetched by one `getUsage()` run (see the classification there). */
@@ -1595,7 +1746,7 @@ async function buildCliBody(options, connection, facts) {
1595
1746
  type: "function",
1596
1747
  name: tool.name,
1597
1748
  description: tool.description,
1598
- input_schema: tool.parameters
1749
+ input_schema: toolParametersSchema(tool.parameters)
1599
1750
  })),
1600
1751
  system: facts.systemText,
1601
1752
  max_tokens: facts.maxTokens,
@@ -1617,7 +1768,7 @@ async function buildOpenAIBody(options, facts) {
1617
1768
  function: {
1618
1769
  name: tool.name,
1619
1770
  description: tool.description,
1620
- parameters: tool.parameters
1771
+ parameters: toolParametersSchema(tool.parameters)
1621
1772
  }
1622
1773
  }));
1623
1774
  return {