fix: 只用最终 agentMessage 生成 reply

This commit is contained in:
Codex
2026-06-01 23:56:49 +08:00
parent 8577e2fbdf
commit bae2e97139
4 changed files with 58 additions and 5 deletions
+2 -2
View File
@@ -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 可聚合时才输出 completedassistant 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` itemadapter 必须以最后一个非空 completed item 文本作为 reply authority,避免把 commentary/status/progress 堆入 final response。
- `tool_call`:工具调用摘要和 redacted 参数。
- `command_output`stdout/stderr 或命令输出摘要。
- `diff`:代码变更摘要或 patch 片段;必须受长度限制。
+33 -2
View File
@@ -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);
+11 -1
View File
@@ -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<void>((resolve) => server.server.close(() => resolve()));
}
+12
View File
@@ -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 });