@bitkyc08/opencodex 2.7.43 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Deterministic teardown for the storage Bun Workers.
3
+ *
4
+ * `Worker.terminate()` returns void and does NOT wait for the thread to be
5
+ * reclaimed. Every caller here used to fire it and move on, which is fine for
6
+ * the proxy but not for `bun test --isolate`: the harness tears a test file's
7
+ * realm down at the file boundary, and on Windows a worker that has not
8
+ * finished exiting by then trips a Bun-internal assertion and kills the whole
9
+ * run.
10
+ *
11
+ * The crash header names the shape exactly — `workers_spawned(9)
12
+ * workers_terminated(8)`, one worker still alive — and it lands right at the
13
+ * `api-storage-policy` → `api-storage` file boundary, the only suite that
14
+ * spawns policy workers. See `devlog/_plan/260730_remote_issue_merge_round/150`,
15
+ * which reproduced the panic twice against an unchanged tree and left this
16
+ * defence as the follow-up if it ever recurred. It recurred.
17
+ *
18
+ * So: keep a registry of live workers, and give shutdown/reset paths something
19
+ * they can actually await. `close` is Bun's post-exit event for a worker
20
+ * thread, so awaiting it is the real "the thread is gone" signal rather than a
21
+ * timer we hope is long enough. The timeout only exists so a wedged worker
22
+ * cannot hang a test teardown forever.
23
+ */
24
+
25
+ const liveWorkers = new Set<Worker>();
26
+
27
+ /** Track a freshly spawned worker so teardown can wait for it later. */
28
+ export function registerStorageWorker(worker: Worker): void {
29
+ liveWorkers.add(worker);
30
+ }
31
+
32
+ /**
33
+ * Terminate a worker and resolve once its thread has actually exited.
34
+ *
35
+ * Safe to call twice: the second call finds the worker already deregistered and
36
+ * resolves immediately.
37
+ */
38
+ export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promise<void> {
39
+ if (!liveWorkers.has(worker)) {
40
+ try { worker.terminate(); } catch { /* already gone */ }
41
+ return Promise.resolve();
42
+ }
43
+ liveWorkers.delete(worker);
44
+
45
+ return new Promise<void>(resolve => {
46
+ let done = false;
47
+ const settle = (): void => {
48
+ if (done) return;
49
+ done = true;
50
+ clearTimeout(timer);
51
+ resolve();
52
+ };
53
+ // A worker that refuses to exit must not wedge teardown; the proxy path
54
+ // never awaits this, and a test teardown would rather continue than hang.
55
+ const timer = setTimeout(settle, timeoutMs);
56
+ try {
57
+ worker.addEventListener("close", settle, { once: true });
58
+ } catch {
59
+ // No close event available — fall back to the timeout above.
60
+ }
61
+ try {
62
+ worker.terminate();
63
+ } catch {
64
+ settle();
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Await every worker this module still tracks. Used by test resets so no
71
+ * storage worker outlives the file that spawned it.
72
+ */
73
+ export async function drainStorageWorkers(timeoutMs = 5_000): Promise<void> {
74
+ const pending = [...liveWorkers];
75
+ await Promise.all(pending.map(worker => terminateStorageWorker(worker, timeoutMs)));
76
+ }
77
+
78
+ /** Live worker count — exported so a regression test can assert the invariant. */
79
+ export function liveStorageWorkerCount(): number {
80
+ return liveWorkers.size;
81
+ }
@@ -414,17 +414,43 @@ function assertWindows(): void {
414
414
  if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`);
415
415
  }
416
416
 
417
- function spawnTray(state: WindowsTrayEntry): void {
418
- const child = spawn(state.bun, [state.cli, "__tray-host"], {
419
- detached: true,
417
+ const DETACHED_TRAY_HOST_LAUNCHER = [
418
+ "$startInfo = New-Object System.Diagnostics.ProcessStartInfo",
419
+ "$startInfo.FileName = $env:OCX_TRAY_HOST_BUN",
420
+ "$startInfo.Arguments = $env:OCX_TRAY_HOST_ARGS",
421
+ "$startInfo.UseShellExecute = $true",
422
+ "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden",
423
+ "$child = [System.Diagnostics.Process]::Start($startInfo)",
424
+ "if ($null -eq $child) { throw 'Windows tray host did not start.' }",
425
+ "$child.Dispose()",
426
+ ].join("; ");
427
+
428
+ const DETACHED_TRAY_HOST_LAUNCHER_B64 = Buffer.from(DETACHED_TRAY_HOST_LAUNCHER, "utf16le").toString("base64");
429
+
430
+ export function launchWindowsTrayHost(state: WindowsTrayEntry): void {
431
+ const bun = safePath(state.bun);
432
+ const cli = safePath(state.cli);
433
+ execFileSync(windowsPowerShellPath(), [
434
+ "-NoLogo",
435
+ "-NoProfile",
436
+ "-NonInteractive",
437
+ "-EncodedCommand",
438
+ DETACHED_TRAY_HOST_LAUNCHER_B64,
439
+ ], {
420
440
  stdio: "ignore",
421
441
  windowsHide: true,
442
+ timeout: 15_000,
422
443
  env: {
423
444
  ...process.env,
445
+ OCX_TRAY_HOST_BUN: bun,
446
+ OCX_TRAY_HOST_ARGS: `${quoteRunValue(cli)} __tray-host`,
424
447
  OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"),
425
448
  },
426
449
  });
427
- child.unref();
450
+ }
451
+
452
+ function spawnTray(state: WindowsTrayEntry): void {
453
+ launchWindowsTrayHost(state);
428
454
  }
429
455
 
430
456
  function parseTrayHostEntry(): WindowsTrayEntry {
@@ -443,6 +469,8 @@ export async function runWindowsTrayHost(): Promise<void> {
443
469
  assertWindows();
444
470
  const entry = parseTrayHostEntry();
445
471
  delete process.env.OCX_TRAY_ENTRY_B64;
472
+ delete process.env.OCX_TRAY_HOST_BUN;
473
+ delete process.env.OCX_TRAY_HOST_ARGS;
446
474
  const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
447
475
  stdio: "ignore",
448
476
  windowsHide: true,
package/src/types.ts CHANGED
@@ -546,6 +546,17 @@ export interface OcxConfig {
546
546
  * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
547
547
  */
548
548
  injectionEffort?: string;
549
+ /**
550
+ * Explicit sideband websocket base for realtime/live joins, mirroring upstream's
551
+ * `experimental_realtime_ws_base_url`. The value is a ROOT (or a recognized
552
+ * `/realtime`, `/realtime/calls/<id>`, `/live/<id>` endpoint form, which is
553
+ * stripped back to the root); `/v1` is appended during normalization. Intended
554
+ * for local development against a fake realtime server — plaintext `http`/`ws`
555
+ * is accepted only for loopback hosts, and URL userinfo is rejected; both
556
+ * failures close to the canonical `https://api.openai.com/v1`. Configured by
557
+ * editing this file; there is deliberately no management-API or GUI surface.
558
+ */
559
+ experimentalRealtimeWsBaseUrl?: string;
549
560
  /**
550
561
  * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty
551
562
  * means "everything visible", which is the historical behaviour — so an existing
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cached "is an update available?" answer for the GUI sidebar badge.
3
+ *
4
+ * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a
5
+ * sidebar that polls it would spawn a process per tick on every page of the GUI.
6
+ * The badge instead READS the 20h version cache the CLI update prompt already
7
+ * maintains (`~/.opencodex/version.json`).
8
+ *
9
+ * This is deliberately read-only: it must never trigger a registry refresh. The GUI
10
+ * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view`
11
+ * helpers with no coalescing. Cache warming stays with `ocx start`
12
+ * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
13
+ * `/api/update/check` the user reaches by clicking the sidebar update button.
14
+ */
15
+ import { currentVersion, defaultUpdateTag, detectInstall, type Channel } from "./index";
16
+ import { isNewer, isSourceBuildVersion, readVersionCache } from "./notify";
17
+
18
+ export interface UpdateBadge {
19
+ /** True only when a newer version exists on the current channel. */
20
+ updateAvailable: boolean;
21
+ currentVersion: string;
22
+ latestVersion: string | null;
23
+ channel: Channel;
24
+ /** False for source checkouts, where the GUI cannot offer a one-click update. */
25
+ canUpdate: boolean;
26
+ /** True when no cached registry answer exists yet, so "no update" is unproven. */
27
+ unknown: boolean;
28
+ }
29
+
30
+ export interface UpdateBadgeDeps {
31
+ currentVersion: () => string;
32
+ detectInstall: () => ReturnType<typeof detectInstall>;
33
+ readCache: (channel: Channel) => ReturnType<typeof readVersionCache>;
34
+ }
35
+
36
+ const defaultDeps: UpdateBadgeDeps = {
37
+ currentVersion,
38
+ detectInstall,
39
+ readCache: readVersionCache,
40
+ };
41
+
42
+ /**
43
+ * Read-only badge state. Source checkouts and unknown versions report no update
44
+ * rather than a dead badge the user cannot act on.
45
+ */
46
+ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadge {
47
+ const current = deps.currentVersion();
48
+ const installer = deps.detectInstall();
49
+ const channel = defaultUpdateTag(current);
50
+ const base: UpdateBadge = {
51
+ updateAvailable: false,
52
+ currentVersion: current,
53
+ latestVersion: null,
54
+ channel,
55
+ canUpdate: installer !== "source",
56
+ unknown: true,
57
+ };
58
+ // A source checkout has nothing to compare against, so "unknown" is not useful there.
59
+ if (installer === "source" || current === "?" || isSourceBuildVersion(current)) {
60
+ return { ...base, canUpdate: false, unknown: false };
61
+ }
62
+
63
+ const cache = deps.readCache(channel);
64
+ if (!cache) return base;
65
+
66
+ return {
67
+ ...base,
68
+ latestVersion: cache.latest_version,
69
+ updateAvailable: isNewer(cache.latest_version, current, channel),
70
+ unknown: false,
71
+ };
72
+ }
package/src/update/job.ts CHANGED
@@ -26,7 +26,7 @@ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/lates
26
26
  const UPDATE_JOB_FILENAME = "update-job.json";
27
27
  const UPDATE_TIMEOUT_MS = 180_000;
28
28
  const RESTART_TIMEOUT_MS = 60_000;
29
- const RESTART_HEALTH_TIMEOUT_MS = 15_000;
29
+ const RESTART_HEALTH_TIMEOUT_MS = 30_000;
30
30
  const RESTART_STABILITY_WINDOW_MS = 15_000;
31
31
  /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
32
32
  export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
@@ -533,7 +533,10 @@ async function awaitRestartedProxyHealthy(
533
533
  const hostname = captured.hostname;
534
534
  const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
535
535
 
536
- while (now() < startDeadline) {
536
+ while (true) {
537
+ // Always make one identity-aware probe at or after the boundary. A replacement
538
+ // becoming healthy on the final tick must not be mistaken for a timeout.
539
+ const finalProbe = now() >= startDeadline;
537
540
  if (await probe(port, hostname)) {
538
541
  updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
539
542
  const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
@@ -547,7 +550,8 @@ async function awaitRestartedProxyHealthy(
547
550
  updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
548
551
  return { ok: true };
549
552
  }
550
- await sleep(250);
553
+ if (finalProbe) break;
554
+ await sleep(Math.min(250, Math.max(0, startDeadline - now())));
551
555
  }
552
556
 
553
557
  return { ok: false, reason: "timeout" };
@@ -570,7 +574,7 @@ async function confirmRestartedProxy(
570
574
  - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
571
575
  - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
572
576
  - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
573
- - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 성공 판정이 최대 30초 늦어질 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
577
+ - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 설정상 성공 판정 창이 30초 도착 + 15초 안정성으로 늘어나고 경계 probe 지연이 추가될 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
574
578
  */
575
579
  const result = await awaitRestartedProxyHealthy(job, captured, io);
576
580
  if (result.ok) return true;
@@ -144,16 +144,17 @@ export function findExpectedPriceOverlay(
144
144
  }
145
145
 
146
146
  /**
147
- * OpenAI service_tier "priority" (Fast) price multipliers by model slug.
148
- * Source: https://platform.openai.com/docs/models (2026-07-24).
149
- * Priority pricing applies uniformly to all token types (input, output, cache).
147
+ * OpenAI Fast mode (`service_tier=priority`) price multipliers by model slug.
148
+ * Source: https://openai.com/api-fast-mode/ (2026-07-31).
149
+ * Fast pricing applies uniformly to all token types (input, output, cache).
150
150
  * Models not listed here fall back to 1× (no multiplier).
151
151
  */
152
152
  export const PRIORITY_MULTIPLIERS: Readonly<Record<string, number>> = {
153
153
  "gpt-5.6-sol": 2,
154
- "gpt-5.6-terra": 2,
155
- "gpt-5.6-luna": 2,
154
+ "gpt-5.6-terra": 1.6,
155
+ "gpt-5.6-luna": 0.4,
156
156
  "gpt-5.5": 2.5,
157
+ "gpt-5.4-mini": 2,
157
158
  "gpt-5.4": 2,
158
159
  };
159
160
 
package/src/usage/log.ts CHANGED
@@ -128,6 +128,13 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined
128
128
  return {
129
129
  inputTokens: usage.inputTokens,
130
130
  outputTokens: usage.outputTokens,
131
+ // Absolute active-context checkpoint (types.ts). Stateful providers such as Kiro report
132
+ // per-attempt usage only, so this field is the ONLY carrier of the cumulative context
133
+ // figure once the log records raw adapter usage instead of re-parsing the bridged wire
134
+ // (usageFromBridge, request-log.ts). Omitting it here silently dropped Kiro's context
135
+ // growth from every persisted row. It is deliberately NOT folded into totalTokens:
136
+ // a checkpoint is not a per-request total and must never be summed across requests.
137
+ ...(typeof usage.contextTotalTokens === "number" ? { contextTotalTokens: usage.contextTotalTokens } : {}),
131
138
  ...(typeof usage.totalTokens === "number" ? { totalTokens: usage.totalTokens } : {}),
132
139
  ...(typeof usage.cachedInputTokens === "number" ? { cachedInputTokens: usage.cachedInputTokens } : {}),
133
140
  ...(typeof usage.cacheReadInputTokens === "number" ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}),
@@ -162,6 +169,7 @@ function normalizeAttemptUsage(raw: unknown): OcxUsage | null {
162
169
  if (!isNonNegativeFiniteNumber(usage.inputTokens)
163
170
  || !isNonNegativeFiniteNumber(usage.outputTokens)) return null;
164
171
  for (const key of [
172
+ "contextTotalTokens",
165
173
  "totalTokens",
166
174
  "cachedInputTokens",
167
175
  "cacheReadInputTokens",
@@ -103,23 +103,63 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
103
103
  * replaying a bare toolCall 400s ("Expected `thinking` or `redacted_thinking`, but found
104
104
  * `tool_use`"). The signature validity gate stays in the anthropic adapter; other adapters
105
105
  * ignore or serialize the part harmlessly.
106
+ *
107
+ * Each signed block keeps its OWN signature and text, mirroring src/images/loop.ts: a signature
108
+ * authenticates the exact block it closed, so flattening two blocks under the last signature
109
+ * 400s on replay just as it does there.
110
+ *
111
+ * Raw reasoning (`reasoning_raw_delta`, what OpenAI-compatible providers emit instead of signed
112
+ * thinking) accumulates into a SEPARATE UNSIGNED part. It must never join a signed block: the
113
+ * anthropic serializer skips signature-less parts, while openai-chat serializes their text as
114
+ * `reasoning_content` — which DeepSeek V4 thinking mode requires back alongside the replayed
115
+ * tool_calls, and whose absence ended the turn as a provider 400 (issue #688).
116
+ *
117
+ * This assumes raw reasoning never interleaves INSIDE an unfinished signed block: Anthropic-family
118
+ * adapters emit thinking_delta/signature and OpenAI-compatible ones emit reasoning_raw_delta, and
119
+ * the two never share a stream. Honoring a genuinely mixed stream would need per-segment state,
120
+ * not another accumulator.
106
121
  */
107
- function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent | null {
122
+ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] {
123
+ const parts: OcxThinkingContent[] = [];
108
124
  let thinking = "";
109
125
  let signature: string | undefined;
110
- const redacted: string[] = [];
126
+ let rawReasoning = "";
127
+
128
+ const flushVisible = () => {
129
+ if (!thinking && !signature) return;
130
+ parts.push({
131
+ type: "thinking",
132
+ thinking,
133
+ ...(signature ? { signature } : {}),
134
+ });
135
+ thinking = "";
136
+ signature = undefined;
137
+ };
138
+ const flushRaw = () => {
139
+ if (!rawReasoning) return;
140
+ parts.push({ type: "thinking", thinking: rawReasoning });
141
+ rawReasoning = "";
142
+ };
143
+
111
144
  for (const e of events) {
112
- if (e.type === "thinking_delta") thinking += e.thinking;
113
- else if (e.type === "thinking_signature") signature = e.signature;
114
- else if (e.type === "redacted_thinking") redacted.push(e.data);
145
+ if (e.type === "thinking_delta") {
146
+ flushRaw();
147
+ thinking += e.thinking;
148
+ } else if (e.type === "reasoning_raw_delta") {
149
+ flushVisible();
150
+ rawReasoning += e.text;
151
+ } else if (e.type === "thinking_signature") {
152
+ signature = e.signature;
153
+ flushVisible();
154
+ } else if (e.type === "redacted_thinking") {
155
+ flushVisible();
156
+ flushRaw();
157
+ parts.push({ type: "thinking", thinking: "", redacted: [e.data] });
158
+ }
115
159
  }
116
- if (!thinking && !signature && redacted.length === 0) return null;
117
- return {
118
- type: "thinking",
119
- thinking,
120
- ...(signature ? { signature } : {}),
121
- ...(redacted.length > 0 ? { redacted } : {}),
122
- };
160
+ flushVisible();
161
+ flushRaw();
162
+ return parts;
123
163
  }
124
164
 
125
165
  /** Normalize a query for failed-query de-duplication (case/whitespace-insensitive). */
@@ -406,7 +446,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
406
446
  // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
407
447
  // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
408
448
  // emit a cell (matching the prior single-query behavior).
409
- async function* runSearchCall(call: WebSearchCall, precedingThinking?: OcxThinkingContent | null): AsyncGenerator<AdapterEvent> {
449
+ async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
410
450
  const results: { query: string; outcome: SidecarOutcome }[] = [];
411
451
  let beganCell = false;
412
452
  if (call.queries.length === 0) {
@@ -465,8 +505,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
465
505
  messages.push({
466
506
  role: "assistant",
467
507
  content: [
468
- // Signed thinking must precede tool_use on replay (Anthropic extended thinking).
469
- ...(precedingThinking ? [precedingThinking] : []),
508
+ // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
509
+ // unsigned raw reasoning has to ride along for providers that require it back (#688).
510
+ ...precedingThinking,
470
511
  { type: "toolCall" as const, id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs },
471
512
  ],
472
513
  timestamp: now,
@@ -559,7 +600,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
559
600
  // The thinking that led to the search belongs to the FIRST call's assistant replay turn.
560
601
  const iterationThinking = extractIterationThinking(split.passthrough);
561
602
  for (const [callIndex, call] of split.calls.entries()) {
562
- yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
603
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
563
604
  }
564
605
  } catch (e) {
565
606
  yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };