From fc8027784b8274e58332da168a0343588d365bfc Mon Sep 17 00:00:00 2001 From: rome-xi <2685138823@qq.com> Date: Fri, 18 Sep 2026 02:06:17 +0000 Subject: [PATCH] fix(codex): fall back to stream items when turn.items is empty Codex app-server can complete a turn with items: [] and itemsView: notLoaded while the assistant text only arrived via item/completed. parseCompletedTurn treated any array turn.items as authoritative, so an empty array dropped the stream-collected messages and failed with PROVIDER_PROTOCOL_ERROR. Prefer non-empty turn.items; otherwise use the collected items. Regression covers the notLoaded empty-array path. Fixes #361 --- src/local-agent-codex.test.ts | 14 ++++++++++++++ src/local-agent-codex.ts | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index ee115e54d..1dcebc043 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -73,6 +73,12 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "completed", items: [] } } }); return; } + if (message.params.input[0].text === "notLoaded") { + const item = { type: "agentMessage", text: "CODEXOK" }; + output({ method: "item/completed", params: { threadId: message.params.threadId, turnId, item } }); + output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "completed", items: [], itemsView: "notLoaded" } } }); + return; + } const item = { type: "agentMessage", text: message.params.input[0].text === "policy" ? JSON.stringify(message.params.sandboxPolicy) : "fake response " + turn }; @@ -135,6 +141,14 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { assert.ok(protocolFailure.error.cause, "provider protocol cause remains available internally"); assert.equal("cause" in toAgentErrorPayload(protocolFailure.error), false); } + const notLoaded = await runtime.run({ + prompt: "notLoaded", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + }); + assert.equal(notLoaded.isOk(), true, "empty turn.items must fall back to item/completed stream items"); + if (notLoaded.isErr()) throw notLoaded.error; + assert.equal(notLoaded.value.finalResponse, "CODEXOK"); const policy = await runtime.run({ prompt: "policy", workspaceRoot: "/tmp/project", diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index fe3161ec8..937e44481 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -497,7 +497,8 @@ function parseCompletedTurn(params: unknown, items: unknown[]): { failure?: string; } { const turn = asRecord(asRecord(params)?.turn); - const completedItems = (Array.isArray(turn?.items) ? turn.items : items).slice(-MAX_TURN_ITEMS); + const turnItems = turn?.items; + const completedItems = (Array.isArray(turnItems) && turnItems.length > 0 ? turnItems : items).slice(-MAX_TURN_ITEMS); let finalResponse = ""; for (const item of completedItems) { const record = asRecord(item);