test: slim gateway session creation coverage (#122789)

* test: slim gateway session creation coverage

* test: move incognito initialization to its owner

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-12 13:43:47 -07:00
committed by GitHub
parent 99a9827077
commit be124de8fb
4 changed files with 208 additions and 557 deletions
+46
View File
@@ -15,6 +15,7 @@ import {
appendTranscriptEvent,
loadSessionEntry,
loadTranscriptEvents,
upsertSessionEntryCore,
} from "../../config/sessions/session-accessor.js";
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
@@ -36,6 +37,10 @@ import {
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
import {
closeOpenClawAgentDatabasesForTest,
resolveIncognitoOpenClawAgentSqlitePath,
} from "../../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
createChannelTestPluginBase,
@@ -428,6 +433,47 @@ afterEach(async () => {
await sessionMcpTesting.resetSessionMcpRuntimeManager();
});
describe("initSessionState guarded initialization", () => {
it("pins an admitted non-default-agent incognito session to its process-local store", async () => {
const stateDir = await makeCaseDir("openclaw-session-incognito-init-");
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const agentId = "work";
const sessionId = "incognito-work-session";
const sessionKey = "agent:work:dashboard:incognito-work-session";
const storePath = resolveIncognitoOpenClawAgentSqlitePath({ agentId });
await upsertSessionEntryCore(
{ agentId, sessionKey, storePath },
{ sessionId, incognito: true, updatedAt: Date.now() },
);
try {
await expect(
initSessionState({
cfg: {
agents: { list: [{ id: "main", default: true }, { id: agentId }] },
session: { store: path.join(stateDir, "durable", "{agentId}", "sessions.json") },
} as OpenClawConfig,
ctx: {
Body: "hello from incognito webchat",
Provider: "webchat",
SessionKey: sessionKey,
Surface: "webchat",
},
expectedExistingSessionId: sessionId,
pinExpectedExistingSession: true,
requestedSessionId: sessionId,
resumeRequestedSession: true,
}),
).resolves.toMatchObject({
sessionId,
sessionKey,
storePath,
});
} finally {
closeOpenClawAgentDatabasesForTest();
}
});
});
it("rejects inbound work for an archived session", async () => {
const storePath = await createStorePath("openclaw-session-init-archived-");
const sessionKey = "agent:main:telegram:chat:archived";
+159 -521
View File
@@ -1,12 +1,12 @@
// Session creation tests protect dashboard-origin session records, transcript
// creation, parent linkage, and model/provider overrides exposed by the gateway API.
import { execFile } from "node:child_process";
import { readdirSync } from "node:fs";
import { constants as fsConstants, readdirSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { findGitCheckoutRoot } from "../agents/worktrees/git.js";
import {
@@ -15,8 +15,6 @@ import {
listRegistryWorktrees,
} from "../agents/worktrees/registry.js";
import { managedWorktrees } from "../agents/worktrees/service.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { initSessionState } from "../auto-reply/reply/session.js";
import { getRuntimeConfig } from "../config/io.js";
import { loadCombinedSessionStoreForGatewayCore } from "../config/sessions/combined-store-gateway.js";
import {
@@ -25,7 +23,6 @@ import {
upsertSessionEntryCore,
} from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { isSessionLifecycleMutationActive } from "../sessions/session-lifecycle-admission.js";
@@ -80,7 +77,6 @@ const sessionDiffBaselineMocks = vi.hoisted(() => ({
}));
const dashboardTitleGenerationMocks = vi.hoisted(() => ({
actual: undefined as GenerateConversationLabelWithFallback | undefined,
generate: vi.fn<GenerateConversationLabelWithFallback>(),
}));
@@ -104,18 +100,9 @@ vi.mock("../sessions/session-diff-baseline.js", async (importOriginal) => {
return { ...actual, ensureSessionDiffBaseline: sessionDiffBaselineMocks.ensure };
});
vi.mock("../auto-reply/reply/conversation-label-generator.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../auto-reply/reply/conversation-label-generator.js")>();
dashboardTitleGenerationMocks.actual = actual.generateConversationLabelWithFallback;
dashboardTitleGenerationMocks.generate.mockImplementation(
actual.generateConversationLabelWithFallback,
);
return {
...actual,
generateConversationLabelWithFallback: dashboardTitleGenerationMocks.generate,
};
});
vi.mock("../auto-reply/reply/conversation-label-generator.js", () => ({
generateConversationLabelWithFallback: dashboardTitleGenerationMocks.generate,
}));
vi.mock("./server-methods/chat-send-background.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./server-methods/chat-send-background.js")>();
@@ -135,16 +122,32 @@ const { createSessionStoreDir, createSelectedGlobalSessionStore, openClient } =
setupGatewaySessionsTestHarness();
const execFileAsync = promisify(execFile);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
let gitWorkspaceTemplateRoot: string;
let gitWorkspaceTemplate: string;
beforeAll(async () => {
gitWorkspaceTemplateRoot = await fs.realpath(
await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-session-git-template-")),
);
const workspace = createGitWorkspace(gitWorkspaceTemplateRoot);
await Promise.all([
import("./server-methods/chat-send-background.js"),
import("./session-transcript-readers.js"),
workspace,
]);
gitWorkspaceTemplate = await workspace;
});
afterAll(async () => {
await fs.rm(gitWorkspaceTemplateRoot, { recursive: true, force: true });
});
beforeEach(() => {
sessionDiffBaselineMocks.ensure.mockClear();
// Baseline capture has dedicated owner coverage and one authenticated integration below.
sessionDiffBaselineMocks.useReal = false;
dashboardTitleGenerationMocks.generate.mockReset();
if (!dashboardTitleGenerationMocks.actual) {
throw new Error("actual dashboard title generator was not loaded");
}
dashboardTitleGenerationMocks.generate.mockImplementation(dashboardTitleGenerationMocks.actual);
dashboardTitleGenerationMocks.generate.mockResolvedValue("Generated Dashboard Title");
dashboardTitleScheduleMocks.schedule.mockReset();
if (!dashboardTitleScheduleMocks.actual) {
throw new Error("actual dashboard title scheduler was not loaded");
@@ -172,50 +175,6 @@ async function makeNonGitTempDir(prefix: string): Promise<string> {
}
}
test("sessions.create and sessions.delete preserve every concurrent session lifecycle", async () => {
const { storePath } = await createSessionStoreDir();
const sessionCount = 24;
const created = await Promise.all(
Array.from({ length: sessionCount }, (_, index) =>
directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
label: `Concurrent session ${index}`,
}),
),
);
expect(created.every((result) => result.ok)).toBe(true);
const sessionKeys = created.map((result) =>
requireNonEmptyString(result.payload?.key, "concurrent session key"),
);
const sessionIds = created.map((result) =>
requireNonEmptyString(result.payload?.sessionId, "concurrent session id"),
);
expect(new Set(sessionKeys).size).toBe(sessionCount);
expect(new Set(sessionIds).size).toBe(sessionCount);
for (const [index, sessionKey] of sessionKeys.entries()) {
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
sessionId: sessionIds[index],
label: `Concurrent session ${index}`,
});
}
const deleted = await Promise.all(
sessionKeys.map((key) =>
directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
}),
),
);
expect(deleted.every((result) => result.ok && result.payload?.deleted === true)).toBe(true);
for (const sessionKey of sessionKeys) {
expect(loadSessionEntry({ sessionKey, storePath })).toBeUndefined();
}
});
// The adoption assertion below flaked once on CI (run 31609081812) with the persisted
// row missing while all 16 creates succeeded; exhaustive owner-path analysis found no
// mechanism, and the failure never reproduced locally. On mismatch, capture which SQLite
@@ -239,7 +198,7 @@ test("concurrent sessions.create requests adopt one canonical keyed session", as
const key = "agent:main:dashboard:concurrent-keyed-session";
const created = await Promise.all(
Array.from({ length: 16 }, () =>
Array.from({ length: 4 }, () =>
directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
key,
@@ -259,50 +218,6 @@ test("concurrent sessions.create requests adopt one canonical keyed session", as
).toBe(canonicalSessionId);
});
test("keyed sessions remain recoverable across overlapping create and delete waves", async () => {
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:concurrent-lifecycle-waves";
for (let wave = 0; wave < 6; wave += 1) {
const operations = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
index % 3 === 0
? directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
})
: directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
key,
}),
),
);
expect(
operations.every((result) => result.ok),
`lifecycle wave ${wave}`,
).toBe(true);
const recovered = await directSessionReq<{ key: string; sessionId: string }>(
"sessions.create",
{ agentId: "main", key },
);
expect(recovered.ok, `creation after lifecycle wave ${wave}`).toBe(true);
expect(recovered.payload?.key).toBe(key);
expect(loadSessionEntry({ sessionKey: key, storePath })?.sessionId).toBe(
recovered.payload?.sessionId,
);
const deleted = await directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
});
expect(deleted.ok, `deletion after lifecycle wave ${wave}`).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(loadSessionEntry({ sessionKey: key, storePath })).toBeUndefined();
}
});
test("sessions.create keeps incognito rows process-local through list, spawn, reset, and delete", async () => {
const { storePath } = await createSessionStoreDir();
try {
@@ -535,7 +450,7 @@ test("sessions.create keeps incognito rows process-local through list, spawn, re
}
});
test("incognito sessions survive non-default-agent webchat reply initialization", async () => {
test("incognito webchat rejects a vanished non-default-agent session before dispatch", async () => {
const { storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] };
const { ws } = await openClient({
@@ -555,61 +470,6 @@ test("incognito sessions survive non-default-agent webchat reply initialization"
expect(created.ok).toBe(true);
const sessionKey = requireNonEmptyString(created.payload?.key, "incognito webchat key");
const sessionId = requireNonEmptyString(created.payload?.sessionId, "incognito webchat id");
let resolveDispatch!: (value: Awaited<ReturnType<typeof initSessionState>>) => void;
let rejectDispatch!: (error: unknown) => void;
const dispatched = new Promise<Awaited<ReturnType<typeof initSessionState>>>(
(resolve, reject) => {
resolveDispatch = resolve;
rejectDispatch = reject;
},
);
dispatchInboundMessageMock.mockImplementationOnce(async (params: unknown) => {
const input = params as {
cfg: OpenClawConfig;
ctx: Parameters<typeof initSessionState>[0]["ctx"];
replyOptions?: {
expectedExistingSessionId?: string;
pinExpectedExistingSession?: boolean;
requestedSessionId?: string;
resumeRequestedSession?: boolean;
};
};
try {
resolveDispatch(
await initSessionState({
cfg: input.cfg,
ctx: finalizeInboundContext(input.ctx),
commandAuthorized: true,
expectedExistingSessionId: input.replyOptions?.expectedExistingSessionId,
pinExpectedExistingSession: input.replyOptions?.pinExpectedExistingSession,
requestedSessionId: input.replyOptions?.requestedSessionId,
resumeRequestedSession: input.replyOptions?.resumeRequestedSession,
}),
);
} catch (error) {
rejectDispatch(error);
}
return {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
};
});
const sent = await rpcReq(ws, "chat.send", {
sessionKey,
sessionId,
message: "hello from incognito webchat",
idempotencyKey: "incognito-webchat-send",
});
expect(sent.ok).toBe(true);
await expect(dispatched).resolves.toMatchObject({
sessionId,
sessionKey,
storePath: resolveIncognitoOpenClawAgentSqlitePath({ agentId: "work" }),
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
closeOpenClawAgentDatabasesForTest();
dispatchInboundMessageMock.mockClear();
@@ -882,7 +742,7 @@ function waitForFast<T>(
return vi.waitFor(callback, { interval: 1, ...options });
}
async function initializeGitWorkspace(root: string): Promise<string> {
async function createGitWorkspace(root: string): Promise<string> {
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true });
await execFileAsync("git", ["-C", workspace, "init", "-b", "main"]);
@@ -902,6 +762,33 @@ async function initializeGitWorkspace(root: string): Promise<string> {
return await fs.realpath(workspace);
}
async function initializeGitWorkspace(root: string): Promise<string> {
const workspace = path.join(root, "workspace");
await fs.cp(gitWorkspaceTemplate, workspace, {
recursive: true,
mode: fsConstants.COPYFILE_FICLONE,
});
return await fs.realpath(workspace);
}
function managedWorktreeFixture(params: {
id: string;
name: string;
ownerId: string;
path: string;
repoRoot: string;
}): NonNullable<ReturnType<typeof managedWorktrees.findLiveById>> {
return {
...params,
baseRef: "HEAD",
branch: `openclaw/${params.name}`,
createdAt: 1,
lastActiveAt: 1,
ownerKind: "session",
repoFingerprint: "test-repository",
};
}
test("sessions.create captures and persists the initial workspace diff baseline", async () => {
const root = tempDirs.make("openclaw-session-diff-baseline-");
const workspace = await initializeGitWorkspace(root);
@@ -1057,44 +944,6 @@ test("sessions.create rejects draft visibility when policy disables drafts", asy
});
});
test("sessions.create provisions its worktree inside the target lifecycle fence", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-session-worktree-fence-",
});
const workspace = await initializeGitWorkspace(openClawState.root);
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace };
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:worktree-fence";
const originalCreate = managedWorktrees.create.bind(managedWorktrees);
const createSpy = vi.spyOn(managedWorktrees, "create").mockImplementation(async (params) => {
expect(isSessionLifecycleMutationActive(storePath, [key])).toBe(true);
return await originalCreate(params);
});
let worktreeId: string | undefined;
try {
const created = await directSessionReq<{
worktree: { id: string; path: string; branch: string };
}>(
"sessions.create",
{ key, agentId: "main", worktree: true },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(created.ok).toBe(true);
worktreeId = created.payload?.worktree.id;
expect(createSpy).toHaveBeenCalledTimes(1);
} finally {
createSpy.mockRestore();
if (worktreeId) {
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
}
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
await openClawState.cleanup();
}
});
test("sessions.create rolls back failed provisioning before a same-key creator proceeds", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
@@ -1222,7 +1071,12 @@ test("sessions.create provisions and reuses a session worktree for later runs",
const workspace = await initializeGitWorkspace(root);
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace };
await createSessionStoreDir();
const { storePath } = await createSessionStoreDir();
const originalCreate = managedWorktrees.create.bind(managedWorktrees);
const createSpy = vi.spyOn(managedWorktrees, "create").mockImplementation(async (params) => {
expect(isSessionLifecycleMutationActive(storePath, [params.ownerId])).toBe(true);
return await originalCreate(params);
});
let worktreeId: string | undefined;
try {
const created = await directSessionReq<{
@@ -1259,6 +1113,7 @@ test("sessions.create provisions and reuses a session worktree for later runs",
expect(recreated.ok).toBe(true);
expect(recreated.payload?.worktree).toEqual(worktree);
expect(recreated.payload?.entry.spawnedCwd).toBe(worktree?.path);
expect(createSpy).toHaveBeenCalledTimes(1);
expect(
listRegistryWorktrees(process.env).filter(
(record) =>
@@ -1283,6 +1138,7 @@ test("sessions.create provisions and reuses a session worktree for later runs",
});
ws.close();
} finally {
createSpy.mockRestore();
if (worktreeId) {
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
}
@@ -1408,47 +1264,40 @@ test("sessions.create names its managed worktree without waiting for the model t
}
});
test("sessions.create honors worktree name/base ref and persists worktree info", async () => {
test("sessions.create maps worktree options and preserves a nested workspace cwd", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-session-worktree-target-",
prefix: "openclaw-session-worktree-options-",
});
const root = openClawState.root;
const workspace = await initializeGitWorkspace(root);
await execFileAsync("git", ["-C", workspace, "checkout", "-b", "base-branch"]);
await fs.writeFile(path.join(workspace, "base.txt"), "base\n");
await execFileAsync("git", ["-C", workspace, "add", "base.txt"]);
await execFileAsync("git", [
"-c",
"user.name=OpenClaw Test",
"-c",
"user.email=openclaw-test@example.invalid",
"-C",
workspace,
"commit",
"-m",
"base branch commit",
const repoRoot = await initializeGitWorkspace(openClawState.root);
const workspace = path.join(repoRoot, "packages", "app");
const worktreePath = path.join(openClawState.root, "managed-worktree");
const key = "agent:main:dashboard:worktree-options";
await Promise.all([
fs.mkdir(workspace, { recursive: true }),
fs.mkdir(worktreePath, { recursive: true }),
]);
const { stdout: baseCommitRaw } = await execFileAsync("git", [
"-C",
workspace,
"rev-parse",
"HEAD",
]);
await execFileAsync("git", ["-C", workspace, "checkout", "main"]);
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace };
await createSessionStoreDir();
let worktreeId: string | undefined;
const createSpy = vi.spyOn(managedWorktrees, "create").mockResolvedValue(
managedWorktreeFixture({
id: "worktree-options",
name: "target-task",
ownerId: key,
path: worktreePath,
repoRoot,
}),
);
try {
const created = await directSessionReq<{
key: string;
entry: { spawnedCwd?: string; worktree?: { id: string; branch: string; repoRoot: string } };
worktree: { id: string; path: string; branch: string };
}>(
"sessions.create",
{
agentId: "main",
key,
worktree: true,
worktreeName: "target-task",
worktreeBaseRef: "base-branch",
@@ -1457,21 +1306,24 @@ test("sessions.create honors worktree name/base ref and persists worktree info",
);
expect(created.ok).toBe(true);
const worktree = created.payload?.worktree;
worktreeId = worktree?.id;
expect(worktree?.branch).toBe("openclaw/target-task");
const { stdout: worktreeCommitRaw } = await execFileAsync("git", [
"-C",
requireNonEmptyString(worktree?.path, "worktree path"),
"rev-parse",
"HEAD",
]);
expect(worktreeCommitRaw.trim()).toBe(baseCommitRaw.trim());
expect(created.payload?.entry.worktree).toEqual({
id: worktree?.id,
branch: "openclaw/target-task",
repoRoot: workspace,
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: workspace,
ownerKind: "session",
ownerId: key,
name: "target-task",
baseRef: "base-branch",
}),
);
expect(created.payload?.entry).toMatchObject({
spawnedCwd: path.join(worktreePath, "packages", "app"),
worktree: {
id: "worktree-options",
branch: "openclaw/target-task",
repoRoot,
},
});
await expect(fs.stat(path.join(worktreePath, "packages", "app"))).resolves.toBeDefined();
const rejected = await directSessionReq(
"sessions.create",
@@ -1480,28 +1332,73 @@ test("sessions.create honors worktree name/base ref and persists worktree info",
);
expect(rejected.ok).toBe(false);
} finally {
if (worktreeId) {
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
}
createSpy.mockRestore();
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
await openClawState.cleanup();
}
});
test("sessions.create execNode binds session exec routing", async () => {
await createSessionStoreDir();
const created = await directSessionReq<{
key: string;
entry: { execHost?: string; execNode?: string };
}>(
"sessions.create",
{ agentId: "main", execNode: "macbook" },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
test("sessions.create maps an admin-selected worktree cwd and rejects repository changes", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-session-selected-workspace-",
});
const selectedRoot = tempDirs.make(
"openclaw-session-selected-repository-",
await fs.realpath(os.tmpdir()),
);
expect(created.ok).toBe(true);
expect(created.payload?.entry.execHost).toBe("node");
expect(created.payload?.entry.execNode).toBe("macbook");
const [configuredWorkspace, selectedWorkspace] = await Promise.all([
initializeGitWorkspace(openClawState.root),
initializeGitWorkspace(selectedRoot),
]);
const worktreePath = path.join(openClawState.root, "selected-worktree");
const key = "agent:main:dashboard:selected-workspace";
await fs.mkdir(worktreePath, { recursive: true });
const record = managedWorktreeFixture({
id: "selected-worktree",
name: "selected-worktree",
ownerId: key,
path: worktreePath,
repoRoot: selectedWorkspace,
});
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace: configuredWorkspace };
await createSessionStoreDir();
const createSpy = vi.spyOn(managedWorktrees, "create").mockResolvedValue(record);
const findSpy = vi.spyOn(managedWorktrees, "findLiveById").mockReturnValue(record);
try {
const created = await directSessionReq<{
entry: { spawnedCwd?: string };
worktree: { id: string; path: string };
}>(
"sessions.create",
{ agentId: "main", key, worktree: true, cwd: selectedWorkspace },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(created.ok).toBe(true);
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({ repoRoot: selectedWorkspace }),
);
expect(created.payload?.entry.spawnedCwd).toBe(worktreePath);
const mismatched = await directSessionReq(
"sessions.create",
{ key, agentId: "main", worktree: true, cwd: configuredWorkspace },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(mismatched).toMatchObject({
ok: false,
error: { message: "session worktree belongs to a different repository" },
});
} finally {
createSpy.mockRestore();
findSpy.mockRestore();
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
await openClawState.cleanup();
}
});
test("sessions.create accepts a node-host cwd without provisioning a Gateway worktree", async () => {
@@ -1615,68 +1512,6 @@ test("sessions.create rejects a Gateway worktree targeting a node", async () =>
});
});
test("sessions.create provisions a worktree from an admin-selected cwd", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-configured-workspace-",
});
const configuredRoot = openClawState.root;
const selectedRoot = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-selected-workspace-"),
);
const configuredWorkspace = await initializeGitWorkspace(configuredRoot);
const selectedWorkspace = await initializeGitWorkspace(selectedRoot);
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace: configuredWorkspace };
await createSessionStoreDir();
let worktreeId: string | undefined;
try {
const created = await directSessionReq<{
key: string;
entry: { spawnedCwd?: string };
worktree: { id: string; path: string };
}>(
"sessions.create",
{ agentId: "main", worktree: true, cwd: selectedWorkspace },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(created.ok).toBe(true);
const worktree = created.payload?.worktree;
worktreeId = worktree?.id;
expect(created.payload?.entry.spawnedCwd).toBe(worktree?.path);
expect(
findLiveRegistryWorktreeByOwner(process.env, "session", created.payload?.key ?? ""),
).toMatchObject({
id: worktree?.id,
repoRoot: selectedWorkspace,
});
const mismatched = await directSessionReq(
"sessions.create",
{
key: created.payload?.key,
agentId: "main",
worktree: true,
cwd: configuredWorkspace,
},
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(mismatched).toMatchObject({
ok: false,
error: { message: "session worktree belongs to a different repository" },
});
} finally {
if (worktreeId) {
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
}
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
await openClawState.cleanup();
await fs.rm(selectedRoot, { recursive: true, force: true });
}
});
test("sessions.create persists a Gateway cwd without a managed worktree", async () => {
const created = await directSessionReq(
"sessions.create",
@@ -1835,50 +1670,6 @@ test("sessions.create skips the worktree setup script for non-admin callers", as
}
});
test("sessions.create preserves a linked-worktree subdirectory", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-subdir-session-worktree-",
});
const root = openClawState.root;
const repoRoot = await initializeGitWorkspace(root);
const linkedRoot = path.join(root, "linked");
await execFileAsync("git", ["-C", repoRoot, "worktree", "add", "-b", "linked", linkedRoot]);
const workspace = path.join(linkedRoot, "packages", "app");
await fs.mkdir(workspace, { recursive: true });
closeOpenClawStateDatabaseForTest();
testState.agentConfig = { workspace };
await createSessionStoreDir();
let worktreeId: string | undefined;
try {
const created = await directSessionReq<{
key: string;
entry: { spawnedCwd?: string };
worktree: { id: string; path: string; branch: string };
}>(
"sessions.create",
{ agentId: "main", worktree: true },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(created.ok).toBe(true);
const worktree = created.payload?.worktree;
worktreeId = worktree?.id;
// The managed worktree anchors at the repo root even when the workspace is nested;
// the session cwd points at the equivalent subdirectory inside the worktree.
expect(worktree?.branch).toMatch(/^openclaw\/[a-z0-9]+(?:-[a-z0-9]+)+$/);
expect(created.payload?.entry.spawnedCwd).toBe(
path.join(requireNonEmptyString(worktree?.path, "worktree path"), "packages", "app"),
);
} finally {
if (worktreeId) {
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
}
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
await openClawState.cleanup();
}
});
test("sessions.create reset-in-place persists the returned worktree cwd", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
@@ -2020,27 +1811,6 @@ test("sessions.create reset-in-place persists the returned worktree cwd", async
}
});
test("sessions.create rejects worktrees for non-git agent workspaces", async () => {
const workspace = await makeNonGitTempDir("openclaw-session-plain-workspace-");
testState.agentConfig = { workspace };
await createSessionStoreDir();
try {
const created = await directSessionReq(
"sessions.create",
{ agentId: "main", worktree: true },
{ client: { connect: { scopes: ["operator.admin"] } } as never },
);
expect(created.ok).toBe(false);
expect(created.error).toMatchObject({
code: "INVALID_REQUEST",
message: "agent workspace is not a git checkout",
});
} finally {
testState.agentConfig = undefined;
}
});
test("sessions.create rejects worktrees for agent workspaces without a commit", async () => {
const workspace = await makeNonGitTempDir("openclaw-session-unborn-workspace-");
await execFileAsync("git", ["init", workspace]);
@@ -4143,138 +3913,6 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
}
});
test("sessions.create completes simultaneous opposite-direction cross-agent forks", async () => {
const { dir } = await createSessionStoreDir();
const storeTemplate = path.join(dir, "{agentId}", "sessions.json");
const mainStorePath = storeTemplate.replace("{agentId}", "main");
const workStorePath = storeTemplate.replace("{agentId}", "work");
testState.sessionStorePath = storeTemplate;
testState.sessionConfig = { scope: "per-sender" };
testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] };
try {
const mainDir = path.dirname(mainStorePath);
const workDir = path.dirname(workStorePath);
await Promise.all([
fs.mkdir(mainDir, { recursive: true }),
fs.mkdir(workDir, { recursive: true }),
]);
const [mainParent, workParent] = await Promise.all([
createCheckpointFixture(mainDir),
createCheckpointFixture(workDir),
]);
await Promise.all([
writeSessionStore({
storePath: mainStorePath,
agentId: "main",
entries: {
main: sessionStoreEntry(mainParent.sessionId, {
sessionFile: mainParent.sessionFile,
}),
},
}),
writeSessionStore({
storePath: workStorePath,
agentId: "work",
entries: {
main: sessionStoreEntry(workParent.sessionId, {
sessionFile: workParent.sessionFile,
}),
},
}),
]);
await Promise.all([
seedSessionTranscript({
agentId: "main",
sessionId: mainParent.sessionId,
sessionKey: "agent:main:main",
storePath: mainStorePath,
messages: [{ role: "user", content: "main parent context" }],
}),
seedSessionTranscript({
agentId: "work",
sessionId: workParent.sessionId,
sessionKey: "agent:work:main",
storePath: workStorePath,
messages: [{ role: "user", content: "work parent context" }],
}),
]);
const requests = Array.from({ length: 12 }, (_, index) =>
index % 2 === 0
? {
agentId: "main",
parentSessionKey: "agent:work:main",
parentSessionId: workParent.sessionId,
storePath: mainStorePath,
}
: {
agentId: "work",
parentSessionKey: "agent:main:main",
parentSessionId: mainParent.sessionId,
storePath: workStorePath,
},
);
const created = await Promise.all(
requests.map((request) =>
directSessionReq<{
key: string;
sessionId: string;
entry: {
parentSessionKey?: string;
forkSource?: { sessionKey: string; sessionId: string };
forkedFromParent?: boolean;
};
}>("sessions.create", {
agentId: request.agentId,
parentSessionKey: request.parentSessionKey,
fork: true,
}),
),
);
expect(
created.every((result) => result.ok),
JSON.stringify(created.filter((result) => !result.ok)),
).toBe(true);
expect(new Set(created.map((result) => result.payload?.key)).size).toBe(requests.length);
expect(new Set(created.map((result) => result.payload?.sessionId)).size).toBe(requests.length);
for (const [index, result] of created.entries()) {
const request = requests[index];
if (!request) {
throw new Error(`missing cross-agent fork request ${index}`);
}
expect(result.payload?.entry).toMatchObject({
forkSource: {
sessionKey: request.parentSessionKey,
sessionId: request.parentSessionId,
},
forkedFromParent: true,
parentSessionKey: request.parentSessionKey,
});
const key = requireNonEmptyString(result.payload?.key, "cross-agent fork session key");
expect(
loadSessionEntry({
agentId: request.agentId,
sessionKey: key,
storePath: request.storePath,
}),
).toMatchObject({
forkSource: {
sessionKey: request.parentSessionKey,
sessionId: request.parentSessionId,
},
parentSessionKey: request.parentSessionKey,
sessionId: result.payload?.sessionId,
});
}
} finally {
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
testState.agentsConfig = undefined;
}
});
test("sessions.create can start the first agent turn from an initial task", async () => {
await createSessionStoreDir();
// Register "ops" so the deleted-agent guard added in #65986 does not
@@ -1,34 +0,0 @@
import { sessionAbortHandlers } from "../server-methods/sessions-abort.js";
import { sessionCompactHandlers } from "../server-methods/sessions-compact.js";
import { sessionCheckpointHandlers } from "../server-methods/sessions-compaction-checkpoints.js";
import { sessionCheckpointQueryHandlers } from "../server-methods/sessions-compaction-queries.js";
import { sessionCreateHandlers } from "../server-methods/sessions-create.js";
import { sessionDeleteHandlers } from "../server-methods/sessions-delete.js";
import { sessionDispatchHandlers } from "../server-methods/sessions-dispatch.js";
import { sessionGroupHandlers } from "../server-methods/sessions-groups.js";
import { sessionMessagingHandlers } from "../server-methods/sessions-messaging.js";
import { sessionMutationHandlers } from "../server-methods/sessions-mutations.js";
import { sessionReadHandlers } from "../server-methods/sessions-read.js";
import { sessionRewindHandlers } from "../server-methods/sessions-rewind.js";
import { sessionSharingHandlers } from "../server-methods/sessions-sharing.js";
import { sessionSubscriptionHandlers } from "../server-methods/sessions-subscriptions.js";
import { sessionSuggestionHandlers } from "../server-methods/sessions-suggestions.js";
import type { GatewayRequestHandlers } from "../server-methods/types.js";
export const sessionHandlerTestSurface: GatewayRequestHandlers = {
...sessionReadHandlers,
...sessionSharingHandlers,
...sessionSuggestionHandlers,
...sessionSubscriptionHandlers,
...sessionCreateHandlers,
...sessionCheckpointQueryHandlers,
...sessionCheckpointHandlers,
...sessionRewindHandlers,
...sessionDispatchHandlers,
...sessionMessagingHandlers,
...sessionAbortHandlers,
...sessionMutationHandlers,
...sessionDeleteHandlers,
...sessionGroupHandlers,
...sessionCompactHandlers,
};
@@ -16,7 +16,6 @@ import type { GatewayServerHarness } from "../server.e2e-ws-harness.js";
import { embeddedRunMock, agentDiscoveryMock, testState } from "../test-helpers.runtime-state.js";
import type { connectOk } from "../test-helpers.server.js";
import { installGatewayTestHooks, writeSessionStore } from "../test-helpers.server.js";
import { sessionHandlerTestSurface } from "./server-sessions-handlers.test-support.js";
export const getSessionManagerModule = createLazyRuntimeModule(
() => import("../../agents/sessions/index.js"),
@@ -34,8 +33,10 @@ const getGatewayServerHarnessModule = createLazyRuntimeModule(
() => import("../server.e2e-ws-harness.js"),
);
const getGatewayServerMethodsModule = createLazyRuntimeModule(() => import("../server-methods.js"));
export async function getSessionsHandlers() {
return sessionHandlerTestSurface;
return (await getGatewayServerMethodsModule()).coreGatewayHandlers;
}
type TestTranscriptMessage = Record<string, unknown> & {