Skip to content

[Kiro] Multiple outputs for one Codex exec call produce duplicate toolResult entries #3734

Closed

Description

@yrlan-montagnier

Client or integration

Codex App

Provider or upstream service

Kiro

OpenCodex version

2.44.0 / af344a2

Endpoint or capability

POST /v1/responses, Codex code-mode exec tool results

Current behaviour

A single Codex code-mode exec call can emit several adjacent custom_tool_call_output items with the same call_id when the script calls notify() before its final text() result. The Kiro adapter currently translates every output into a separate toolResult with the same toolUseId. Kiro consumes the tool use with the first result, then rejects the next duplicate result because no unmatched tool use remains.

Expected behaviour

The Kiro adapter should emit one toolResult for adjacent outputs belonging to the same call_id. It should append their content in input order, retain images, and propagate error status if any merged output is an error. If the same call_id reappears after a different call's result, the adapter should continue rejecting that ambiguous ordering.

Minimal redacted request or reproduction

Run one Codex exec tool call whose script emits:

notify("update-1");
notify("update-2");
text("final-result");

On the next model request, the Responses input contains three adjacent outputs associated with the same call_id.

Actual response or error

Kiro tool result has no matching tool use "call_58be3e59-ef13-4c50-8863-c48a9126183b"

Upstream documentation

No public Kiro or CodeWhisperer specification was found for multiple results on one toolUseId. The concrete client requirement is the observed Codex code-mode output sequence together with Kiro's validation that accepts one result for each tool use.

Suggested mapping or implementation notes

While converting Responses messages to Kiro messages, merge only immediately adjacent results whose toolUseId matches the last emitted toolResult. Append text and images without reordering them and change the merged status to error if a later adjacent result is an error. Do not search earlier turns or merge across another tool's result, because that could hide invalid parallel ordering.

A focused regression test should cover three adjacent outputs becoming one ordered result, plus call-a, call-b, call-a remaining invalid.

Additional context and attachments

The same report was previously opened as #3731 and closed only because it did not use a recognized issue template.

A local fix was validated with 83 Kiro adapter tests, typecheck, and git diff --check. The focused cases cover ordered text, retained images, propagated error status, call-a / call-b / call-a rejection, and rejection when user input interrupts two results for the same call. A live Codex smoke test completed a code-mode call with notifications and a final result through Kiro. The broad changed-test suite was not rerun because a previous run exhausted system memory and the Linux kernel terminated Chromium; affected suites were run independently.

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 6, 2026
  2. yrlan-montagnier commented on Sep 6, 2026

    @yrlan-montagnier
    ContributorAuthor

    I implemented and validated a focused local patch against base commit af344a28eabcee09a5e04c48ab897449792719c2 (the current origin/dev at the time of testing).

    The implementation tracks adjacency in the original parsed message sequence, rather than only looking at the normalized Kiro user turn. This matters because normalization can combine adjacent user-side content: a user/developer message or assistant message resets the merge candidate. Only a directly following result with the same normalized toolUseId is appended to the last Kiro result.

    The regression coverage verifies ordered text, image retention, error propagation, rejection of call-a / call-b / call-a, and rejection when user input interrupts two results for the same call.

    Local patch
    diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts
    index fb83572d3..9a409b90e 100644
    --- a/src/adapters/kiro.ts
    +++ b/src/adapters/kiro.ts
    @@ -664,6 +664,7 @@ export function buildKiroPayload(
       const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : "";
       const turns: KiroTurn[] = [];
       const priorCalls = new Map<string, { wireName: string }>();
    +  let adjacentToolResultId: string | undefined;
       const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => {
         const last = turns.at(-1);
         if (last?.kind === "user") {
    @@ -697,10 +698,12 @@ export function buildKiroPayload(
     
       for (const msg of kiroPayloadMessages(parsed)) {
         if (msg.role === "user" || msg.role === "developer") {
    +      adjacentToolResultId = undefined;
           const text = userContentText((msg as { content: string | OcxContentPart[] }).content);
           const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
           pushUser(text, images);
         } else if (msg.role === "assistant") {
    +      adjacentToolResultId = undefined;
           const aMsg = msg as OcxAssistantMessage;
           const text = (aMsg.content || [])
             .filter((b): b is OcxTextContent => b.type === "text")
    @@ -744,6 +747,19 @@ export function buildKiroPayload(
           if (!priorCalls.has(toolUseId)) {
             throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
           }
    +      const openUserTurn = turns.at(-1);
    +      if (adjacentToolResultId === toolUseId && openUserTurn?.kind === "user") {
    +        const previousResult = openUserTurn.toolResults.at(-1);
    +        if (previousResult?.toolUseId === toolUseId) {
    +          // Codex code mode can publish several ordered updates for one call through notify() and
    +          // the final cell result, while Kiro permits exactly one result for each tool use.
    +          previousResult.content.push({ text: resultText });
    +          if (tr.isError) previousResult.status = "error";
    +          openUserTurn.images.push(...images);
    +          adjacentToolResultId = toolUseId;
    +          continue;
    +        }
    +      }
           // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix.
           // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code
           // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the
    @@ -753,6 +769,7 @@ export function buildKiroPayload(
             status: tr.isError ? "error" : "success",
             toolUseId,
           }]);
    +      adjacentToolResultId = toolUseId;
         }
       }
     
    diff --git a/tests/providers/kiro/kiro-adapter.test.ts b/tests/providers/kiro/kiro-adapter.test.ts
    index cb530a8d5..4c8257c12 100644
    --- a/tests/providers/kiro/kiro-adapter.test.ts
    +++ b/tests/providers/kiro/kiro-adapter.test.ts
    @@ -1061,6 +1061,112 @@ describe("kiro adapter — buildRequest", () => {
         expect(current.userInputMessageContext.tools).toBeUndefined();
       });
     
    +  test("multiple code-mode updates for one call become one ordered Kiro tool result", async () => {
    +    const parsed = parseRequest({
    +      model: "claude-sonnet-4.5",
    +      input: [
    +        { role: "user", content: "Wait for the job." },
    +        {
    +          type: "custom_tool_call",
    +          call_id: "call_58be3e59-ef13-4c50-8863-c48a9126183b",
    +          name: "exec",
    +          input: "await pollJob();",
    +        },
    +        {
    +          type: "custom_tool_call_output",
    +          call_id: "call_58be3e59-ef13-4c50-8863-c48a9126183b",
    +          output: "Script running with cell ID 29",
    +        },
    +        {
    +          type: "custom_tool_call_output",
    +          call_id: "call_58be3e59-ef13-4c50-8863-c48a9126183b",
    +          output: "running",
    +        },
    +        {
    +          type: "custom_tool_call_output",
    +          call_id: "call_58be3e59-ef13-4c50-8863-c48a9126183b",
    +          output: "success",
    +        },
    +      ],
    +    });
    +
    +    const { body } = await createKiroAdapter(provider).buildRequest(parsed);
    +    const current = JSON.parse(body).conversationState.currentMessage.userInputMessage;
    +
    +    expect(current.userInputMessageContext.toolResults).toEqual([{
    +      content: [
    +        { text: "Script running with cell ID 29" },
    +        { text: "running" },
    +        { text: "success" },
    +      ],
    +      status: "success",
    +      toolUseId: "call_58be3e59-ef13-4c50-8863-c48a9126183b",
    +    }]);
    +  });
    +
    +  test("merged code-mode updates preserve images and propagate failure status", async () => {
    +    const imageUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
    +    const messages = [
    +      { role: "user", content: "Run the visual check." },
    +      { role: "assistant", content: [{ type: "toolCall", id: "call-visual", name: "exec", arguments: {} }] },
    +      { role: "toolResult", toolCallId: "call-visual", toolName: "exec", content: "starting", isError: false },
    +      {
    +        role: "toolResult",
    +        toolCallId: "call-visual",
    +        toolName: "exec",
    +        content: [
    +          { type: "text", text: "visual check failed" },
    +          { type: "image", imageUrl, detail: "high" },
    +        ],
    +        isError: true,
    +      },
    +    ];
    +
    +    const { body } = await createKiroAdapter(provider).buildRequest(
    +      parsedWith(messages, [{ name: "exec", description: "Run JavaScript", parameters: { type: "object" } }]),
    +    );
    +    const current = JSON.parse(body).conversationState.currentMessage.userInputMessage;
    +
    +    expect(current.userInputMessageContext.toolResults).toEqual([{
    +      content: [{ text: "starting" }, { text: "visual check failed" }],
    +      status: "error",
    +      toolUseId: "call-visual",
    +    }]);
    +    expect(current.images).toEqual([{
    +      format: "png",
    +      source: { bytes: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" },
    +    }]);
    +  });
    +
    +  test("non-consecutive results for the same call remain rejected", async () => {
    +    const parsed = parseRequest({
    +      model: "claude-sonnet-4.5",
    +      input: [
    +        { role: "user", content: "Run both checks." },
    +        { type: "custom_tool_call", call_id: "call-a", name: "exec", input: "checkA()" },
    +        { type: "custom_tool_call", call_id: "call-b", name: "exec", input: "checkB()" },
    +        { type: "custom_tool_call_output", call_id: "call-a", output: "a-1" },
    +        { type: "custom_tool_call_output", call_id: "call-b", output: "b" },
    +        { type: "custom_tool_call_output", call_id: "call-a", output: "a-2" },
    +      ],
    +    });
    +
    +    await expect(createKiroAdapter(provider).buildRequest(parsed)).rejects.toThrow(
    +      'Kiro tool result has no matching tool use "call-a"',
    +    );
    +
    +    const withInterveningUserInput = parsedWith([
    +      { role: "user", content: "Run the check." },
    +      { role: "assistant", content: [{ type: "toolCall", id: "call-a", name: "exec", arguments: {} }] },
    +      { role: "toolResult", toolCallId: "call-a", toolName: "exec", content: "a-1", isError: false },
    +      { role: "user", content: "Stop after this update." },
    +      { role: "toolResult", toolCallId: "call-a", toolName: "exec", content: "a-2", isError: false },
    +    ]);
    +    await expect(createKiroAdapter(provider).buildRequest(withInterveningUserInput)).rejects.toThrow(
    +      'Kiro tool result has no matching tool use "call-a"',
    +    );
    +  });
    +
       test("orphaned and encrypted tool results are rejected instead of fictionalized", async () => {
         const messages = [
           { role: "toolResult", toolCallId: "missing-call", toolName: "bash", content: "orphaned", isError: true },

    Validation:

    • bun test tests/providers/kiro/kiro-adapter.test.ts: 83 pass, 0 fail
    • bun run typecheck: passed
    • git diff --check: passed
    • Live Codex/Kiro smoke test after proxy restart: two notify() calls followed by text() returned all three outputs without a duplicate toolResult error

    The patch is currently uncommitted, so this comment records the exact proposed implementation rather than pointing to a branch that may change.

  3. Ingwannu commented on Sep 6, 2026

    @Ingwannu
    Owner

    Confirmed the local failure on exact dev head af344a28eabcee09a5e04c48ab897449792719c2, without making an upstream request. A single custom_tool_call_output builds successfully; three adjacent outputs for that same call are preserved as three parser results and then fail Kiro's local conversation validation with Kiro tool result has no matching tool use.

    The relevant boundary is src/adapters/kiro.ts: pushUser() appends separate result entries, while validateKiroConversationState() consumes each pending tool use once. This is an adapter compatibility defect, not evidence that the code-mode call itself failed.

    Thanks for posting the proposed patch. Please submit your existing change as a focused PR targeting dev, with its regression tests and a short note in the adapter/transport documentation. Tracking adjacency in the original message sequence, not just the collapsed Kiro user turn, is important: keep the intervening user/developer/assistant and call-a / call-b / call-a negative cases. Retain the image/error-status cases too.

    My isolated diagnostic checks included the single-output control and the failing multi-output case; they establish the current bug, not approval of the proposed fix or a fresh live Kiro test. Keeping this open until a reviewed fix lands; I will not duplicate your implementation under another author.

  4. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 73 / 80

    이 이슈는 Codex code-mode exec가 한 번의 호출에서 notify() 여러 번 + 최종 text()를 내면, 같은 call_id의 custom_tool_call_output이 여러 개 인접하고, Kiro 어댑터가 각각을 별도 toolResult로 보내 두 번째부터 Kiro tool result has no matching tool use로 죽는 문제입니다. 지금 dev(HEAD af344a28e)의 src/adapters/kiro.ts는 pushUser()로 toolResults를 붙이고, validateKiroConversationState()는 pending tool use를 결과 하나당 한 번만 소비합니다. 파서는 인접 출력을 여러 toolResult로 유지하므로, 어댑터 호환 결함입니다(코드 실행 자체가 실패한 증거가 아님). OWNER(Ingwannu)가 단일 출력은 성공·다중 인접 출력은 로컬 검증 실패로 HEAD에서 확인했습니다.

    보고자 패치는 원본 메시지 순서로 adjacency를 추적합니다. user/developer/assistant가 나오면 후보를 리셋하고, 직전 toolResult와 같은 toolUseId인 인접 결과만 텍스트·이미지를 append하며, 에러면 status를 error로 올립니다. call-a / call-b / call-a나 중간에 user 입력이 끼면 계속 거부합니다. tests/providers/kiro/kiro-adapter.test.ts에 순서·이미지·에러·비연속 거부 회귀를 넣었고, 라이브 스모크도 했다고 적었습니다. Ingwannu는 이 패치를 focused PR로 dev에 제출하고, adjacency를 collapsed Kiro user turn이 아니라 원본 시퀀스 기준으로 유지하며, 어댑터/전송 문서에 짧은 노트를 남기라고 했습니다. 이전 템플릿 없는 #3731 후속입니다.

    우선순위 73인 이유다. Kiro+Codex exec/notify는 실사용 도구 경로이고, 재현·패치·메인테이너 확인이 끝났습니다. #2819/#2835 등 Kiro 종료·중복 답변 불변식과는 겹치되, 이번은 “한 call_id의 다중 결과 병합”이라 별 이슈로 두는 게 맞습니다. HEAD의 kiro.ts에는 아직 adjacentToolResultId가 없습니다.

    경로/심볼 - src/adapters/kiro.ts pushUser / validateKiroConversationState - 결과마다 tool use 1회 소비. 다중 인접 출력에서 실패
    경로/심볼 - 제안 패치 adjacentToolResultId - 원본 메시지 루프에서만 병합, user/assistant면 리셋
    경로 - tests/providers/kiro/kiro-adapter.test.ts - 3인접→1결과, 이미지/에러, call-a/b/a, user 개입 거부
    경로/심볼 - 관련 닫힌 #3731 (템플릿 없음). HEAD af344a28e에서 Ingwannu 재현 확인
    경로/심볼 - 에러 문구 - Kiro tool result has no matching tool use "call_…"

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

    • 병합을 Kiro 전용으로 둘지, 다른 1:1 toolResult 어댑터에도 같은 규칙을 검토할지
    • notify 중간 업데이트를 모두 붙일지, 최종 text만 살릴지(패치는 전부 순서 유지)
    • 문서 노트를 kiro 어댑터 README/devlog 어디에 둘지

    너의 추천
    Ingwannu 지시대로 회귀·문서 노트 포함한 집중 PR을 dev에 열어 주세요. 구현을 다른 작성자가 다시 짜지 말고 보고자 패치를 리뷰 트랙에 올리는 쪽이 맞습니다. 머지 후 이 이슈를 닫으면 됩니다.

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

  5. Sfrui commented on Sep 6, 2026

    @Sfrui

    Hi @lidge-jun, I'd like to work on #3734 if nobody is already handling it.

    My current understanding is that when a Codex exec call produces multiple outputs, the translated adapter emits duplicate toolResult entries instead of preserving a single ordered result sequence.

    Before changing the implementation, I plan to:

    1. add a focused regression fixture containing multiple outputs from one exec call;
    2. identify whether duplication happens during normalization or provider translation;
    3. make the smallest change at the owning layer;
    4. verify that single-output calls and existing provider adapters remain unchanged.

    I will keep authentication, provider configuration, and unrelated refactoring out of scope. Does this direction match the intended protocol behavior?

  6. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    Fixed on dev by #3750 (merge c5ccd5d). Adjacent outputs now coalesce only for the same original call ID; collision, message-boundary and duplicate/orphan guards remain. Full hosted Linux/macOS/gates passed at 36d1e55: https://github.com/lidge-jun/opencodex/actions/runs/34017511752. Independent review passed. Verification used synthetic regression fixtures; available saved local metadata lacked a current reproduction, and no live Kiro request was made because quota was unavailable.

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