Skip to content

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

Closed

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

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Sep 16, 2026
  2. briascoi commented on Sep 16, 2026

    @briascoi
    ContributorAuthor

    Field confirmation for the diff proposed above, in case it helps with triage.

    I applied it to a live 2.49.0 install (no other change) and restarted the proxy. The affected history holds 162 tool calls, all of them resolved: the only defect is a single developer item that landed while call_kbpiwzyb was open, and that one item had ended four consecutive turns of the same thread (the first after a 14-minute turn, the next three about 30 seconds in each). After the restart the thread resumed on the first attempt and kept working on exactly that replayed history.

    So half 1 alone (deferring the boundary and releasing it after the tool messages) fixes the reported case; half 2 is not needed for it. No turn has ended with this error since the restart, and the rollouts that still contain the message carry it from turns that ended before it.

  3. lidge-jun commented on Sep 16, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 74 / 80

    이 이슈는 Codex가 한 턴 안에 PostToolUse 훅 같은 중간 developer 메시지를 tool call과 tool result 사이에 끼워 넣을 때, ollama-native만 재생을 거절하는 문제다. 지금 dev HEAD 6ed8986c64afb2e37b1055e1afeb7e12d32ca8e4의 src/adapters/ollama-native.ts buildNativeMessages는 pending tool batch가 열려 있는 동안 새 대화 메시지(user/developer 포함)를 만나면 무조건 flushPending()을 부른다(대략 346–352행 근처). 그때 아직 result가 안 붙은 call이 있으면 314행에서 missing its tool result; refusing interrupted replay로 던진다. 결과는 "없음"이 아니라 히스토리 더 뒤에 있는데, 중간 developer가 배치를 닫아버려서 죽은 것처럼 보인다. 같은 히스토리를 openai-chat 경로(src/adapters/openai-chat/messages.ts의 deferredBarrierMessages, 77–94·194행)는 이미 미뤄 두었다가 tool 뒤에 풀어 주므로, native Ollama만 막히는 비대칭이다.

    실제 피해가 크다. 실패한 순서가 스레드 히스토리에 남으면 그 스레드의 이후 턴도 전부 같은 검사에 걸린다. 사용자는 로컬 히스토리 검증 실패를 502 Provider unreachable로 본다. #3259에서 grok-bot이 짚었던 "업스트림 장애처럼 보이는 로컬 거절"과 같은 분류 구멍이다. 재현 순서를 call → developer → result로 바꾸면 깨지고, call → result → developer로만 바꾸면 통과한다는 설명은 경계 처리가 원인임을 잘 고정한다. deepseek-v4.1-flash:cloud뿐 아니라 ollama-native로 라우팅되는 모든 Ollama 행에 같은 함수가 쓰인다고 본문에 적혀 있고, buildNativeMessages가 2.49.0 / main 2.57.0 / dev 2.58.0에서 동일하다는 확인도 HEAD와 맞다.

    제안 패치 두 반은 방향이 옳다. (1) open batch 중 user/developer는 deferred에 넣고 flush 뒤에 방출 — openai-chat과 같은 모양. (2) 히스토리 어디에도 result가 없는 진짜 끊긴 재생은 throw 대신 unknown-status tool 메시지를 넣기 — 역시 chat 어댑터의 flushPendingToolCalls와 같은 철학. 보고된 케이스만 고치려면 (1)만으로도 충분하고, (2)는 분리 가능하다는 본문 판단도 유지보수에 좋다. types.ts/config.ts 분리와 무관해서 "닫고 리베이스" 대상이 아니다. 우선순위는 실제 Codex×Ollama 스레드를 영구 사망시키는 호환 버그라서 높다.

    src/adapters/ollama-native.ts 314 - pending flush 시 result 없으면 throw. 중간 developer 때문에 result가 "아직 안 온" 경우와 "진짜 없음"을 구분하지 못한다.
    src/adapters/ollama-native.ts 346–352 근처 - pending 중 conversational 메시지를 hard boundary로 flush. Codex mid-turn injection과 충돌한다.
    src/adapters/openai-chat/messages.ts deferredBarrierMessages - 같은 클라이언트 히스토리를 이미 허용. native만 거절하는 비대칭의 기준선이다.
    502 Provider unreachable 표면 - 로컬 throw가 업스트림 장애처럼 보여 운영 신호가 오염된다. #3259와 같은 분류 개선이 같이 있으면 좋다.
    제안 diff half 2 - unknown tool 메시지를 넣으면 "거절"에서 "재생 가능"으로 바뀐다. 메인테이너가 엄격 거절을 선호하면 half 1만 머지해도 이슈는 닫힌다.

    메인테이너의 판단이 필요한 지점

    • half 1만 할지, half 2(미기록 result → unknown tool 메시지)까지 같은 PR에 넣을지
    • 회귀 테스트를 tests/providers/ollama/ollama-native.test.ts의 describe("ollama-native — request shape")에 바로 붙일지
    • 502 문구를 로컬 히스토리 검증 실패로 구분하는 후속을 이 이슈에 묶을지 따로 둘지

    너의 추천
    받아들여라. dev의 buildNativeMessages에 deferred barrier를 openai-chat과 같이 넣고, 본문 재현으로 role 순서 ["user","assistant","tool","system"] 회귀 테스트를 추가하는 PR을 열어라. half 2는 별 PR이어도 된다. 이슈는 provider-compatibility로 이미 잘 라벨돼 있으니 그 스택에서 처리하면 된다.

    이 댓글은 grok-bot이 작성했습니다

  4. briascoi commented on Sep 17, 2026

    @briascoi
    ContributorAuthor

    Landed on dev in 46b38161 (fix(ollama-native): defer a mid-turn conversational message instead of refusing the replay (#4848)). Closing manually, since PRs here target dev and GitHub does not auto-close from that base.

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