feat(codex): fork upstream-linked sessions at a message via thread/fork (#111149)

* feat(codex): fork upstream-linked sessions at a message via thread/fork

* fix(gateway): fail closed for rewind and branch switch on upstream-linked sessions

* fix(codex): fail closed on first-message forks, image-only prompts, and orphan archival

* fix(codex): baseline retained history and reject paginated threads on upstream fork

* fix(codex): validate the full fork prefix and fail closed across crash windows

* fix(codex): treat all non-text inputs as unverifiable in fork drift checks

* fix(codex): support first-message forks as empty-history upstream cuts

* fix(codex): reject source-id reuse and unverifiable hidden inputs in fork boundaries

* refactor(codex): materialize upstream forks from verified thread read-back

* fix(codex): satisfy strict type lanes and knip for upstream fork
This commit is contained in:
Peter Steinberger
2026-07-19 00:11:10 -07:00
committed by GitHub
parent aae5b0f041
commit ea54060223
19 changed files with 1487 additions and 50 deletions
+24
View File
@@ -8,7 +8,9 @@ import type {
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
import type { CodexSessionCatalogControl } from "./src/session-catalog-types.js";
// `codex` is legacy input only until Part 2 doctor migration rewrites stored refs.
// New runtime identity uses the `openai` provider.
@@ -50,7 +52,9 @@ export function createCodexAppServerAgentHarness(options: {
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
resolveConfig?: () => OpenClawConfig | undefined;
runtime?: PluginRuntime;
bindingStore: CodexAppServerBindingStore;
sessionCatalogControl?: CodexSessionCatalogControl;
}): AgentHarness {
const harnessRuntimeId = options?.id ?? "codex";
const normalizedHarnessRuntimeId = harnessRuntimeId.trim().toLowerCase();
@@ -59,6 +63,8 @@ export function createCodexAppServerAgentHarness(options: {
id.trim().toLowerCase(),
),
);
const sessionCatalogControl = options.sessionCatalogControl;
const sessionRuntime = options.runtime;
const harness: CodexAppServerAgentHarness = {
id: harnessRuntimeId,
label: options?.label ?? "Codex agent harness",
@@ -69,6 +75,24 @@ export function createCodexAppServerAgentHarness(options: {
visibleReplies: "message_tool",
},
authBootstrap: "harness",
...(sessionCatalogControl && sessionRuntime
? {
sessionFork: {
upstreamKinds: ["codex-app-server"] as const,
fork: async (params) => {
const { forkCodexUpstreamSession } =
await import("./src/app-server/upstream-session-fork.js");
return await forkCodexUpstreamSession(params, {
bindingStore: options.bindingStore,
control: sessionCatalogControl,
harnessRuntimeId,
resolveConfig: options.resolveConfig,
runtime: sessionRuntime,
});
},
},
}
: {}),
authBinding: {
fingerprint: async (params) => {
const { fingerprintCodexAppServerAuthBinding } =
+2
View File
@@ -150,8 +150,10 @@ export default definePluginEntry({
api.registerAgentHarness(
createCodexAppServerAgentHarness({
bindingStore,
sessionCatalogControl,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
runtime: api.runtime,
}),
);
api.registerMediaUnderstandingProvider(
@@ -1,6 +1,7 @@
// Codex tests cover protocol validators plugin behavior.
import { describe, expect, it } from "vitest";
import {
assertCodexThreadForkParams,
readCodexModelListResponse,
readCodexTurn,
assertCodexThreadStartResponse,
@@ -52,6 +53,24 @@ describe("Codex thread response validators", () => {
});
});
describe("assertCodexThreadForkParams", () => {
it("accepts the experimental beforeTurnId boundary", () => {
expect(
assertCodexThreadForkParams({
threadId: "thread-1",
beforeTurnId: "turn-2",
excludeTurns: true,
}),
).toMatchObject({ beforeTurnId: "turn-2" });
});
it("rejects a non-string beforeTurnId", () => {
expect(() => assertCodexThreadForkParams({ threadId: "thread-1", beforeTurnId: 2 })).toThrow(
"Invalid Codex app-server thread/fork params",
);
});
});
describe("assertCodexThreadStartResponse", () => {
it("accepts response with both id and sessionId", () => {
const response = makeMinimalResponse();
@@ -16,6 +16,7 @@ import type {
CodexErrorNotification,
CodexModelListResponse,
CodexThreadForkResponse,
CodexThreadForkParams,
CodexThreadResumeResponse,
CodexThreadStartResponse,
CodexTurn,
@@ -236,6 +237,21 @@ export function assertCodexThreadForkResponse(value: unknown): CodexThreadForkRe
return assertCodexShape(validateThreadStartResponse, normalized, "thread/fork response");
}
/** Asserts the experimental beforeTurnId request field before it crosses the app-server boundary. */
export function assertCodexThreadForkParams(value: unknown): CodexThreadForkParams {
if (
!isRecord(value) ||
typeof value.threadId !== "string" ||
!value.threadId.trim() ||
(value.beforeTurnId !== undefined &&
value.beforeTurnId !== null &&
typeof value.beforeTurnId !== "string")
) {
throw new Error("Invalid Codex app-server thread/fork params");
}
return value as CodexThreadForkParams;
}
/** Asserts and normalizes a Codex thread/resume response. */
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
const normalized = normalizeWithDefaults(threadResumeResponseSchema, value);
@@ -165,6 +165,7 @@ export type CodexThreadStartResponse = {
export type CodexThreadForkParams = JsonObject & {
threadId: string;
lastTurnId?: string | null;
beforeTurnId?: string | null;
path?: string | null;
model?: string | null;
modelProvider?: string | null;
@@ -0,0 +1,64 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import type { CodexThread } from "./protocol.js";
import { importCodexThreadHistoryToTranscript } from "./transcript-mirror.js";
type CreatedCodexImportedSession = Awaited<
ReturnType<PluginRuntime["agent"]["session"]["createSessionEntry"]>
>;
/** Creates a session whose transcript is derived from one verified Codex thread snapshot. */
export async function createImportedCodexSession(params: {
runtime: PluginRuntime;
config: OpenClawConfig;
key: string;
agentId: string;
thread: CodexThread;
throughTurnId: string | null;
recoverMatchingInitialEntry?: true;
initialEntry: {
agentHarnessId: string;
modelSelectionLocked?: true;
pluginExtensions?: CreatedCodexImportedSession["entry"]["pluginExtensions"];
};
afterImport: (
created: CreatedCodexImportedSession,
) => Promise<{ pluginExtensions: CreatedCodexImportedSession["entry"]["pluginExtensions"] }>;
}): Promise<CreatedCodexImportedSession> {
const label = params.thread.name?.trim() || undefined;
const spawnedCwd = params.thread.cwd?.trim() || undefined;
const createParams = {
cfg: params.config,
key: params.key,
agentId: params.agentId,
...(label ? { label } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
initialEntry: params.initialEntry,
afterCreate: async (entry: CreatedCodexImportedSession) => {
// Post-flip the mirror targets SQLite rows; resolve the agent's store
// path instead of trusting the legacy sessionFile locator marker.
const storePath = resolveStorePath(params.config.session?.store, {
agentId: entry.agentId,
});
await importCodexThreadHistoryToTranscript({
thread: params.thread,
throughTurnId: params.throughTurnId,
storePath,
sessionId: entry.sessionId,
sessionKey: entry.key,
agentId: entry.agentId,
...(spawnedCwd ? { cwd: spawnedCwd } : {}),
modelProvider: params.thread.modelProvider,
config: params.config,
});
return await params.afterImport(entry);
},
};
return params.recoverMatchingInitialEntry
? await params.runtime.agent.session.createSessionEntry({
...createParams,
recoverMatchingInitialEntry: true,
})
: await params.runtime.agent.session.createSessionEntry(createParams);
}
@@ -0,0 +1,205 @@
import type { SessionTranscriptMessageEntry } from "openclaw/plugin-sdk/session-transcript-runtime";
import { describe, expect, it, vi } from "vitest";
import type { CodexThreadItem, CodexTurn } from "./protocol.js";
import { resolveCodexUpstreamForkBoundary } from "./upstream-fork-boundary.js";
const transcriptMocks = vi.hoisted(() => ({
readVisibleEntries: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({
readVisibleSessionTranscriptMessageEntries: transcriptMocks.readVisibleEntries,
}));
function item(type: string, overrides: Record<string, unknown> = {}): CodexThreadItem {
return { id: `${type}-item`, type, ...overrides } as CodexThreadItem;
}
function user(text: string): CodexThreadItem {
return item("userMessage", { content: [{ type: "text", text, textElements: [] }] });
}
function turn(id: string, items: CodexThreadItem[], overrides: Partial<CodexTurn> = {}): CodexTurn {
return { id, status: "completed", items, ...overrides };
}
async function resolveFromTurns(params: {
turns: readonly CodexTurn[];
userMessageOrdinal: number;
localPrefixTexts: readonly (string | undefined)[];
}) {
const entries: SessionTranscriptMessageEntry[] = params.localPrefixTexts.map((text, index) => ({
entryId: `entry-${index}`,
parentId: index > 0 ? `entry-${index - 1}` : null,
seq: index,
role: "user",
message: {
role: "user",
content: text ?? [{ type: "image", data: "", mimeType: "image/png" }],
timestamp: index,
},
}));
transcriptMocks.readVisibleEntries.mockResolvedValue(entries);
const result = await resolveCodexUpstreamForkBoundary({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:upstream",
storePath: "/tmp/does-not-matter",
entryId: `entry-${params.userMessageOrdinal}`,
threadId: "thread-1",
control: {
readThread: vi.fn(async () => ({ id: "thread-1" })),
listTurnPage: vi.fn(async () => ({ data: [...params.turns] })),
} as unknown as Parameters<typeof resolveCodexUpstreamForkBoundary>[0]["control"],
});
return result.ok ? { ok: true as const, boundary: result.boundary } : result;
}
describe("resolveCodexUpstreamForkBoundaryFromTurns", () => {
it("maps the local user ordinal to the upstream turn", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("one")]), turn("turn-2", [user("two")])],
userMessageOrdinal: 1,
localPrefixTexts: ["one", "two"],
});
expect(result).toEqual({
ok: true,
boundary: {
beforeTurnId: "turn-2",
targetTurnId: "turn-2",
retainedMarker: { turnId: "turn-1", userMessageCount: 1 },
},
});
});
it("cuts before the first turn with an empty retained baseline", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("one")])],
userMessageOrdinal: 0,
localPrefixTexts: ["one"],
});
expect(result).toEqual({
ok: true,
boundary: {
beforeTurnId: "turn-1",
targetTurnId: "turn-1",
retainedMarker: { turnId: null, userMessageCount: 0 },
},
});
});
it("rejects a selected steer message", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("one"), user("steer")])],
userMessageOrdinal: 1,
localPrefixTexts: ["one", "steer"],
});
expect(result).toMatchObject({ ok: false, code: "steer-message" });
});
it("skips prompts inside review spans", async () => {
const result = await resolveFromTurns({
turns: [
turn("turn-review", [
item("enteredReviewMode"),
user("hidden review prompt"),
item("exitedReviewMode"),
]),
turn("turn-2", [user("visible")]),
],
userMessageOrdinal: 0,
localPrefixTexts: ["visible"],
});
expect(result).toEqual({
ok: true,
boundary: {
beforeTurnId: "turn-2",
targetTurnId: "turn-2",
retainedMarker: { turnId: "turn-review", userMessageCount: 1 },
},
});
});
it("rejects an in-progress target turn", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("one")], { status: "inProgress" })],
userMessageOrdinal: 0,
localPrefixTexts: ["one"],
});
expect(result).toMatchObject({ ok: false, code: "in-progress-turn" });
});
it("rejects local and upstream text drift", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("persisted")])],
userMessageOrdinal: 0,
localPrefixTexts: ["local mirror"],
});
expect(result).toMatchObject({ ok: false, code: "drift-mismatch" });
});
it("rejects equal targets over divergent prefixes", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("upstream-old")]), turn("turn-2", [user("target")])],
userMessageOrdinal: 1,
localPrefixTexts: ["local-old", "target"],
});
expect(result).toMatchObject({ ok: false, code: "drift-mismatch" });
});
it("rejects upstream messages carrying semantic non-text inputs", async () => {
const result = await resolveFromTurns({
turns: [
turn("turn-1", [
item("userMessage", {
content: [
{ type: "text", text: "one", textElements: [] },
{ type: "skill", name: "reviewer" },
],
}),
]),
turn("turn-2", [user("target")]),
],
userMessageOrdinal: 1,
localPrefixTexts: ["one", "target"],
});
expect(result).toMatchObject({ ok: false, code: "drift-mismatch" });
});
it("rejects prefixes whose content identity cannot be verified", async () => {
const result = await resolveFromTurns({
turns: [turn("turn-1", [user("one")]), turn("turn-2", [user("target")])],
userMessageOrdinal: 1,
localPrefixTexts: [undefined, "target"],
});
expect(result).toMatchObject({ ok: false, code: "drift-mismatch" });
});
});
describe("resolveCodexUpstreamForkBoundary", () => {
it("rejects paginated-history threads before reading turns", async () => {
const readThread = vi.fn(async () => ({ id: "thread-1", historyMode: "paginated" }));
const result = await resolveCodexUpstreamForkBoundary({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:upstream",
storePath: "/tmp/does-not-matter",
entryId: "entry-1",
threadId: "thread-1",
control: { readThread } as unknown as Parameters<
typeof resolveCodexUpstreamForkBoundary
>[0]["control"],
});
expect(result).toMatchObject({ ok: false, code: "upstream-unavailable" });
expect(readThread).toHaveBeenCalledWith("thread-1", false);
});
});
@@ -0,0 +1,321 @@
import { readVisibleSessionTranscriptMessageEntries } from "openclaw/plugin-sdk/session-transcript-runtime";
import type { CodexSessionCatalogControl } from "../session-catalog-types.js";
import type { CodexThreadItem, CodexTurn } from "./protocol.js";
type CodexUpstreamForkBoundaryFailureCode =
| "steer-message"
| "in-progress-turn"
| "drift-mismatch"
| "upstream-unavailable";
type CodexUpstreamForkBoundary = {
beforeTurnId: string;
targetTurnId: string;
/** Baseline for the forked thread: the last retained turn (null when the cut is
* before the first turn), so the upstream monitor does not replay retained
* history as fresh external activity. */
retainedMarker: { turnId: string | null; userMessageCount: number };
};
type CodexUpstreamForkBoundaryResult =
| { ok: true; boundary: CodexUpstreamForkBoundary; editorText?: string }
| { ok: false; code: CodexUpstreamForkBoundaryFailureCode; message: string };
const TURN_PAGE_LIMIT = 100;
type UserInput = {
type?: unknown;
text?: unknown;
textElements?: unknown;
url?: unknown;
path?: unknown;
};
function failure(
code: CodexUpstreamForkBoundaryFailureCode,
message: string,
): CodexUpstreamForkBoundaryResult {
return { ok: false, code, message };
}
function asInputs(item: CodexThreadItem): UserInput[] {
return Array.isArray(item.content) ? (item.content as UserInput[]) : [];
}
function userMessageDisplay(item: CodexThreadItem): {
text: string;
visible: boolean;
hasUnverifiableInput: boolean;
} {
let text = "";
let hasTextElement = false;
let hasImage = false;
// Any non-text input (images, skills, mentions, future variants) has no canonical
// cross-system identity; its presence makes the message unverifiable for drift checks.
let hasUnverifiableInput = false;
for (const input of asInputs(item)) {
if (input.type === "text") {
if (typeof input.text === "string") {
text += input.text;
}
hasTextElement ||= Array.isArray(input.textElements) && input.textElements.length > 0;
} else {
hasUnverifiableInput = true;
hasImage ||= input.type === "image" || input.type === "localImage";
}
}
return {
text,
visible: Boolean(text.trim()) || hasTextElement || hasImage,
hasUnverifiableInput,
};
}
function isHiddenNestedReviewTurn(previous: CodexTurn | undefined, turn: CodexTurn): boolean {
if (
previous?.status !== "completed" ||
turn.status !== "interrupted" ||
turn.completedAt != null ||
!previous.items.some((item) => item.type === "enteredReviewMode") ||
!previous.items.some((item) => item.type === "exitedReviewMode")
) {
return false;
}
const userMessages = turn.items.filter((item) => item.type === "userMessage");
const [firstUserMessage, secondUserMessage] = userMessages;
if (!firstUserMessage || !secondUserMessage || userMessages.length !== 2) {
return false;
}
return JSON.stringify(asInputs(firstUserMessage)) === JSON.stringify(asInputs(secondUserMessage));
}
function localMessageText(content: unknown): string | undefined {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
// Non-text blocks (images/attachments) have no canonical cross-system identity;
// undefined marks the message unverifiable so boundary resolution fails closed.
const texts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return undefined;
}
const typed = block as { type?: unknown; text?: unknown };
if (typed.type !== "text" || typeof typed.text !== "string") {
return undefined;
}
texts.push(typed.text);
}
return texts.join("");
}
function resolveCodexUpstreamForkBoundaryFromTurns(params: {
turns: readonly CodexTurn[];
userMessageOrdinal: number;
/** Canonical text for every visible local user message through the target ordinal;
* undefined marks content (images/attachments) whose identity cannot be verified. */
localPrefixTexts: readonly (string | undefined)[];
}): CodexUpstreamForkBoundaryResult {
let visibleUserMessagesSeen = 0;
let reviewMode = false;
for (const [turnIndex, turn] of params.turns.entries()) {
const hiddenNestedReviewTurn = isHiddenNestedReviewTurn(params.turns[turnIndex - 1], turn);
let userMessagesInTurn = 0;
for (const item of turn.items) {
if (item.type === "enteredReviewMode") {
reviewMode = true;
continue;
}
if (item.type === "exitedReviewMode") {
reviewMode = false;
continue;
}
if (item.type !== "userMessage") {
continue;
}
const isSteer = userMessagesInTurn > 0;
userMessagesInTurn += 1;
if (reviewMode || hiddenNestedReviewTurn) {
continue;
}
const display = userMessageDisplay(item);
// Unverifiable inputs fail closed even when display-invisible: a skipped
// skill/mention-only message would silently desync ordinals against the mirror.
if (display.hasUnverifiableInput) {
return failure(
"drift-mismatch",
"A message before the fork point contains images or attachments that cannot be verified across OpenClaw and Codex. Fork from a text-only span instead.",
);
}
if (!display.visible) {
continue;
}
const ordinal = visibleUserMessagesSeen;
if (ordinal > params.userMessageOrdinal) {
break;
}
// The local transcript is only a mirror; every prefix message must match, not just
// the target — equal tails over different prefixes would bind divergent histories.
const localText = params.localPrefixTexts[ordinal];
if (localText === undefined) {
return failure(
"drift-mismatch",
"A message before the fork point contains images or attachments that cannot be verified across OpenClaw and Codex. Fork from a text-only span instead.",
);
}
if (display.text !== localText) {
return failure(
"drift-mismatch",
"The local conversation no longer matches the Codex thread. Refresh the session and try again.",
);
}
if (ordinal !== params.userMessageOrdinal) {
visibleUserMessagesSeen += 1;
continue;
}
if (isSteer) {
return failure(
"steer-message",
"This message steered an existing Codex turn and cannot be forked independently. Fork from the turn's first message instead.",
);
}
if (turn.status === "inProgress") {
return failure(
"in-progress-turn",
"This Codex turn is still in progress. Wait for it to finish, then try forking again.",
);
}
// beforeTurnId at the first turn yields a valid empty-history fork upstream
// (codex-rs thread_fork_inner has no minimum-turn guard), matching the empty
// local mirror prefix.
const retained = turnIndex > 0 ? params.turns[turnIndex - 1] : undefined;
return {
ok: true,
boundary: {
beforeTurnId: turn.id,
targetTurnId: turn.id,
retainedMarker: retained
? {
turnId: retained.id,
userMessageCount: retained.items.filter(
(retainedItem) => retainedItem.type === "userMessage",
).length,
}
: { turnId: null, userMessageCount: 0 },
},
};
}
}
return failure(
"drift-mismatch",
"The message could not be matched to the Codex thread. Refresh the session and try again.",
);
}
export async function listCodexUpstreamTurns(
control: CodexSessionCatalogControl,
threadId: string,
): Promise<CodexTurn[]> {
const turns: CodexTurn[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
for (;;) {
const page = await control.listTurnPage({
threadId,
limit: TURN_PAGE_LIMIT,
sortDirection: "asc",
itemsView: "full",
...(cursor ? { cursor } : {}),
});
turns.push(...page.data);
const nextCursor = page.nextCursor?.trim() || undefined;
if (!nextCursor) {
return turns;
}
if (seenCursors.has(nextCursor)) {
throw new Error("Codex returned a repeated thread/turns/list cursor");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
}
export async function resolveCodexUpstreamForkBoundary(params: {
agentId: string;
sessionId: string;
sessionKey: string;
storePath: string;
entryId: string;
threadId: string;
control: CodexSessionCatalogControl;
}): Promise<CodexUpstreamForkBoundaryResult> {
try {
// Paginated-history threads reject itemsView "full" turn reads (thread/items/list
// is required); fork support for them is future work — fail closed with intent.
const thread = await params.control.readThread(params.threadId, false);
if (thread.historyMode === "paginated") {
return failure(
"upstream-unavailable",
"This Codex thread uses paginated history, which cannot be forked from OpenClaw yet.",
);
}
const entries = await readVisibleSessionTranscriptMessageEntries({
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
const visibleUserEntries = entries.filter((entry) => entry.role === "user");
const userMessageOrdinal = visibleUserEntries.findIndex(
(entry) => entry.entryId === params.entryId,
);
if (userMessageOrdinal < 0) {
return failure(
"drift-mismatch",
"The local message could not be mapped to the Codex thread. Refresh the session and try again.",
);
}
const localPrefixTexts = visibleUserEntries
.slice(0, userMessageOrdinal + 1)
.map((entry) =>
localMessageText("content" in entry.message ? entry.message.content : undefined),
);
const turns = await listCodexUpstreamTurns(params.control, params.threadId);
const resolved = resolveCodexUpstreamForkBoundaryFromTurns({
turns,
userMessageOrdinal,
localPrefixTexts,
});
return resolved.ok
? { ...resolved, editorText: localPrefixTexts[userMessageOrdinal] }
: resolved;
} catch {
return failure(
"upstream-unavailable",
"The Codex thread could not be read. Check that Codex is available, then try again.",
);
}
}
export function precheckCodexUpstreamForkBoundary(params: {
boundary: CodexUpstreamForkBoundary;
turns: readonly CodexTurn[];
}): CodexUpstreamForkBoundaryResult {
const target = params.turns.find((turn) => turn.id === params.boundary.targetTurnId);
if (!target) {
return failure(
"upstream-unavailable",
"The Codex thread changed before it could be forked. Refresh the session and try again.",
);
}
if (target.status === "inProgress") {
return failure(
"in-progress-turn",
"This Codex turn is still in progress. Wait for it to finish, then try forking again.",
);
}
return { ok: true, boundary: params.boundary };
}
@@ -0,0 +1,281 @@
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexSessionCatalogControl } from "../session-catalog-types.js";
import type { CodexThreadForkParams, CodexTurn } from "./protocol.js";
import type { CodexAppServerBindingStore } from "./session-binding.js";
const boundaryMocks = vi.hoisted(() => ({
listTurns: vi.fn(),
}));
const linkMocks = vi.hoisted(() => ({
delete: vi.fn(),
upsert: vi.fn(),
}));
const transcriptMocks = vi.hoisted(() => ({
importHistory: vi.fn(),
}));
const boundary = {
beforeTurnId: "turn-2",
targetTurnId: "turn-2",
retainedMarker: { turnId: "turn-1", userMessageCount: 1 },
} as const;
vi.mock("openclaw/plugin-sdk/session-catalog", async (importOriginal) => ({
...(await importOriginal()),
deleteSessionUpstreamLink: linkMocks.delete,
upsertSessionUpstreamLink: linkMocks.upsert,
}));
vi.mock("./transcript-mirror.js", () => ({
importCodexThreadHistoryToTranscript: transcriptMocks.importHistory,
}));
vi.mock("./upstream-fork-boundary.js", () => ({
resolveCodexUpstreamForkBoundary: vi.fn(async () => ({
ok: true,
boundary,
editorText: "edit me",
})),
listCodexUpstreamTurns: boundaryMocks.listTurns,
precheckCodexUpstreamForkBoundary: vi.fn(() => ({ ok: true, boundary })),
}));
import { forkCodexUpstreamSession } from "./upstream-session-fork.js";
function turn(id: string, text: string): CodexTurn {
return {
id,
status: "completed",
items: [
{
aggregatedOutput: null,
changes: [],
command: null,
cwd: null,
id: `${id}-user`,
name: null,
query: null,
server: null,
status: null,
text: "",
title: null,
tool: null,
content: [{ type: "text", text, textElements: [] }],
type: "userMessage",
},
],
};
}
function forkResponse(threadId = "thread-forked") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
cwd: "/tmp",
model: "gpt-5.4",
modelProvider: "openai",
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
sessionId: "session-forked",
cliVersion: "0.143.0",
createdAt: 1715299200,
updatedAt: 1715299200,
cwd: "/tmp",
ephemeral: false,
modelProvider: "openai",
preview: "forked thread",
source: "appServer",
status: { type: "notLoaded" },
turns: [],
},
};
}
function forkParams() {
return {
targetKey: "agent:main:dashboard:forked",
source: {
agentId: "main",
sessionId: "session-source",
sessionKey: "agent:main:source",
storePath: "/tmp/sessions.db",
entryId: "entry-2",
},
upstream: {
catalogId: "codex",
hostId: "gateway:local",
kind: "codex-app-server" as const,
threadId: "thread-source",
ref: { connectionFingerprint: "fingerprint", threadId: "thread-source" },
},
};
}
type ForkThreadStub = (params: CodexThreadForkParams) => Promise<unknown>;
function forkControl(forkThread: ForkThreadStub = vi.fn(async () => forkResponse())) {
const archiveThread = vi.fn(async () => undefined);
const control = {
archiveThread,
connectionFingerprint: "fingerprint",
forkThread,
} as unknown as CodexSessionCatalogControl;
control.withPinnedConnection = async (run) => await run(control);
return { archiveThread, control, forkThread };
}
beforeEach(() => {
boundaryMocks.listTurns.mockReset();
linkMocks.delete.mockReset();
linkMocks.upsert.mockReset().mockReturnValue(true);
transcriptMocks.importHistory.mockReset().mockResolvedValue({
importedMessages: 1,
omittedMessages: 0,
});
});
describe("forkCodexUpstreamSession", () => {
it("verifies the cut, imports the fork history, then links before binding", async () => {
const retainedTurn = turn("turn-1", "one");
boundaryMocks.listTurns
.mockResolvedValueOnce([turn("turn-2", "edit me")])
.mockResolvedValueOnce([retainedTurn]);
const { archiveThread, control, forkThread } = forkControl();
const events: string[] = [];
linkMocks.upsert.mockImplementation(() => {
events.push("link");
return true;
});
const mutate = vi.fn(async () => {
events.push("bind");
return true;
});
const runtime = createPluginRuntimeMock();
const createSessionEntry = vi.mocked(runtime.agent.session.createSessionEntry);
const result = await forkCodexUpstreamSession(forkParams(), {
bindingStore: { mutate } as unknown as CodexAppServerBindingStore,
control,
harnessRuntimeId: "codex-custom",
resolveConfig: () => ({}),
runtime,
});
expect(forkThread).toHaveBeenCalledWith({
threadId: "thread-source",
beforeTurnId: "turn-2",
excludeTurns: true,
});
expect(boundaryMocks.listTurns).toHaveBeenLastCalledWith(control, "thread-forked");
expect(transcriptMocks.importHistory).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:dashboard:forked",
thread: expect.objectContaining({ id: "thread-forked", turns: [retainedTurn] }),
throughTurnId: "turn-1",
}),
);
expect(linkMocks.upsert).toHaveBeenCalledWith(
expect.objectContaining({
marker: { turnId: "turn-1", userMessageCount: 1 },
sessionKey: "agent:main:dashboard:forked",
threadId: "thread-forked",
}),
);
expect(runtime.agent.session.createSessionEntry).toHaveBeenCalledWith(
expect.objectContaining({
initialEntry: expect.objectContaining({ agentHarnessId: "codex-custom" }),
}),
);
expect(createSessionEntry.mock.calls[0]?.[0]).not.toHaveProperty("recoverMatchingInitialEntry");
expect(events).toEqual(["link", "bind"]);
expect(result).toEqual({
status: "created",
key: "agent:main:dashboard:forked",
editorText: "edit me",
});
expect(archiveThread).not.toHaveBeenCalled();
});
it("archives a fork whose read-back history proves beforeTurnId was ignored", async () => {
boundaryMocks.listTurns
.mockResolvedValueOnce([turn("turn-2", "edit me")])
.mockResolvedValueOnce([turn("turn-1", "one"), turn("turn-2", "edit me")]);
const { archiveThread, control } = forkControl();
const runtime = createPluginRuntimeMock();
const result = await forkCodexUpstreamSession(forkParams(), {
bindingStore: { mutate: vi.fn() } as unknown as CodexAppServerBindingStore,
control,
harnessRuntimeId: "codex",
runtime,
});
expect(result).toMatchObject({
status: "failed",
code: "upstream-unavailable",
message: expect.stringContaining("Codex version"),
});
expect(archiveThread).toHaveBeenCalledWith("thread-forked");
expect(runtime.agent.session.createSessionEntry).not.toHaveBeenCalled();
expect(linkMocks.upsert).not.toHaveBeenCalled();
});
it("cleans the link and archives the fork when binding materialization fails", async () => {
boundaryMocks.listTurns
.mockResolvedValueOnce([turn("turn-2", "edit me")])
.mockResolvedValueOnce([turn("turn-1", "one")]);
const { archiveThread, control } = forkControl();
const mutate = vi.fn(async () => false);
const result = await forkCodexUpstreamSession(forkParams(), {
bindingStore: { mutate } as unknown as CodexAppServerBindingStore,
control,
harnessRuntimeId: "codex",
runtime: createPluginRuntimeMock(),
});
expect(result).toMatchObject({ status: "failed", code: "upstream-unavailable" });
expect(linkMocks.delete).toHaveBeenCalledWith("agent:main:dashboard:forked", "main");
expect(mutate).toHaveBeenLastCalledWith(expect.anything(), {
kind: "clear",
threadId: "thread-forked",
});
expect(archiveThread).toHaveBeenCalledWith("thread-forked");
});
it("archives a recoverable orphan id when the fork response is invalid", async () => {
boundaryMocks.listTurns.mockResolvedValueOnce([turn("turn-2", "edit me")]);
const { archiveThread, control } = forkControl(
vi.fn(async () => ({ thread: { id: "thread-orphan" } })),
);
const result = await forkCodexUpstreamSession(forkParams(), {
bindingStore: {} as CodexAppServerBindingStore,
control,
harnessRuntimeId: "codex",
runtime: createPluginRuntimeMock(),
});
expect(result).toMatchObject({ status: "failed", code: "upstream-unavailable" });
expect(archiveThread).toHaveBeenCalledWith("thread-orphan");
});
it("rejects a fork response that reuses the source thread id", async () => {
boundaryMocks.listTurns.mockResolvedValueOnce([turn("turn-2", "edit me")]);
const { archiveThread, control } = forkControl(
vi.fn(async () => forkResponse("thread-source")),
);
const result = await forkCodexUpstreamSession(forkParams(), {
bindingStore: { mutate: vi.fn() } as unknown as CodexAppServerBindingStore,
control,
harnessRuntimeId: "codex",
runtime: createPluginRuntimeMock(),
});
expect(result).toMatchObject({ status: "failed", code: "upstream-unavailable" });
expect(archiveThread).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,219 @@
import type {
AgentHarnessSessionForkParams,
AgentHarnessSessionForkResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import {
deleteSessionUpstreamLink,
upsertSessionUpstreamLink,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CodexSessionCatalogControl } from "../session-catalog-types.js";
import { codexLastTerminalTurnId, codexUpstreamBaseline } from "../session-upstream-marker.js";
import { assertCodexThreadForkResponse } from "./protocol-validators.js";
import type { CodexThread, CodexThreadForkResponse } from "./protocol.js";
import { sessionBindingIdentity, type CodexAppServerBindingStore } from "./session-binding.js";
import { createImportedCodexSession } from "./session-history-import.js";
import {
listCodexUpstreamTurns,
precheckCodexUpstreamForkBoundary,
resolveCodexUpstreamForkBoundary,
} from "./upstream-fork-boundary.js";
function readConnectionFingerprint(ref: unknown): string | undefined {
if (!isRecord(ref)) {
return undefined;
}
return typeof ref.connectionFingerprint === "string" && ref.connectionFingerprint.trim()
? ref.connectionFingerprint
: undefined;
}
function normalizeTurnId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export async function forkCodexUpstreamSession(
params: AgentHarnessSessionForkParams,
options: {
bindingStore: CodexAppServerBindingStore;
control: CodexSessionCatalogControl;
harnessRuntimeId: string;
resolveConfig?: () => OpenClawConfig | undefined;
runtime: PluginRuntime;
},
): Promise<AgentHarnessSessionForkResult> {
try {
return await options.control.withPinnedConnection(async (control) => {
let linked = false;
let bindingIdentity: ReturnType<typeof sessionBindingIdentity> | undefined;
const compensateFork = async (forkedThreadId: string) => {
if (bindingIdentity) {
await options.bindingStore
.mutate(bindingIdentity, { kind: "clear", threadId: forkedThreadId })
.catch(() => undefined);
}
if (linked) {
deleteSessionUpstreamLink(params.targetKey, params.source.agentId);
}
await control.archiveThread(forkedThreadId).catch(() => undefined);
};
const sourceFingerprint = readConnectionFingerprint(params.upstream.ref);
if (
params.upstream.kind !== "codex-app-server" ||
!sourceFingerprint ||
sourceFingerprint !== control.connectionFingerprint
) {
return {
status: "failed",
code: "upstream-unavailable",
message:
"This Codex thread is not available on the current connection. Reconnect to its host and try again.",
};
}
const resolved = await resolveCodexUpstreamForkBoundary({
...params.source,
threadId: params.upstream.threadId,
control,
});
if (!resolved.ok) {
return { status: "failed", code: resolved.code, message: resolved.message };
}
const liveTurns = await listCodexUpstreamTurns(control, params.upstream.threadId);
const precheck = precheckCodexUpstreamForkBoundary({
boundary: resolved.boundary,
turns: liveTurns,
});
if (!precheck.ok) {
return { status: "failed", code: precheck.code, message: precheck.message };
}
// beforeTurnId is experimental; the initialized shared client explicitly negotiates it.
const rawResponse = await control.forkThread({
threadId: params.upstream.threadId,
beforeTurnId: resolved.boundary.beforeTurnId,
excludeTurns: true,
});
let response: CodexThreadForkResponse;
try {
response = assertCodexThreadForkResponse(rawResponse);
} catch (error) {
const orphanThreadId =
isRecord(rawResponse.thread) && typeof rawResponse.thread.id === "string"
? rawResponse.thread.id.trim()
: "";
// A malformed response cannot be trusted to name a NEW thread; never archive an
// id that matches the source conversation.
if (orphanThreadId && orphanThreadId !== params.upstream.threadId) {
await control.archiveThread(orphanThreadId).catch(() => undefined);
}
throw error;
}
const threadId = response.thread.id.trim();
if (!threadId) {
throw new Error("Codex thread/fork response did not include a thread id");
}
// A contract-violating response reusing the source id would bind (and later
// archive) the original conversation; reject identity reuse outright.
if (threadId === params.upstream.threadId) {
throw new Error("Codex thread/fork response reused the source thread id");
}
const forkedThreadId = threadId;
try {
const connectionFingerprint = control.connectionFingerprint;
if (!connectionFingerprint) {
throw new Error("Codex fork connection did not include a fingerprint");
}
const forkedTurns = await listCodexUpstreamTurns(control, threadId);
const expectedLastTurnId = resolved.boundary.retainedMarker.turnId;
const actualLastTurnId = forkedTurns.at(-1)?.id ?? null;
// Boundary resolution already verified the source prefix; this read-back tail identity
// detects app-server versions that ignored the exclusive beforeTurnId cut.
if (actualLastTurnId !== expectedLastTurnId) {
await compensateFork(forkedThreadId);
return {
status: "failed",
code: "upstream-unavailable",
message:
"This Codex version does not support message-level forks. Update Codex, reconnect, and try again.",
};
}
const forkedThread: CodexThread = { ...response.thread, turns: forkedTurns };
const throughTurnId = codexLastTerminalTurnId(forkedThread, normalizeTurnId) ?? null;
const marker = codexUpstreamBaseline(forkedThread, normalizeTurnId);
const config = options.resolveConfig?.() ?? {};
const created = await createImportedCodexSession({
runtime: options.runtime,
config,
key: params.targetKey,
agentId: params.source.agentId,
thread: forkedThread,
throughTurnId,
initialEntry: {
agentHarnessId: options.harnessRuntimeId,
modelSelectionLocked: true,
},
afterImport: async (entry) => {
bindingIdentity = sessionBindingIdentity({
agentId: entry.agentId,
sessionId: entry.sessionId,
sessionKey: entry.key,
config,
});
// Link BEFORE bind: a crash cannot expose a bound session to local-only
// rewind/switch while its canonical upstream ownership is missing.
linked = upsertSessionUpstreamLink({
sessionKey: entry.key,
agentId: entry.agentId,
catalogId: params.upstream.catalogId,
hostId: params.upstream.hostId,
threadId,
upstreamKind: params.upstream.kind,
upstreamRef: { connectionFingerprint, threadId },
marker,
});
if (!linked) {
throw new Error("Codex fork link could not be persisted");
}
const attached = await options.bindingStore.mutate(bindingIdentity, {
kind: "set",
binding: {
threadId,
cwd: forkedThread.cwd ?? "",
model: response.model,
modelProvider: response.modelProvider ?? undefined,
historyCoveredThrough: new Date().toISOString(),
},
});
if (!attached) {
throw new Error("Codex session binding changed before the fork could be attached");
}
return { pluginExtensions: entry.entry.pluginExtensions };
},
});
return {
status: "created",
key: created.key,
...(resolved.editorText !== undefined ? { editorText: resolved.editorText } : {}),
};
} catch {
// thread/fork commits before local materialization. The guarded session initializer
// rolls back its row/transcript; this capability clears link/binding and archives the orphan.
await compensateFork(forkedThreadId);
return {
status: "failed",
code: "upstream-unavailable",
message:
"The Codex fork could not be verified or imported into a new session. Refresh sessions and try again.",
};
}
});
} catch {
return {
status: "failed",
code: "upstream-unavailable",
message:
"The Codex thread could not be forked. Check that Codex is available, then try again.",
};
}
}
@@ -1,5 +1,7 @@
import type {
CodexThread,
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadListParams,
CodexThreadListResponse,
CodexThreadTurnsListParams,
@@ -45,6 +47,7 @@ export type CodexSessionCatalogControl = {
listPage(params: CodexSessionCatalogPageParams): Promise<CodexSessionCatalogPage>;
listDescendantPage(params: CodexThreadListParams): Promise<CodexThreadListResponse>;
listTurnPage(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
readThread(threadId: string, includeTurns?: boolean): Promise<CodexThread>;
archiveThread(threadId: string): Promise<void>;
};
+29 -24
View File
@@ -11,13 +11,15 @@ import type {
SessionCatalogHost,
SessionCatalogProvider,
} from "openclaw/plugin-sdk/session-catalog";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import { resolveCodexSupervisionAppServerRuntimeOptions } from "./app-server/config.js";
import { buildCodexAppServerConnectionFingerprint } from "./app-server/plugin-app-cache-key.js";
import { assertCodexThreadForkParams } from "./app-server/protocol-validators.js";
import type {
CodexThread,
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadListParams,
CodexThreadListResponse,
CodexThreadTurnsListParams,
@@ -31,12 +33,12 @@ import {
type CodexAppServerPendingSupervisionBranch,
type CodexAppServerThreadBinding,
} from "./app-server/session-binding.js";
import { createImportedCodexSession } from "./app-server/session-history-import.js";
import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./app-server/shared-client.js";
import { assertCodexArchiveDescendantsUnowned } from "./app-server/thread-archive-guard.js";
import { importCodexThreadHistoryToTranscript } from "./app-server/transcript-mirror.js";
import { codexControlRequest } from "./command-rpc.js";
import {
adoptedSourceKey,
@@ -123,6 +125,7 @@ type CodexSessionCatalogRequestSnapshot = {
requestTimeoutMs: number;
listThreads(params: CodexThreadListParams, timeoutMs: number): Promise<CodexThreadListResponse>;
listThreadTurns(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
readThread(threadId: string, includeTurns: boolean): Promise<CodexThread>;
archiveThread(threadId: string): Promise<void>;
};
@@ -213,6 +216,9 @@ function createCodexSessionCatalogControlFromRequests(params: {
const response = await params.createRequestSnapshot().listThreadTurns(listParams);
return response;
},
async forkThread(forkParams) {
return await params.createRequestSnapshot().forkThread(forkParams);
},
async archiveThread(threadId) {
await params.createRequestSnapshot().archiveThread(threadId);
},
@@ -257,6 +263,13 @@ export function createCodexSessionCatalogControl(params: {
listParams,
requestOptions,
),
forkThread: async (forkParams) =>
await codexControlRequest(
pluginConfig,
CODEX_CONTROL_METHODS.forkThread,
assertCodexThreadForkParams(forkParams),
requestOptions,
),
archiveThread: async (threadId) => {
await codexControlRequest(
pluginConfig,
@@ -307,6 +320,14 @@ export function createCodexSessionCatalogControl(params: {
config: runtimeConfig,
timeoutMs: runtime.requestTimeoutMs,
}),
forkThread: async (forkParams) =>
await requestCodexAppServerClientJson<CodexThreadForkResponse>({
client,
method: CODEX_CONTROL_METHODS.forkThread,
requestParams: assertCodexThreadForkParams(forkParams),
config: runtimeConfig,
timeoutMs: runtime.requestTimeoutMs,
}),
archiveThread: async (threadId) => {
await requestCodexAppServerClientJson({
client,
@@ -866,17 +887,17 @@ async function createOrReuseAdoptedSession(params: {
let createdBindingIdentity: ReturnType<typeof sessionBindingIdentity> | undefined;
let createdPendingBinding: CodexAppServerPendingSupervisionBranch | undefined;
try {
const label = params.sourceThread.name?.trim() || undefined;
const spawnedCwd = params.sourceThread.cwd?.trim() || undefined;
const pendingLastTurnId = codexLastTerminalTurnId(params.sourceThread, boundCatalogSessionId);
const marker: CodexSupervisionMarker = { sourceThreadId: params.sourceThread.id };
const created = await params.api.runtime.agent.session.createSessionEntry({
cfg: params.config,
const created = await createImportedCodexSession({
runtime: params.api.runtime,
config: params.config,
key: adoptionSessionKey(params.sourceThread.id),
agentId: resolveDefaultAgentId(params.config),
thread: params.sourceThread,
throughTurnId: pendingLastTurnId ?? null,
recoverMatchingInitialEntry: true,
...(label ? { label } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
initialEntry: {
agentHarnessId: "codex",
modelSelectionLocked: true,
@@ -890,28 +911,12 @@ async function createOrReuseAdoptedSession(params: {
},
},
},
afterCreate: async (entry) => {
afterImport: async (entry) => {
createdBindingIdentity = sessionBindingIdentity({
sessionId: entry.sessionId,
sessionKey: entry.key,
config: params.config,
});
// Post-flip the mirror targets SQLite rows; resolve the agent's store
// path instead of trusting the legacy sessionFile locator marker.
const storePath = resolveStorePath(params.config.session?.store, {
agentId: entry.agentId,
});
await importCodexThreadHistoryToTranscript({
thread: params.sourceThread,
throughTurnId: pendingLastTurnId ?? null,
storePath,
sessionId: entry.sessionId,
sessionKey: entry.key,
agentId: entry.agentId,
...(spawnedCwd ? { cwd: spawnedCwd } : {}),
modelProvider: params.sourceThread.modelProvider,
config: params.config,
});
createdPendingBinding = {
sourceThreadId: params.sourceThread.id,
connectionFingerprint: params.connectionFingerprint,
+5 -2
View File
@@ -282,7 +282,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +9: shared ingress monitor factory and lifecycle/result contracts across
// channel-outbound and its two deprecated compatibility barrels.
// +1: SwarmConfig exposes the tools.swarm contract through config-types.
8178,
// +3: harness sessionFork capability params, result, and failure-code contracts.
// +2: upstream-link registry write/delete for harness-owned session forks.
8183,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -327,7 +329,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: bounded raw transcript cursor reader.
// +1: bounded visible transcript cursor reader.
// +3: shared ingress monitor factory across channel-outbound and compat mirrors.
4546,
// +2: upstream-link registry write/delete for harness-owned session forks.
4548,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+44
View File
@@ -128,6 +128,42 @@ export type AgentHarnessResetParams = {
reason?: "new" | "reset" | "idle" | "daily" | "compaction" | "deleted" | "unknown";
};
export type AgentHarnessSessionForkFailureCode =
| "steer-message"
| "in-progress-turn"
| "drift-mismatch"
| "upstream-unavailable";
export type AgentHarnessSessionForkParams = {
targetKey: string;
source: {
agentId: string;
sessionId: string;
sessionKey: string;
storePath: string;
entryId: string;
};
upstream: {
catalogId: string;
hostId: string;
kind: import("../../plugins/session-catalog.js").SessionUpstreamKind;
threadId: string;
ref: import("../../plugins/session-catalog.js").SessionUpstreamJsonValue;
};
};
export type AgentHarnessSessionForkResult =
| {
status: "created";
key: string;
editorText?: string;
}
| {
status: "failed";
code: AgentHarnessSessionForkFailureCode;
message: string;
};
export type AgentHarnessResultClassification =
| "ok"
| NonNullable<AgentHarnessAttemptResult["agentHarnessResultClassification"]>;
@@ -188,6 +224,13 @@ type AgentHarnessSessionLifecycleCapability = {
dispose?(): Promise<void> | void;
};
type AgentHarnessSessionForkCapability = {
sessionFork?: {
upstreamKinds: readonly import("../../plugins/session-catalog.js").SessionUpstreamKind[];
fork(params: AgentHarnessSessionForkParams): Promise<AgentHarnessSessionForkResult>;
};
};
type AgentHarnessRuntimeArtifactCapability = {
/** Revalidate an artifact only at setup and persistent-operation boundaries. */
runtimeArtifact?: {
@@ -225,6 +268,7 @@ export type AgentHarness = AgentHarnessRunCapability &
AgentHarnessRuntimeArtifactCapability &
AgentHarnessAuthBindingCapability &
AgentHarnessProviderUsageCapability &
AgentHarnessSessionForkCapability &
AgentHarnessSessionLifecycleCapability;
export type RegisteredAgentHarness = {
@@ -8,16 +8,46 @@ import type { GatewayRequestContext, RespondFn } from "./types.js";
const mocks = vi.hoisted(() => ({
active: false,
capability: false,
external: false,
upstreamFork: vi.fn(),
queueClear: vi.fn(),
}));
vi.mock("../../agents/harness/registry.js", () => ({
listRegisteredAgentHarnesses: () =>
mocks.capability
? [
{
harness: {
sessionFork: {
upstreamKinds: ["codex-app-server"],
fork: mocks.upstreamFork,
},
},
},
]
: [],
}));
vi.mock("../../auto-reply/reply/queue/cleanup.js", () => ({
clearSessionQueues: mocks.queueClear,
}));
vi.mock("../../sessions/session-upstream-links.js", () => ({
readSessionUpstreamLink: () => (mocks.external ? { upstreamKind: "external" } : undefined),
readSessionUpstreamLink: () =>
mocks.external
? {
agentId: "main",
catalogId: "codex",
hostId: "gateway:local",
marker: { turnId: "turn-2", userMessageCount: 1 },
sessionKey,
threadId: "thread-source",
upstreamKind: "codex-app-server",
upstreamRef: { connectionFingerprint: "fingerprint", threadId: "thread-source" },
}
: undefined,
}));
vi.mock("./session-active-runs.js", () => {
@@ -27,6 +57,7 @@ vi.mock("./session-active-runs.js", () => {
import {
appendTranscriptEvent,
appendTranscriptMessage,
listSessionEntries,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import { sessionsHandlers } from "./sessions.js";
@@ -36,7 +67,9 @@ const sessionKey = "agent:main:rewind-handler";
beforeEach(async () => {
mocks.active = false;
mocks.capability = false;
mocks.external = false;
mocks.upstreamFork.mockReset();
mocks.queueClear.mockReset();
vi.stubEnv("OPENCLAW_STATE_DIR", tempDirs.make("openclaw-rewind-handler-"));
await upsertSessionEntry(
@@ -222,6 +255,102 @@ describe("session message-cut methods", () => {
}
});
it.each(["sessions.rewind", "sessions.branches.switch"] as const)(
"rejects %s for upstream-linked sessions even with a fork-capable harness",
async (method) => {
mocks.external = true;
mocks.capability = true;
const respond = await invoke(method, "user-entry");
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
message: expect.stringContaining("external agent harness"),
}),
);
expect(mocks.upstreamFork).not.toHaveBeenCalled();
},
);
it("delegates complete upstream fork materialization to the harness", async () => {
mocks.external = true;
mocks.capability = true;
mocks.upstreamFork.mockResolvedValue({
status: "created",
key: "agent:main:dashboard:forked",
editorText: "edit me",
});
const respond = await invoke("sessions.fork", "user-entry");
expect(respond).toHaveBeenCalledWith(
true,
{ editorText: "edit me", sessionKey: "agent:main:dashboard:forked" },
undefined,
);
expect(mocks.upstreamFork).toHaveBeenCalledWith(
expect.objectContaining({
source: expect.objectContaining({ entryId: "user-entry", sessionKey }),
targetKey: expect.stringMatching(/^agent:main:dashboard:/),
upstream: expect.objectContaining({
catalogId: "codex",
hostId: "gateway:local",
kind: "codex-app-server",
threadId: "thread-source",
}),
}),
);
});
it("does not mutate the local session when the upstream fork fails", async () => {
mocks.external = true;
mocks.capability = true;
mocks.upstreamFork.mockResolvedValue({
status: "failed",
code: "upstream-unavailable",
message: "Codex is offline. Try again.",
});
const entryCount = listSessionEntries({ agentId: "main" }).length;
const respond = await invoke("sessions.fork", "user-entry");
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.UNAVAILABLE,
details: { reason: "upstream-unavailable" },
}),
);
expect(listSessionEntries({ agentId: "main" })).toHaveLength(entryCount);
});
it.each(["steer-message", "in-progress-turn", "drift-mismatch"] as const)(
"passes through the %s boundary failure",
async (reason) => {
mocks.external = true;
mocks.capability = true;
mocks.upstreamFork.mockResolvedValue({
status: "failed",
code: reason,
message: `boundary failed: ${reason}`,
});
const respond = await invoke("sessions.fork", "user-entry");
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: ErrorCodes.INVALID_REQUEST,
details: { reason },
message: `boundary failed: ${reason}`,
}),
);
},
);
it("returns a typed error for unsupported transcript storage", async () => {
await upsertSessionEntry(
{ agentId: "main", sessionKey },
+113 -21
View File
@@ -7,6 +7,7 @@ import {
validateSessionsRewindParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listRegisteredAgentHarnesses } from "../../agents/harness/registry.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
import {
forkSessionAtMessage,
@@ -21,7 +22,10 @@ import {
isCompetingSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { readSessionUpstreamLink } from "../../sessions/session-upstream-links.js";
import {
readSessionUpstreamLink,
type SessionUpstreamLink,
} from "../../sessions/session-upstream-links.js";
import {
buildDashboardSessionKey,
resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId,
@@ -42,6 +46,13 @@ type MessageCutAction = "fork" | "rewind" | "switch";
const EXTERNAL_CONVERSATION_ERROR =
"Session history changes are unavailable because this session is owned by an external agent harness.";
function resolveUpstreamForkHarness(link: SessionUpstreamLink) {
const matches = listRegisteredAgentHarnesses().filter((entry) =>
entry.harness.sessionFork?.upstreamKinds.includes(link.upstreamKind),
);
return matches.length === 1 ? matches[0]?.harness.sessionFork : undefined;
}
export const sessionRewindHandlers: GatewayRequestHandlers = {
"sessions.branches.list": async (options) => {
if (
@@ -179,7 +190,10 @@ async function mutateSessionAtMessage(
}
const initialSessionId = initial.entry.sessionId;
const initialLifecycleRevision = initial.entry.lifecycleRevision;
if (readSessionUpstreamLink(initial.canonicalKey, initial.target.agentId)) {
const initialUpstreamLink = readSessionUpstreamLink(initial.canonicalKey, initial.target.agentId);
// Only fork may cross to an upstream-owned conversation (it creates a new thread).
// Rewind and switch would mutate the shared upstream history in place; fail closed.
if (initialUpstreamLink && action !== "fork") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, EXTERNAL_CONVERSATION_ERROR));
return;
}
@@ -273,7 +287,8 @@ async function mutateSessionAtMessage(
);
return;
}
if (readSessionUpstreamLink(current.canonicalKey, current.target.agentId)) {
const upstreamLink = readSessionUpstreamLink(current.canonicalKey, current.target.agentId);
if (upstreamLink && action !== "fork") {
respond(
false,
undefined,
@@ -293,30 +308,107 @@ async function mutateSessionAtMessage(
}
const targetKey =
action === "fork" ? buildDashboardSessionKey(current.target.agentId) : current.canonicalKey;
const result = await (action === "fork"
? forkSessionAtMessage({
agentId: current.target.agentId,
entryId,
sessionKey: current.canonicalKey,
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
targetKey,
})
: action === "rewind"
? rewindSessionToMessage({
const upstreamForkHarness = upstreamLink
? resolveUpstreamForkHarness(upstreamLink)
: undefined;
if (upstreamLink && !upstreamForkHarness) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, EXTERNAL_CONVERSATION_ERROR),
);
return;
}
const upstreamFork =
upstreamLink && upstreamForkHarness
? await upstreamForkHarness.fork({
targetKey,
source: {
agentId: current.target.agentId,
sessionId: current.entry.sessionId,
sessionKey: current.canonicalKey,
storePath: current.storePath,
entryId,
},
upstream: {
catalogId: upstreamLink.catalogId,
hostId: upstreamLink.hostId,
kind: upstreamLink.upstreamKind,
threadId: upstreamLink.threadId,
ref: upstreamLink.upstreamRef,
},
})
: undefined;
if (upstreamFork?.status === "failed") {
respond(
false,
undefined,
errorShape(
upstreamFork.code === "upstream-unavailable"
? ErrorCodes.UNAVAILABLE
: ErrorCodes.INVALID_REQUEST,
upstreamFork.message,
{ details: { reason: upstreamFork.code } },
),
);
return;
}
if (upstreamFork?.status === "created") {
// Canonical fork lineage stays upstream. Linked sessions intentionally do not enter
// the local branch graph; branch listing/switching remains rejected for them above.
respond(
true,
{
sessionKey: upstreamFork.key,
...(upstreamFork.editorText !== undefined
? { editorText: upstreamFork.editorText }
: {}),
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: upstreamFork.key,
...(upstreamFork.key === "global" && requestedAgent.agentId
? { agentId: requestedAgent.agentId }
: {}),
reason: "fork",
});
return;
}
let result: SessionMessageCutMutationResult | SessionBranchSwitchMutationResult;
try {
result = await (action === "fork"
? forkSessionAtMessage({
agentId: current.target.agentId,
entryId,
sessionKey: current.canonicalKey,
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
targetKey,
})
: switchSessionBranch({
agentId: current.target.agentId,
leafEntryId: entryId,
sessionKey: current.canonicalKey,
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
}));
: action === "rewind"
? rewindSessionToMessage({
agentId: current.target.agentId,
entryId,
sessionKey: current.canonicalKey,
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
})
: switchSessionBranch({
agentId: current.target.agentId,
leafEntryId: entryId,
sessionKey: current.canonicalKey,
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
}));
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `Failed to ${action} the local session. Try again.`),
);
return;
}
if (result.status !== "created") {
respondMessageCutError(result, action, entryId, respond);
return;
+3
View File
@@ -51,6 +51,9 @@ export type {
AgentHarnessSideQuestionParams,
AgentHarnessSideQuestionResult,
AgentHarnessResetParams,
AgentHarnessSessionForkFailureCode,
AgentHarnessSessionForkParams,
AgentHarnessSessionForkResult,
AgentHarnessSupport,
AgentHarnessSupportContext,
} from "../agents/harness/types.js";
+4
View File
@@ -27,6 +27,10 @@ export type {
SessionsCatalogReadParams,
SessionsCatalogReadResult,
} from "../../packages/gateway-protocol/src/schema/sessions-catalog.js";
export {
deleteSessionUpstreamLink,
upsertSessionUpstreamLink,
} from "../sessions/session-upstream-links.js";
export {
classifyClaudeCliHistoryMessage,
classifyClaudeCliHistoryLine,
+4 -2
View File
@@ -18,7 +18,7 @@ type SessionUpstreamDatabase = Pick<
>;
type SessionUpstreamLinkRow = Selectable<OpenClawStateKyselyDatabase["session_upstream_links"]>;
type SessionUpstreamLink = {
export type SessionUpstreamLink = {
sessionKey: string;
agentId: string;
catalogId: string;
@@ -79,7 +79,7 @@ export function upsertSessionUpstreamLink(
marker: SessionUpstreamJsonValue;
},
options: OpenClawStateDatabaseOptions & { now?: number } = {},
): void {
): boolean {
const now = options.now ?? Date.now();
try {
runOpenClawStateWriteTransaction(({ db }) => {
@@ -141,8 +141,10 @@ export function upsertSessionUpstreamLink(
),
);
}, options);
return true;
} catch (error) {
log.warn(`failed to upsert session upstream link: ${String(error)}`);
return false;
}
}