diff --git a/devlog/_plan/261003_ollama_commentary_replay/000_plan.md b/devlog/_plan/261003_ollama_commentary_replay/000_plan.md new file mode 100644 index 00000000000..a2cbdba3627 --- /dev/null +++ b/devlog/_plan/261003_ollama_commentary_replay/000_plan.md @@ -0,0 +1,25 @@ +# Preserve native Ollama tool batches through assistant commentary + +Ollama replay currently settles an open tool batch when assistant commentary appears, so the later genuine result becomes orphaned. Carry #6509's local batch deferral and add adversarial validation/immutability coverage. This is independent of the Antigravity PR #6514. + +Reader: release coordinator choosing an independently verified head for integration. + +- Archetype/trigger/goal: satisfy-spec Lane C 020, C3 adapter contract. Preserve genuine results next to their original calls while retaining strict validation. +- Non-goals: merge/release, account/config/service changes, OpenAI adapter changes, cross-batch result recovery, shared registry normalization, new paid resources. +- Verifiers: existing native request/parser/reasoning/structured-output tests, shared tool-conformance tests, new malformed/history/frozen-input cases; typecheck/privacy/structure/file-size/docs build and applicable exact-head PR CI. Baseline seven Ollama/conformance files passed 87 tests; commands and logs in ignored scratch. Full local suite exception: concurrent worktrees and import-graph expansion to 1,490 files; prior broad run failed in documented unrelated test-home fixtures, not claimed passing. +- Stop/outcomes: local implementation cycle closes after focused proof and independent code/security review; c-4 retains actual successful CI and complete handoff. DONE requires all lane criteria; unavailable native service or unrelated harness coverage remains explicit, never fabricated. +- Artifacts: this unit and .tmp/release-stabilization/report.md. Escalate only new scope/authority or valid unresolved blockers; no user token/time cap. Bounded commands, inherited leaf subagents, no account mutations or new paid resources. + +Previous D: Antigravity local cycle closed at f9e1e6b492 with focused tests/gates/docs and code/security PASS; #6514 current-head CI remains c-4. Continue the prewritten independent Ollama 020 direction. Coordinator ROADMAP LOCKED remains in force. Its final integrated parallel regression is coordinator-owned; this slice may publish promptly after scoped review/tests. Lane B exclusively owns shared registry duplicate normalization; this slice needs no new registry entries because the existing native test file is uncapped. + +Source #6509 de5bb1f215c613670e03585e918a344c510ba97a remains open; carry all four source commits with provenance and Co-authored-by: potota90 <85318310+adtumk@users.noreply.github.com>. Use own codex/release-261003-c-ollama branch from fresh dev, ordinary PR target dev, no native stack registration. Preserve source author branch and PR. + +Prior roadmap architect Hume 01a1020f-0150-7a62-ba3e-e2b7bad4494a proposed D5/D6 and reflected ALIGNED; whole-roadmap independent audit PASS. This cycle revalidates the exact 010 source diff and specified new tests at current dev. No change in module responsibility or interface: unresolvedCount is request-local pending-batch state. Independent A recheck precedes B. + +File map: MODIFY src/adapters/ollama-native.ts; tests/providers/ollama/ollama-native.test.ts; docs-site/src/content/docs/reference/adapters.md; structure/providers/chat-compat.md. ADD this numbered unit documentation. Detailed source diff and acceptance scenarios are in 010_replay.md. No new public field, stored schema, endpoint or dependency. Runtime flows and all adjacent adapter owning documents are reviewed; only the Ollama replay paragraph changes. + +Revalidation: git apply --check on the source four-file diff exits 0 against current dev. Existing adapter source remains byte-identical to the original roadmap baseline. Baseline counts are 26 native + 29 parser + 6 v4 + 7 reasoning + 10 structured-output + 8 tool-conformance + 1 buffered-conformance = 87 pass, 0 fail, each file isolated. + +Same-architect D5/D6 reflection ALIGNED; corrected stale self-reference and removed unused new-file alternative. Exact additional adversarial test block is recorded in 010. Coordinator provided common registry normalization 29a9e91f400d24ac746a18dfe3af357f5da6dec3 for adoption with -x provenance; no auth runtime carry. + +B carried all four #6509 commits with original authors and -x provenance. Additional negative/frozen-input tests failed five cases on the original adapter (31 pass/5 fail) and all42 native tests passed after carry. No new fixture registry entry or routing/account change. Next C validates related native/conformance suites, typecheck/privacy/structure/docs and independent security review. diff --git a/devlog/_plan/261003_ollama_commentary_replay/010_replay.md b/devlog/_plan/261003_ollama_commentary_replay/010_replay.md new file mode 100644 index 00000000000..c9525fbf01f --- /dev/null +++ b/devlog/_plan/261003_ollama_commentary_replay/010_replay.md @@ -0,0 +1,397 @@ +# Keep unresolved native batches open across commentary + +Depends on: roadmap/unlock only; runtime independent of the Antigravity slice. Carry #6509 at de5bb1f215c613670e03585e918a344c510ba97a on a separate own branch from current dev. + +MODIFY the four files in the exact proposed source diff below, adapting only current-base conflicts. PendingToolBatch.unresolvedCount is created from wireCalls.length, decremented only after validated result attachment and consumed only by the no-new-call assistant deferral branch. It is request-local internal state: serialization/deserialization N/A; no persisted or public API field. + +The new branch activates with an open batch, unresolvedCount > 0, assistant role and no extracted calls. It defers assistant text/thinking until flushPending emits genuine results in original call order and then releaseDeferred emits conversation arrival order. New tool-call batches and EOF settle missing results with existing unknown markers. Fully resolved batches flush before normal assistant turns. Validation remains before decrement: unknown/duplicate/name/namespace results reject; invalid/reused call IDs retain their existing rules. No parsed input mutation. + +## Negative verification delta beyond source PR + +MODIFY tests/providers/ollama/ollama-native.test.ts (existing uncapped file; no new registry entries): construct batches with commentary interleaved before orphan IDs, duplicate result IDs, mismatched tool names and mismatched namespaces; each must throw the existing error. Add old-batch result after a new tool-call batch (must reject), unresolved EOF (must emit exactly one unknown result before commentary), fully resolved result then commentary (must not defer past subsequent conversation), reverse-arrival results in a multi-call batch (must output call order), and deep-frozen parsed context with nested content/call argument objects (must build unchanged). Assert output payload order and values rather than internal counters. Keep all existing malformed-input tests. + +Commands planned, NOT RUN under initial gate: bun test tests/providers/ollama/ollama-native.test.ts; native-parser/native-v4/native-reasoning-wire/native-structured-output files in separate Bun processes; tests/adapters/adapter-tool-conformance.test.ts and adapter-buffered-tool-conformance.test.ts; typecheck/privacy/structure/layout/file-size/test:changed gates. Document full-local-suite exception for concurrent worktrees. No live Ollama service is assumed; absence is an explicit evidence limit. + +Review all src/adapters ownership docs; preserve OpenAI behavior and touch only the Ollama paragraph in chat-compat. User docs describe observed contract, never promise caller-side physical tool execution. Independent code/security review and applicable exact-head CI are required. Source #6509 stays open. + +Credit: Co-authored-by: potota90 <85318310+adtumk@users.noreply.github.com>. + +## Executable source delta + +```diff +diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md +index a765419710..c616f58a0e 100644 +--- a/docs-site/src/content/docs/reference/adapters.md ++++ b/docs-site/src/content/docs/reference/adapters.md +@@ -118,6 +118,10 @@ be configured on a separately named custom or self-hosted Ollama provider with + is refused rather than mis-sent, and remote image URLs are not fetched. + - **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with + object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name. ++ Codex may record assistant commentary before a pending call's results. Text/thinking with no ++ new tool calls is deferred until the batch is settled, so genuine results remain beside their ++ originating calls. A new tool-call batch still settles the preceding one; missing results retain ++ an explicit unknown-status marker, and orphan or duplicate results remain invalid. + `tool_choice: "none"` and `auto` behave normally; **`required` or an exact named choice fails + closed**, because Ollama's `/api/chat` has no `tool_choice` field to enforce it with. + - **Structured output is refused on canonical Ollama Cloud.** Ollama currently documents structured +diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts +index 2f95c5dbdc..b180d1f1d5 100644 +--- a/src/adapters/ollama-native.ts ++++ b/src/adapters/ollama-native.ts +@@ -77,6 +77,7 @@ interface PendingToolCall { + interface PendingToolBatch { + calls: PendingToolCall[]; + byId: Map; ++ unresolvedCount: number; + } + + interface NativeStreamToolCall { +@@ -302,6 +303,12 @@ function assistantTextThinkingAndCalls(message: OcxAssistantMessage): { + }; + } + ++/** ++ * Build native replay messages without mutating the parsed history. Keep tool results ++ * beside their originating batch, deferring intervening conversation until settlement. ++ * Reserve replayed call IDs for response translation and mark missing results explicitly. ++ * @throws When call IDs are invalid or results are orphaned, duplicated, or mismatched. ++ */ + function buildNativeMessages( + parsed: OcxParsedRequest, + reservedToolCallIds: Set, +@@ -323,12 +330,14 @@ function buildNativeMessages( + // early. The openai-chat adapter defers them the same way; refusing the replay killed the turn. + let deferred: OllamaNativeMessage[] = []; + ++ /** Emit held conversation messages in their recorded arrival order after settlement. */ + const releaseDeferred = (): void => { + if (deferred.length === 0) return; + messages.push(...deferred); + deferred = []; + }; + ++ /** Settle the open batch with genuine results or unknown markers, then release deferred messages. */ + const flushPending = (): void => { + if (!pending) return; + for (const call of pending.calls) { +@@ -376,13 +385,14 @@ function buildNativeMessages( + throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`); + } + call.result = message; ++ pending.unresolvedCount--; + continue; + } + + // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A + // conversational message that arrives while the batch is still open is held aside instead of + // closing it, so the call keeps its results adjacent; it is released right after the batch +- // flushes. Anything else (a new assistant turn) settles the batch first. ++ // flushes. A new assistant tool-call batch settles the preceding batch first. + if (pending) { + if (message.role === "user" || message.role === "developer") { + const translated = message.role === "user" +@@ -393,6 +403,19 @@ function buildNativeMessages( + : { role: "system", content: translated.content }); + continue; + } ++ if (message.role === "assistant" && pending.unresolvedCount > 0) { ++ const extracted = assistantTextThinkingAndCalls(message); ++ if (extracted.calls.length === 0) { ++ // Commentary can follow the call items but precede their recorded results. ++ // Keep the batch open and release its text/thinking after the actual results. ++ deferred.push({ ++ role: "assistant", ++ content: extracted.content, ++ ...(extracted.thinking ? { thinking: extracted.thinking } : {}), ++ }); ++ continue; ++ } ++ } + flushPending(); + } + +@@ -443,7 +466,11 @@ function buildNativeMessages( + }; + messages.push(native); + if (wireCalls.length > 0) { +- pending = { calls: wireCalls, byId: new Map(wireCalls.map(call => [call.id, call])) }; ++ pending = { ++ calls: wireCalls, ++ byId: new Map(wireCalls.map(call => [call.id, call])), ++ unresolvedCount: wireCalls.length, ++ }; + } + break; + } +diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md +index a469a3c8ef..740bb7a115 100644 +--- a/structure/providers/chat-compat.md ++++ b/structure/providers/chat-compat.md +@@ -122,13 +122,13 @@ and synthesizing explicit "no tool result was recorded" answers only when no rea + (Kimi/Moonshot 400 `ocx-mrqaiw05-269`; unit `devlog/_fin/260718_dangling_toolcall_hardening`). + + The native Ollama wire carries the same contract. `src/adapters/ollama-native.ts` +-`buildNativeMessages` defers `user`/`developer` messages that arrive while a batch is open and +-releases them after the tool messages, and answers a call with no result anywhere in the replayed +-history with the same `[ocx] no tool result was recorded for ""` marker. The shape it +-absorbs is ordinary Codex history, not a malformed one: Codex records mid-turn items (a +-`PostToolUse` hook verdict, a context notice) between an assistant `tool_calls` message and that +-call's own result. The strict pair checks (orphan result, duplicate result, result naming another +-tool) still throw on both wires (#4842). ++`buildNativeMessages` defers `user`/`developer` messages while a batch is open; assistant text/thinking ++with no new tool calls is also deferred while results remain outstanding. Deferred messages retain ++arrival order after recorded results. An unresolved counter avoids rescanning calls per message. ++A new tool-call batch settles its predecessor; completed batches keep subsequent messages in place. ++Codex can record hook notices or commentary between calls and results. Missing results keep the ++`[ocx] no tool result was recorded for ""` marker; orphan IDs, duplicates, and mismatched names ++still throw (#4842). `tests/providers/ollama/ollama-native.test.ts` covers replay and compaction. + + Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses + API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit, +diff --git a/tests/providers/ollama/ollama-native.test.ts b/tests/providers/ollama/ollama-native.test.ts +index 44a09e8a8c..c17e5c1967 100644 +--- a/tests/providers/ollama/ollama-native.test.ts ++++ b/tests/providers/ollama/ollama-native.test.ts +@@ -9,12 +9,14 @@ import { getProviderRegistryEntry } from "../../../src/providers/registry"; + import { withStubbedProviderFetch } from "../../helpers/catalog-provider-fetch"; + import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + ++/** Discover routed test models with provider fetches stubbed to avoid live requests. */ + const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + + /** The four ids this transport is maintained against. */ + const TARGETS = ["glm-5.3-flash", "deepseek-v4-flash:0731", "glm-5.2", "kimi-k3"] as const; + ++/** Configure a native Ollama test provider with inert credentials and caller overrides. */ + function ollamaProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", +@@ -28,6 +30,7 @@ function ollamaProvider(overrides: Partial = {}): OcxProvider + } as OcxProviderConfig; + } + ++/** Build a minimal streamed replay request from synthetic messages and caller options. */ + function parsedWith( + messages: unknown[], + options: Record = {}, +@@ -285,6 +288,120 @@ describe("ollama-native — request shape", () => { + expect(messages[3].content).toBe("[hook] design findings requiring review"); + }); + ++ test("assistant commentary before parallel results keeps the original batch open", async () => { ++ const adapter = createOllamaNativeAdapter(ollamaProvider()); ++ const { body } = await adapter.buildRequest(parsedWith([ ++ { ++ role: "assistant", ++ content: [ ++ { type: "toolCall", id: "call_commentary_first", name: "exec", arguments: { input: "text('first')" } }, ++ { type: "toolCall", id: "call_commentary_second", name: "exec", arguments: { input: "text('second')" } }, ++ ], ++ timestamp: 1, ++ }, ++ { role: "assistant", content: [{ type: "text", text: "checking both results" }], timestamp: 2 }, ++ { role: "toolResult", toolCallId: "call_commentary_second", toolName: "exec", content: "second result", isError: false, timestamp: 3 }, ++ { role: "toolResult", toolCallId: "call_commentary_first", toolName: "exec", content: "first result", isError: false, timestamp: 4 }, ++ ])); ++ const messages = JSON.parse(String(body)).messages; ++ expect(messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "tool", "assistant"]); ++ expect(messages[1]).toMatchObject({ tool_call_id: "call_commentary_first", content: "first result" }); ++ expect(messages[2]).toMatchObject({ tool_call_id: "call_commentary_second", content: "second result" }); ++ expect(messages[3].content).toBe("checking both results"); ++ }); ++ ++ test("assistant commentary after one parallel result preserves the remaining genuine result", async () => { ++ const adapter = createOllamaNativeAdapter(ollamaProvider()); ++ const { body } = await adapter.buildRequest(parsedWith([ ++ { ++ role: "assistant", ++ content: [ ++ { type: "toolCall", id: "call_partial_first", name: "exec", arguments: {} }, ++ { type: "toolCall", id: "call_partial_second", name: "exec", arguments: {} }, ++ ], ++ timestamp: 1, ++ }, ++ { role: "toolResult", toolCallId: "call_partial_first", toolName: "exec", content: "first done", isError: false, timestamp: 2 }, ++ { role: "assistant", content: [{ type: "text", text: "waiting for the second result" }], timestamp: 3 }, ++ { role: "toolResult", toolCallId: "call_partial_second", toolName: "exec", content: "second done", isError: false, timestamp: 4 }, ++ ])); ++ const messages = JSON.parse(String(body)).messages; ++ expect(messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "tool", "assistant"]); ++ expect(messages[1].content).toBe("first done"); ++ expect(messages[2].content).toBe("second done"); ++ expect(messages[3].content).toBe("waiting for the second result"); ++ }); ++ ++ test("deferred assistant text and thinking retain their order without mutating parsed history", async () => { ++ const parsed = parsedWith([ ++ { role: "assistant", content: [{ type: "toolCall", id: "call_thinking", name: "exec", arguments: {} }], timestamp: 1 }, ++ { role: "developer", content: "context notice", timestamp: 2 }, ++ { role: "assistant", content: [{ type: "text", text: "first comment" }, { type: "thinking", thinking: "synthetic reasoning" }], timestamp: 3 }, ++ { role: "assistant", content: [{ type: "text", text: "second comment" }], timestamp: 4 }, ++ { role: "toolResult", toolCallId: "call_thinking", toolName: "exec", content: "recorded result", isError: false, timestamp: 5 }, ++ ]); ++ const original = JSON.stringify(parsed); ++ const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); ++ const messages = JSON.parse(String(body)).messages; ++ expect(messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "system", "assistant", "assistant"]); ++ expect(messages[1].content).toBe("recorded result"); ++ expect(messages[2].content).toBe("context notice"); ++ expect(messages[3]).toMatchObject({ content: "first comment", thinking: "synthetic reasoning" }); ++ expect(messages[4].content).toBe("second comment"); ++ expect(JSON.stringify(parsed)).toBe(original); ++ }); ++ ++ test("assistant commentary after a complete batch keeps its existing wire position", async () => { ++ const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ ++ { role: "assistant", content: [{ type: "toolCall", id: "call_complete", name: "exec", arguments: {} }], timestamp: 1 }, ++ { role: "toolResult", toolCallId: "call_complete", toolName: "exec", content: "done", isError: false, timestamp: 2 }, ++ { role: "assistant", content: [{ type: "text", text: "completed comment" }], timestamp: 3 }, ++ { role: "user", content: "next turn", timestamp: 4 }, ++ ])); ++ const messages = JSON.parse(String(body)).messages; ++ expect(messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "assistant", "user"]); ++ expect(messages[1].content).toBe("done"); ++ expect(messages[2].content).toBe("completed comment"); ++ expect(messages[3].content).toBe("next turn"); ++ }); ++ ++ test("a new tool-call batch still settles an unresolved batch after assistant commentary", async () => { ++ const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ ++ { role: "assistant", content: [{ type: "toolCall", id: "call_unfinished", name: "exec", arguments: {} }], timestamp: 1 }, ++ { role: "assistant", content: [{ type: "text", text: "before the next batch" }], timestamp: 2 }, ++ { role: "assistant", content: [{ type: "toolCall", id: "call_next", name: "exec", arguments: {} }], timestamp: 3 }, ++ { role: "toolResult", toolCallId: "call_next", toolName: "exec", content: "next result", isError: false, timestamp: 4 }, ++ ])); ++ const messages = JSON.parse(String(body)).messages; ++ expect(messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "assistant", "assistant", "tool"]); ++ expect(messages[1].tool_call_id).toBe("call_unfinished"); ++ expect(messages[1].content).toContain("execution status unknown"); ++ expect(messages[2].content).toBe("before the next batch"); ++ expect(messages[4]).toMatchObject({ tool_call_id: "call_next", content: "next result" }); ++ }); ++ ++ test("routed compaction preserves a result recorded after assistant commentary", async () => { ++ const parsed = parsedWith([ ++ { role: "assistant", content: [{ type: "toolCall", id: "call_compaction", name: "exec", arguments: {} }], timestamp: 1 }, ++ { role: "assistant", content: [{ type: "text", text: "commentary before compaction" }], timestamp: 2 }, ++ { role: "toolResult", toolCallId: "call_compaction", toolName: "exec", content: "genuine result", isError: false, timestamp: 3 }, ++ { role: "user", content: "summarize this history", timestamp: 4 }, ++ ], { toolChoice: "none" }); ++ parsed._compactionRequest = true; ++ const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); ++ const request = JSON.parse(String(body)); ++ expect(request.messages.map((message: { role: string }) => message.role)) ++ .toEqual(["assistant", "tool", "assistant", "user"]); ++ expect(request.messages[1]).toMatchObject({ tool_call_id: "call_compaction", content: "genuine result" }); ++ expect(request.messages[2].content).toBe("commentary before compaction"); ++ expect(request.tools).toBeUndefined(); ++ }); ++ + test("a deferred user message keeps its text and images after the tool result", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const png = "data:image/png;base64,iVBORw0KGgo="; + +``` + +## Exact additional regression block + +Append this block to the existing uncapped native test file; no shared registry mutation is required. It reuses parsedWith/ollamaProvider and tests the public adapter boundary. + +```typescript + +describe("ollama-native commentary validation and replay ownership", () => { + const call = (id = "call_guard") => ({ + role: "assistant", content: [{ type: "toolCall", id, name: "exec", namespace: "ops", arguments: { cmd: "pwd" } }], timestamp: 1, + }); + const commentary = { role: "assistant", content: [{ type: "text", text: "Working." }], timestamp: 2 }; + const result = (id = "call_guard") => ({ + role: "toolResult", toolCallId: id, toolName: "exec", toolNamespace: "ops", content: "done", isError: false, timestamp: 3, + }); + + for (const [label, invalid, error] of [ + ["unknown id", { ...result(), toolCallId: "call_other" }, /has no originating call/], + ["wrong name", { ...result(), toolName: "other" }, /names the wrong originating tool/], + ["wrong namespace", { ...result(), toolNamespace: "other" }, /names the wrong originating tool/], + ] as const) { + test(`commentary does not bypass result validation: ${label}`, () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call(), commentary, invalid]))).toThrow(error); + }); + } + + test("duplicate results remain rejected while commentary holds an unresolved batch", () => { + const first = call("first"); + const second = call("second"); + const batch = { ...first, content: [...first.content, ...second.content] }; + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([batch, result("first"), commentary, result("first")]))).toThrow(/duplicate tool result/); + }); + + test("a result cannot cross a subsequent tool-call batch", () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call("old"), commentary, call("new"), result("old")]))).toThrow(/has no originating call/); + }); + + for (const id of ["", "call_guard"]) { + test(`new batches still reject ${id ? "reused" : "empty"} call IDs after commentary`, () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call(), commentary, call(id)]))).toThrow(/id is missing or duplicated/); + }); + } + + test("EOF settles a missing result once before deferred commentary", () => { + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([call(), commentary])); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "assistant"]); + expect(messages[1].tool_call_id).toBe("call_guard"); + expect(messages[1].content).toContain("execution status unknown"); + expect(messages[2].content).toBe("Working."); + }); + + test("completed batches release commentary before the next conversation turn", () => { + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ + call(), result(), commentary, { role: "user", content: "next" }, call("next"), result("next"), + ])); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "assistant", "user", "assistant", "tool"]); + expect(messages[2].content).toBe("Working."); + expect(messages[3].content).toBe("next"); + expect(messages[5].tool_call_id).toBe("next"); + }); + + test("deep-frozen history keeps call order, deferred arrival order and original arguments", () => { + const first = call("first"); + const second = call("second"); + const batch = { ...first, content: [...first.content, ...second.content] }; + const parsed = parsedWith([ + batch, + { role: "developer", content: "hook" }, + result("second"), + { role: "assistant", content: [{ type: "thinking", thinking: "Checking." }, { type: "text", text: "Working." }] }, + { role: "user", content: "notice" }, + result("first"), + ]); + function freeze(value: unknown): void { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return; + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + const before = JSON.stringify(parsed); + freeze(parsed); + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); + expect(JSON.stringify(parsed)).toBe(before); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "tool", "system", "assistant", "user"]); + expect(messages.slice(1, 3).map((message: { tool_call_id: string }) => message.tool_call_id)).toEqual(["first", "second"]); + expect(messages.slice(3).map((message: { content: string }) => message.content)).toEqual(["hook", "Working.", "notice"]); + expect(messages[4].thinking).toBe("Checking."); + expect(messages[0].tool_calls[0].function.arguments).toEqual({ cmd: "pwd" }); + }); +}); +``` diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index a765419710e..c616f58a0ed 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -118,6 +118,10 @@ be configured on a separately named custom or self-hosted Ollama provider with is refused rather than mis-sent, and remote image URLs are not fetched. - **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name. + Codex may record assistant commentary before a pending call's results. Text/thinking with no + new tool calls is deferred until the batch is settled, so genuine results remain beside their + originating calls. A new tool-call batch still settles the preceding one; missing results retain + an explicit unknown-status marker, and orphan or duplicate results remain invalid. `tool_choice: "none"` and `auto` behave normally; **`required` or an exact named choice fails closed**, because Ollama's `/api/chat` has no `tool_choice` field to enforce it with. - **Structured output is refused on canonical Ollama Cloud.** Ollama currently documents structured diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 807444659c2..63c69bc2ab6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -57,42 +57,11 @@ "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", - "gui-desktop-sidecar-script.test.ts": "gui", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", - "server-combo-held-response.test.ts": "server", - "key-attribution.test.ts": "usage", "jev-stats.test.ts": "usage", - "provider-send-path-import.test.ts": "server", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", - "provider-egress.test.ts": "lib", - "provider-egress-outbound.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", - "responses-core-modules.test.ts": "responses", "responses-devin-401-replay.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", - "responses-4546-incident-regression.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", - "stepfun-provider.test.ts": "providers", - "warmup-registration.test.ts": "ci-workflows", - "hub-usage.test.ts": "server", - "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", "deepseek-quota-currency.test.ts": "providers", "mimo-token-plan-capacity.test.ts": "providers", "command-code-tool-text-prose-split.test.ts": "providers", diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index 2f95c5dbdc9..b180d1f1d57 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -77,6 +77,7 @@ interface PendingToolCall { interface PendingToolBatch { calls: PendingToolCall[]; byId: Map; + unresolvedCount: number; } interface NativeStreamToolCall { @@ -302,6 +303,12 @@ function assistantTextThinkingAndCalls(message: OcxAssistantMessage): { }; } +/** + * Build native replay messages without mutating the parsed history. Keep tool results + * beside their originating batch, deferring intervening conversation until settlement. + * Reserve replayed call IDs for response translation and mark missing results explicitly. + * @throws When call IDs are invalid or results are orphaned, duplicated, or mismatched. + */ function buildNativeMessages( parsed: OcxParsedRequest, reservedToolCallIds: Set, @@ -323,12 +330,14 @@ function buildNativeMessages( // early. The openai-chat adapter defers them the same way; refusing the replay killed the turn. let deferred: OllamaNativeMessage[] = []; + /** Emit held conversation messages in their recorded arrival order after settlement. */ const releaseDeferred = (): void => { if (deferred.length === 0) return; messages.push(...deferred); deferred = []; }; + /** Settle the open batch with genuine results or unknown markers, then release deferred messages. */ const flushPending = (): void => { if (!pending) return; for (const call of pending.calls) { @@ -376,13 +385,14 @@ function buildNativeMessages( throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`); } call.result = message; + pending.unresolvedCount--; continue; } // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A // conversational message that arrives while the batch is still open is held aside instead of // closing it, so the call keeps its results adjacent; it is released right after the batch - // flushes. Anything else (a new assistant turn) settles the batch first. + // flushes. A new assistant tool-call batch settles the preceding batch first. if (pending) { if (message.role === "user" || message.role === "developer") { const translated = message.role === "user" @@ -393,6 +403,19 @@ function buildNativeMessages( : { role: "system", content: translated.content }); continue; } + if (message.role === "assistant" && pending.unresolvedCount > 0) { + const extracted = assistantTextThinkingAndCalls(message); + if (extracted.calls.length === 0) { + // Commentary can follow the call items but precede their recorded results. + // Keep the batch open and release its text/thinking after the actual results. + deferred.push({ + role: "assistant", + content: extracted.content, + ...(extracted.thinking ? { thinking: extracted.thinking } : {}), + }); + continue; + } + } flushPending(); } @@ -443,7 +466,11 @@ function buildNativeMessages( }; messages.push(native); if (wireCalls.length > 0) { - pending = { calls: wireCalls, byId: new Map(wireCalls.map(call => [call.id, call])) }; + pending = { + calls: wireCalls, + byId: new Map(wireCalls.map(call => [call.id, call])), + unresolvedCount: wireCalls.length, + }; } break; } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a469a3c8ef7..740bb7a115f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -122,13 +122,13 @@ and synthesizing explicit "no tool result was recorded" answers only when no rea (Kimi/Moonshot 400 `ocx-mrqaiw05-269`; unit `devlog/_fin/260718_dangling_toolcall_hardening`). The native Ollama wire carries the same contract. `src/adapters/ollama-native.ts` -`buildNativeMessages` defers `user`/`developer` messages that arrive while a batch is open and -releases them after the tool messages, and answers a call with no result anywhere in the replayed -history with the same `[ocx] no tool result was recorded for ""` marker. The shape it -absorbs is ordinary Codex history, not a malformed one: Codex records mid-turn items (a -`PostToolUse` hook verdict, a context notice) between an assistant `tool_calls` message and that -call's own result. The strict pair checks (orphan result, duplicate result, result naming another -tool) still throw on both wires (#4842). +`buildNativeMessages` defers `user`/`developer` messages while a batch is open; assistant text/thinking +with no new tool calls is also deferred while results remain outstanding. Deferred messages retain +arrival order after recorded results. An unresolved counter avoids rescanning calls per message. +A new tool-call batch settles its predecessor; completed batches keep subsequent messages in place. +Codex can record hook notices or commentary between calls and results. Missing results keep the +`[ocx] no tool result was recorded for ""` marker; orphan IDs, duplicates, and mismatched names +still throw (#4842). `tests/providers/ollama/ollama-native.test.ts` covers replay and compaction. Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 50e7c781a4f..a8207236a02 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -70,42 +70,11 @@ "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", - "gui-desktop-sidecar-script.test.ts": "gui", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", - "server-combo-held-response.test.ts": "server", - "key-attribution.test.ts": "usage", "jev-stats.test.ts": "usage", - "provider-send-path-import.test.ts": "server", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", - "provider-egress.test.ts": "lib", - "provider-egress-outbound.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", - "responses-core-modules.test.ts": "responses", "responses-devin-401-replay.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", - "responses-4546-incident-regression.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", - "stepfun-provider.test.ts": "providers", - "warmup-registration.test.ts": "ci-workflows", - "hub-usage.test.ts": "server", - "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", diff --git a/tests/providers/ollama/ollama-native.test.ts b/tests/providers/ollama/ollama-native.test.ts index 44a09e8a8c1..26d25423a6e 100644 --- a/tests/providers/ollama/ollama-native.test.ts +++ b/tests/providers/ollama/ollama-native.test.ts @@ -9,12 +9,14 @@ import { getProviderRegistryEntry } from "../../../src/providers/registry"; import { withStubbedProviderFetch } from "../../helpers/catalog-provider-fetch"; import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +/** Discover routed test models with provider fetches stubbed to avoid live requests. */ const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); /** The four ids this transport is maintained against. */ const TARGETS = ["glm-5.3-flash", "deepseek-v4-flash:0731", "glm-5.2", "kimi-k3"] as const; +/** Configure a native Ollama test provider with inert credentials and caller overrides. */ function ollamaProvider(overrides: Partial = {}): OcxProviderConfig { return { adapter: "ollama-native", @@ -28,6 +30,7 @@ function ollamaProvider(overrides: Partial = {}): OcxProvider } as OcxProviderConfig; } +/** Build a minimal streamed replay request from synthetic messages and caller options. */ function parsedWith( messages: unknown[], options: Record = {}, @@ -285,6 +288,120 @@ describe("ollama-native — request shape", () => { expect(messages[3].content).toBe("[hook] design findings requiring review"); }); + test("assistant commentary before parallel results keeps the original batch open", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { body } = await adapter.buildRequest(parsedWith([ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_commentary_first", name: "exec", arguments: { input: "text('first')" } }, + { type: "toolCall", id: "call_commentary_second", name: "exec", arguments: { input: "text('second')" } }, + ], + timestamp: 1, + }, + { role: "assistant", content: [{ type: "text", text: "checking both results" }], timestamp: 2 }, + { role: "toolResult", toolCallId: "call_commentary_second", toolName: "exec", content: "second result", isError: false, timestamp: 3 }, + { role: "toolResult", toolCallId: "call_commentary_first", toolName: "exec", content: "first result", isError: false, timestamp: 4 }, + ])); + const messages = JSON.parse(String(body)).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "tool", "assistant"]); + expect(messages[1]).toMatchObject({ tool_call_id: "call_commentary_first", content: "first result" }); + expect(messages[2]).toMatchObject({ tool_call_id: "call_commentary_second", content: "second result" }); + expect(messages[3].content).toBe("checking both results"); + }); + + test("assistant commentary after one parallel result preserves the remaining genuine result", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { body } = await adapter.buildRequest(parsedWith([ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_partial_first", name: "exec", arguments: {} }, + { type: "toolCall", id: "call_partial_second", name: "exec", arguments: {} }, + ], + timestamp: 1, + }, + { role: "toolResult", toolCallId: "call_partial_first", toolName: "exec", content: "first done", isError: false, timestamp: 2 }, + { role: "assistant", content: [{ type: "text", text: "waiting for the second result" }], timestamp: 3 }, + { role: "toolResult", toolCallId: "call_partial_second", toolName: "exec", content: "second done", isError: false, timestamp: 4 }, + ])); + const messages = JSON.parse(String(body)).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "tool", "assistant"]); + expect(messages[1].content).toBe("first done"); + expect(messages[2].content).toBe("second done"); + expect(messages[3].content).toBe("waiting for the second result"); + }); + + test("deferred assistant text and thinking retain their order without mutating parsed history", async () => { + const parsed = parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: "call_thinking", name: "exec", arguments: {} }], timestamp: 1 }, + { role: "developer", content: "context notice", timestamp: 2 }, + { role: "assistant", content: [{ type: "text", text: "first comment" }, { type: "thinking", thinking: "synthetic reasoning" }], timestamp: 3 }, + { role: "assistant", content: [{ type: "text", text: "second comment" }], timestamp: 4 }, + { role: "toolResult", toolCallId: "call_thinking", toolName: "exec", content: "recorded result", isError: false, timestamp: 5 }, + ]); + const original = JSON.stringify(parsed); + const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); + const messages = JSON.parse(String(body)).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "system", "assistant", "assistant"]); + expect(messages[1].content).toBe("recorded result"); + expect(messages[2].content).toBe("context notice"); + expect(messages[3]).toMatchObject({ content: "first comment", thinking: "synthetic reasoning" }); + expect(messages[4].content).toBe("second comment"); + expect(JSON.stringify(parsed)).toBe(original); + }); + + test("assistant commentary after a complete batch keeps its existing wire position", async () => { + const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: "call_complete", name: "exec", arguments: {} }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_complete", toolName: "exec", content: "done", isError: false, timestamp: 2 }, + { role: "assistant", content: [{ type: "text", text: "completed comment" }], timestamp: 3 }, + { role: "user", content: "next turn", timestamp: 4 }, + ])); + const messages = JSON.parse(String(body)).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "assistant", "user"]); + expect(messages[1].content).toBe("done"); + expect(messages[2].content).toBe("completed comment"); + expect(messages[3].content).toBe("next turn"); + }); + + test("a new tool-call batch still settles an unresolved batch after assistant commentary", async () => { + const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: "call_unfinished", name: "exec", arguments: {} }], timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "before the next batch" }], timestamp: 2 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_next", name: "exec", arguments: {} }], timestamp: 3 }, + { role: "toolResult", toolCallId: "call_next", toolName: "exec", content: "next result", isError: false, timestamp: 4 }, + ])); + const messages = JSON.parse(String(body)).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "assistant", "assistant", "tool"]); + expect(messages[1].tool_call_id).toBe("call_unfinished"); + expect(messages[1].content).toContain("execution status unknown"); + expect(messages[2].content).toBe("before the next batch"); + expect(messages[4]).toMatchObject({ tool_call_id: "call_next", content: "next result" }); + }); + + test("routed compaction preserves a result recorded after assistant commentary", async () => { + const parsed = parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: "call_compaction", name: "exec", arguments: {} }], timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "commentary before compaction" }], timestamp: 2 }, + { role: "toolResult", toolCallId: "call_compaction", toolName: "exec", content: "genuine result", isError: false, timestamp: 3 }, + { role: "user", content: "summarize this history", timestamp: 4 }, + ], { toolChoice: "none" }); + parsed._compactionRequest = true; + const { body } = await createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); + const request = JSON.parse(String(body)); + expect(request.messages.map((message: { role: string }) => message.role)) + .toEqual(["assistant", "tool", "assistant", "user"]); + expect(request.messages[1]).toMatchObject({ tool_call_id: "call_compaction", content: "genuine result" }); + expect(request.messages[2].content).toBe("commentary before compaction"); + expect(request.tools).toBeUndefined(); + }); + test("a deferred user message keeps its text and images after the tool result", async () => { const adapter = createOllamaNativeAdapter(ollamaProvider()); const png = "data:image/png;base64,iVBORw0KGgo="; @@ -408,3 +525,93 @@ describe("ollama-native — request shape", () => { ]))).toThrow(/duplicate tool result/); }); }); + +describe("ollama-native commentary validation and replay ownership", () => { + const call = (id = "call_guard") => ({ + role: "assistant", content: [{ type: "toolCall", id, name: "exec", namespace: "ops", arguments: { cmd: "pwd" } }], timestamp: 1, + }); + const commentary = { role: "assistant", content: [{ type: "text", text: "Working." }], timestamp: 2 }; + const result = (id = "call_guard") => ({ + role: "toolResult", toolCallId: id, toolName: "exec", toolNamespace: "ops", content: "done", isError: false, timestamp: 3, + }); + + for (const [label, invalid, error] of [ + ["unknown id", { ...result(), toolCallId: "call_other" }, /has no originating call/], + ["wrong name", { ...result(), toolName: "other" }, /names the wrong originating tool/], + ["wrong namespace", { ...result(), toolNamespace: "other" }, /names the wrong originating tool/], + ] as const) { + test(`commentary does not bypass result validation: ${label}`, () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call(), commentary, invalid]))).toThrow(error); + }); + } + + test("duplicate results remain rejected while commentary holds an unresolved batch", () => { + const first = call("first"); + const second = call("second"); + const batch = { ...first, content: [...first.content, ...second.content] }; + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([batch, result("first"), commentary, result("first")]))).toThrow(/duplicate tool result/); + }); + + test("a result cannot cross a subsequent tool-call batch", () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call("old"), commentary, call("new"), result("old")]))).toThrow(/has no originating call/); + }); + + for (const id of ["", "call_guard"]) { + test(`new batches still reject ${id ? "reused" : "empty"} call IDs after commentary`, () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => adapter.buildRequest(parsedWith([call(), commentary, call(id)]))).toThrow(/id is missing or duplicated/); + }); + } + + test("EOF settles a missing result once before deferred commentary", () => { + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([call(), commentary])); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "assistant"]); + expect(messages[1].tool_call_id).toBe("call_guard"); + expect(messages[1].content).toContain("execution status unknown"); + expect(messages[2].content).toBe("Working."); + }); + + test("completed batches release commentary before the next conversation turn", () => { + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsedWith([ + call(), result(), commentary, { role: "user", content: "next" }, call("next"), result("next"), + ])); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "assistant", "user", "assistant", "tool"]); + expect(messages[2].content).toBe("Working."); + expect(messages[3].content).toBe("next"); + expect(messages[5].tool_call_id).toBe("next"); + }); + + test("deep-frozen history keeps call order, deferred arrival order and original arguments", () => { + const first = call("first"); + const second = call("second"); + const batch = { ...first, content: [...first.content, ...second.content] }; + const parsed = parsedWith([ + batch, + { role: "developer", content: "hook" }, + result("second"), + { role: "assistant", content: [{ type: "thinking", thinking: "Checking." }, { type: "text", text: "Working." }] }, + { role: "user", content: "notice" }, + result("first"), + ]); + function freeze(value: unknown): void { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return; + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + const before = JSON.stringify(parsed); + freeze(parsed); + const { body } = createOllamaNativeAdapter(ollamaProvider()).buildRequest(parsed); + expect(JSON.stringify(parsed)).toBe(before); + const messages = JSON.parse(body).messages; + expect(messages.map((message: { role: string }) => message.role)).toEqual(["assistant", "tool", "tool", "system", "assistant", "user"]); + expect(messages.slice(1, 3).map((message: { tool_call_id: string }) => message.tool_call_id)).toEqual(["first", "second"]); + expect(messages.slice(3).map((message: { content: string }) => message.content)).toEqual(["hook", "Working.", "notice"]); + expect(messages[4].thinking).toBe("Checking."); + expect(messages[0].tool_calls[0].function.arguments).toEqual({ cmd: "pwd" }); + }); +});