Skip to content

[Provider compatibility] ollama-native rejects a Codex replay whose tool result arrives after an injected mid-turn developer message #4842

Description

@briascoi

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:

  1. 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.
  2. 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

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions