From 99422b084b0c9f12d791447f547347f72f540691 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 06:49:22 -0700 Subject: [PATCH 1/4] Add think-tag reclassification for the ollama-adapter (CL-6654) Ollama's OpenAI-compatible endpoint never populates the reasoning_content field @intx/inference's OpenAI provider checks for reasoning; gpt-oss and qwen instead wrap their chain-of-thought inline in ... inside the ordinary content stream, so it rides through as plain reply text. This module splits those spans out into inference.thinking.delta events instead, at the one place that knows the tokens came from Ollama. --- .../ollama-adapter/src/think-tags.test.ts | 87 +++++++++++ packages/ollama-adapter/src/think-tags.ts | 144 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 packages/ollama-adapter/src/think-tags.test.ts create mode 100644 packages/ollama-adapter/src/think-tags.ts diff --git a/packages/ollama-adapter/src/think-tags.test.ts b/packages/ollama-adapter/src/think-tags.test.ts new file mode 100644 index 00000000..e4af4e41 --- /dev/null +++ b/packages/ollama-adapter/src/think-tags.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import type { InferenceEvent } from "@intx/types/runtime"; + +import { createThinkSplitState, reclassifyThinkingEvents } from "./think-tags"; + +function textDelta(token: string, seq = 1): InferenceEvent { + return { + type: "inference.text.delta", + seq, + data: { token, partial: { text: token }, index: 0 }, + }; +} + +describe("reclassifyThinkingEvents", () => { + test("a whole ... span in one token becomes thinking-delta, not text-delta", () => { + const state = createThinkSplitState(); + const out = reclassifyThinkingEvents( + [textDelta("plan the approachHere is the answer.")], + state, + ); + expect(out).toHaveLength(2); + expect(out[0]?.type).toBe("inference.thinking.delta"); + expect((out[0]?.data as { token: string }).token).toBe("plan the approach"); + expect(out[1]?.type).toBe("inference.text.delta"); + expect((out[1]?.data as { token: string }).token).toBe("Here is the answer."); + }); + + test("a span split across multiple chunks stays classified as thinking across the boundary", () => { + const state = createThinkSplitState(); + const first = reclassifyThinkingEvents([textDelta("step one, ")], state); + const second = reclassifyThinkingEvents([textDelta("step twofinal reply")], state); + + expect(first).toHaveLength(1); + expect(first[0]?.type).toBe("inference.thinking.delta"); + expect(second).toHaveLength(2); + expect(second[0]?.type).toBe("inference.thinking.delta"); + expect((second[0]?.data as { token: string }).token).toBe("step two"); + expect(second[1]?.type).toBe("inference.text.delta"); + expect((second[1]?.data as { token: string }).token).toBe("final reply"); + }); + + test("ordinary text with no tag passes through as text-delta unchanged", () => { + const state = createThinkSplitState(); + const out = reclassifyThinkingEvents([textDelta("just a normal reply")], state); + expect(out).toEqual([textDelta("just a normal reply")]); + }); + + test("non-text events (tool calls, done) pass through untouched", () => { + const state = createThinkSplitState(); + const toolCallStart: InferenceEvent = { + type: "inference.tool_call.start", + seq: 1, + data: { callId: "call-1", name: "slack__post_message", partial: { text: "" } }, + }; + const out = reclassifyThinkingEvents([toolCallStart], state); + expect(out).toEqual([toolCallStart]); + }); + + test("thinking events never share an index with the text stream, so the harness's per-index blockMap can't collide them", () => { + const state = createThinkSplitState(); + const out = reclassifyThinkingEvents( + [textDelta("internal notesvisible reply", 3)], + state, + ); + const thinkingEvent = out.find((event) => event.type === "inference.thinking.delta"); + const textEvent = out.find((event) => event.type === "inference.text.delta"); + expect((thinkingEvent?.data as { index?: number }).index).not.toBe( + (textEvent?.data as { index?: number }).index, + ); + expect((textEvent?.data as { index?: number }).index).toBe(0); + }); + + test("cumulative partial.text/partial.thinking reflect only their own kind, never the raw tags", () => { + const state = createThinkSplitState(); + const out = reclassifyThinkingEvents( + [textDelta("internal notesvisible reply")], + state, + ); + const thinkingEvent = out.find((event) => event.type === "inference.thinking.delta"); + const textEvent = out.find((event) => event.type === "inference.text.delta"); + expect((thinkingEvent?.data as { partial: { thinking?: string } }).partial.thinking).toBe( + "internal notes", + ); + expect((textEvent?.data as { partial: { text: string } }).partial.text).toBe("visible reply"); + expect((textEvent?.data as { partial: { text: string } }).partial.text).not.toContain(""); + }); +}); diff --git a/packages/ollama-adapter/src/think-tags.ts b/packages/ollama-adapter/src/think-tags.ts new file mode 100644 index 00000000..362a2348 --- /dev/null +++ b/packages/ollama-adapter/src/think-tags.ts @@ -0,0 +1,144 @@ +// Ollama's OpenAI-compatible endpoint never populates the `reasoning`/ +// `reasoning_content` delta fields `@intx/inference`'s OpenAI provider +// looks for (see `providers/openai.js`'s `reasoningFieldNames` handling). +// gpt-oss and qwen instead emit their chain-of-thought inline inside the +// ordinary `content` field, wrapped in ``. Left alone, that +// text is indistinguishable from the reply and rides every hop downstream +// as a genuine `inference.text.delta` — this is the CL-6654 leak. This +// module reclassifies it into `inference.thinking.delta` before anything +// else ever sees it, at the one place that already knows these tokens came +// from Ollama. +import type { InferenceEvent } from "@intx/types/runtime"; + +/** Carries the split state across every chunk of one streamed response — + * a `` tag can land at a chunk boundary, so "are we inside a + * thinking span" has to survive from one `parseResponse` call to the next. */ +export type ThinkSplitState = { + inThink: boolean; + /** Once a `` tag has appeared at all, every later text-delta gets + * its `partial` recomputed from this module's own tally rather than the + * built-in adapter's — otherwise a token after the closing tag would + * still carry the raw-tagged cumulative text the built-in parser tracked + * on its own. Before the first tag, events pass through byte-identical. */ + everInThink: boolean; + textAcc: string; + thinkingAcc: string; +}; + +export function createThinkSplitState(): ThinkSplitState { + return { inThink: false, everInThink: false, textAcc: "", thinkingAcc: "" }; +} + +const THINK_OPEN = ""; +const THINK_CLOSE = ""; + +/** The harness (`@intx/inference`'s `dist/harness.js`) keys its per-index + * `blockMap` by `event.data.index` and throws a `ProtocolMismatchError` the + * moment two different block kinds land at the same index — so the + * thinking half of a split can never reuse the text index the built-in + * OpenAI adapter already assigned to Ollama's one undifferentiated content + * stream. Ollama's own indexer starts at 0 and only counts up (one index + * per real block: text, then any tool calls), so a fixed negative + * sentinel is guaranteed to never collide with one it hands out. */ +const THINKING_BLOCK_INDEX = -1; + +type TokenSplit = { readonly textToken: string; readonly thinkingToken: string }; + +/** Peels ``/`` spans out of one token, folding the result + * into the running cumulative text/thinking strings. A tag never splits + * across two tokens in practice, but a token straddling the tag boundary + * (open and content in the same token, or close and content) is still + * handled correctly by looping until the whole token is consumed. */ +function splitToken(state: ThinkSplitState, token: string): TokenSplit { + let remaining = token; + let textToken = ""; + let thinkingToken = ""; + + while (remaining.length > 0) { + if (!state.inThink) { + const openIndex = remaining.indexOf(THINK_OPEN); + if (openIndex === -1) { + textToken += remaining; + remaining = ""; + break; + } + textToken += remaining.slice(0, openIndex); + remaining = remaining.slice(openIndex + THINK_OPEN.length); + state.inThink = true; + } else { + const closeIndex = remaining.indexOf(THINK_CLOSE); + if (closeIndex === -1) { + thinkingToken += remaining; + remaining = ""; + break; + } + thinkingToken += remaining.slice(0, closeIndex); + remaining = remaining.slice(closeIndex + THINK_CLOSE.length); + state.inThink = false; + } + } + + state.textAcc += textToken; + state.thinkingAcc += thinkingToken; + return { textToken, thinkingToken }; +} + +/** + * Rewrites one `parseResponse`/`parseJSONResponse` result so a + * `` span in an `inference.text.delta`'s token becomes an + * `inference.thinking.delta` instead — every other event (tool calls, + * usage, `inference.done`, ...) passes through untouched. `state` is + * mutated in place so a caller threads the same instance across every + * chunk of one response. + */ +export function reclassifyThinkingEvents( + events: readonly InferenceEvent[], + state: ThinkSplitState, +): InferenceEvent[] { + const output: InferenceEvent[] = []; + + for (const event of events) { + if (event.type !== "inference.text.delta") { + output.push(event); + continue; + } + + const token = event.data.token; + if (!state.inThink && !state.everInThink && !token.includes(THINK_OPEN)) { + output.push(event); + continue; + } + state.everInThink = true; + + const { textToken, thinkingToken } = splitToken(state, token); + const partial = { + text: state.textAcc, + ...(state.thinkingAcc !== "" ? { thinking: state.thinkingAcc } : {}), + }; + + if (thinkingToken !== "") { + output.push({ + type: "inference.thinking.delta", + seq: event.seq, + data: { + token: thinkingToken, + partial, + index: THINKING_BLOCK_INDEX, + }, + }); + } + if (textToken !== "") { + output.push({ + type: "inference.text.delta", + seq: event.seq, + data: { + token: textToken, + partial, + ...(event.data.index !== undefined ? { index: event.data.index } : {}), + }, + }); + } + } + + return output; +} From 55d95c539331a706f173d80ec4a51f5843e8009a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 06:49:30 -0700 Subject: [PATCH 2/4] ollama-adapter: stop leaking raw chain-of-thought into chat replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps parseResponse/parseJSONResponse so a ... span in a local model's output is reclassified as thinking rather than text before anything downstream sees it. Once correctly typed, the harness already keeps thinking content out of the finalized turn's visible text (see agent-events's existing "drops block kinds with no chat-part equivalent" behavior), and the existing live "Thinking..." strip in turn-activity.tsx now activates for the whole reasoning window instead of never firing — this closes CL-6654's leak of raw model reasoning and internal tool identifiers into the visible transcript. Reclassified thinking events use a fixed sentinel index distinct from the adapter's own text/tool-call indices, since the harness's per-index block map throws a ProtocolMismatchError if two block kinds share an index. --- packages/ollama-adapter/src/adapter.ts | 11 +++++++++++ packages/ollama-adapter/src/index.ts | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/packages/ollama-adapter/src/adapter.ts b/packages/ollama-adapter/src/adapter.ts index e9d29bd6..8a3bac82 100644 --- a/packages/ollama-adapter/src/adapter.ts +++ b/packages/ollama-adapter/src/adapter.ts @@ -25,6 +25,7 @@ import { resolveOverride, type OllamaAdapterOverride, } from "./overrides"; +import { createThinkSplitState, reclassifyThinkingEvents } from "./think-tags"; type OllamaChatBody = { options?: Record; @@ -78,6 +79,12 @@ export const createOllamaAdapter: AdapterFactory = ( ): ProviderAdapter => { const config = parseOllamaAdapterConfig(quirks); const inner = createOpenAIAdapter(source); + // One split state per adapter instance: the registry resolves a fresh + // adapter per request (see `createAdapterRegistry`'s own doc comment), + // so this safely tracks "are we inside a `` span" across every + // chunk of one response without leaking state between requests. + const streamThinkState = createThinkSplitState(); + const jsonThinkState = createThinkSplitState(); return { ...inner, buildRequest: (messages, model, options) => @@ -85,5 +92,9 @@ export const createOllamaAdapter: AdapterFactory = ( inner.buildRequest(messages, model, options), resolveOverride(config, model), ), + parseResponse: (sseData) => + reclassifyThinkingEvents(inner.parseResponse(sseData), streamThinkState), + parseJSONResponse: (body) => + reclassifyThinkingEvents(inner.parseJSONResponse(body), jsonThinkState), }; }; diff --git a/packages/ollama-adapter/src/index.ts b/packages/ollama-adapter/src/index.ts index 6145719e..d7bd1463 100644 --- a/packages/ollama-adapter/src/index.ts +++ b/packages/ollama-adapter/src/index.ts @@ -6,3 +6,8 @@ export { parseOllamaAdapterConfig, resolveOverride, } from "./overrides"; +export { + createThinkSplitState, + reclassifyThinkingEvents, + type ThinkSplitState, +} from "./think-tags"; From e24fca7375dc861507ebe4e15916decb7e1a7974 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 07:17:48 -0700 Subject: [PATCH 3/4] think-tags: drop dead assignments before break --- packages/ollama-adapter/src/think-tags.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/ollama-adapter/src/think-tags.ts b/packages/ollama-adapter/src/think-tags.ts index 362a2348..830693e9 100644 --- a/packages/ollama-adapter/src/think-tags.ts +++ b/packages/ollama-adapter/src/think-tags.ts @@ -42,7 +42,10 @@ const THINK_CLOSE = ""; * sentinel is guaranteed to never collide with one it hands out. */ const THINKING_BLOCK_INDEX = -1; -type TokenSplit = { readonly textToken: string; readonly thinkingToken: string }; +type TokenSplit = { + readonly textToken: string; + readonly thinkingToken: string; +}; /** Peels ``/`` spans out of one token, folding the result * into the running cumulative text/thinking strings. A tag never splits @@ -59,7 +62,6 @@ function splitToken(state: ThinkSplitState, token: string): TokenSplit { const openIndex = remaining.indexOf(THINK_OPEN); if (openIndex === -1) { textToken += remaining; - remaining = ""; break; } textToken += remaining.slice(0, openIndex); @@ -69,7 +71,6 @@ function splitToken(state: ThinkSplitState, token: string): TokenSplit { const closeIndex = remaining.indexOf(THINK_CLOSE); if (closeIndex === -1) { thinkingToken += remaining; - remaining = ""; break; } thinkingToken += remaining.slice(0, closeIndex); @@ -134,7 +135,9 @@ export function reclassifyThinkingEvents( data: { token: textToken, partial, - ...(event.data.index !== undefined ? { index: event.data.index } : {}), + ...(event.data.index !== undefined + ? { index: event.data.index } + : {}), }, }); } From a6122bccf27cda882ec8bd5bb804f96838452bb4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 07:50:57 -0700 Subject: [PATCH 4/4] think-tags test: prettier formatting --- .../ollama-adapter/src/think-tags.test.ts | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/ollama-adapter/src/think-tags.test.ts b/packages/ollama-adapter/src/think-tags.test.ts index e4af4e41..24611f8d 100644 --- a/packages/ollama-adapter/src/think-tags.test.ts +++ b/packages/ollama-adapter/src/think-tags.test.ts @@ -22,13 +22,21 @@ describe("reclassifyThinkingEvents", () => { expect(out[0]?.type).toBe("inference.thinking.delta"); expect((out[0]?.data as { token: string }).token).toBe("plan the approach"); expect(out[1]?.type).toBe("inference.text.delta"); - expect((out[1]?.data as { token: string }).token).toBe("Here is the answer."); + expect((out[1]?.data as { token: string }).token).toBe( + "Here is the answer.", + ); }); test("a span split across multiple chunks stays classified as thinking across the boundary", () => { const state = createThinkSplitState(); - const first = reclassifyThinkingEvents([textDelta("step one, ")], state); - const second = reclassifyThinkingEvents([textDelta("step twofinal reply")], state); + const first = reclassifyThinkingEvents( + [textDelta("step one, ")], + state, + ); + const second = reclassifyThinkingEvents( + [textDelta("step twofinal reply")], + state, + ); expect(first).toHaveLength(1); expect(first[0]?.type).toBe("inference.thinking.delta"); @@ -41,7 +49,10 @@ describe("reclassifyThinkingEvents", () => { test("ordinary text with no tag passes through as text-delta unchanged", () => { const state = createThinkSplitState(); - const out = reclassifyThinkingEvents([textDelta("just a normal reply")], state); + const out = reclassifyThinkingEvents( + [textDelta("just a normal reply")], + state, + ); expect(out).toEqual([textDelta("just a normal reply")]); }); @@ -50,7 +61,11 @@ describe("reclassifyThinkingEvents", () => { const toolCallStart: InferenceEvent = { type: "inference.tool_call.start", seq: 1, - data: { callId: "call-1", name: "slack__post_message", partial: { text: "" } }, + data: { + callId: "call-1", + name: "slack__post_message", + partial: { text: "" }, + }, }; const out = reclassifyThinkingEvents([toolCallStart], state); expect(out).toEqual([toolCallStart]); @@ -62,8 +77,12 @@ describe("reclassifyThinkingEvents", () => { [textDelta("internal notesvisible reply", 3)], state, ); - const thinkingEvent = out.find((event) => event.type === "inference.thinking.delta"); - const textEvent = out.find((event) => event.type === "inference.text.delta"); + const thinkingEvent = out.find( + (event) => event.type === "inference.thinking.delta", + ); + const textEvent = out.find( + (event) => event.type === "inference.text.delta", + ); expect((thinkingEvent?.data as { index?: number }).index).not.toBe( (textEvent?.data as { index?: number }).index, ); @@ -76,12 +95,21 @@ describe("reclassifyThinkingEvents", () => { [textDelta("internal notesvisible reply")], state, ); - const thinkingEvent = out.find((event) => event.type === "inference.thinking.delta"); - const textEvent = out.find((event) => event.type === "inference.text.delta"); - expect((thinkingEvent?.data as { partial: { thinking?: string } }).partial.thinking).toBe( - "internal notes", + const thinkingEvent = out.find( + (event) => event.type === "inference.thinking.delta", + ); + const textEvent = out.find( + (event) => event.type === "inference.text.delta", ); - expect((textEvent?.data as { partial: { text: string } }).partial.text).toBe("visible reply"); - expect((textEvent?.data as { partial: { text: string } }).partial.text).not.toContain(""); + expect( + (thinkingEvent?.data as { partial: { thinking?: string } }).partial + .thinking, + ).toBe("internal notes"); + expect( + (textEvent?.data as { partial: { text: string } }).partial.text, + ).toBe("visible reply"); + expect( + (textEvent?.data as { partial: { text: string } }).partial.text, + ).not.toContain(""); }); });