Client or integration
Codex App. Any Codex surface that records mid-turn context items reproduces it (CLI and SDK included), because the offending order comes from the client's own history, not from the App UI.
Provider or upstream service
ollama-cloud (provider config adapter: "ollama-native", baseUrl: https://ollama.com/v1, native /api/chat wire). Reproduced with deepseek-v4.1-flash:cloud; the same function serves every routed Ollama row (glm-5.3, glm-5.3-flash, kimi-k3, minimax-m3, gpt-oss:120b). The same client, same history, same turn works on the non-Ollama routes (openai gpt-5.6-*, vercel-ai-gateway, openrouter).
OpenCodex version
2.49.0 installed (npm @bitkyc08/opencodex). Source-checked afterwards: the throw is still on dev (2.58.0) at src/adapters/ollama-native.ts:314 and on main (2.57.0) at the same line. buildNativeMessages is byte-identical across 2.49.0, 2.57.0 and dev.
Endpoint or capability
Inbound /v1/responses from Codex App, upstream native /api/chat with tool_calls and role: "tool" messages.
Current behaviour
buildNativeMessages treats every user/developer message as a hard boundary for the open tool batch. It flushes the batch, finds a call whose result is not there yet, and throws before any upstream request is built:
ollama-native tool call call_kbpiwzyb is missing its tool result; refusing interrupted replay
The result is not missing. It is recorded after the injected message. Codex writes mid-turn context items inside a single turn, and one of them routinely lands between the assistant tool_calls message and that call's tool result. The concrete trigger here is a PostToolUse hook (hooks.json, matcher Edit|Write|apply_patch) whose verdict is injected as a developer message. The recorded item order of the real failing turn:
response_item custom_tool_call call_kbpiwzyb name=exec <- assistant turn, holds the call
response_item message role=developer "[impeccable@1] Design hook findings ..."
response_item custom_tool_call_output call_kbpiwzyb <- the tool result, 5 items later
Reordering those three items as call, result, injected message is the only difference that matters.
The order is part of the persisted thread history, so the thread is dead for good: every later turn fails the same way, including a plain follow-up user message. On top of that, the user sees a 502 Provider unreachable, which reads as an upstream outage for what is a local history-validation failure (the same classification gap grok-bot flagged in #3259).
Expected behaviour
An injected conversational message should be deferred, not treated as the end of the batch: emit the assistant tool call, then its tool result, then the injected message. That is what the sibling chat adapter already does with deferredBarrierMessages (src/adapters/openai-chat/messages.ts, dev, lines 77-94 and 194) for the same client and the same history shape, so the native transport is the only one that refuses it.
Minimal redacted request or reproduction
The parsed history shape, written the way tests/providers/ollama/ollama-native.test.ts builds it:
import { createOllamaNativeAdapter } from "../../../src/adapters/ollama-native";
const adapter = createOllamaNativeAdapter(ollamaProvider());
const { body } = await adapter.buildRequest(parsedWith([
{ role: "user", content: "continue", timestamp: 0 },
{ role: "assistant", content: [
{ type: "text", text: "applying the patch" },
{ type: "toolCall", id: "call_1", name: "exec", arguments: { cmd: "ls" } },
], timestamp: 1 },
{ role: "developer", content: "[hook] design findings requiring review", timestamp: 2 },
{ role: "toolResult", toolCallId: "call_1", toolName: "exec", content: "done", isError: false, timestamp: 3 },
]));
console.log(JSON.parse(String(body)).messages.map((m) => m.role));
// current: throws before a request exists
// expected: ["user", "assistant", "tool", "system"]
Swapping the last two items (call, result, developer) already passes today, which pins the failure to the boundary handling rather than to the pairing rules.
Actual response or error
Codex App surfaces the adapter throw as an upstream failure, and no request reaches ollama.com:
unexpected status 502 Bad Gateway: Provider unreachable: ollama-native tool call call_kbpiwzyb is missing its tool result; refusing interrupted replay, url: http://127.0.0.1:10100/v1/responses
Upstream documentation
https://docs.ollama.com/api/chat : the native messages array takes role: "tool" messages answering a preceding assistant tool_calls block. The ordering that breaks here is a Codex-side item, so the requirement comes from the client integration rather than from Ollama. The injected verdict is a developer item in the Responses input, and Codex keeps it in place between the call and the result.
Suggested mapping or implementation notes
Two independent halves, both mirroring openai-chat/messages.ts:
- Defer
user/developer messages that arrive with an open batch, and release them right after the tool messages. This is the half that fixes the case above.
- For a batch that genuinely has no result anywhere in the history (a turn interrupted mid-tool-call), answer with the explicit unknown-status tool message the chat adapter already emits, instead of aborting the request.
The diff below applies cleanly to dev (patch -p1 from the repository root) and is what I am running locally. Verified with the real module: the interleaved order now serializes as ["user", "assistant", "tool", "system"], the normal order is unchanged, parallel calls with out-of-order results still emit in call order, and the strict guards (orphan result, duplicate result, result naming another tool) still throw.
@@
reservedToolCallIds.clear();
let pending: PendingToolBatch | undefined;
+ // Codex records mid-turn injections (a PostToolUse hook verdict, a context notice) between an
+ // assistant tool call and that call's own tool result. Native Ollama needs the call and its
+ // results adjacent, so those conversational messages wait here instead of closing the batch
+ // early. The openai-chat adapter defers them the same way; refusing the replay killed the turn.
+ let deferred: OllamaNativeMessage[] = [];
+
+ const releaseDeferred = (): void => {
+ if (deferred.length === 0) return;
+ messages.push(...deferred);
+ deferred = [];
+ };
const flushPending = (): void => {
if (!pending) return;
for (const call of pending.calls) {
if (!call.result) {
- throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`);
+ // No result exists anywhere in the replayed history: the turn was interrupted, or the
+ // result never reached it. State exactly that instead of inventing an outcome, and keep
+ // the conversation replayable.
+ messages.push({
+ role: "tool",
+ tool_call_id: call.id,
+ tool_name: call.wireName,
+ content: "[ocx] no tool result was recorded for this call; execution status unknown. Do not treat this as success, failure, or user-provided input.",
+ });
+ continue;
}
- }
- for (const call of pending.calls) {
- const result = call.result!;
- const translated = contentToNative(result.content, "tool result");
+ const translated = contentToNative(call.result.content, "tool result");
messages.push({
role: "tool",
tool_call_id: call.id,
tool_name: call.wireName,
content: translated.content,
...(translated.images ? { images: translated.images } : {}),
});
}
pending = undefined;
+ releaseDeferred();
};
@@
- // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
- // new conversational message is a hard boundary; unresolved calls are never fabricated.
- if (pending) flushPending();
+ // 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.
+ if (pending) {
+ if (message.role === "user" || message.role === "developer") {
+ const translated = message.role === "user"
+ ? contentToNative(message.content, "user")
+ : contentToNative(message.content, "developer", false);
+ deferred.push(message.role === "user"
+ ? { role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) }
+ : { role: "system", content: translated.content });
+ continue;
+ }
+ flushPending();
+ }
Regression test belongs next to the existing cases in describe("ollama-native — request shape"), asserting the role order is ["user", "assistant", "tool", "system"] and that the tool message carries the real recorded output rather than a synthetic one.
If maintainers prefer to keep refusing a genuinely truncated replay, half 1 alone is still a strict improvement and covers the reported case; half 2 is separable.
Additional context and attachments
Related but distinct: #3259 (orphan result with a missing id, closed as not planned) and #2199 (repair of orphan and missing tool results in the Google / Claude-on-Antigravity adapter). Both deal with the pairing check; this one is a resolved call whose result arrives after an injected message, which is ordinary Codex history.
Checks
Client or integration
Codex App. Any Codex surface that records mid-turn context items reproduces it (CLI and SDK included), because the offending order comes from the client's own history, not from the App UI.
Provider or upstream service
ollama-cloud(provider configadapter: "ollama-native",baseUrl: https://ollama.com/v1, native/api/chatwire). Reproduced withdeepseek-v4.1-flash:cloud; the same function serves every routed Ollama row (glm-5.3,glm-5.3-flash,kimi-k3,minimax-m3,gpt-oss:120b). The same client, same history, same turn works on the non-Ollama routes (openaigpt-5.6-*,vercel-ai-gateway,openrouter).OpenCodex version
2.49.0 installed (npm
@bitkyc08/opencodex). Source-checked afterwards: the throw is still ondev(2.58.0) atsrc/adapters/ollama-native.ts:314and onmain(2.57.0) at the same line.buildNativeMessagesis byte-identical across 2.49.0, 2.57.0 anddev.Endpoint or capability
Inbound
/v1/responsesfrom Codex App, upstream native/api/chatwithtool_callsandrole: "tool"messages.Current behaviour
buildNativeMessagestreats everyuser/developermessage as a hard boundary for the open tool batch. It flushes the batch, finds a call whose result is not there yet, and throws before any upstream request is built:The result is not missing. It is recorded after the injected message. Codex writes mid-turn context items inside a single turn, and one of them routinely lands between the assistant
tool_callsmessage and that call's tool result. The concrete trigger here is aPostToolUsehook (hooks.json, matcherEdit|Write|apply_patch) whose verdict is injected as adevelopermessage. The recorded item order of the real failing turn:Reordering those three items as call, result, injected message is the only difference that matters.
The order is part of the persisted thread history, so the thread is dead for good: every later turn fails the same way, including a plain follow-up user message. On top of that, the user sees a 502
Provider unreachable, which reads as an upstream outage for what is a local history-validation failure (the same classification gap grok-bot flagged in #3259).Expected behaviour
An injected conversational message should be deferred, not treated as the end of the batch: emit the assistant tool call, then its tool result, then the injected message. That is what the sibling chat adapter already does with
deferredBarrierMessages(src/adapters/openai-chat/messages.ts,dev, lines 77-94 and 194) for the same client and the same history shape, so the native transport is the only one that refuses it.Minimal redacted request or reproduction
The parsed history shape, written the way
tests/providers/ollama/ollama-native.test.tsbuilds it:Swapping the last two items (call, result, developer) already passes today, which pins the failure to the boundary handling rather than to the pairing rules.
Actual response or error
Codex App surfaces the adapter throw as an upstream failure, and no request reaches
ollama.com:Upstream documentation
https://docs.ollama.com/api/chat : the native messages array takes
role: "tool"messages answering a preceding assistanttool_callsblock. The ordering that breaks here is a Codex-side item, so the requirement comes from the client integration rather than from Ollama. The injected verdict is adeveloperitem in the Responses input, and Codex keeps it in place between the call and the result.Suggested mapping or implementation notes
Two independent halves, both mirroring
openai-chat/messages.ts:user/developermessages that arrive with an open batch, and release them right after the tool messages. This is the half that fixes the case above.The diff below applies cleanly to
dev(patch -p1from the repository root) and is what I am running locally. Verified with the real module: the interleaved order now serializes as["user", "assistant", "tool", "system"], the normal order is unchanged, parallel calls with out-of-order results still emit in call order, and the strict guards (orphan result, duplicate result, result naming another tool) still throw.Regression test belongs next to the existing cases in
describe("ollama-native — request shape"), asserting the role order is["user", "assistant", "tool", "system"]and that the tool message carries the real recorded output rather than a synthetic one.If maintainers prefer to keep refusing a genuinely truncated replay, half 1 alone is still a strict improvement and covers the reported case; half 2 is separable.
Additional context and attachments
Related but distinct: #3259 (orphan result with a missing id, closed as not planned) and #2199 (repair of orphan and missing tool results in the Google / Claude-on-Antigravity adapter). Both deal with the pairing check; this one is a resolved call whose result arrives after an injected message, which is ordinary Codex history.
Checks