From bae2e971391c284cf55cc4a16d30b9786bcb9504 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 23:56:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8F=AA=E7=94=A8=E6=9C=80=E7=BB=88=20a?= =?UTF-8?q?gentMessage=20=E7=94=9F=E6=88=90=20reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v01-backend-adapter.md | 4 +-- src/backend/codex-stdio.ts | 35 ++++++++++++++++++++-- src/selftest/cases/30-codex-stdio.ts | 12 +++++++- src/selftest/fake-codex-app-server.ts | 12 ++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/reference/spec-v01-backend-adapter.md b/docs/reference/spec-v01-backend-adapter.md index 8f43819..f01ed33 100644 --- a/docs/reference/spec-v01-backend-adapter.md +++ b/docs/reference/spec-v01-backend-adapter.md @@ -38,7 +38,7 @@ Backend adapter 的第一阶段实现应吸收 HWLAB v0.2 已验证的 Codex std | --- | --- | --- | | Codex app-server JSON-RPC stdio | `internal/cloud/codex-stdio-session.ts`、`internal/cloud/codex-stdio-session-turn-state.ts` | 支持 `initialize`、`thread/start`、`thread/resume`、`turn/start`,并处理 app-server client request;未知请求要记录 unsupported error,不能静默等待。 | | completed 判定 | `docs/reference/code-agent-chat-readiness.md` | 只有 Codex turn terminal completed 且 assistant reply 可聚合时才输出 completed;assistant delta、item completed、stdout 或 transport close 不能单独完成。 | -| assistant stream 和 trace | `internal/cloud/code-agent-trace-store.ts` | assistant delta 可归并为 `assistant_message`;event 必须保留 `threadId`、`turnId`、session 摘要和 redacted backend metadata。 | +| assistant stream 和 trace | `internal/cloud/code-agent-trace-store.ts`、`internal/cloud/codex-stdio-session-turn-state.ts` | assistant delta 只能作为 stream/progress 证据;最终 `assistant_message` 必须优先来自最后一个 completed `agentMessage` item,不能把 commentary/progress delta 与 final response 直接串接。event 必须保留 `threadId`、`turnId`、session 摘要和 redacted backend metadata。 | | command/tool output bounded | `docs/reference/code-agent-chat-readiness.md`、`web/hwlab-cloud-web/app-trace.ts` | `tool_call` 和 `command_output` 必须记录状态、摘要、字节数、截断标记;完整大输出只能通过后续 log/artifact 引用。 | | provider/profile 隔离 | `internal/cloud/code-agent-contract.ts` | `codex` 与 `deepseek` 共享同一 backend kind,但必须使用 profile-scoped SecretRef、model/base-url/config 和 writable runtime home。 | | Secret redaction | `internal/cloud/code-agent-trace-store.ts` | `OPENAI_API_KEY`、auth/config、token、password、kubeconfig、URL credential 不得进入 event、result、log 或 health。 | @@ -59,7 +59,7 @@ Registry 只表达能力和选择边界,不读取 Secret 值。Manager 负责 Adapter 输出给 runner 的 event 类型至少包括: - `backend_status`:backend 启动、模型/profile、能力和阶段状态,不包含 Secret 值。 -- `assistant_message`:模型回复的用户可见文本。 +- `assistant_message`:模型回复的最终用户可见文本。Codex app-server 的 `item/agentMessage/delta` 只能作为流式过程证据或缺少 completed item 时的兜底;一旦收到 completed `agentMessage` item,adapter 必须以最后一个非空 completed item 文本作为 reply authority,避免把 commentary/status/progress 堆入 final response。 - `tool_call`:工具调用摘要和 redacted 参数。 - `command_output`:stdout/stderr 或命令输出摘要。 - `diff`:代码变更摘要或 patch 片段;必须受长度限制。 diff --git a/src/backend/codex-stdio.ts b/src/backend/codex-stdio.ts index a2ec0d3..def3ef3 100644 --- a/src/backend/codex-stdio.ts +++ b/src/backend/codex-stdio.ts @@ -282,6 +282,7 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise return { terminalStatus: cancelled.status, failureKind: cancelled.failureKind, failureMessage: cancelled.message, events: events.map((event) => ({ ...event, payload: redactJson(event.payload) })) }; } let assistantText = ""; + let finalAssistantText = ""; let threadId: string | undefined = options.threadId; let turnId: string | undefined; let terminal: { status: TerminalStatus; failureKind: FailureKind | null; message: string | null } | null = null; @@ -313,6 +314,7 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise if (normalized.threadId) threadId = normalized.threadId; if (normalized.turnId) turnId = normalized.turnId; if (normalized.assistantDelta) assistantText += normalized.assistantDelta; + if (typeof normalized.assistantFinal === "string" && normalized.assistantFinal.trim().length > 0) finalAssistantText = normalized.assistantFinal; events.push(...normalized.events); if (normalized.terminal && !terminal) { terminal = normalized.terminal; @@ -365,7 +367,8 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise } } if (!terminal) terminal = { status: "failed", failureKind: "backend-response-invalid", message: "codex app-server finished without terminal status" }; - if (assistantText.trim().length > 0) events.push({ type: "assistant_message", payload: { text: assistantText } }); + const reply = finalAssistantText.trim().length > 0 ? finalAssistantText : assistantText; + if (reply.trim().length > 0) events.push({ type: "assistant_message", payload: { text: reply } }); events.push({ type: "terminal_status", payload: { terminalStatus: terminal.status, failureKind: terminal.failureKind, message: terminal.message } }); return { terminalStatus: terminal.status, failureKind: terminal.failureKind, failureMessage: terminal.message, events: events.map((event) => ({ ...event, payload: redactJson(event.payload) })), ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) }; } @@ -426,7 +429,7 @@ function codexHomeReadiness(codexHome: string): BackendTurnResult | null { }; } -function normalizeCodexNotification(message: JsonRecord): { events: BackendEvent[]; assistantDelta?: string; threadId?: string; turnId?: string; terminal?: { status: TerminalStatus; failureKind: FailureKind | null; message: string | null } } { +function normalizeCodexNotification(message: JsonRecord): { events: BackendEvent[]; assistantDelta?: string; assistantFinal?: string; threadId?: string; turnId?: string; terminal?: { status: TerminalStatus; failureKind: FailureKind | null; message: string | null } } { const method = typeof message.method === "string" ? message.method : "unknown"; const params = asRecordAt(message, "params"); if (method === "thread/started") { @@ -439,6 +442,15 @@ function normalizeCodexNotification(message: JsonRecord): { events: BackendEvent } if (method === "item/agentMessage/delta") return { events: [], assistantDelta: typeof params.delta === "string" ? params.delta : "" }; if (method === "item/commandExecution/outputDelta") return { events: [{ type: "command_output", payload: commandOutputPayload("stdout", typeof params.delta === "string" ? params.delta : "") }] }; + if ((method === "item/started" || method === "item/completed") && asRecordAt(params, "item").type === "agentMessage") { + const item = asRecordAt(params, "item"); + const itemId = stringAt(item, "id") ?? stringAt(params, "itemId"); + const text = method === "item/completed" ? agentMessageText(item) : ""; + return { + events: [{ type: "backend_status", payload: { phase: method === "item/completed" ? "item/agentMessage:completed" : "item/agentMessage:started", itemId, textBytes: Buffer.byteLength(text, "utf8") } }], + ...(text.trim().length > 0 ? { assistantFinal: text } : {}), + }; + } if (method === "item/started" || method === "item/completed") return { events: [{ type: "tool_call", payload: toolCallPayload(method, asRecordAt(params, "item")) }] }; if (method === "error") { const error = asRecordAt(params, "error"); @@ -518,6 +530,25 @@ function textInput(text: string): JsonValue[] { return [{ type: "text", text, text_elements: [] }]; } +function agentMessageText(item: JsonRecord): string { + for (const key of ["text", "content", "message"]) { + const value = item[key]; + if (typeof value === "string") return value; + } + for (const key of ["text_elements", "content"]) { + const value = item[key]; + if (!Array.isArray(value)) continue; + const parts = value.flatMap((entry) => { + if (typeof entry === "string") return [entry]; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return []; + const record = entry as JsonRecord; + return typeof record.text === "string" ? [record.text] : []; + }); + if (parts.length > 0) return parts.join(""); + } + return ""; +} + function fileReadable(filePath: string): JsonRecord { try { accessSync(filePath, fsConstants.R_OK); diff --git a/src/selftest/cases/30-codex-stdio.ts b/src/selftest/cases/30-codex-stdio.ts index e5056cf..5a1be40 100644 --- a/src/selftest/cases/30-codex-stdio.ts +++ b/src/selftest/cases/30-codex-stdio.ts @@ -58,6 +58,16 @@ const selfTest: SelfTestCase = async (context) => { const explicitModelResult = await runOnce({ managerUrl: server.baseUrl, runId: explicitModel.runId, commandId: explicitCommand.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model" }, oneShot: true }); assert.equal(explicitModelResult.terminalStatus, "completed", "explicit command payload model should still be forwarded"); + const finalMessage = await createRunWithCommand(client, context, "hello final message", "selftest-final-agent-message", 15_000); + const finalMessageResult = await runOnce({ managerUrl: server.baseUrl, runId: finalMessage.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "multi-agent-message-final" }, oneShot: true }); + assert.equal(finalMessageResult.terminalStatus, "completed", "multi agentMessage run should complete"); + const finalMessageEnvelope = await client.get(`/api/v1/runs/${finalMessage.runId}/commands/${finalMessage.commandId}/result`) as JsonRecord; + assert.equal(finalMessageEnvelope.reply, "Final answer only.", "result reply should use the final completed agentMessage instead of concatenating progress deltas"); + const finalMessageEvents = await client.get(`/api/v1/runs/${finalMessage.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: unknown }> }; + const assistantEvents = finalMessageEvents.items?.filter((event) => event.type === "assistant_message") ?? []; + assert.equal(assistantEvents.length, 1, "backend should emit one final assistant_message event"); + assert.equal(eventPayload(assistantEvents[0] ?? { payload: {} }).text, "Final answer only."); + await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "missing-turn-result", expectedStatus: "failed", expectedFailureKind: "backend-response-invalid" }); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "provider-401-rpc-error", expectedStatus: "failed", expectedFailureKind: "provider-auth-failed" }); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "provider-429-terminal", expectedStatus: "failed", expectedFailureKind: "provider-rate-limited" }); @@ -69,7 +79,7 @@ const selfTest: SelfTestCase = async (context) => { await runSecretFailureCase({ client, managerUrl: server.baseUrl, context }); await runSpawnFailureCase({ client, managerUrl: server.baseUrl, context }); - return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] }; + return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-final-agent-message-only", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 985079e..14c142d 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -116,6 +116,18 @@ for await (const line of rl) { respond(message.id, { turn }); continue; } + if (mode === "multi-agent-message-final") { + turnCounter += 1; + const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" }; + notify("turn/started", { turn }); + notify("item/agentMessage/delta", { itemId: "msg_progress", delta: "I am checking the workspace. " }); + notify("item/completed", { item: { id: "msg_progress", type: "agentMessage", text: "I am checking the workspace." } }); + notify("item/agentMessage/delta", { itemId: "msg_final", delta: "Final answer only." }); + notify("item/completed", { item: { id: "msg_final", type: "agentMessage", text: "Final answer only." } }); + notify("turn/completed", { turn }); + respond(message.id, { turn }); + continue; + } turnCounter += 1; const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" }; notify("turn/started", { turn });