Files
openclaw/extensions/codex/src/app-server/event-projector.commentary.test.ts

684 lines
23 KiB
TypeScript

import {
describe,
registerCodexEventProjectorTestLifecycle,
expect,
it,
vi,
CodexAppServerEventProjector,
createCodexTestModel,
THREAD_ID,
TURN_ID,
createParams,
createProjector,
createProjectorWithAssistantHooks,
buildEmptyToolTelemetry,
forCurrentTurn,
agentMessageDelta,
turnCompleted,
type EmbeddedRunAttemptParams,
} from "./event-projector.test-harness.js";
registerCodexEventProjectorTestLifecycle();
describe("CodexAppServerEventProjector commentary projection", () => {
it("keeps intermediate agentMessage items out of the final visible reply", async () => {
const { onAssistantMessageStart, onPartialReply, projector } =
await createProjectorWithAssistantHooks();
await projector.handleNotification(
agentMessageDelta(
"checking thread context; then post a tight progress reply here.",
"msg-commentary",
),
);
await projector.handleNotification(
agentMessageDelta(
"release fixes first. please drop affected PRs, failing checks, and blockers here.",
"msg-final",
),
);
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-commentary",
text: "checking thread context; then post a tight progress reply here.",
},
{
type: "agentMessage",
id: "msg-final",
text: "release fixes first. please drop affected PRs, failing checks, and blockers here.",
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
// Phase-less snapshots stay on the replaceable agent-event path so legacy
// append-only channel previews do not render superseded coordination text.
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual([
"release fixes first. please drop affected PRs, failing checks, and blockers here.",
]);
expect(result.lastAssistant?.content).toEqual([
{
type: "text",
text: "release fixes first. please drop affected PRs, failing checks, and blockers here.",
},
]);
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("checking thread context");
});
it("preserves an empty final assistant item after tool activity", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-search",
tool: "memory_search",
arguments: { query: "scheduler" },
});
projector.recordDynamicToolResult({
callId: "call-search",
tool: "memory_search",
success: true,
sideEffectEvidence: false,
contentItems: [{ type: "inputText", text: "no matches" }],
});
await projector.handleNotification(
turnCompleted([
{ type: "agentMessage", id: "msg-before-tool", text: "Checking the scheduler now." },
{ type: "agentMessage", id: "msg-final", text: "" },
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["Checking the scheduler now."]);
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "" }]);
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it.each([
{ itemId: "msg_mock_1", text: "" },
{ itemId: "msg_mock_1", text: " \n " },
{ itemId: undefined, text: "" },
{ itemId: undefined, text: " \n " },
])(
"preserves an explicit raw empty stop ($itemId) after a settled write",
async ({ itemId, text }) => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
const priorAssistant = { type: "agentMessage", id: "msg-before-write", text: "" };
await projector.handleNotification(forCurrentTurn("item/started", { item: priorAssistant }));
await projector.handleNotification(
forCurrentTurn("item/completed", { item: priorAssistant }),
);
const item = {
type: "dynamicToolCall",
id: "call-write",
namespace: null,
tool: "write",
arguments: { path: "note.txt", content: "written once" },
status: "inProgress",
contentItems: null,
success: null,
durationMs: null,
};
await projector.handleNotification(forCurrentTurn("item/started", { item }));
projector.recordDynamicToolCall({
callId: item.id,
tool: item.tool,
arguments: item.arguments,
});
projector.recordDynamicToolResult({
callId: item.id,
tool: item.tool,
success: true,
sideEffectEvidence: true,
contentItems: [{ type: "inputText", text: "written once" }],
});
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
...item,
status: "completed",
contentItems: [{ type: "inputText", text: "written once" }],
success: true,
durationMs: 1,
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
...(itemId ? { id: itemId } : {}),
role: "assistant",
content: [{ type: "output_text", text }],
},
}),
);
await projector.handleNotification(turnCompleted([]));
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.currentAttemptAssistant).toMatchObject({
stopReason: "stop",
content: [{ type: "text", text: "" }],
});
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
expect(result.itemLifecycle).toEqual({ startedCount: 2, completedCount: 2, activeCount: 0 });
expect(result.messagesSnapshot.filter((message) => message.role === "assistant")).toEqual([
expect.objectContaining({ content: [expect.objectContaining({ type: "toolCall" })] }),
]);
expect(onAgentEvent.mock.calls.some(([event]) => event.stream === "assistant")).toBe(false);
},
);
it.each([
{ label: "missing content", content: [] },
{ label: "missing text", content: [{ type: "output_text" }] },
{ label: "non-text content", content: [{ type: "reasoning", text: "" }] },
{ label: "commentary", content: [{ type: "output_text", text: "" }], phase: "commentary" },
{ label: "active tool", content: [{ type: "output_text", text: "" }], active: true },
])("does not fabricate a terminal assistant for $label", async ({ content, phase, active }) => {
const projector = await createProjector();
if (active) {
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "commandExecution", id: "pending-tool", status: "inProgress" },
}),
);
}
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: { type: "message", role: "assistant", ...(phase ? { phase } : {}), content },
}),
);
await projector.handleNotification(turnCompleted([]));
expect(
projector.buildResult(buildEmptyToolTelemetry()).currentAttemptAssistant,
).toBeUndefined();
});
it("streams commentary agent messages as keyed progress events", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "",
},
}),
);
await projector.handleNotification(agentMessageDelta("Checking", "msg-commentary"));
await projector.handleNotification(
agentMessageDelta(" the app-server stream", "msg-commentary"),
);
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the app-server stream",
},
{
type: "agentMessage",
id: "msg-final",
phase: "final_answer",
text: "final answer",
},
]),
);
const progressEvents = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(onPartialReply).not.toHaveBeenCalled();
expect(progressEvents.map((event) => event.data)).toEqual([
{
itemId: "msg-commentary",
kind: "preamble",
title: "Preamble",
phase: "update",
progressText: "Checking",
source: "codex-app-server",
},
{
itemId: "msg-commentary",
kind: "preamble",
title: "Preamble",
phase: "update",
progressText: "Checking the app-server stream",
source: "codex-app-server",
},
]);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["final answer"]);
const commentary = result.messagesSnapshot.find(
(message) =>
(message as { openclawStreamFallback?: { itemId?: unknown } }).openclawStreamFallback
?.itemId === "msg-commentary",
);
expect(commentary).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "Checking the app-server stream" }],
openclawStreamFallback: {
replacementText: "Checking the app-server stream",
source: "segment",
itemId: "msg-commentary",
},
__openclaw: { mirrorIdentity: `${TURN_ID}:commentary:msg-commentary` },
});
expect((commentary as { phase?: unknown } | undefined)?.phase).toBeUndefined();
});
it("omits durable commentary when the operator explicitly disables persistence", async () => {
const params = await createParams();
params.config = { ui: { prefs: { chatPersistCommentary: false } } };
const projector = await createProjector(params);
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the workspace",
},
{ type: "agentMessage", id: "msg-final", phase: "final_answer", text: "Done" },
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["Done"]);
expect(
result.messagesSnapshot.some(
(message) =>
(message as { openclawStreamFallback?: { itemId?: unknown } }).openclawStreamFallback
?.itemId === "msg-commentary",
),
).toBe(false);
});
it("mirrors commentary and tool activity in event order when timestamps collide", async () => {
const projector = await createProjector();
vi.spyOn(Date, "now").mockReturnValue(100);
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-before-tool", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("Before the tool", "msg-before-tool"));
projector.recordDynamicToolCall({ callId: "call-search", tool: "memory_search" });
projector.recordDynamicToolResult({
callId: "call-search",
tool: "memory_search",
success: true,
contentItems: [{ type: "inputText", text: "found it" }],
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-after-tool", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("After the tool", "msg-after-tool"));
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-before-tool",
phase: "commentary",
text: "Before the tool",
},
{
type: "agentMessage",
id: "msg-after-tool",
phase: "commentary",
text: "After the tool",
},
{ type: "agentMessage", id: "msg-final", phase: "final_answer", text: "Done" },
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
const identities = result.messagesSnapshot.flatMap((message) => {
const identity = (message as { __openclaw?: { mirrorIdentity?: unknown } })["__openclaw"]
?.mirrorIdentity;
return typeof identity === "string" &&
(identity.includes(":commentary:") || identity.includes(":tool:"))
? [identity]
: [];
});
expect(identities).toEqual([
`${TURN_ID}:commentary:msg-before-tool`,
`${TURN_ID}:tool:call-search:call`,
`${TURN_ID}:tool:call-search:result`,
`${TURN_ID}:commentary:msg-after-tool`,
]);
});
it("does not double-deliver a commentary note echoed on the raw response lane", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
// Typed agentMessage lane streams the note, keyed by the thread item id.
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
agentMessageDelta("Checking the workspace", "msg-commentary"),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the workspace",
},
}),
);
// Raw response lane echoes the same note. Codex omits the message id on the
// wire (ResponseItem::Message.id is skip_serializing), so the projector
// synthesizes a `raw-assistant-*` id that never matches the thread item id.
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking the workspace" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual(["Checking the workspace"]);
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
});
it("delivers distinct same-text commentary notes from the same lane within a turn", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
// Two separate notes that happen to share text must each be delivered.
for (const id of ["msg-1", "msg-2"]) {
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id, phase: "commentary", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("Checking the workspace", id));
}
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.itemId)).toEqual(["msg-1", "msg-2"]);
expect(preambles.map((event) => event.data.progressText)).toEqual([
"Checking the workspace",
"Checking the workspace",
]);
});
it("delivers a later raw-only commentary note after consuming a same-text typed echo", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
const rawCommentary = () =>
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking the workspace" }],
},
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
agentMessageDelta("Checking the workspace", "msg-commentary"),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the workspace",
},
}),
);
await projector.handleNotification(rawCommentary());
await projector.handleNotification(rawCommentary());
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.itemId)).toEqual([
"msg-commentary",
"raw-assistant-2",
]);
});
it("pairs a raw commentary echo after a rewritten typed completion", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Contributor-rewritten note",
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Original model note" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual([
"Contributor-rewritten note",
]);
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
});
it("clears a pending commentary echo when the raw envelope has no text", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: " ",
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [],
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Later raw-only note" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual(["Later raw-only note"]);
});
it("does not resolve commentary-phase assistant text as the final reply", async () => {
const projector = await createProjector();
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-final",
phase: "final_answer",
text: "final answer",
},
{
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "I am checking one more thing.",
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["final answer"]);
});
it("ignores notifications for other turns", async () => {
const projector = await createProjector();
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: THREAD_ID, turnId: "turn-2", itemId: "msg-1", delta: "wrong" },
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toStrictEqual([]);
});
it("ignores notifications that omit top-level thread and turn ids", async () => {
const projector = await createProjector();
await projector.handleNotification({
method: "turn/completed",
params: {
turn: {
id: TURN_ID,
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "wrong turn" }],
},
},
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toStrictEqual([]);
expect(result.lastAssistant).toBeUndefined();
});
it("preserves accepted session spawns as yield continuation evidence", () => {
const projector = new CodexAppServerEventProjector(
{
prompt: "hello",
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
runId: "run-1",
provider: "openai",
modelId: "gpt-5.4-codex",
model: createCodexTestModel(),
thinkLevel: "medium",
} as EmbeddedRunAttemptParams,
THREAD_ID,
TURN_ID,
);
const result = projector.buildResult(
{
...buildEmptyToolTelemetry(),
acceptedSessionSpawns: [
{ runId: "child-run", childSessionKey: "agent:main:subagent:child" },
],
},
{ yieldDetected: true },
);
expect(result.yieldDetected).toBe(true);
expect(result.acceptedSessionSpawns).toEqual([
{ runId: "child-run", childSessionKey: "agent:main:subagent:child" },
]);
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
});