From 604abdd2b6f84cd45a1867b2b97d8acc91260e85 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:01:18 +0200 Subject: [PATCH 01/10] feat(web-search): opt-in live streaming of routed-model output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the sidecar engaged, runWithWebSearch buffers every semantic adapter event of an iteration before scanning for web_search calls, so clients see nothing until the turn ends — 6-50s of silence, then the whole answer as one burst, on every routed-model turn (Codex sends the hosted web_search tool on every real turn). New config option webSearchSidecar.streamRoutedModelOutput (default false, behavior unchanged without opt-in): stream each iteration's leading text/thinking deltas live; the live window closes permanently at the first buffer-only event (tool calls above all), so web_search interception stays atomic, live events are exactly the first N passthrough entries, and the terminal replay skips them by count — nothing is delivered twice. Scanner semantics (thinking extraction, forced-answer output check) are unchanged. Verified: bun run test — 11197 pass / 0 fail (691 files); 4 new tests including a gated adapter proving live delivery mid-turn; tsc clean. Co-Authored-By: Claude Fable 5 --- .../000_findings_and_design.md | 55 +++++ docs-site/src/content/docs/guides/sidecars.md | 19 +- src/server/responses/core.ts | 1 + src/types.ts | 8 + src/web-search/index.ts | 5 + src/web-search/loop.ts | 47 +++- tests/web-search.test.ts | 201 ++++++++++++++++++ 7 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md diff --git a/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md new file mode 100644 index 0000000000..82944f875e --- /dev/null +++ b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md @@ -0,0 +1,55 @@ +# Web-search sidecar: opt-in live streaming (`streamRoutedModelOutput`) + +Date: 2026-08-12. Follow-up to `260806_codex_desktop_streaming/000_findings.md` — this identifies +the concrete cause of the "chat answers arrive as one end-of-turn burst" symptom that investigation +left open, and lands the fix. + +## Root cause (reproduced end-to-end) + +Codex CLI/Desktop sends a hosted `web_search` tool on every real turn. For routed (non-passthrough) +models with a usable ChatGPT credential, `planWebSearch` engages the web-search sidecar, and +`runWithWebSearch` → `consumeIterationEvents` **fully buffers** every semantic adapter event of an +iteration before scanning for `web_search` calls. Client-visible output therefore arrives only at +turn end — 6–50 s of silence on reasoning-heavy turns, then a burst. + +Evidence chain (all on one machine, same provider `opencode-go/deepseek-v4-flash`, same key): + +- Two bit-identical proxy installs behaved differently: the instance with ChatGPT auth buffered + (`firstOutputMs ≈ durationMs` on 99/103 conversation requests), the instance with an EMPTY + `CODEX_HOME` streamed (`firstOutputMs ≈ 1.5–3 s`) — because only the former could engage the + sidecar. +- Byte-identical replay of a captured real `codex exec` request: buffered on the sidecar-enabled + instance, streamed on the other. Field bisect: removing only the `web_search` tool made the + sidecar-enabled instance stream (first delta 3.5 s, 448 deltas); removing `tool_search` / + `namespace` tools did not. + +## Why buffering exists, and what the fix preserves + +Buffering keeps two invariants: (1) the synthetic `web_search` tool call must never leak to Codex, +and (2) preliminary output from a pre-search iteration must not surface as the answer. The fix +keeps both by construction: + +- Live delivery is **opt-in** (`webSearchSidecar.streamRoutedModelOutput`, default `false`). +- Only event types the sidecar-less path would deliver identically may leave the live window: + `text_delta`, `thinking_delta`, `reasoning_raw_delta`, `thinking_signature`, + `redacted_thinking`, `kiro_redacted_reasoning` (allowlist in `loop.ts`). +- The window closes permanently at the first buffer-only event — tool calls above all — so the + `web_search` interception decision stays atomic and live events are exactly the first N + passthrough entries. The terminal replay skips them by count; nothing is delivered twice. +- Scanner semantics are unchanged: live events are still buffered for `extractIterationThinking` + and the forced-answer output check (#1001 behavior intact). + +Accepted tradeoff (documented in `docs-site/.../sidecars.md`): text the model emits before deciding +to search — which buffered mode silently drops — becomes visible and may partially repeat in the +post-search answer. Reasoning-first models (the common case) are unaffected. + +## Verification + +- `bun test tests/web-search.test.ts` — 55 pass, including 4 new tests: a gated adapter proves + deltas reach the client while the adapter is still mid-turn (buffered mode would deadlock the + gate); default-off buffering; window close at `tool_call_start` with exactly-once replay of the + tail; search-loop pass with pre-search text delivered exactly once. +- `bun test tests/web-search-*.test.ts` — 78 pass. `bun x tsc --noEmit` clean. +- Live replay of the captured Codex request through a patched instance (sidecar-less path): + 893 deltas, first at 2.6–3.3 s, unchanged totals. Sidecar-active live verification requires the + native-main-owner instance and is covered by the gated unit tests instead. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index e62e55c3ef..809c8bde64 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -29,10 +29,18 @@ When Codex requests hosted `web_search` for a non-passthrough routed model, open (default 3), then removes the search tool and forces a final answer. Real client tools such as `apply_patch` or shell finalize the turn so those calls reach Codex. -Every routed-model iteration requests upstream `stream: true`, but opencodex fully buffers semantic -events internally before deciding whether to search or return the final answer. Only the first -iteration's final headers/status and 429 key rotations are acquired eagerly. Thus synthetic search -calls and preliminary output are never exposed as client-visible model output. +Every routed-model iteration requests upstream `stream: true`, but by default opencodex fully +buffers semantic events internally before deciding whether to search or return the final answer. +Only the first iteration's final headers/status and 429 key rotations are acquired eagerly. Thus +synthetic search calls and preliminary output are never exposed as client-visible model output. + +Opt-in `webSearchSidecar.streamRoutedModelOutput` (default `false`) streams each iteration's +leading text/thinking deltas live instead — the client sees output as soon as the model produces +it, exactly like the sidecar-less path. The live window closes permanently at the first tool-call +boundary, so the decision to intercept `web_search` stays atomic and nothing is ever delivered +twice (the terminal replay skips what already streamed). Tradeoff: text the model emits *before* +deciding to search — which buffered mode silently drops — becomes visible and may partially repeat +in the post-search answer. The injected result is wrapped in an untrusted-data boundary, length-capped, and de-duplicated by source URL. In structured-output turns (`json_schema` / `json_object`) it is handed over as compact @@ -48,7 +56,8 @@ relevant images in words and include their source URLs. "reasoning": "low", "maxSearchesPerTurn": 3, "routedModelStallTimeoutMs": 200000, - "timeoutMs": 200000 + "timeoutMs": 200000, + "streamRoutedModelOutput": false } } ``` diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 42e0e193b3..74ddcf9962 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2905,6 +2905,7 @@ async function handleResponsesInner( connectTimeoutMs: config.connectTimeoutMs ?? 200_000, routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, stallTimeoutSec: wsPlan.stallTimeoutSec, + streamRoutedModelOutput: wsPlan.streamRoutedModelOutput, on429: retryAfter => { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter, diff --git a/src/types.ts b/src/types.ts index 6de3fae29b..0e1091a2a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1151,6 +1151,14 @@ export interface OcxWebSearchSidecarConfig { * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647. */ routedModelStallTimeoutMs?: number; + /** + * Stream the routed model's leading output (text/thinking deltas) live instead of buffering the + * whole iteration. Live delivery stops at the first tool-call boundary so web_search interception + * stays atomic. Tradeoff: text the model emits BEFORE deciding to search — which buffered mode + * silently drops — becomes visible to the client and may partially repeat in the post-search + * answer. Default: false (buffered, previous behavior). + */ + streamRoutedModelOutput?: boolean; } export interface OpenRouterProviderRouting { diff --git a/src/web-search/index.ts b/src/web-search/index.ts index e902828bdc..f79787059d 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -120,6 +120,8 @@ export interface SidecarPlan { routedModelStallTimeoutMs: number; /** Effective bridge stall deadline for the sidecar turn (see webSearchStallTimeoutSec). */ stallTimeoutSec: number; + /** Stream leading routed-model output live until the first tool-call boundary (opt-in). */ + streamRoutedModelOutput: boolean; } export function shouldResolveOpenAiWebSearchSidecar( @@ -166,6 +168,7 @@ export function planWebSearch( // The routed model being text-only means the search model must verbalize image results (either backend). const describeImages = modelInList(provider.noVisionModels, modelId); const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING; + const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true; // Anthropic backend authenticates with the STORED credential — no forward provider or ChatGPT login gate. // resolveSidecarBackend only returns "anthropic" when it was explicitly configured OR a usable credential @@ -181,6 +184,7 @@ export function planWebSearch( maxSearches, routedModelStallTimeoutMs, stallTimeoutSec, + streamRoutedModelOutput, }; } @@ -194,5 +198,6 @@ export function planWebSearch( maxSearches, routedModelStallTimeoutMs, stallTimeoutSec, + streamRoutedModelOutput, }; } diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 0460a98cf5..4be7bdbbc6 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -267,6 +267,12 @@ export interface WebSearchLoopDeps { * sidecar search, so a legitimately slow-but-progressing unit never trips the bridge watchdog. */ stallTimeoutSec?: number; + /** + * Opt-in: stream the routed model's leading text/thinking deltas live instead of holding the whole + * iteration back. The live window closes at the first buffer-only event (tool calls above all) so + * the web_search interception decision stays atomic; everything after replays in order at the end. + */ + streamRoutedModelOutput?: boolean; /** One-shot TTFT callback: first non-empty model output observed (WP4). */ onFirstOutput?: () => void; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ @@ -336,7 +342,14 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise; + type IterationSplit = ReturnType & { + /** + * How many leading passthrough events were already delivered live this iteration. They are + * exactly the first N passthrough entries (live delivery stops before the first event that + * scanEventsForWebSearch could group or reorder), so the terminal replay skips them by count. + */ + streamedPassthroughCount: number; + }; // Same-target 429 budget is per REQUEST, not per model iteration: later search rounds inherit // what earlier rounds left of `attempts`, so a bounded multi-round turn can never exceed the @@ -531,10 +544,23 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise([ + "text_delta", "thinking_delta", "reasoning_raw_delta", + "thinking_signature", "redacted_thinking", "kiro_redacted_reasoning", + ]); + // Consume and validate one successful response body under a resettable raw-byte inactivity guard. - // Only invisible heartbeat events escape while semantic output remains buffered for safe scanning. + // By default only invisible heartbeat events escape while semantic output remains buffered for + // safe scanning; with `streamRoutedModelOutput` the leading text/thinking deltas stream live and + // the live window closes permanently at the first buffer-only event (see LIVE_STREAMABLE). const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator { const events: AdapterEvent[] = []; + let liveWindowOpen = deps.streamRoutedModelOutput === true; + let streamedPassthroughCount = 0; try { const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter); for await (const event of parseStreamWithProgress(prepared.response, parse, { @@ -550,7 +576,16 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 ? "s" : ""}, ${Date.now() - loopT0}ms`, ); } - yield* replay(split.passthrough); + // Live-streamed leading events are exactly the first N passthrough entries — replay + // only the buffered tail so nothing reaches the client twice. + yield* replay(split.passthrough.slice(split.streamedPassthroughCount)); return; } // The thinking that led to the search belongs to the FIRST call's assistant replay turn. diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 062fa6f050..b071b0fcca 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2094,3 +2094,204 @@ describe("#398 sidecar failure degradation", () => { expect(raw.includes("LEAKMARKER_should_not_appear")).toBe(false); }); }); + +describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { + /** Incremental SSE frame reader so tests can observe delivery ORDER relative to adapter progress. */ + function frameReader(stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + const frames: { event?: string; data: Record }[] = []; + const parse = (frame: string) => { + const lines = frame.split("\n"); + const event = lines.find(line => line.startsWith("event: "))?.slice(7); + const dataLine = lines.find(line => line.startsWith("data: ")); + if (dataLine?.slice(6) === "[DONE]") return undefined; + return { event, data: JSON.parse(dataLine?.slice(6) ?? "{}") as Record }; + }; + return { + frames, + /** Read until a frame matches, or the stream ends. Returns the matching frame or undefined. */ + async readUntil(match: (f: { event?: string; data: Record }) => boolean) { + for (const f of frames) if (match(f)) return f; + while (true) { + const { done, value } = await reader.read(); + if (done) return undefined; + buffered += decoder.decode(value, { stream: true }); + const parts = buffered.split("\n\n"); + buffered = parts.pop() ?? ""; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + const parsed = parse(trimmed); + if (!parsed) continue; + frames.push(parsed); + if (match(parsed)) return parsed; + } + } + }, + async drain() { + await this.readUntil(() => false); + return frames; + }, + }; + } + + const outputTextOf = (frames: { event?: string; data: Record }[]): string => + frames + .filter(f => f.data.type === "response.output_text.delta") + .map(f => String(f.data.delta ?? "")) + .join(""); + + test("leading text deltas stream live: the client sees them while the adapter is still mid-turn", async () => { + // The adapter blocks after its first delta until the TEST has observed that delta on the wire. + // Buffered delivery would deadlock here; a 5s guard turns that into a clean failure. + let releaseAdapter!: () => void; + const clientSawFirstDelta = new Promise(resolve => { releaseAdapter = resolve; }); + const adapter: ProviderAdapter = { + name: "gated", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "text_delta", text: "Hello " } satisfies AdapterEvent; + await clientSawFirstDelta; + yield { type: "text_delta", text: "World" } satisfies AdapterEvent; + yield { type: "done" } satisfies AdapterEvent; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + streamRoutedModelOutput: true, + }); + const sse = frameReader(response.body!); + const guard = setTimeout(releaseAdapter, 5_000); + const first = await sse.readUntil(f => f.data.type === "response.output_text.delta"); + clearTimeout(guard); + expect(first?.data.delta).toBe("Hello "); + releaseAdapter(); + const frames = await sse.drain(); + // Every delta exactly once — the terminal replay must skip what already streamed. + expect(outputTextOf(frames)).toBe("Hello World"); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); + + test("default (flag unset) keeps full buffering: no text reaches the client before the adapter finishes", async () => { + let adapterFinished = false; + const adapter: ProviderAdapter = { + name: "paced", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "text_delta", text: "Hello " } satisfies AdapterEvent; + await new Promise(resolve => setTimeout(resolve, 100)); + yield { type: "text_delta", text: "World" } satisfies AdapterEvent; + adapterFinished = true; + yield { type: "done" } satisfies AdapterEvent; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + const sse = frameReader(response.body!); + const first = await sse.readUntil(f => f.data.type === "response.output_text.delta"); + // By the time the FIRST delta is visible, the adapter must already be past its last delta. + expect(adapterFinished).toBe(true); + expect(first).toBeDefined(); + const frames = await sse.drain(); + expect(outputTextOf(frames)).toBe("Hello World"); + }); + + test("the live window closes at the first tool_call_start; the buffered tail replays once, in order", async () => { + const adapter: ProviderAdapter = { + name: "tool-tail", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "text_delta", text: "prefix " } satisfies AdapterEvent; + yield { type: "tool_call_start", id: "call_1", name: "shell" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: "{\"cmd\":\"ls\"}" } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { type: "text_delta", text: "suffix" } satisfies AdapterEvent; + yield { type: "done" } satisfies AdapterEvent; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + streamRoutedModelOutput: true, + }); + const frames = await collectSse(response.body!); + expect(outputTextOf(frames)).toBe("prefix suffix"); + // The real tool call still reaches the client exactly once. + const callAdds = frames.filter(f => + f.data.type === "response.output_item.added" + && (f.data.item as Record | undefined)?.type === "function_call"); + expect(callAdds.length).toBe(1); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); + + test("search loop: pre-search text streams live (documented tradeoff), the final answer arrives once", async () => { + let pass = 0; + const adapter: ProviderAdapter = { + name: "search-then-answer", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + if (pass++ === 0) { + yield { type: "text_delta", text: "Let me check. " } satisfies AdapterEvent; + yield { type: "tool_call_start", id: "ws1", name: "web_search" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: "{\"query\":\"docs\"}" } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { type: "done" } satisfies AdapterEvent; + } else { + yield { type: "text_delta", text: "Final answer." } satisfies AdapterEvent; + yield { type: "done" } satisfies AdapterEvent; + } + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + forwardProvider: { ...forwardProvider, baseUrl: "https://chatgpt.test/v1" }, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + streamRoutedModelOutput: true, + }); + const frames = await collectSse(response.body!); + const text = outputTextOf(frames); + // Pre-search text is visible exactly once, then the post-search answer exactly once. + expect(text).toBe("Let me check. Final answer."); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); +}); From 5ccd514759d0f49380bf4dbf05e7a0e4593ea83a Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:32:45 +0200 Subject: [PATCH 02/10] test(web-search): gate tool-boundary and search-loop tests on live client receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: both tests previously asserted only final frames, which a fully buffered implementation also produces. They now withhold the tool call until the test has observed the leading delta on the wire (buffered delivery deadlocks the gate), and the tool-boundary test additionally asserts wire order: prefix delta -> function_call item -> suffix delta. Docs: note that Kiro commentary streaming is independent of the new option; devlog: qualify the reasoning-first-model claim (their leading reasoning becomes visible too — that visibility is the point). Verified: bun run test tests/web-search.test.ts — 55 pass / 0 fail; tsc clean. Co-Authored-By: Claude Fable 5 --- .../000_findings_and_design.md | 4 +- docs-site/src/content/docs/guides/sidecars.md | 5 +++ tests/web-search.test.ts | 43 ++++++++++++++++--- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md index 82944f875e..40b8aa78e1 100644 --- a/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md +++ b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md @@ -41,7 +41,9 @@ keeps both by construction: Accepted tradeoff (documented in `docs-site/.../sidecars.md`): text the model emits before deciding to search — which buffered mode silently drops — becomes visible and may partially repeat in the -post-search answer. Reasoning-first models (the common case) are unaffected. +post-search answer. Reasoning-first models (the common case) avoid the text-repetition case, though +their leading reasoning deltas become client-visible too — that visibility is the point of the +option. ## Verification diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 809c8bde64..6281a41118 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -42,6 +42,11 @@ twice (the terminal replay skips what already streamed). Tradeoff: text the mode deciding to search — which buffered mode silently drops — becomes visible and may partially repeat in the post-search answer. +Kiro commentary is independent of this option: commentary-phase text already streams ahead of the +terminal event in buffered mode, and that bypass is unchanged — with or without +`streamRoutedModelOutput`, only search-decision events (tool calls and everything after the first +tool-call boundary) remain buffered for the atomic `web_search` decision. + The injected result is wrapped in an untrusted-data boundary, length-capped, and de-duplicated by source URL. In structured-output turns (`json_schema` / `json_object`) it is handed over as compact JSON instead of prose. For text-only routed models, the search model is also told to describe diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index b071b0fcca..0c182e255e 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2220,12 +2220,18 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { }); test("the live window closes at the first tool_call_start; the buffered tail replays once, in order", async () => { + // The adapter withholds the tool call until the TEST has seen "prefix " on the wire, so a + // buffered implementation (which delivers nothing before the terminal replay) deadlocks the + // gate instead of passing on identical final frames. + let releaseToolCall!: () => void; + const clientSawPrefix = new Promise(resolve => { releaseToolCall = resolve; }); const adapter: ProviderAdapter = { name: "tool-tail", buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), fetchResponse: async () => new Response("wire", { status: 200 }), async *parseStream() { yield { type: "text_delta", text: "prefix " } satisfies AdapterEvent; + await clientSawPrefix; yield { type: "tool_call_start", id: "call_1", name: "shell" } satisfies AdapterEvent; yield { type: "tool_call_delta", arguments: "{\"cmd\":\"ls\"}" } satisfies AdapterEvent; yield { type: "tool_call_end" } satisfies AdapterEvent; @@ -2246,17 +2252,35 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { maxSearches: 1, streamRoutedModelOutput: true, }); - const frames = await collectSse(response.body!); + const sse = frameReader(response.body!); + const guard = setTimeout(releaseToolCall, 5_000); + const prefixDelta = await sse.readUntil(f => f.data.type === "response.output_text.delta"); + clearTimeout(guard); + expect(prefixDelta?.data.delta).toBe("prefix "); + releaseToolCall(); + const frames = await sse.drain(); expect(outputTextOf(frames)).toBe("prefix suffix"); - // The real tool call still reaches the client exactly once. - const callAdds = frames.filter(f => + // The real tool call still reaches the client exactly once, and the replayed tail keeps + // wire order: prefix delta → function_call item → suffix delta. + const isCallAdd = (f: { data: Record }) => f.data.type === "response.output_item.added" - && (f.data.item as Record | undefined)?.type === "function_call"); - expect(callAdds.length).toBe(1); + && (f.data.item as Record | undefined)?.type === "function_call"; + expect(frames.filter(isCallAdd).length).toBe(1); + const prefixIdx = frames.findIndex(f => f.data.type === "response.output_text.delta" && f.data.delta === "prefix "); + const callIdx = frames.findIndex(isCallAdd); + const suffixIdx = frames.findIndex(f => f.data.type === "response.output_text.delta" && f.data.delta === "suffix"); + expect(prefixIdx).toBeGreaterThanOrEqual(0); + expect(callIdx).toBeGreaterThan(prefixIdx); + expect(suffixIdx).toBeGreaterThan(callIdx); expect(frames.some(f => f.event === "response.completed")).toBe(true); }); test("search loop: pre-search text streams live (documented tradeoff), the final answer arrives once", async () => { + // The first pass withholds its web_search call until the TEST has seen "Let me check. " on + // the wire — a buffered implementation would deadlock the gate rather than pass on final + // frames alone. + let releaseWebSearch!: () => void; + const clientSawPreSearchText = new Promise(resolve => { releaseWebSearch = resolve; }); let pass = 0; const adapter: ProviderAdapter = { name: "search-then-answer", @@ -2265,6 +2289,7 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { async *parseStream() { if (pass++ === 0) { yield { type: "text_delta", text: "Let me check. " } satisfies AdapterEvent; + await clientSawPreSearchText; yield { type: "tool_call_start", id: "ws1", name: "web_search" } satisfies AdapterEvent; yield { type: "tool_call_delta", arguments: "{\"query\":\"docs\"}" } satisfies AdapterEvent; yield { type: "tool_call_end" } satisfies AdapterEvent; @@ -2288,7 +2313,13 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { maxSearches: 1, streamRoutedModelOutput: true, }); - const frames = await collectSse(response.body!); + const sse = frameReader(response.body!); + const guard = setTimeout(releaseWebSearch, 5_000); + const preSearchDelta = await sse.readUntil(f => f.data.type === "response.output_text.delta"); + clearTimeout(guard); + expect(preSearchDelta?.data.delta).toBe("Let me check. "); + releaseWebSearch(); + const frames = await sse.drain(); const text = outputTextOf(frames); // Pre-search text is visible exactly once, then the post-search answer exactly once. expect(text).toBe("Let me check. Final answer."); From 6da15abf3b89879eff46e8dc8e3b2a9f228b225f Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:45:45 +0200 Subject: [PATCH 03/10] test(web-search): make the live-delivery deadline fail the test instead of releasing the gate The previous 5s guard called the adapter's release function on timeout, so a fully buffered implementation could still pass: the timer opens the gate, the terminal replay delivers the leading delta, and readUntil observes the replayed copy. The deadline now rejects the readUntil wait; the gate opens only after the client has genuinely observed the live delta. Co-Authored-By: Claude Fable 5 --- tests/web-search.test.ts | 42 ++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 0c182e255e..45e696462a 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2143,9 +2143,28 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { .map(f => String(f.data.delta ?? "")) .join(""); + /** + * Bound a readUntil wait with a deadline that REJECTS. The deadline must never release an + * adapter gate: doing so would let a buffered implementation pass via the terminal replay. + */ + const within = async (wait: Promise, what: string): Promise => { + let timer!: ReturnType; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out waiting for ${what} — output was not delivered live`)), + 5_000, + ); + }); + try { + return await Promise.race([wait, deadline]); + } finally { + clearTimeout(timer); + } + }; + test("leading text deltas stream live: the client sees them while the adapter is still mid-turn", async () => { // The adapter blocks after its first delta until the TEST has observed that delta on the wire. - // Buffered delivery would deadlock here; a 5s guard turns that into a clean failure. + // Buffered delivery would deadlock here; the rejecting 5s deadline turns that into a failure. let releaseAdapter!: () => void; const clientSawFirstDelta = new Promise(resolve => { releaseAdapter = resolve; }); const adapter: ProviderAdapter = { @@ -2173,9 +2192,10 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { streamRoutedModelOutput: true, }); const sse = frameReader(response.body!); - const guard = setTimeout(releaseAdapter, 5_000); - const first = await sse.readUntil(f => f.data.type === "response.output_text.delta"); - clearTimeout(guard); + const first = await within( + sse.readUntil(f => f.data.type === "response.output_text.delta"), + "the first live text delta", + ); expect(first?.data.delta).toBe("Hello "); releaseAdapter(); const frames = await sse.drain(); @@ -2253,9 +2273,10 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { streamRoutedModelOutput: true, }); const sse = frameReader(response.body!); - const guard = setTimeout(releaseToolCall, 5_000); - const prefixDelta = await sse.readUntil(f => f.data.type === "response.output_text.delta"); - clearTimeout(guard); + const prefixDelta = await within( + sse.readUntil(f => f.data.type === "response.output_text.delta"), + "the live prefix delta before the tool call", + ); expect(prefixDelta?.data.delta).toBe("prefix "); releaseToolCall(); const frames = await sse.drain(); @@ -2314,9 +2335,10 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { streamRoutedModelOutput: true, }); const sse = frameReader(response.body!); - const guard = setTimeout(releaseWebSearch, 5_000); - const preSearchDelta = await sse.readUntil(f => f.data.type === "response.output_text.delta"); - clearTimeout(guard); + const preSearchDelta = await within( + sse.readUntil(f => f.data.type === "response.output_text.delta"), + "the live pre-search text delta", + ); expect(preSearchDelta?.data.delta).toBe("Let me check. "); releaseWebSearch(); const frames = await sse.drain(); From 0e32d0c22ed1c1b87dafbf781757b875bdd854ec Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:02:33 +0200 Subject: [PATCH 04/10] feat(dashboard): expose streamRoutedModelOutput as a live-streaming toggle on the overview page GET/PUT /api/sidecar-settings now carry webSearch.streamRoutedModelOutput (boolean; false is the default and removes the key so config files stay minimal), and the web-search sidecar card on the Dashboard overview gains a "Stream answers live" switch so the option is discoverable without editing config.json. Strings added to all eight locales. Co-Authored-By: Claude Fable 5 --- .../000_findings.md | 90 ++++++++++++++++ docs-site/src/content/docs/guides/sidecars.md | 4 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/dashboard-overview-sections.tsx | 15 +++ gui/src/pages/dashboard-shared.ts | 7 +- src/server/management/config-routes.ts | 23 +++- ...sidecar-settings-web-search-stream.test.ts | 100 ++++++++++++++++++ 14 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 devlog/_plan/260806_codex_desktop_streaming/000_findings.md create mode 100644 tests/sidecar-settings-web-search-stream.test.ts diff --git a/devlog/_plan/260806_codex_desktop_streaming/000_findings.md b/devlog/_plan/260806_codex_desktop_streaming/000_findings.md new file mode 100644 index 0000000000..c030f02d9e --- /dev/null +++ b/devlog/_plan/260806_codex_desktop_streaming/000_findings.md @@ -0,0 +1,90 @@ +# Findings: Codex-Desktop-Streaming über OpenCodex + +Stand: 2026-08-11 + +## Problem + +- DeepSeek direkt in Codex streamt in Codex Desktop sichtbar progressiv. +- Über OpenCodex traten modellabhängig End-Bursts, dauerhaftes „Denke nach“ und bei misslungenen Patches hängende Turns auf. +- Betroffen waren Responses und Chat Completions; ein einzelner Provider oder Wire-Typ erklärt das Problem nicht. +- Ziel ist echter Live-Text im Codex-Responses-Dialekt, nicht synthetisches SSE aus einer fertigen JSON-Antwort. + +## Gesicherte Messungen + +| Pfad | erstes erkanntes Output | Ende | Ergebnis | +|---|---:|---:|---| +| DeepSeek direkt in Codex | 2,983 s | 17,469 s | progressiv | +| OpenRouter → DeepSeek V4 Flash | 12,108 s | 50,571 s | progressiv | +| OpenRouter Responses → GLM-5.2 | 37,638 s | 37,707 s | 69-ms-Burst | +| OpenRouter Responses → Muse Spark 1.2 | 9,360 s | 9,388 s | 28-ms-Burst | +| OpenRouter Chat → GLM-5.2 | 38,534 s | 38,560 s | 26-ms-Burst | + +- OpenRouter kann grundsätzlich progressiv liefern: direkter Test mit 1.945 Textdeltas über 22,126 s. +- OpenCodex kann grundsätzlich progressiv weiterleiten: HTTP-Test mit ca. 13,711 s Deltafenster und App-Server-Test mit 918 Textdeltas über 5,871 s. +- Codex-Rollouts enthalten keine Zeitstempel pro Textdelta; finale Nachrichtenzeiten beweisen weder Burst noch Streaming. +- Der OpenCode-Vergleich ist noch nicht modellidentisch: dort ist OpenRouter/DeepSeek, nicht GLM-5.2 oder Muse Spark, bestätigt. + +## Getrennte Symptome + +- Fehlendes `item.phase: "final_answer"` kann erklären, warum Text streamt, während „Denke nach“ bis zum Turn-Ende bleibt. +- Es erklärt keinen Text-Burst: Eine minimale Phase-Reparatur änderte GLM nicht. +- Der eingebaute DeepSeek-Pfad hatte zusätzlich `modelResponsesUpstreamStreaming: false`; das erzwang `stream:false`, vollständiges JSON und synthetisches SSE. Dies ist nicht die allgemeine Ursache. +- GPT-Zwischenmeldungen sind weder als Tool-Aufruf noch als Sidecar-Modell nachgewiesen. + +## Grenze der bisherigen Messung + +- Native Responses werden weitgehend unverändert weitergeleitet; der Standardpfad auf macOS nutzt `ReadableStream.tee()`. +- `firstOutputMs` erkennt nur `response.output_text.delta`, `response.reasoning_summary_text.delta` und `response.reasoning_text.delta`. +- OpenRouter dokumentiert außerdem `response.reasoning.delta`; frühe Events dieses Typs lösen die Metrik nicht aus und können für Codex unsichtbar sein. +- Daher beweist spätes `firstOutputMs` nicht, dass vorher keine Rohbytes oder anderen SSE-Events ankamen. +- Der Chat-Adapter reicht jedes `delta.content` sofort weiter. Er erkennt String-Felder `reasoning` und `reasoning_content`, aber kein strukturiertes `reasoning_details`. +- Noch offen ist, ob Text bereits früh am ersten Reader ankommt, Bun Chunks spät exponiert oder Requestfelder upstream ein anderes Streaming-Verhalten auslösen. + +## Widerlegte oder gefährliche Ansätze + +- Reasoning, ein einzelner Provider oder Responses/Chat als pauschale Hauptursache. +- `stream:false`: verhindert echtes Upstream-Streaming. +- Globales Parsen und Serialisieren aller SSE-Events: veränderte das Chunking und korrelierte mit Bursts. +- Synthetischer Terminal-Timer und globale Phase-Reparatur: verursachten Hänger und wurden entfernt. +- Alte PR-#123-Macrotask-Yields (`008c879f`): änderten GLM im minimalen Versuch nicht. +- `streamMode: "eager-relay"` kann `tee()` isolieren, erklärt aber keinen Chat-Completions-Burst. + +## Nächster entscheidender Test + +Direkt am ersten Upstream-Reader nur Zeit, Bytezahl und SSE-Eventtyp erfassen; niemals Text, Prompt, Header oder Schlüssel: + +- erster Rohchunk +- erstes `response.reasoning.delta` +- erstes und letztes `response.output_text.delta` +- Anzahl und Größe der Textdeltas +- Terminal-Event + +Auswertung: + +- Frühe `output_text`-Events am Reader, später Client-Burst → Fehler nach dem Reader in OpenCodex. +- Frühe Reasoning-, aber späte Text-Events → Upstream-/Requestverhalten plus Dialekt-/UI-Lücke. +- Erster Rohchunk erst am Ende → exakt denselben Request mit Bun und `curl --no-buffer` vergleichen. +- Danach GLM-5.2 modellidentisch in OpenCode testen und Requestfelder vergleichen. + +## Isolierte Experiment-Installation + +- Offiziell: `/opt/homebrew/bin/ocx`, Version 2.12.0; nicht verändert. +- Experiment: `ocx-exp`, Version 2.12.0 aus Release-Commit `6d881db20`, Port 10101. +- Eigene Daten: `~/.local/share/opencodex-experiment/home`; eigenes Codex-Home: `~/.local/share/opencodex-experiment/codex-home`. +- Paketkopie: `~/.local/share/opencodex-experiment/runtime`; Befehl: `~/.local/bin/ocx-exp`. +- `update`, `service`, `codex-shim`, `tray` und der parameterlose Start sind gesperrt. +- Start nur durch Robin im Vordergrund: `ocx-exp start`; der Agent führt keine Lifecycle-Kommandos aus. + +## Aktueller Zustand + +- Keine untersuchte Streaming-Lösung ist im Quellbaum aktiv. +- Kein Patch gilt als Lösung des GLM-/Muse-Bursts. +- Offizielle und experimentelle Installation basieren für den A/B-Ausgangspunkt auf Release 2.12.0; der geöffnete Entwicklungsbranch bleibt separat auf `origin/dev` (`e8db4e036`). + +## A/B 2026-08-11 abends (beide Instanzen live, 10100 + 10101) + +- Quellcode identisch (`diff -rq` offiziell vs. `runtime/` leer), `config.json` identisch bis auf `port` + `googleAntigravityStaticCatalogVersion: 2` (nur Experiment), API-Keys identisch (SHA-256). +- HTTP-A/B, gleicher Request (Zahlen 1–500, `opencode-go/deepseek-v4-flash`, effort max, 3 Läufe): beide Instanzen liefern 999 `output_text.delta` über ~4 s (p50 0 ms, p90 14–22 ms), `response.completed` + `final_answer` vorhanden. KEIN Burst, kein fehlender Terminal-Event. +- Einziger messbarer Unterschied: TTFT offiziell ~5 s höher (14,5–18,1 s vs. 9,5 s), mehr `response.heartbeat` (7/5 vs. 3). +- Weitere Ist-Differenzen: offizielle Instanz bedient Desktop UND Agent-Turns (ein Bun-Event-Loop), Experiment exklusiv; `responses-state.json` 24 MB/24 States vs. 2,6 KB/3; Start als Service vs. Vordergrund; `config_generation` 28 vs. 2. +- Schluss: Kein Code-/Config-Unterschied erklärt Burst oder fehlendes Completed. Nächstliegend: Last-Kontention auf der offiziellen Instanz (parallele Agent-Session) verzögert die SSE-Weitergabe im Desktop. Offener Test: Integer-Turn via offiziell ohne parallelen Agent-Traffic bzw. mit definierter Parallellast. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 6281a41118..d0e487a47f 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -40,7 +40,9 @@ it, exactly like the sidecar-less path. The live window closes permanently at th boundary, so the decision to intercept `web_search` stays atomic and nothing is ever delivered twice (the terminal replay skips what already streamed). Tradeoff: text the model emits *before* deciding to search — which buffered mode silently drops — becomes visible and may partially repeat -in the post-search answer. +in the post-search answer. The Dashboard overview page exposes this as the **Stream answers live** +toggle on the web-search sidecar card (`PUT /api/sidecar-settings` with +`webSearch.streamRoutedModelOutput`). Kiro commentary is independent of this option: commentary-phase text already streams ahead of the terminal event in buffered mode, and that bypass is unchanged — with or without diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 98c476c617..1d05c66cf0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -269,6 +269,8 @@ export const de: Record = { "dash.visionModelHint": "Modell zur Beschreibung von Bildern für nur-Text-Routen. Erfordert ChatGPT-Login.", "dash.webSearchSidecar": "Websuche-Sidecar", "dash.webSearchSidecarHint": "Backend und Modell für die Websuche gerouteter Modelle auswählen.", + "dash.webSearchStream": "Antworten live streamen", + "dash.webSearchStreamHint": "Ausgabe gerouteter Modelle sofort anzeigen statt bis zum Turn-Ende zu puffern. Text vor einer Suche kann sich teilweise wiederholen.", "dash.visionSidecar": "Vision-Sidecar", "dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 77648b63ca..fdd58f4344 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -281,6 +281,8 @@ export const en = { "dash.visionModelHint": "Model used to describe images for text-only routed models. Requires ChatGPT login.", "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Choose the backend and model used for web search on routed models.", + "dash.webSearchStream": "Stream answers live", + "dash.webSearchStreamHint": "Show routed-model output as it streams instead of buffering until the turn ends. Text written before a search may partially repeat.", "dash.visionSidecar": "Vision sidecar", "dash.visionSidecarHint": "Choose the backend and model used to describe images for text-only routed models.", "dash.shadowCallIntercept": "Shadow Call Intercept", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4e56f55d38..e9e4a83a31 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -278,6 +278,8 @@ export const ja: Record = { "dash.visionModelHint": "テキスト専用ルーティングモデルで画像を説明するために使うモデル。ChatGPT ログインが必要です。", "dash.webSearchSidecar": "ウェブ検索サイドカー", "dash.webSearchSidecarHint": "ルーティングモデルでウェブ検索に使うバックエンドとモデルを選択します。", + "dash.webSearchStream": "回答をライブ配信", + "dash.webSearchStreamHint": "ターン終了までバッファせず、ルーティングモデルの出力を逐次表示します。検索前のテキストは一部繰り返される場合があります。", "dash.visionSidecar": "ビジョンサイドカー", "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", "dash.shadowCallIntercept": "シャドウコール傍受", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c59cfde7fb..6b80e7dd53 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -273,6 +273,8 @@ export const ko: Record = { "dash.visionModelHint": "텍스트 전용 라우팅 모델에 이미지를 설명하는 데 사용되는 모델입니다. ChatGPT 로그인 필요.", "dash.webSearchSidecar": "웹 검색 사이드카", "dash.webSearchSidecarHint": "라우팅 모델의 웹 검색에 쓸 백엔드와 모델을 고릅니다.", + "dash.webSearchStream": "응답 실시간 스트리밍", + "dash.webSearchStreamHint": "턴이 끝날 때까지 버퍼링하지 않고 라우팅 모델 출력을 즉시 표시합니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", "dash.visionSidecar": "비전 사이드카", "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ed86f7eb3d..14fd38156c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -278,6 +278,8 @@ export const ru: Record = { "dash.visionModelHint": "Модель, которая описывает изображения для маршрутизируемых моделей, работающих только с текстом. Требуется вход в аккаунт ChatGPT.", "dash.webSearchSidecar": "Сайдкар веб-поиска", "dash.webSearchSidecarHint": "Выберите бэкенд и модель, используемые для веб-поиска на маршрутизируемых моделях.", + "dash.webSearchStream": "Стримить ответы вживую", + "dash.webSearchStreamHint": "Показывать вывод маршрутизируемой модели сразу, не буферизуя до конца хода. Текст до поиска может частично повторяться.", "dash.visionSidecar": "Сайдкар для изображений", "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", "dash.shadowCallIntercept": "Перехват теневых вызовов", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3aa8275305..7e7f18ab85 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -279,6 +279,8 @@ export const tr: Record = { "dash.visionModelHint": "Salt metin yönlendirilen modeller için görselleri tanımlamakta kullanılan model. ChatGPT girişi gerektirir.", "dash.webSearchSidecar": "Web arama yan aracı (sidecar)", "dash.webSearchSidecarHint": "Yönlendirilen modellerde web araması için kullanılan arka ucu ve modeli seçin.", + "dash.webSearchStream": "Yanıtları canlı akıt", + "dash.webSearchStreamHint": "Yönlendirilen modelin çıktısını tur sonuna kadar arabelleğe almak yerine anında gösterir. Aramadan önce yazılan metin kısmen tekrarlanabilir.", "dash.visionSidecar": "Görsel yan aracı (sidecar)", "dash.visionSidecarHint": "Salt metin modeller için görselleri tanımlamakta kullanılan arka ucu ve modeli seçin.", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 09782ffe73..1c710535f8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -172,6 +172,8 @@ export const zhTW: Record = { "dash.visionModelHint": "為純文字路由模型描述圖像的模型。需要 ChatGPT 登入。", "dash.webSearchSidecar": "網頁搜尋附屬服務", "dash.webSearchSidecarHint": "選擇路由模型進行網頁搜尋時使用的後端和模型。", + "dash.webSearchStream": "即時串流輸出回答", + "dash.webSearchStreamHint": "立即顯示路由模型的輸出,而不是緩衝到回合結束。搜尋前的文字可能會部分重複。", "dash.visionSidecar": "視覺附屬服務", "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", "dash.shadowCallIntercept": "影子呼叫攔截", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ede0ffcef2..99e1a327fe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -273,6 +273,8 @@ export const zh: Record = { "dash.visionModelHint": "为纯文本路由模型描述图像的模型。需要 ChatGPT 登录。", "dash.webSearchSidecar": "网页搜索附属服务", "dash.webSearchSidecarHint": "选择路由模型进行网页搜索时使用的后端和模型。", + "dash.webSearchStream": "实时流式输出回答", + "dash.webSearchStreamHint": "立即显示路由模型的输出,而不是缓冲到回合结束。搜索前的文本可能会部分重复。", "dash.visionSidecar": "视觉附属服务", "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", "dash.shadowCallIntercept": "影子调用拦截", diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 61ac28f8be..bf22ff796a 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -284,6 +284,21 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { disabled={!sidecar || sidecarSaving} label={t("dash.sidecarModel")} /> +
+ {t("dash.webSearchStream")} + +
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index fe906724b3..faa99d4d43 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -56,7 +56,7 @@ export interface SettingsData { } export type SidecarBackend = "openai" | "anthropic"; export type VisionReasoning = "low" | "medium" | "high" | "xhigh" | "max"; -export interface SidecarSetting { backend?: SidecarBackend; model: string; reasoning?: VisionReasoning } +export interface SidecarSetting { backend?: SidecarBackend; model: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean } export interface VisionModelOption { value: string; label: string; backend: SidecarBackend; baseline?: boolean } export interface SidecarData { webSearch: SidecarSetting; @@ -67,7 +67,7 @@ export interface SidecarData { visionModels?: VisionModelOption[]; } export interface SidecarPatch { - webSearch?: { backend?: SidecarBackend | null; model?: string }; + webSearch?: { backend?: SidecarBackend | null; model?: string; streamRoutedModelOutput?: boolean }; vision?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } @@ -151,13 +151,14 @@ export function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string export function mergeSidecarSetting( current: SidecarSetting, - update?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning }, + update?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean }, ): SidecarSetting { const merged = { ...current }; if (update?.model !== undefined) merged.model = update.model; if (update?.backend === null) delete merged.backend; else if (update?.backend !== undefined) merged.backend = update.backend; if (update?.reasoning !== undefined) merged.reasoning = update.reasoning; + if (update?.streamRoutedModelOutput !== undefined) merged.streamRoutedModelOutput = update.streamRoutedModelOutput; return merged; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 0e3cffc67e..ca37248f36 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -410,7 +410,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new Request(url), url, config); + if (!response) throw new Error("sidecar settings route did not handle GET"); + return response; +} + +async function putSidecarSettings(config: OcxConfig, webSearch: Record): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI( + new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ webSearch }), + }), + url, + config, + ); + if (!response) throw new Error("sidecar settings route did not handle PUT"); + return response; +} + +function emptyConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "none", + providers: {}, + ...overrides, + } as OcxConfig; +} + +describe("sidecar-settings webSearch.streamRoutedModelOutput", () => { + let previousHome: string | undefined; + let isolatedHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedHome = mkdtempSync(join(tmpdir(), "ocx-sidecar-ws-stream-")); + process.env.OPENCODEX_HOME = isolatedHome; + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true }); + isolatedHome = undefined; + }); + + test("GET reports false when unset and true when configured", async () => { + const off = await getSidecarSettings(emptyConfig()); + expect(off.status).toBe(200); + expect(((await off.json()) as { webSearch: { streamRoutedModelOutput: boolean } }) + .webSearch.streamRoutedModelOutput).toBe(false); + + const on = await getSidecarSettings(emptyConfig({ + webSearchSidecar: { streamRoutedModelOutput: true }, + })); + expect(((await on.json()) as { webSearch: { streamRoutedModelOutput: boolean } }) + .webSearch.streamRoutedModelOutput).toBe(true); + }); + + test("PUT true persists the flag and echoes it; PUT false removes the key", async () => { + const config = emptyConfig(); + const enable = await putSidecarSettings(config, { streamRoutedModelOutput: true }); + expect(enable.status).toBe(200); + expect(((await enable.json()) as { webSearch: { streamRoutedModelOutput: boolean } }) + .webSearch.streamRoutedModelOutput).toBe(true); + expect(config.webSearchSidecar?.streamRoutedModelOutput).toBe(true); + + const disable = await putSidecarSettings(config, { streamRoutedModelOutput: false }); + expect(disable.status).toBe(200); + expect(((await disable.json()) as { webSearch: { streamRoutedModelOutput: boolean } }) + .webSearch.streamRoutedModelOutput).toBe(false); + // false is the default — the key is dropped so config files stay minimal. + expect("streamRoutedModelOutput" in (config.webSearchSidecar ?? {})).toBe(false); + }); + + test("PUT rejects a non-boolean value and leaves other fields untouched", async () => { + const config = emptyConfig({ webSearchSidecar: { model: "gpt-5.6-luna" } }); + const response = await putSidecarSettings(config, { streamRoutedModelOutput: "yes" }); + expect(response.status).toBe(400); + expect(config.webSearchSidecar?.streamRoutedModelOutput).toBeUndefined(); + expect(config.webSearchSidecar?.model).toBe("gpt-5.6-luna"); + }); + + test("PUT that omits the flag does not disturb an enabled value", async () => { + const config = emptyConfig({ webSearchSidecar: { streamRoutedModelOutput: true } }); + const response = await putSidecarSettings(config, { model: "gpt-5.6-luna" }); + expect(response.status).toBe(200); + expect(config.webSearchSidecar?.streamRoutedModelOutput).toBe(true); + }); +}); From d5b12b16ac5fb9c7377d4276f98e1b435bc13f60 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:03:49 +0200 Subject: [PATCH 05/10] chore(devlog): drop accidentally added German-language investigation notes Swept in by a bulk add; the follow-up devlog in 260812_websearch_sidecar_live_streaming stands on its own. Co-Authored-By: Claude Fable 5 --- .../000_findings.md | 90 ------------------- 1 file changed, 90 deletions(-) delete mode 100644 devlog/_plan/260806_codex_desktop_streaming/000_findings.md diff --git a/devlog/_plan/260806_codex_desktop_streaming/000_findings.md b/devlog/_plan/260806_codex_desktop_streaming/000_findings.md deleted file mode 100644 index c030f02d9e..0000000000 --- a/devlog/_plan/260806_codex_desktop_streaming/000_findings.md +++ /dev/null @@ -1,90 +0,0 @@ -# Findings: Codex-Desktop-Streaming über OpenCodex - -Stand: 2026-08-11 - -## Problem - -- DeepSeek direkt in Codex streamt in Codex Desktop sichtbar progressiv. -- Über OpenCodex traten modellabhängig End-Bursts, dauerhaftes „Denke nach“ und bei misslungenen Patches hängende Turns auf. -- Betroffen waren Responses und Chat Completions; ein einzelner Provider oder Wire-Typ erklärt das Problem nicht. -- Ziel ist echter Live-Text im Codex-Responses-Dialekt, nicht synthetisches SSE aus einer fertigen JSON-Antwort. - -## Gesicherte Messungen - -| Pfad | erstes erkanntes Output | Ende | Ergebnis | -|---|---:|---:|---| -| DeepSeek direkt in Codex | 2,983 s | 17,469 s | progressiv | -| OpenRouter → DeepSeek V4 Flash | 12,108 s | 50,571 s | progressiv | -| OpenRouter Responses → GLM-5.2 | 37,638 s | 37,707 s | 69-ms-Burst | -| OpenRouter Responses → Muse Spark 1.2 | 9,360 s | 9,388 s | 28-ms-Burst | -| OpenRouter Chat → GLM-5.2 | 38,534 s | 38,560 s | 26-ms-Burst | - -- OpenRouter kann grundsätzlich progressiv liefern: direkter Test mit 1.945 Textdeltas über 22,126 s. -- OpenCodex kann grundsätzlich progressiv weiterleiten: HTTP-Test mit ca. 13,711 s Deltafenster und App-Server-Test mit 918 Textdeltas über 5,871 s. -- Codex-Rollouts enthalten keine Zeitstempel pro Textdelta; finale Nachrichtenzeiten beweisen weder Burst noch Streaming. -- Der OpenCode-Vergleich ist noch nicht modellidentisch: dort ist OpenRouter/DeepSeek, nicht GLM-5.2 oder Muse Spark, bestätigt. - -## Getrennte Symptome - -- Fehlendes `item.phase: "final_answer"` kann erklären, warum Text streamt, während „Denke nach“ bis zum Turn-Ende bleibt. -- Es erklärt keinen Text-Burst: Eine minimale Phase-Reparatur änderte GLM nicht. -- Der eingebaute DeepSeek-Pfad hatte zusätzlich `modelResponsesUpstreamStreaming: false`; das erzwang `stream:false`, vollständiges JSON und synthetisches SSE. Dies ist nicht die allgemeine Ursache. -- GPT-Zwischenmeldungen sind weder als Tool-Aufruf noch als Sidecar-Modell nachgewiesen. - -## Grenze der bisherigen Messung - -- Native Responses werden weitgehend unverändert weitergeleitet; der Standardpfad auf macOS nutzt `ReadableStream.tee()`. -- `firstOutputMs` erkennt nur `response.output_text.delta`, `response.reasoning_summary_text.delta` und `response.reasoning_text.delta`. -- OpenRouter dokumentiert außerdem `response.reasoning.delta`; frühe Events dieses Typs lösen die Metrik nicht aus und können für Codex unsichtbar sein. -- Daher beweist spätes `firstOutputMs` nicht, dass vorher keine Rohbytes oder anderen SSE-Events ankamen. -- Der Chat-Adapter reicht jedes `delta.content` sofort weiter. Er erkennt String-Felder `reasoning` und `reasoning_content`, aber kein strukturiertes `reasoning_details`. -- Noch offen ist, ob Text bereits früh am ersten Reader ankommt, Bun Chunks spät exponiert oder Requestfelder upstream ein anderes Streaming-Verhalten auslösen. - -## Widerlegte oder gefährliche Ansätze - -- Reasoning, ein einzelner Provider oder Responses/Chat als pauschale Hauptursache. -- `stream:false`: verhindert echtes Upstream-Streaming. -- Globales Parsen und Serialisieren aller SSE-Events: veränderte das Chunking und korrelierte mit Bursts. -- Synthetischer Terminal-Timer und globale Phase-Reparatur: verursachten Hänger und wurden entfernt. -- Alte PR-#123-Macrotask-Yields (`008c879f`): änderten GLM im minimalen Versuch nicht. -- `streamMode: "eager-relay"` kann `tee()` isolieren, erklärt aber keinen Chat-Completions-Burst. - -## Nächster entscheidender Test - -Direkt am ersten Upstream-Reader nur Zeit, Bytezahl und SSE-Eventtyp erfassen; niemals Text, Prompt, Header oder Schlüssel: - -- erster Rohchunk -- erstes `response.reasoning.delta` -- erstes und letztes `response.output_text.delta` -- Anzahl und Größe der Textdeltas -- Terminal-Event - -Auswertung: - -- Frühe `output_text`-Events am Reader, später Client-Burst → Fehler nach dem Reader in OpenCodex. -- Frühe Reasoning-, aber späte Text-Events → Upstream-/Requestverhalten plus Dialekt-/UI-Lücke. -- Erster Rohchunk erst am Ende → exakt denselben Request mit Bun und `curl --no-buffer` vergleichen. -- Danach GLM-5.2 modellidentisch in OpenCode testen und Requestfelder vergleichen. - -## Isolierte Experiment-Installation - -- Offiziell: `/opt/homebrew/bin/ocx`, Version 2.12.0; nicht verändert. -- Experiment: `ocx-exp`, Version 2.12.0 aus Release-Commit `6d881db20`, Port 10101. -- Eigene Daten: `~/.local/share/opencodex-experiment/home`; eigenes Codex-Home: `~/.local/share/opencodex-experiment/codex-home`. -- Paketkopie: `~/.local/share/opencodex-experiment/runtime`; Befehl: `~/.local/bin/ocx-exp`. -- `update`, `service`, `codex-shim`, `tray` und der parameterlose Start sind gesperrt. -- Start nur durch Robin im Vordergrund: `ocx-exp start`; der Agent führt keine Lifecycle-Kommandos aus. - -## Aktueller Zustand - -- Keine untersuchte Streaming-Lösung ist im Quellbaum aktiv. -- Kein Patch gilt als Lösung des GLM-/Muse-Bursts. -- Offizielle und experimentelle Installation basieren für den A/B-Ausgangspunkt auf Release 2.12.0; der geöffnete Entwicklungsbranch bleibt separat auf `origin/dev` (`e8db4e036`). - -## A/B 2026-08-11 abends (beide Instanzen live, 10100 + 10101) - -- Quellcode identisch (`diff -rq` offiziell vs. `runtime/` leer), `config.json` identisch bis auf `port` + `googleAntigravityStaticCatalogVersion: 2` (nur Experiment), API-Keys identisch (SHA-256). -- HTTP-A/B, gleicher Request (Zahlen 1–500, `opencode-go/deepseek-v4-flash`, effort max, 3 Läufe): beide Instanzen liefern 999 `output_text.delta` über ~4 s (p50 0 ms, p90 14–22 ms), `response.completed` + `final_answer` vorhanden. KEIN Burst, kein fehlender Terminal-Event. -- Einziger messbarer Unterschied: TTFT offiziell ~5 s höher (14,5–18,1 s vs. 9,5 s), mehr `response.heartbeat` (7/5 vs. 3). -- Weitere Ist-Differenzen: offizielle Instanz bedient Desktop UND Agent-Turns (ein Bun-Event-Loop), Experiment exklusiv; `responses-state.json` 24 MB/24 States vs. 2,6 KB/3; Start als Service vs. Vordergrund; `config_generation` 28 vs. 2. -- Schluss: Kein Code-/Config-Unterschied erklärt Burst oder fehlendes Completed. Nächstliegend: Last-Kontention auf der offiziellen Instanz (parallele Agent-Session) verzögert die SSE-Weitergabe im Desktop. Offener Test: Integer-Turn via offiziell ohne parallelen Agent-Traffic bzw. mit definierter Parallellast. From aa720d25595829a78064f6bf0678c5c74d2f4ec4 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:14:19 +0200 Subject: [PATCH 06/10] test(management): expect streamRoutedModelOutput in sidecar-settings webSearch shape Co-Authored-By: Claude Fable 5 --- tests/vision-anthropic.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/vision-anthropic.test.ts b/tests/vision-anthropic.test.ts index ad93e6856a..2fbb3d141b 100644 --- a/tests/vision-anthropic.test.ts +++ b/tests/vision-anthropic.test.ts @@ -254,7 +254,7 @@ describe("Anthropic vision planning and management config", () => { config, ); const getBody = await get!.json() as Record; - expect(getBody.webSearch).toEqual({ model: "claude-search", backend: "anthropic" }); + expect(getBody.webSearch).toEqual({ model: "claude-search", backend: "anthropic", streamRoutedModelOutput: false }); expect(getBody.vision).toEqual({ model: "claude-sonnet-5", backend: "anthropic", @@ -276,7 +276,7 @@ describe("Anthropic vision planning and management config", () => { ); expect(clear.status).toBe(200); const clearBody = await clear.json() as Record; - expect(clearBody.webSearch).toEqual({ model: "gpt-5.6-luna" }); + expect(clearBody.webSearch).toEqual({ model: "gpt-5.6-luna", streamRoutedModelOutput: false }); expect(clearBody.vision).toEqual({ model: "gpt-5.4-mini", reasoning: "low", maxDescriptionsPerTurn: 4 }); expect(config.webSearchSidecar).toEqual({ reasoning: "high" }); expect(config.visionSidecar).toEqual({ maxDescriptionsPerTurn: 4 }); From 140ab2b7c5eaf5213b5043a229550bec90712397 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:08:13 +0200 Subject: [PATCH 07/10] docs(devlog): record sidecar-active live E2E verification of streamRoutedModelOutput Co-Authored-By: Claude Fable 5 --- .../000_findings_and_design.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md index 40b8aa78e1..11e3c90103 100644 --- a/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md +++ b/devlog/_plan/260812_websearch_sidecar_live_streaming/000_findings_and_design.md @@ -53,5 +53,10 @@ option. tail; search-loop pass with pre-search text delivered exactly once. - `bun test tests/web-search-*.test.ts` — 78 pass. `bun x tsc --noEmit` clean. - Live replay of the captured Codex request through a patched instance (sidecar-less path): - 893 deltas, first at 2.6–3.3 s, unchanged totals. Sidecar-active live verification requires the - native-main-owner instance and is covered by the gated unit tests instead. + 893 deltas, first at 2.6–3.3 s, unchanged totals. +- Sidecar-ACTIVE live E2E (patched build running as the native-main owner with a real ChatGPT + credential, identical text-forcing request, routed `opencode-go/deepseek-v4-flash`): toggle off → + 18.3 s silence then 2829 deltas in one 0.02 s burst; toggle on → first delta at 4.0 s, 2538 + deltas over 9.9 s. The toggle applied without restart via `PUT /api/sidecar-settings`. + Field note: a paused ChatGPT account (`pausedCodexAccountIds`) silently disables the sidecar and + masks both bug and fix — everything streams because the sidecar-less path runs. From 7f64ca057a5d8ac081575423747a342597d9cdd7 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:34:51 +0200 Subject: [PATCH 08/10] fix(review): scope the live-streaming hint to the actual window; assert durable persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round 4: the dashboard hint implied the whole turn streams — reworded in all eight locales to say leading text/reasoning streams until the model decides on a tool call, with the rest buffered for search interception. The sidecar-settings test now reloads the config from disk after each PUT and asserts the flag survives (true persists, false removes the key); the fixture gained a schema-valid provider because loadConfig() discards invalid files wholesale, which would have voided the reload assertions. Co-Authored-By: Claude Fable 5 --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- tests/sidecar-settings-web-search-stream.test.ts | 10 ++++++++-- 9 files changed, 16 insertions(+), 10 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1d05c66cf0..189d1d27bb 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -270,7 +270,7 @@ export const de: Record = { "dash.webSearchSidecar": "Websuche-Sidecar", "dash.webSearchSidecarHint": "Backend und Modell für die Websuche gerouteter Modelle auswählen.", "dash.webSearchStream": "Antworten live streamen", - "dash.webSearchStreamHint": "Ausgabe gerouteter Modelle sofort anzeigen statt bis zum Turn-Ende zu puffern. Text vor einer Suche kann sich teilweise wiederholen.", + "dash.webSearchStreamHint": "Führenden Text und Reasoning live streamen, bis das Modell über einen Tool-Aufruf entscheidet; der Rest bleibt für das Abfangen der Suche gepuffert. Text vor einer Suche kann sich teilweise wiederholen.", "dash.visionSidecar": "Vision-Sidecar", "dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fdd58f4344..c25243a935 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -282,7 +282,7 @@ export const en = { "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Choose the backend and model used for web search on routed models.", "dash.webSearchStream": "Stream answers live", - "dash.webSearchStreamHint": "Show routed-model output as it streams instead of buffering until the turn ends. Text written before a search may partially repeat.", + "dash.webSearchStreamHint": "Stream the model’s leading text and reasoning live until it decides on a tool call; the rest of the turn stays buffered for search interception. Text written before a search may partially repeat.", "dash.visionSidecar": "Vision sidecar", "dash.visionSidecarHint": "Choose the backend and model used to describe images for text-only routed models.", "dash.shadowCallIntercept": "Shadow Call Intercept", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9e4a83a31..2e5b277dc6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -279,7 +279,7 @@ export const ja: Record = { "dash.webSearchSidecar": "ウェブ検索サイドカー", "dash.webSearchSidecarHint": "ルーティングモデルでウェブ検索に使うバックエンドとモデルを選択します。", "dash.webSearchStream": "回答をライブ配信", - "dash.webSearchStreamHint": "ターン終了までバッファせず、ルーティングモデルの出力を逐次表示します。検索前のテキストは一部繰り返される場合があります。", + "dash.webSearchStreamHint": "モデルがツール呼び出しを決定するまで、先頭のテキストと推論をライブ配信します。以降は検索インターセプトのためバッファされます。検索前のテキストは一部繰り返される場合があります。", "dash.visionSidecar": "ビジョンサイドカー", "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", "dash.shadowCallIntercept": "シャドウコール傍受", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6b80e7dd53..96d0f226fe 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -274,7 +274,7 @@ export const ko: Record = { "dash.webSearchSidecar": "웹 검색 사이드카", "dash.webSearchSidecarHint": "라우팅 모델의 웹 검색에 쓸 백엔드와 모델을 고릅니다.", "dash.webSearchStream": "응답 실시간 스트리밍", - "dash.webSearchStreamHint": "턴이 끝날 때까지 버퍼링하지 않고 라우팅 모델 출력을 즉시 표시합니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", + "dash.webSearchStreamHint": "모델이 도구 호출을 결정할 때까지 앞부분 텍스트와 추론을 실시간 스트리밍합니다. 이후는 검색 가로채기를 위해 버퍼링됩니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", "dash.visionSidecar": "비전 사이드카", "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 14fd38156c..d1d8429662 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -279,7 +279,7 @@ export const ru: Record = { "dash.webSearchSidecar": "Сайдкар веб-поиска", "dash.webSearchSidecarHint": "Выберите бэкенд и модель, используемые для веб-поиска на маршрутизируемых моделях.", "dash.webSearchStream": "Стримить ответы вживую", - "dash.webSearchStreamHint": "Показывать вывод маршрутизируемой модели сразу, не буферизуя до конца хода. Текст до поиска может частично повторяться.", + "dash.webSearchStreamHint": "Транслировать начальный текст и рассуждения вживую, пока модель не решит вызвать инструмент; остальное буферизуется для перехвата поиска. Текст до поиска может частично повторяться.", "dash.visionSidecar": "Сайдкар для изображений", "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", "dash.shadowCallIntercept": "Перехват теневых вызовов", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7e7f18ab85..77c81d666a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -280,7 +280,7 @@ export const tr: Record = { "dash.webSearchSidecar": "Web arama yan aracı (sidecar)", "dash.webSearchSidecarHint": "Yönlendirilen modellerde web araması için kullanılan arka ucu ve modeli seçin.", "dash.webSearchStream": "Yanıtları canlı akıt", - "dash.webSearchStreamHint": "Yönlendirilen modelin çıktısını tur sonuna kadar arabelleğe almak yerine anında gösterir. Aramadan önce yazılan metin kısmen tekrarlanabilir.", + "dash.webSearchStreamHint": "Model bir araç çağrısına karar verene kadar baştaki metni ve akıl yürütmeyi canlı akıtır; kalanı arama yakalama için arabelleğe alınır. Aramadan önce yazılan metin kısmen tekrarlanabilir.", "dash.visionSidecar": "Görsel yan aracı (sidecar)", "dash.visionSidecarHint": "Salt metin modeller için görselleri tanımlamakta kullanılan arka ucu ve modeli seçin.", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1c710535f8..7a119a6667 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -173,7 +173,7 @@ export const zhTW: Record = { "dash.webSearchSidecar": "網頁搜尋附屬服務", "dash.webSearchSidecarHint": "選擇路由模型進行網頁搜尋時使用的後端和模型。", "dash.webSearchStream": "即時串流輸出回答", - "dash.webSearchStreamHint": "立即顯示路由模型的輸出,而不是緩衝到回合結束。搜尋前的文字可能會部分重複。", + "dash.webSearchStreamHint": "即時串流輸出開頭的文字和推理,直到模型決定呼叫工具;其餘部分為攔截搜尋而保持緩衝。搜尋前的文字可能會部分重複。", "dash.visionSidecar": "視覺附屬服務", "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", "dash.shadowCallIntercept": "影子呼叫攔截", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 99e1a327fe..9825780ec2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -274,7 +274,7 @@ export const zh: Record = { "dash.webSearchSidecar": "网页搜索附属服务", "dash.webSearchSidecarHint": "选择路由模型进行网页搜索时使用的后端和模型。", "dash.webSearchStream": "实时流式输出回答", - "dash.webSearchStreamHint": "立即显示路由模型的输出,而不是缓冲到回合结束。搜索前的文本可能会部分重复。", + "dash.webSearchStreamHint": "实时流式输出开头的文本和推理,直到模型决定调用工具;其余部分为拦截搜索而保持缓冲。搜索前的文本可能会部分重复。", "dash.visionSidecar": "视觉附属服务", "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", "dash.shadowCallIntercept": "影子调用拦截", diff --git a/tests/sidecar-settings-web-search-stream.test.ts b/tests/sidecar-settings-web-search-stream.test.ts index acd2a23575..97ac2a4213 100644 --- a/tests/sidecar-settings-web-search-stream.test.ts +++ b/tests/sidecar-settings-web-search-stream.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { loadConfig } from "../src/config"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import { ManagementRequest as Request } from "./helpers/management-auth"; @@ -29,10 +30,12 @@ async function putSidecarSettings(config: OcxConfig, webSearch: Record = {}): OcxConfig { + // A schema-valid provider setup: loadConfig() discards invalid files wholesale + // (backup + defaults), which would silently void the reload assertions below. return { port: 10100, - defaultProvider: "none", - providers: {}, + defaultProvider: "dummy", + providers: { dummy: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, ...overrides, } as OcxConfig; } @@ -74,6 +77,8 @@ describe("sidecar-settings webSearch.streamRoutedModelOutput", () => { expect(((await enable.json()) as { webSearch: { streamRoutedModelOutput: boolean } }) .webSearch.streamRoutedModelOutput).toBe(true); expect(config.webSearchSidecar?.streamRoutedModelOutput).toBe(true); + // Durable persistence: the flag must survive a config reload from disk. + expect(loadConfig().webSearchSidecar?.streamRoutedModelOutput).toBe(true); const disable = await putSidecarSettings(config, { streamRoutedModelOutput: false }); expect(disable.status).toBe(200); @@ -81,6 +86,7 @@ describe("sidecar-settings webSearch.streamRoutedModelOutput", () => { .webSearch.streamRoutedModelOutput).toBe(false); // false is the default — the key is dropped so config files stay minimal. expect("streamRoutedModelOutput" in (config.webSearchSidecar ?? {})).toBe(false); + expect("streamRoutedModelOutput" in (loadConfig().webSearchSidecar ?? {})).toBe(false); }); test("PUT rejects a non-boolean value and leaves other fields untouched", async () => { From 175179778bdcf8a8ceba1b83aca5a56267c3a0fc Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:44:41 +0200 Subject: [PATCH 09/10] docs(test): describe loadConfig's defaults-merge repair path accurately Co-Authored-By: Claude Fable 5 --- tests/sidecar-settings-web-search-stream.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/sidecar-settings-web-search-stream.test.ts b/tests/sidecar-settings-web-search-stream.test.ts index 97ac2a4213..19847d848a 100644 --- a/tests/sidecar-settings-web-search-stream.test.ts +++ b/tests/sidecar-settings-web-search-stream.test.ts @@ -30,8 +30,9 @@ async function putSidecarSettings(config: OcxConfig, webSearch: Record = {}): OcxConfig { - // A schema-valid provider setup: loadConfig() discards invalid files wholesale - // (backup + defaults), which would silently void the reload assertions below. + // A schema-valid provider setup: for an invalid file, loadConfig() first retries with + // defaults merged in and falls back to backup + pure defaults only when that repair also + // fails validation — either path would silently void the reload assertions below. return { port: 10100, defaultProvider: "dummy", From 209154f68cf82d61ff1f1466a0534f744c756efe Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:24:29 +0200 Subject: [PATCH 10/10] test(web-search): gated live-delivery test for reasoning deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round 6: the live-window tests only covered text_delta, so a regression that buffers or drops thinking_delta would pass. The new gated test blocks the adapter until the client has observed the leading response.reasoning_summary_text.delta on the wire (rejecting 5s deadline), then asserts exactly-once delivery across the terminal replay. Requires reasoning.summary=auto in the request — without it the parser sets hideThinkingSummary and reasoning is never client-visible by design. Co-Authored-By: Claude Fable 5 --- tests/web-search.test.ts | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 45e696462a..c8e6d68471 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2204,6 +2204,59 @@ describe("web-search sidecar live streaming (streamRoutedModelOutput)", () => { expect(frames.some(f => f.event === "response.completed")).toBe(true); }); + test("leading reasoning deltas stream live: the client sees them while the adapter is still mid-turn", async () => { + // Same gate as the text test, but for the reasoning path: thinking_delta must reach the + // client as response.reasoning_summary_text.delta before the adapter is allowed to finish. + let releaseAdapter!: () => void; + const clientSawFirstReasoning = new Promise(resolve => { releaseAdapter = resolve; }); + const adapter: ProviderAdapter = { + name: "gated-reasoning", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "thinking_delta", thinking: "Considering " } satisfies AdapterEvent; + await clientSawFirstReasoning; + yield { type: "thinking_delta", thinking: "options" } satisfies AdapterEvent; + yield { type: "text_delta", text: "Answer" } satisfies AdapterEvent; + yield { type: "done" } satisfies AdapterEvent; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + const response = await runWithWebSearch({ + // Without reasoning.summary the parser sets hideThinkingSummary and no reasoning frame is + // ever client-visible; "auto" matches what Codex sends on real turns. + parsed: parseRequest({ + model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }], + reasoning: { summary: "auto" }, + }), + adapter, + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + streamRoutedModelOutput: true, + }); + const sse = frameReader(response.body!); + const first = await within( + sse.readUntil(f => f.data.type === "response.reasoning_summary_text.delta"), + "the first live reasoning delta", + ); + expect(first?.data.delta).toBe("Considering "); + releaseAdapter(); + const frames = await sse.drain(); + // Each reasoning delta exactly once — the terminal replay must not duplicate the streamed head. + const reasoning = frames + .filter(f => f.data.type === "response.reasoning_summary_text.delta") + .map(f => String(f.data.delta ?? "")) + .join(""); + expect(reasoning).toBe("Considering options"); + expect(outputTextOf(frames)).toBe("Answer"); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); + test("default (flag unset) keeps full buffering: no text reaches the client before the adapter finishes", async () => { let adapterFinished = false; const adapter: ProviderAdapter = {