Merge pull request #314 from pikasTech/fix/219-assistant-final-seal
Pipelines as Code CI / agentrun-nc01-v02-ci-f95f83789273ee6e6c869dc08357ab53770c51a7 Success

修复 assistant final 重复与历史通知重放
This commit is contained in:
Lyon
2026-07-12 14:31:09 +08:00
committed by GitHub
6 changed files with 300 additions and 12 deletions
+31 -10
View File
@@ -130,7 +130,7 @@ interface FinalAssistantMessage {
text: string;
messageIndex: number | null;
messageCount: number | null;
source: string;
targetSource: string;
}
interface AssistantDeltaProgressItem {
@@ -157,6 +157,7 @@ export class CodexStdioEventReducer {
private assistantText = "";
private readonly assistantDeltaProgress = createAssistantDeltaProgressState();
private readonly completedAssistantMessages: CompletedAssistantMessage[] = [];
private readonly completedAssistantItemIds = new Set<string>();
private readonly deferredTerminalEvents: BackendEvent[] = [];
private readonly suppressedNotifications = createSuppressedNotificationSummary();
@@ -169,8 +170,14 @@ export class CodexStdioEventReducer {
if (progress) events.push(progress);
}
if (normalized.completedAssistantMessage) {
this.completedAssistantMessages.push(normalized.completedAssistantMessage);
events.push(assistantMessageEventForCompleted(normalized.completedAssistantMessage, this.completedAssistantMessages.length));
const itemId = normalized.completedAssistantMessage.itemId;
if (itemId && this.completedAssistantItemIds.has(itemId)) {
recordSuppressedNotification(this.suppressedNotifications, "item/completed:duplicate-agentMessage", "agentMessage");
} else {
if (itemId) this.completedAssistantItemIds.add(itemId);
this.completedAssistantMessages.push(normalized.completedAssistantMessage);
events.push(assistantMessageEventForCompleted(normalized.completedAssistantMessage, this.completedAssistantMessages.length));
}
}
if (normalized.terminalEvents) this.deferredTerminalEvents.push(...normalized.terminalEvents);
return {
@@ -183,9 +190,9 @@ export class CodexStdioEventReducer {
finalize(terminal: { status: TerminalStatus; failureKind: FailureKind | null; message: string | null } | null): BackendEvent[] {
const events = flushAssistantDeltaProgress(this.assistantDeltaProgress);
if (this.completedAssistantMessages.length === 0) events.push(...assistantMessageEventsForTurn(this.assistantText, false));
const finalAssistant = terminal?.status === "completed" ? finalAssistantMessageForTurn(this.completedAssistantMessages, this.assistantText) : null;
if (finalAssistant) events.push(assistantFinalResponseEvent(finalAssistant));
else if (this.completedAssistantMessages.length === 0) events.push(...assistantMessageEventsForTurn(this.assistantText, false));
if (finalAssistant) events.push(assistantFinalSealEvent(finalAssistant));
events.push(...suppressedNotificationEvents(this.suppressedNotifications));
events.push(...this.deferredTerminalEvents);
if (terminal) events.push({ type: "terminal_status", payload: { terminalStatus: terminal.status, failureKind: terminal.failureKind, message: terminal.message } });
@@ -1297,6 +1304,7 @@ function assistantMessageEventForCompleted(message: CompletedAssistantMessage, m
messageCount: null,
replyAuthority: false,
final: false,
durableText: true,
},
};
}
@@ -1313,6 +1321,7 @@ function assistantMessageEventsForTurn(assistantDeltaText: string, completed: bo
messageCount: 1,
replyAuthority: completed,
final: completed,
durableText: true,
},
}];
}
@@ -1325,7 +1334,7 @@ function finalAssistantMessageForTurn(completedMessages: CompletedAssistantMessa
text: latestCompleted.text,
messageIndex: completedMessages.length,
messageCount: completedMessages.length,
source: "completed-agent-message-final",
targetSource: "completed-agent-message",
};
}
if (assistantDeltaText.trim().length === 0) return null;
@@ -1334,25 +1343,37 @@ function finalAssistantMessageForTurn(completedMessages: CompletedAssistantMessa
text: assistantDeltaText,
messageIndex: 1,
messageCount: 1,
source: "agent-message-delta-final",
targetSource: "agent-message-delta-fallback",
};
}
function assistantFinalResponseEvent(message: FinalAssistantMessage): BackendEvent {
function assistantFinalSealEvent(message: FinalAssistantMessage): BackendEvent {
return {
type: "assistant_message",
payload: {
text: message.text,
itemId: message.itemId,
source: message.source,
source: "assistant-message-final-seal",
messageIndex: message.messageIndex,
messageCount: message.messageCount,
replyAuthority: true,
final: true,
finalSeal: true,
replyRef: {
eventType: "assistant_message",
itemId: message.itemId,
messageIndex: message.messageIndex,
messageCount: message.messageCount,
source: message.targetSource,
textSha256: sha256Text(redactText(message.text)),
},
},
};
}
function sha256Text(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function createAssistantDeltaProgressState(): AssistantDeltaProgressState {
return new Map();
}
+30
View File
@@ -123,6 +123,8 @@ function nonEmptyText(value: unknown): string | null {
function normalizeTextPayload(payload: JsonRecord): JsonRecord {
const { text: _text, delta: _delta, content: _content, summary: _summary, textBytes: _textBytes, textTruncated: _textTruncated, outputBytes: _outputBytes, outputTruncated: _outputTruncated, ...rest } = payload;
const value = typeof payload.text === "string" ? payload.text : typeof payload.delta === "string" ? payload.delta : typeof payload.content === "string" ? payload.content : "";
if (payload.finalSeal === true) return normalizeAssistantFinalSeal(rest, value);
if (payload.durableText === true) return normalizeDurableAssistantText(rest, value);
if (payload.replyAuthority === true || payload.final === true) {
const text = redactText(value);
const outputBytes = Buffer.byteLength(text, "utf8");
@@ -133,6 +135,34 @@ function normalizeTextPayload(payload: JsonRecord): JsonRecord {
return { ...rest, text: summary.text, summary, textBytes: summary.textBytes, textTruncated: summary.textTruncated, outputBytes: summary.outputBytes, outputTruncated: summary.outputTruncated };
}
function normalizeDurableAssistantText(payload: JsonRecord, body: string): JsonRecord {
const source = typeof payload.source === "string" ? payload.source : "";
if (source !== "completed-agent-message" && source !== "agent-message-delta-fallback") {
throw new AgentRunError("schema-invalid", "durable assistant text requires a completed-agent-message or agent-message-delta-fallback source", { httpStatus: 400 });
}
const text = redactText(body);
const textBytes = Buffer.byteLength(text, "utf8");
return {
...payload,
text,
summary: { textChars: text.length, textBytes, outputBytes: textBytes, textTruncated: false, outputTruncated: false },
textBytes,
textTruncated: false,
outputBytes: textBytes,
outputTruncated: false,
};
}
function normalizeAssistantFinalSeal(payload: JsonRecord, body: string): JsonRecord {
const replyRef = typeof payload.replyRef === "object" && payload.replyRef !== null && !Array.isArray(payload.replyRef) ? payload.replyRef as JsonRecord : null;
const textSha256 = replyRef && typeof replyRef.textSha256 === "string" ? replyRef.textSha256 : "";
if (body.length > 0) throw new AgentRunError("schema-invalid", "assistant final seal must not carry text, content, or delta", { httpStatus: 400 });
if (payload.replyAuthority !== true || payload.final !== true || replyRef?.eventType !== "assistant_message" || typeof replyRef.source !== "string" || !/^[0-9a-f]{64}$/u.test(textSha256)) {
throw new AgentRunError("schema-invalid", "assistant final seal requires final replyAuthority and an assistant_message replyRef with source and textSha256", { httpStatus: 400 });
}
return { ...payload, replyRef: redactJson(replyRef), textBytes: 0, textTruncated: false, outputBytes: 0, outputTruncated: false };
}
function normalizeToolCallPayload(payload: JsonRecord): JsonRecord {
const redacted = redactJson(payload);
if (isCommandExecutionToolCall(redacted)) {
+35
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import type { AgentRunStore } from "./store.js";
import type { CommandRecord, FailureKind, JsonRecord, JsonValue, RunEvent, RunRecord, RunnerJobRecord, TerminalStatus } from "../common/types.js";
import { boundedTextSummary, outputBytesFromPayload, outputTruncatedFromPayload } from "../common/output.js";
@@ -794,6 +795,8 @@ function resultFailureDetails(events: RunEvent[], terminal: TerminalStatus | nul
function assistantReply(events: RunEvent[]): AssistantReplySummary {
const assistantEvents = events.filter((event) => event.type === "assistant_message");
const sealedReply = assistantReplyFromFinalSeal(assistantEvents);
if (sealedReply) return sealedReply;
const latestAuthoritative = [...assistantEvents].reverse().find((event) => (event.payload.replyAuthority === true || event.payload.final === true) && textPayload(event.payload).length > 0);
if (latestAuthoritative) return assistantReplySummary(latestAuthoritative);
const latestAssistant = [...assistantEvents].reverse().find((event) => textPayload(event.payload).length > 0);
@@ -801,6 +804,34 @@ function assistantReply(events: RunEvent[]): AssistantReplySummary {
return { text: "", seq: null, source: null, final: false, replyAuthority: false, textTruncated: false, outputTruncated: false };
}
function assistantReplyFromFinalSeal(events: RunEvent[]): AssistantReplySummary | null {
const seals = [...events].reverse().filter((event) => event.payload.finalSeal === true && event.payload.replyAuthority === true);
for (const seal of seals) {
const replyRef = jsonRecordValue(seal.payload.replyRef);
if (!replyRef || replyRef.eventType !== "assistant_message") continue;
const target = [...events].reverse().find((event) => event.seq < seal.seq && assistantReplyRefMatches(event, replyRef));
if (!target) continue;
const targetSummary = assistantReplySummary(target);
return {
...targetSummary,
seq: seal.seq,
source: typeof seal.payload.source === "string" ? seal.payload.source : "assistant-message-final-seal",
final: true,
replyAuthority: true,
};
}
return null;
}
function assistantReplyRefMatches(event: RunEvent, replyRef: JsonRecord): boolean {
if (event.payload.finalSeal === true || textPayload(event.payload).length === 0) return false;
if (stringJsonValue(event.payload.source) !== stringJsonValue(replyRef.source)) return false;
if ((stringJsonValue(event.payload.itemId) ?? null) !== (stringJsonValue(replyRef.itemId) ?? null)) return false;
if ((numberJsonValue(event.payload.messageIndex) ?? null) !== (numberJsonValue(replyRef.messageIndex) ?? null)) return false;
const textSha256 = stringJsonValue(replyRef.textSha256);
return textSha256 !== null && textSha256 === sha256Text(textPayload(event.payload));
}
function assistantReplySummary(event: RunEvent): AssistantReplySummary {
return {
text: textPayload(event.payload),
@@ -821,6 +852,10 @@ function textPayload(payload: JsonRecord): string {
return "";
}
function sha256Text(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function artifactSummary(events: RunEvent[]): JsonRecord {
let commandOutputEvents = 0;
let diffEvents = 0;
@@ -7,6 +7,7 @@ import {
closeAgentRunKafkaProducer,
publishAgentRunKafkaMessage,
setAgentRunKafkaProducerFactoryForSelfTest,
sha256Hex,
type AgentRunKafkaConfig,
} from "../../common/kafka-events.js";
import type { JsonRecord } from "../../common/types.js";
@@ -53,12 +54,23 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal((record.value.reconstruction as JsonRecord).originalOutboxSeq, null);
assert.equal(((record.value.reconstruction as JsonRecord).identityDerivation as JsonRecord).reversible, true);
}
const finalAssistant = reconstructed.records.find((record) => {
const finalSeal = reconstructed.records.find((record) => {
const event = record.value.event as JsonRecord;
const payload = event.payload as JsonRecord;
return event.type === "assistant_message" && payload.replyAuthority === true;
});
assert.equal(((finalAssistant?.value.event as JsonRecord).payload as JsonRecord).text, "stdio reconstruction fixture answer");
const finalSealPayload = (finalSeal?.value.event as JsonRecord).payload as JsonRecord;
assert.equal(finalSealPayload.finalSeal, true);
assert.equal(Object.hasOwn(finalSealPayload, "text"), false);
const replyRef = finalSealPayload.replyRef as JsonRecord;
const finalAssistant = reconstructed.records.find((record) => {
const event = record.value.event as JsonRecord;
const payload = event.payload as JsonRecord;
return event.type === "assistant_message" && payload.itemId === replyRef.itemId && payload.source === replyRef.source;
});
const finalAssistantText = String(((finalAssistant?.value.event as JsonRecord).payload as JsonRecord).text ?? "");
assert.equal(finalAssistantText, "stdio reconstruction fixture answer");
assert.equal(replyRef.textSha256, sha256Hex(finalAssistantText));
assert.equal((reconstructed.records.at(-1)?.value.event as JsonRecord).type, "terminal_status");
let readGroupId = "";
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import type { JsonRecord } from "../../common/types.js";
import { ManagerClient } from "../../mgr/client.js";
import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import { runOnce } from "../../runner/run-once.js";
import { createRunWithCommand, type SelfTestCase, type SelfTestContext } from "../harness.js";
interface EventRecord {
seq: number;
type: string;
payload: JsonRecord;
}
const selfTest: SelfTestCase = async (context) => {
const server = await startManagerServer({
port: 0,
host: "127.0.0.1",
sourceCommit: "self-test",
store: new MemoryAgentRunStore(),
runnerJobDefaults: {
namespace: "agentrun-v02",
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
runnerApiKeySecretRef: { name: "agentrun-v02-api-key", key: "HWLAB_API_KEY" },
image: "registry.invalid/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111",
serviceAccountName: "agentrun-v02-runner",
jobNamePrefix: "agentrun-v02-runner",
lane: "v0.2",
},
});
try {
const client = new ManagerClient(server.baseUrl);
await assertHistoryReplaySealedOnce(client, context, server.baseUrl);
await assertDeltaFallbackUsesBodylessSeal(client, context, server.baseUrl);
return {
name: "assistant-final-seal",
tests: [
"assistant-final-seal-history-replay",
"assistant-final-seal-distinct-identical-text",
"assistant-final-seal-late-final",
"assistant-final-seal-manager-result-pointer",
"assistant-final-seal-delta-fallback",
],
};
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
};
async function assertHistoryReplaySealedOnce(client: ManagerClient, context: SelfTestContext, managerUrl: string): Promise<void> {
const lateFinalText = `Late unique final answer:${"x".repeat(5_000)} Authorization: Bearer REDACTED`;
const rawLateFinalSecret = "final-secret-token-value";
const command = await createRunWithCommand(client, context, "history replay final seal", "selftest-assistant-history-replay-final-seal", 15_000);
const run = await runOnce({
managerUrl,
runId: command.runId,
codexCommand: context.fakeCodexCommand,
codexArgs: context.fakeCodexArgs,
codexHome: context.codexHome,
env: {
CODEX_HOME: context.codexHome,
AGENTRUN_FAKE_CODEX_MODE: "assistant-history-replay-final-seal",
AGENTRUN_CODEX_TERMINAL_NOTIFICATION_DRAIN_MS: "200",
},
oneShot: true,
}) as JsonRecord;
assert.equal(run.terminalStatus, "completed");
const events = await listEvents(client, command.runId);
const completedMessages = events.filter((event) => event.type === "assistant_message" && event.payload.source === "completed-agent-message");
assert.deepEqual(completedMessages.map((event) => event.payload.itemId), [
"msg_replay_progress",
"msg_same_text_a",
"msg_same_text_b",
"msg_late_final_seal",
]);
assert.equal(completedMessages.filter((event) => event.payload.text === "Legitimate identical assistant text.").length, 2, "different itemId values with identical text must both remain durable");
for (const itemId of ["msg_replay_progress", "msg_same_text_a", "msg_same_text_b", "msg_late_final_seal"]) {
assert.equal(completedMessages.filter((event) => event.payload.itemId === itemId).length, 1, `item ${itemId} must be durable exactly once`);
}
const lateFinal = completedMessages.find((event) => event.payload.itemId === "msg_late_final_seal");
assert.equal(lateFinal?.payload.text, lateFinalText, "the unique durable message body must remain untruncated for seal dereferencing");
assert.equal(lateFinal?.payload.outputTruncated, false);
assert.equal(JSON.stringify(events).includes(rawLateFinalSecret), false, "secret-like final text must be redacted before becoming durable");
const seals = events.filter((event) => event.type === "assistant_message" && event.payload.finalSeal === true);
assert.equal(seals.length, 1, "one completed turn must have exactly one final seal");
const seal = seals[0]!;
assert.equal(Object.hasOwn(seal.payload, "text"), false, "final seal must not duplicate the assistant body");
assert.equal(Object.hasOwn(seal.payload, "content"), false, "final seal must not duplicate the assistant body through content");
assert.equal(Object.hasOwn(seal.payload, "delta"), false, "final seal must not duplicate the assistant body through delta");
assert.equal(seal.payload.source, "assistant-message-final-seal");
assert.equal(seal.payload.replyAuthority, true);
assert.equal(seal.payload.final, true);
const replyRef = asRecord(seal.payload.replyRef);
assert.equal(replyRef.itemId, "msg_late_final_seal");
assert.equal(replyRef.source, "completed-agent-message");
assert.match(String(replyRef.textSha256), /^[0-9a-f]{64}$/u);
const suppression = events.find((event) => event.type === "backend_status" && event.payload.phase === "codex-app-server-notifications-suppressed");
assert.ok(suppression, "history replay suppression must remain visible without persisting duplicate bodies");
const replayMethod = asRecords(suppression?.payload.methods).find((item) => item.method === "item/completed:duplicate-agentMessage");
assert.equal(replayMethod?.count, 5);
const toolEvents = events.filter((event) => event.type === "tool_call" && event.payload.itemId === "tool_before_history_replay");
assert.equal(toolEvents.length, 2, "assistant replay suppression must neither replay nor drop the tool start/completion pair");
assert.deepEqual(toolEvents.map((event) => event.payload.method), ["item/started", "item/completed"]);
const turnCompleted = events.find((event) => event.type === "backend_status" && event.payload.phase === "turn/completed");
assert.ok(turnCompleted && seal.seq < turnCompleted.seq, "bodyless final seal must precede the deferred turn terminal event");
const result = await client.get(`/api/v1/runs/${command.runId}/commands/${command.commandId}/result`) as JsonRecord;
assert.equal(result.reply, lateFinalText, "Manager result must dereference the untruncated final seal target instead of reading duplicate final text");
assert.equal(JSON.stringify(result).includes(rawLateFinalSecret), false, "Manager result must not re-expose the redacted secret-like source text");
assert.equal(result.finalResponseAuthority, "authoritative");
assert.equal(result.finalAssistantSeq, seal.seq);
const finalResponse = asRecord(result.finalResponse);
assert.equal(finalResponse.source, "assistant-message-final-seal");
assert.equal(finalResponse.replyAuthority, true);
assert.equal(finalResponse.final, true);
}
async function assertDeltaFallbackUsesBodylessSeal(client: ManagerClient, context: SelfTestContext, managerUrl: string): Promise<void> {
const command = await createRunWithCommand(client, context, "delta fallback final seal", "selftest-assistant-delta-fallback-final-seal", 15_000);
const run = await runOnce({
managerUrl,
runId: command.runId,
codexCommand: context.fakeCodexCommand,
codexArgs: context.fakeCodexArgs,
codexHome: context.codexHome,
env: { CODEX_HOME: context.codexHome },
oneShot: true,
}) as JsonRecord;
assert.equal(run.terminalStatus, "completed");
const events = await listEvents(client, command.runId);
const fallback = events.filter((event) => event.type === "assistant_message" && event.payload.source === "agent-message-delta-fallback");
assert.equal(fallback.length, 1, "delta-only turns must persist one full fallback body before sealing it");
assert.equal(fallback[0]?.payload.text, "fake codex stdio reply");
const seal = events.find((event) => event.type === "assistant_message" && event.payload.finalSeal === true);
assert.ok(seal);
assert.equal(Object.hasOwn(seal.payload, "text"), false);
assert.equal(asRecord(seal.payload.replyRef).source, "agent-message-delta-fallback");
const result = await client.get(`/api/v1/runs/${command.runId}/commands/${command.commandId}/result`) as JsonRecord;
assert.equal(result.reply, "fake codex stdio reply");
assert.equal(result.finalResponseAuthority, "authoritative");
assert.equal(result.finalAssistantSeq, seal.seq);
}
async function listEvents(client: ManagerClient, runId: string): Promise<EventRecord[]> {
const response = await client.get(`/api/v1/runs/${runId}/events?afterSeq=0&limit=200`) as { items?: EventRecord[] };
return response.items ?? [];
}
function asRecord(value: unknown): JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {};
}
function asRecords(value: unknown): JsonRecord[] {
return Array.isArray(value) ? value.map(asRecord) : [];
}
export default selfTest;
+26
View File
@@ -239,6 +239,32 @@ for await (const line of rl) {
}, 450).unref?.();
continue;
}
if (mode === "assistant-history-replay-final-seal") {
turnCounter += 1;
const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" };
const lateFinalText = `Late unique final answer:${"x".repeat(5_000)} Authorization: Bearer final-secret-token-value`;
respond(message.id, { turn });
notify("turn/started", { turn });
notify("item/started", { item: { id: "tool_before_history_replay", type: "commandExecution", command: "echo durable-once", status: "running" } });
notify("item/completed", { item: { id: "tool_before_history_replay", type: "commandExecution", command: "echo durable-once", status: "completed", exitCode: 0 } });
notify("item/completed", { item: { id: "msg_replay_progress", type: "agentMessage", text: "Progress before history replay." } });
notify("item/completed", { item: { id: "msg_same_text_a", type: "agentMessage", text: "Legitimate identical assistant text." } });
notify("item/completed", { item: { id: "msg_same_text_b", type: "agentMessage", text: "Legitimate identical assistant text." } });
notify("item/completed", { item: { id: "msg_replay_progress", type: "agentMessage", text: "Progress before history replay." } });
notify("turn/completed", { turn });
setTimeout(() => {
notify("item/completed", { item: { id: "msg_replay_progress", type: "agentMessage", text: "Progress before history replay." } });
notify("item/completed", { item: { id: "msg_same_text_a", type: "agentMessage", text: "Legitimate identical assistant text." } });
notify("item/completed", { item: { id: "msg_same_text_b", type: "agentMessage", text: "Legitimate identical assistant text." } });
}, 20).unref?.();
setTimeout(() => {
notify("item/completed", { item: { id: "msg_late_final_seal", type: "agentMessage", text: lateFinalText } });
}, 40).unref?.();
setTimeout(() => {
notify("item/completed", { item: { id: "msg_late_final_seal", type: "agentMessage", text: lateFinalText } });
}, 60).unref?.();
continue;
}
if (mode === "web-search-progress") {
turnCounter += 1;
const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" };