Repository navigation
[Provider compatibility] ollama-native rejects a Codex replay whose tool result arrives after an injected mid-turn developer message #4842
Description
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/developermessages 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
- 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
briascoi commentedon Sep 16, 2026
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
developeritem that landed whilecall_kbpiwzybwas 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.
lidge-jun commentedon Sep 16, 2026
리뷰 · 우선순위 74 / 80
이 이슈는 Codex가 한 턴 안에 PostToolUse 훅 같은 중간
developer메시지를 tool call과 tool result 사이에 끼워 넣을 때,ollama-native만 재생을 거절하는 문제다. 지금devHEAD6ed8986c64afb2e37b1055e1afeb7e12d32ca8e4의src/adapters/ollama-native.tsbuildNativeMessages는 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 /dev2.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.ts314 - pending flush 시 result 없으면 throw. 중간 developer 때문에 result가 "아직 안 온" 경우와 "진짜 없음"을 구분하지 못한다.src/adapters/ollama-native.ts346–352 근처 - pending 중 conversational 메시지를 hard boundary로 flush. Codex mid-turn injection과 충돌한다.src/adapters/openai-chat/messages.tsdeferredBarrierMessages - 같은 클라이언트 히스토리를 이미 허용. native만 거절하는 비대칭의 기준선이다.502 Provider unreachable 표면 - 로컬 throw가 업스트림 장애처럼 보여 운영 신호가 오염된다.
#3259와 같은 분류 개선이 같이 있으면 좋다.제안 diff half 2 - unknown tool 메시지를 넣으면 "거절"에서 "재생 가능"으로 바뀐다. 메인테이너가 엄격 거절을 선호하면 half 1만 머지해도 이슈는 닫힌다.
메인테이너의 판단이 필요한 지점
tests/providers/ollama/ollama-native.test.ts의describe("ollama-native — request shape")에 바로 붙일지너의 추천
받아들여라.
dev의buildNativeMessages에 deferred barrier를 openai-chat과 같이 넣고, 본문 재현으로 role 순서["user","assistant","tool","system"]회귀 테스트를 추가하는 PR을 열어라. half 2는 별 PR이어도 된다. 이슈는 provider-compatibility로 이미 잘 라벨돼 있으니 그 스택에서 처리하면 된다.이 댓글은 grok-bot이 작성했습니다
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
fix(ollama-native): defer a mid-turn conversational message instead o…
briascoi commentedon Sep 17, 2026
Landed on
devin46b38161(fix(ollama-native): defer a mid-turn conversational message instead of refusing the replay (#4848)). Closing manually, since PRs here targetdevand GitHub does not auto-close from that base.fix(ollama-native): defer a mid-turn conversational message instead o…