From be124de8fb7874ea2b18fddc7f25455193cc71f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 13:43:47 -0700 Subject: [PATCH] test: slim gateway session creation coverage (#122789) * test: slim gateway session creation coverage * test: move incognito initialization to its owner --------- Co-authored-by: Amp --- src/auto-reply/reply/session.test.ts | 46 ++ src/gateway/server.sessions.create.test.ts | 680 ++++-------------- .../server-sessions-handlers.test-support.ts | 34 - .../test/server-sessions.test-helpers.ts | 5 +- 4 files changed, 208 insertions(+), 557 deletions(-) delete mode 100644 src/gateway/test/server-sessions-handlers.test-support.ts diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 85ed0d75bf40..722510d93628 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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"; diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 9b7ba7db980e..9b738f3fb34d 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -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(), })); @@ -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(); - 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(); @@ -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 { } } -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>) => void; - let rejectDispatch!: (error: unknown) => void; - const dispatched = new Promise>>( - (resolve, reject) => { - resolveDispatch = resolve; - rejectDispatch = reject; - }, - ); - dispatchInboundMessageMock.mockImplementationOnce(async (params: unknown) => { - const input = params as { - cfg: OpenClawConfig; - ctx: Parameters[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((resolve) => { - setImmediate(resolve); - }); closeOpenClawAgentDatabasesForTest(); dispatchInboundMessageMock.mockClear(); @@ -882,7 +742,7 @@ function waitForFast( return vi.waitFor(callback, { interval: 1, ...options }); } -async function initializeGitWorkspace(root: string): Promise { +async function createGitWorkspace(root: string): Promise { 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 { return await fs.realpath(workspace); } +async function initializeGitWorkspace(root: string): Promise { + 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> { + 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 diff --git a/src/gateway/test/server-sessions-handlers.test-support.ts b/src/gateway/test/server-sessions-handlers.test-support.ts deleted file mode 100644 index bb0113639e1c..000000000000 --- a/src/gateway/test/server-sessions-handlers.test-support.ts +++ /dev/null @@ -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, -}; diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 55af84452b21..bf8abf83b87a 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -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 & {