Files
openclaw/extensions/codex/src/app-server/transcript-mirror.test.ts
Peter Steinberger bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00

705 lines
24 KiB
TypeScript

import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import {
castAgentMessage,
makeAgentAssistantMessage,
makeAgentUserMessage,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
attachCodexMirrorIdentity,
buildCodexUserPromptMessage,
mirrorCodexAppServerTranscript,
} from "./transcript-mirror.js";
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
return {
...actual,
emitSessionTranscriptUpdate: emitSessionTranscriptUpdateMock,
};
});
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
// Mirrors transcript-mirror.ts's fallback fingerprint exactly so test
// expectations stay in sync without exposing the helper publicly.
function expectedFingerprint(message: MirroredAgentMessage): string {
const payload = JSON.stringify({ role: message.role, content: message.content });
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
const tempDirs: string[] = [];
afterEach(async () => {
resetGlobalHookRunner();
emitSessionTranscriptUpdateMock.mockReset();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
async function createTempSessionFile() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-transcript-"));
tempDirs.push(dir);
return path.join(dir, "session.jsonl");
}
async function makeRoot(prefix: string): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(root);
return root;
}
describe("buildCodexUserPromptMessage", () => {
it("uses the prepared user transcript message for app-server prompt mirrors", () => {
const message = buildCodexUserPromptMessage({
prompt: "[Mon 2026-05-25 19:14 GMT+1] What is in this image?",
messageChannel: "webchat",
userTurnTranscriptRecorder: {
message: {
role: "user",
content: "What is in this image?",
timestamp: 1779732875151,
MediaPath: "/tmp/image.png",
MediaPaths: ["/tmp/image.png"],
MediaType: "image/png",
MediaTypes: ["image/png"],
},
},
} as unknown as Parameters<typeof buildCodexUserPromptMessage>[0]);
expect(message).toMatchObject({
role: "user",
content: "What is in this image?",
timestamp: 1779732875151,
sourceChannel: "webchat",
MediaPath: "/tmp/image.png",
MediaPaths: ["/tmp/image.png"],
MediaType: "image/png",
MediaTypes: ["image/png"],
});
});
});
function parseJsonLines<T>(raw: string): T[] {
const records: T[] = [];
for (const line of raw.trim().split("\n")) {
if (line.length > 0) {
records.push(JSON.parse(line) as T);
}
}
return records;
}
describe("mirrorCodexAppServerTranscript", () => {
it("mirrors user, assistant, and tool result messages into the embedded-agent transcript", async () => {
const sessionFile = await createTempSessionFile();
const userMessage = makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
});
const assistantMessage = makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
});
const toolResultMessage = castAgentMessage({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{
type: "toolResult",
toolCallId: "call-1",
content: "read output",
},
],
timestamp: Date.now() + 2,
}) as MirroredAgentMessage;
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userMessage, assistantMessage, toolResultMessage],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"role":"user"');
expect(raw).toContain('"content":[{"type":"text","text":"hello"}]');
expect(raw).toContain('"role":"assistant"');
expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]');
expect(raw).toContain('"role":"toolResult"');
expect(raw).toContain('"toolCallId":"call-1"');
expect(raw).toContain('"content":"read output"');
expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`);
expect(raw).toContain(
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`,
);
expect(raw).toContain(
`"idempotencyKey":"scope-1:toolResult:${expectedFingerprint(toolResultMessage)}"`,
);
});
it("emits message-bearing updates for newly appended mirrored messages only", async () => {
const sessionFile = await createTempSessionFile();
const userMessage = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "show me live" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
);
const firstMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const secondMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
);
expect(updates).toHaveLength(1);
expect(updates[0]?.sessionFile).toBe(sessionFile);
expect(updates[0]?.sessionKey).toBe("agent:main:main");
expect(updates[0]?.messageId).toEqual(expect.any(String));
expect(updates[0]?.message).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(updates[0]?.messageSeq).toBe(1);
expect(firstMirror.userMessagesPresent).toHaveLength(1);
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(secondMirror.userMessagesPresent).toHaveLength(1);
expect(secondMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
});
it("emits stable sequence numbers for multi-message mirror batches", async () => {
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:main",
messages: [
attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "first" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
),
attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "second" }],
timestamp: Date.now() + 1,
}),
"turn-1:assistant",
),
],
idempotencyScope: "codex-app-server:thread-1",
});
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
);
expect(updates.map((update) => update.messageSeq)).toEqual([1, 2]);
expect(updates.map((update) => (update.message as { role?: string }).role)).toEqual([
"user",
"assistant",
]);
});
it("creates the transcript directory on first mirror", async () => {
const root = await makeRoot("openclaw-codex-transcript-missing-dir-");
const sessionFile = path.join(root, "nested", "sessions", "session.jsonl");
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "first mirror" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"role":"assistant"');
expect(raw).toContain('"content":[{"type":"text","text":"first mirror"}]');
});
it("deduplicates app-server turn mirrors by idempotency scope", async () => {
const sessionFile = await createTempSessionFile();
const messages = [
makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
}),
] as const;
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
await fs.readFile(sessionFile, "utf8"),
);
expect(records.slice(1)).toHaveLength(2);
});
it("runs before_message_write before appending mirrored transcript messages", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
content: [{ type: "text", text: "hello [hooked]" }],
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
const sourceMessage = makeAgentAssistantMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]');
// The idempotency fingerprint is derived from the pre-hook message so a
// hook rewrite cannot bypass dedupe by reshaping content on every retry.
expect(raw).toContain(
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`,
);
});
it("returns the persisted user message for duplicate mirror hits", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
content: [{ type: "text", text: "[redacted by hook]" }],
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
const sourceMessage = makeAgentUserMessage({
content: [{ type: "text", text: "secret prompt" }],
timestamp: Date.now(),
});
const first = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const second = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
expect(first.userMessagesPresent[0]?.content).toEqual([
{ type: "text", text: "[redacted by hook]" },
]);
expect(second.userMessagesPresent[0]?.content).toEqual([
{ type: "text", text: "[redacted by hook]" },
]);
expect(JSON.stringify(second.userMessagesPresent)).not.toContain("secret prompt");
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
await fs.readFile(sessionFile, "utf8"),
);
expect(records.filter((record) => record.message?.role === "user")).toHaveLength(1);
});
it("preserves the computed idempotency key when hooks rewrite message keys", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
idempotencyKey: "hook-rewritten-key",
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
const sourceMessage = makeAgentAssistantMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain(
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`,
);
expect(raw).not.toContain("hook-rewritten-key");
});
it("respects before_message_write blocking decisions", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: () => ({ block: true }),
},
]),
);
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should not persist" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
await expect(fs.readFile(sessionFile, "utf8")).rejects.toHaveProperty("code", "ENOENT");
});
it("migrates small linear transcripts before mirroring", async () => {
const sessionFile = await createTempSessionFile();
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "linear-codex-session",
timestamp: new Date().toISOString(),
cwd: process.cwd(),
}),
JSON.stringify({
type: "message",
id: "legacy-user",
timestamp: new Date().toISOString(),
message: { role: "user", content: "legacy user" },
}),
].join("\n") + "\n",
"utf8",
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "mirrored assistant" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
type?: string;
id?: string;
parentId?: string | null;
message?: { role?: string };
},
)
.filter((record) => record.type === "message");
expect(records[0]?.id).toBe("legacy-user");
expect(records[0]?.parentId).toBeNull();
expect(records[1]?.parentId).toBe("legacy-user");
});
// Helpers for the identity-based regression tests below.
//
// The mirror dedupe key is now `${idempotencyScope}:${identity}`, where
// `identity` is either an explicit `attachCodexMirrorIdentity` tag (the
// production path; event-projector emits `${turnId}:${kind}`) or the
// role/content fingerprint fallback (legacy callers).
type FileMessage = {
type?: string;
message?: { role?: string; content?: Array<{ text?: string }> };
};
function readFileMessages(raw: string): Array<{ role?: string; text?: string }> {
return parseJsonLines<FileMessage>(raw)
.filter((record) => record.type === "message")
.map((record) => ({
role: record.message?.role,
text: record.message?.content?.[0]?.text,
}));
}
// Regression for #77012 (within-turn snapshot reordering). When mirror is
// invoked twice under the same scope/turn but the second snapshot inserts
// a reasoning record between the user prompt and the assistant reply,
// every assistant-role record after the inserted slot shifts. With the
// previous `:role:index` key, the second call's reasoning record collided
// with the first call's assistant key (both `:assistant:1`) — the
// legitimately-new reasoning entry was silently dropped, and the
// assistant content was re-appended under `:assistant:2`, producing a
// duplicate assistant entry. The identity-based key (event-projector
// tags `${turnId}:reasoning` and `${turnId}:assistant`) makes each kind
// its own dedupe slot.
it("dedupes mirrored messages despite snapshot positional shifts", async () => {
const sessionFile = await createTempSessionFile();
const userMessage = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
);
const assistantMessage = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
}),
"turn-1:assistant",
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
});
const reasoningMessage = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "[Codex reasoning] thinking" }],
timestamp: Date.now() + 2,
}),
"turn-1:reasoning",
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userMessage, reasoningMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
});
const messageTexts = readFileMessages(await fs.readFile(sessionFile, "utf8")).map(
(m) => m.text,
);
expect(messageTexts).toEqual(["hello", "hi there", "[Codex reasoning] thinking"]);
});
// Two distinct turns where the user types the same thing must not collapse:
// each entry carries its own `${turnId}:${kind}` identity so the dedupe
// key differs even when role+content match. (Prior content-fingerprint-only
// designs would have collapsed the second user turn here.)
it("keeps repeated same-content turns distinct", async () => {
const sessionFile = await createTempSessionFile();
const userTurn1 = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "yes" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
);
const assistantTurn1 = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "ok 1" }],
timestamp: Date.now() + 1,
}),
"turn-1:assistant",
);
const userTurn2 = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "yes" }],
timestamp: Date.now() + 2,
}),
"turn-2:prompt",
);
const assistantTurn2 = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "ok 2" }],
timestamp: Date.now() + 3,
}),
"turn-2:assistant",
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
});
expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([
{ role: "user", text: "yes" },
{ role: "assistant", text: "ok 1" },
{ role: "user", text: "yes" },
{ role: "assistant", text: "ok 2" },
]);
});
// Cross-turn re-emit: an entry first written under turn 1 may be re-emitted
// as part of a later turn's snapshot (e.g. a context-engine flow that
// bundles prior history). Because every entry carries its own original
// `${turnId}:${kind}` identity, the re-emitted entries collide with their
// existing on-disk keys and become true no-ops — instead of being
// appended again on a sibling branch (the on-disk symptom in #77012).
it("dedupes prior-turn entries re-emitted into a later turn's snapshot", async () => {
const sessionFile = await createTempSessionFile();
const userTurn1 = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "msg1" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
);
const assistantTurn1 = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "reply1" }],
timestamp: Date.now() + 1,
}),
"turn-1:assistant",
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
});
const userTurn2 = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "msg2" }],
timestamp: Date.now() + 2,
}),
"turn-2:prompt",
);
const assistantTurn2 = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "text", text: "reply2" }],
timestamp: Date.now() + 3,
}),
"turn-2:assistant",
);
// Buggy upstream: snapshot for turn 2 also includes the just-completed
// turn 1's entries (with their original identities preserved).
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
});
expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([
{ role: "user", text: "msg1" },
{ role: "assistant", text: "reply1" },
{ role: "user", text: "msg2" },
{ role: "assistant", text: "reply2" },
]);
});
// Backward-compat: callers that do not tag messages with a mirror identity
// (e.g. third-party harnesses or tests routed through the legacy path)
// still get the role/content fingerprint key. Distinct turns are then
// distinguished by the caller's idempotency scope.
it("falls back to the role+content fingerprint when no identity is attached", async () => {
const sessionFile = await createTempSessionFile();
const userMessage = makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
});
const assistantMessage = makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`);
expect(raw).toContain(
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`,
);
});
});