Files
openclaw/extensions/codex/src/app-server/session-history.ts
T
Vito Cappello 5621979a46 fix(models): preserve session selection across fallback turns (#119325)
* feat(models): add session-only model selection

* fix(models): use trailing session scope option

* test(models): satisfy session scope lint

* fix(models): reject duplicate model options

* fix(models): clarify default and session scope

* fix(models): require complete session option tokens

* fix(models): report configured default dispatch

* fix(models): keep directive handler within lint limit

* fix(models): parse model options in either order

* fix(models): apply session scope to aliases

* fix(models): align alias scope with reply routing

* fix(discord): surface model selection scope in picker

* fix(models): preserve mixed-text model selection

* fix(models): centralize command selection ownership

* fix(models): align session scope lifecycle

* fix(models): preserve command and auth ownership

* fixup! fix(models): preserve command and auth ownership

* fix(auth): preserve scoped CLI provider discovery

* test(models): align result and cron fixtures

* test(models): nest result timing metadata

* fix(discord): narrow silent dispatch results

* fix(transcript): preserve admitted turn identity

* fix(context-engine): fence the admitted transcript turn

* fix(context-engine): stabilize plugin compatibility contract

* chore(plugin-sdk): refresh context engine API baseline

* chore(plugin-sdk): use Linux context engine API baseline

* fix(context-engine): align fallback ownership

* fix(fallback): scope auth skip cache by profile

* fix(context-engine): settle only accepted fallback turns

* refactor(sessions): issue canonical turn admissions

* refactor(context-engine): own logical turn advancement

* fix(context-engine): settle cron fallback winners

* fix(models): align picker and fallback transactions

* fix(delivery): notify block admission after queueing

* fix(sessions): preserve canonical admission receipts

* chore(plugin-sdk): refresh API baseline hash

* fix(context-engine): commit accepted turns durably

* fix(context-engine): validate durable host transitions

* fix(context-engine): preserve fallback turn ownership

* fix(context-engine): preserve queued turn order

* fix(models): preserve fallback retry ownership

* fix(context-engine): enforce durable transcript anchors

* fix(runtime): close fallback persistence gaps

* fix(context-engine): preflight fallback harnesses

* chore(plugin-sdk): use Linux API baseline

* fix(context-engine): drain durable commits before reads

* fix(models): scope harness auth failures by profile

* fix(codex): fence legacy transcript history

* fix(commands): honor suppressed directive interpretation

* chore(runtime): remove unused branch exports

* test(context-engine): derive private outbox payload type

* fix(context-engine): apply durable drain degradation

* fix(context-engine): recover durable turn intents

* fix(context-engine): settle durable turn intents

* refactor(context-engine): satisfy branch quality gates

* fix(context-engine): close durable recovery gaps

* fix(discord): preserve dropped model command outcome

* test(copilot): keep journal fixture types local

* fix(auto-reply): preserve model alias provenance

* fix: close model scope review gaps

* fix(models): close review-found scope leaks

* fix(review): satisfy branch line budgets

* fix(agents): preserve context engine turn facts

* fix(agents): finalize silent context turns

* fix(context-engine): preserve compatibility window

* test(agents): cover both harness preparations

* fix(context-engine): retain blocked turn advancements

* fix(models): parse compact runtime options

* fix(telegram): report runtime resets accurately

* fix(models): isolate automatic auth failure skips

* fix(context-engine): project commit turn host params

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-07 16:19:12 +08:00

180 lines
6.1 KiB
TypeScript

/**
* Reads OpenClaw session history for Codex transcript mirroring and sanitizes
* image payloads before replaying messages into the app-server projector.
*/
import fs from "node:fs/promises";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SessionEntry } from "openclaw/plugin-sdk/agent-sessions";
import {
buildSessionContext,
migrateSessionEntries,
parseSessionEntries,
} from "openclaw/plugin-sdk/agent-sessions";
import { readCodexSessionTranscriptEventsBeforeAdmission } from "openclaw/plugin-sdk/codex-session-transcript-runtime";
import {
getSessionEntry,
parseSqliteSessionFileMarker,
resolveTranscriptSessionKeyBySessionId,
type SqliteSessionFileMarker,
} from "openclaw/plugin-sdk/session-store-runtime";
import {
readSessionTranscriptEvents,
type TranscriptTurnAdmission,
type SessionTranscriptTargetParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
function isMissingFileError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}
export type CodexMirroredSessionHistoryTarget = {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
sessionTarget?: Partial<SessionTranscriptTargetParams>;
};
/** Returns sanitized session-context messages for a Codex mirrored session file. */
export async function readCodexMirroredSessionHistoryMessages(
target: CodexMirroredSessionHistoryTarget,
admission?: TranscriptTurnAdmission,
): Promise<AgentMessage[] | undefined> {
try {
const entries = await readCodexMirroredSessionEntries(target, admission);
if (entries.length === 0) {
return [];
}
const firstEntry = entries[0] as { type?: unknown; id?: unknown } | undefined;
if (firstEntry?.type !== "session") {
// A well-formed transcript that does not open with a `session` marker is
// simply not a Codex-mirrored session (e.g. a non-Codex model run reusing
// this hook) — an empty mirror, not a read failure, so callers must not
// warn. `undefined` stays reserved for genuine failures: read/parse errors
// (caught below) and malformed `session` headers (next check).
return [];
}
if (typeof firstEntry.id !== "string") {
// A `session` header without a string id is a corrupted Codex transcript,
// not a foreign one — keep it on the warn path.
return undefined;
}
if (firstEntry.id !== target.sessionId) {
return [];
}
migrateSessionEntries(entries);
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
return (
entry !== null &&
typeof entry === "object" &&
!Array.isArray(entry) &&
(entry as { type?: unknown }).type !== "session"
);
});
return sanitizeCodexHistoryImagePayloads(
buildSessionContext(sessionEntries).messages,
"codex mirrored history",
);
} catch (error) {
// A new Codex session can be read before its transcript exists; other failures still warn.
if (isMissingFileError(error)) {
return [];
}
return undefined;
}
}
async function readCodexMirroredSessionEntries(
target: CodexMirroredSessionHistoryTarget,
admission?: TranscriptTurnAdmission,
): Promise<SessionEntry[]> {
if (target.sessionTarget) {
const { agentId, sessionId, sessionKey, storePath } = target.sessionTarget;
if (
!agentId ||
!sessionId ||
!sessionKey ||
!storePath ||
sessionId !== target.sessionId ||
(target.agentId !== undefined && agentId !== target.agentId) ||
(target.sessionKey !== undefined && sessionKey !== target.sessionKey)
) {
return [];
}
const transcriptTarget = {
agentId,
sessionId,
sessionKey,
storePath,
};
return (await (admission
? readCodexSessionTranscriptEventsBeforeAdmission(transcriptTarget, admission)
: readSessionTranscriptEvents(transcriptTarget))) as SessionEntry[];
}
const sqliteMarker = parseSqliteSessionFileMarker(target.sessionFile);
if (sqliteMarker) {
if (
sqliteMarker.sessionId !== target.sessionId ||
(target.agentId !== undefined && sqliteMarker.agentId !== target.agentId)
) {
return [];
}
const sessionKey = resolveSqliteMarkerSessionKey(target, sqliteMarker);
if (!sessionKey) {
return [];
}
const transcriptTarget = {
agentId: sqliteMarker.agentId,
sessionId: sqliteMarker.sessionId,
sessionKey,
storePath: sqliteMarker.storePath,
};
return (await (admission
? readCodexSessionTranscriptEventsBeforeAdmission(transcriptTarget, admission)
: readSessionTranscriptEvents(transcriptTarget))) as SessionEntry[];
}
if (admission) {
if (
admission.sessionId !== target.sessionId ||
(target.agentId !== undefined && admission.agentId !== target.agentId) ||
(target.sessionKey !== undefined && admission.sessionKey !== target.sessionKey)
) {
return [];
}
return (await readCodexSessionTranscriptEventsBeforeAdmission(
{
agentId: admission.agentId,
sessionId: admission.sessionId,
sessionKey: admission.sessionKey,
storePath: admission.storePath,
},
admission,
)) as SessionEntry[];
}
return parseSessionEntries(await fs.readFile(target.sessionFile, "utf-8")) as SessionEntry[];
}
function resolveSqliteMarkerSessionKey(
target: CodexMirroredSessionHistoryTarget,
marker: SqliteSessionFileMarker,
): string | undefined {
const explicitSessionKey = target.sessionKey?.trim();
if (explicitSessionKey) {
// The SDK exact-entry accessor uses a read-only database handle.
const explicitEntry = getSessionEntry({
agentId: marker.agentId,
sessionKey: explicitSessionKey,
storePath: marker.storePath,
});
if (explicitEntry) {
return explicitEntry.sessionId === marker.sessionId ? explicitSessionKey : undefined;
}
}
return resolveTranscriptSessionKeyBySessionId({
agentId: marker.agentId,
sessionId: marker.sessionId,
storePath: marker.storePath,
});
}