From bb70b413401f83e80512b5865d661d50fb47c336 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 07:58:52 +0100 Subject: [PATCH] test: split lightweight agent session coverage --- src/commands/agent.session.test.ts | 164 +++++++++++++++++++++++++++++ src/commands/agent.test.ts | 125 ---------------------- 2 files changed, 164 insertions(+), 125 deletions(-) create mode 100644 src/commands/agent.session.test.ts diff --git a/src/commands/agent.session.test.ts b/src/commands/agent.session.test.ts new file mode 100644 index 00000000000..e601221e1aa --- /dev/null +++ b/src/commands/agent.session.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js"; +import { resolveSession } from "../agents/command/session.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; + +async function withTempHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase(fn, { prefix: "openclaw-agent-session-" }); +} + +function mockConfig( + home: string, + storePath: string, + agentsList?: Array<{ id: string; default?: boolean }>, +): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { "anthropic/claude-opus-4-6": {} }, + workspace: path.join(home, "openclaw"), + }, + list: agentsList, + }, + session: { store: storePath, mainKey: "main" }, + } as OpenClawConfig; +} + +function writeSessionStoreSeed( + storePath: string, + sessions: Record>, +) { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); +} + +async function withCrossAgentResumeFixture( + run: (params: { sessionId: string; sessionKey: string; cfg: OpenClawConfig }) => Promise, +): Promise { + await withTempHome(async (home) => { + const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); + const execStore = path.join(home, "sessions", "exec", "sessions.json"); + const sessionId = "session-exec-hook"; + const sessionKey = "agent:exec:hook:gmail:thread-1"; + writeSessionStoreSeed(execStore, { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + systemSent: true, + }, + }); + const cfg = mockConfig(home, storePattern, [{ id: "dev" }, { id: "exec", default: true }]); + await run({ sessionId, sessionKey, cfg }); + }); +} + +beforeEach(() => { + clearSessionStoreCacheForTest(); +}); + +describe("agent session resolution", () => { + it("creates a stable session key for explicit session-id-only runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const cfg = mockConfig(home, store); + + const resolution = resolveSession({ cfg, sessionId: "explicit-session-123" }); + + expect(resolution.sessionKey).toBe("agent:main:explicit:explicit-session-123"); + expect(resolution.sessionId).toBe("explicit-session-123"); + }); + }); + + it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => { + await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => { + const resolution = resolveSession({ cfg, sessionId }); + expect(resolution.sessionKey).toBe(sessionKey); + const agentId = resolveSessionAgentId({ sessionKey: resolution.sessionKey, config: cfg }); + expect(agentId).toBe("exec"); + expect(resolveAgentDir(cfg, agentId)).toContain( + `${path.sep}agents${path.sep}exec${path.sep}agent`, + ); + }); + }); + + it("resolves duplicate cross-agent sessionIds deterministically", async () => { + await withTempHome(async (home) => { + const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); + const otherStore = path.join(home, "sessions", "other", "sessions.json"); + const retiredStore = path.join(home, "sessions", "retired", "sessions.json"); + writeSessionStoreSeed(otherStore, { + "agent:other:main": { + sessionId: "run-dup", + updatedAt: Date.now() + 1_000, + }, + }); + writeSessionStoreSeed(retiredStore, { + "agent:retired:acp:run-dup": { + sessionId: "run-dup", + updatedAt: Date.now(), + }, + }); + const cfg = mockConfig(home, storePattern, [ + { id: "other" }, + { id: "retired", default: true }, + ]); + + const resolution = resolveSession({ cfg, sessionId: "run-dup" }); + + expect(resolution.sessionKey).toBe("agent:retired:acp:run-dup"); + expect(resolution.storePath).toBe(retiredStore); + }); + }); + + it("uses origin.provider for channel-specific session reset overrides", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + main: { + sessionId: "origin-provider-reset", + updatedAt: Date.now() - 30 * 60_000, + origin: { provider: "discord" }, + }, + }); + const cfg = mockConfig(home, store); + cfg.session = { + ...cfg.session, + reset: { mode: "idle", idleMinutes: 10 }, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 120 }, + }, + }; + + const resolution = resolveSession({ cfg, sessionKey: "main" }); + + expect(resolution.sessionId).toBe("origin-provider-reset"); + expect(resolution.isNewSession).toBe(false); + }); + }); + + it("forwards resolved outbound session context when resuming by sessionId", async () => { + await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => { + const resolution = resolveSession({ cfg, sessionId }); + expect(resolution.sessionKey).toBe(sessionKey); + const agentId = resolveSessionAgentId({ sessionKey: resolution.sessionKey, config: cfg }); + expect( + buildOutboundSessionContext({ + cfg, + sessionKey: resolution.sessionKey, + agentId, + }), + ).toEqual( + expect.objectContaining({ + key: sessionKey, + agentId: "exec", + }), + ); + }); + }); +}); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 169ff25ef9f..f7eceb4e2bd 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -5,10 +5,8 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j import "./agent-command.test-mocks.js"; import "../cron/isolated-agent.mocks.js"; import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js"; -import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js"; import * as authProfilesModule from "../agents/auth-profiles.js"; import * as sessionStoreModule from "../agents/command/session-store.runtime.js"; -import { resolveSession } from "../agents/command/session.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -23,7 +21,6 @@ import { resetAgentEventsForTest, resetAgentRunContextForTest, } from "../infra/agent-events.js"; -import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -161,29 +158,6 @@ function readSessionStore(storePath: string): Record { return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; } -async function withCrossAgentResumeFixture( - run: (params: { sessionId: string; sessionKey: string; cfg: OpenClawConfig }) => Promise, -): Promise { - await withTempHome(async (home) => { - const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); - const execStore = path.join(home, "sessions", "exec", "sessions.json"); - const sessionId = "session-exec-hook"; - const sessionKey = "agent:exec:hook:gmail:thread-1"; - writeSessionStoreSeed(execStore, { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - systemSent: true, - }, - }); - const cfg = mockConfig(home, storePattern, undefined, undefined, [ - { id: "dev" }, - { id: "exec", default: true }, - ]); - await run({ sessionId, sessionKey, cfg }); - }); -} - async function expectPersistedSessionFile(params: { seedKey: string; sessionId: string; @@ -381,18 +355,6 @@ describe("agentCommand", () => { }); }); - it("creates a stable session key for explicit session-id-only runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - const cfg = mockConfig(home, store); - - const resolution = resolveSession({ cfg, sessionId: "explicit-session-123" }); - - expect(resolution.sessionKey).toBe("agent:main:explicit:explicit-session-123"); - expect(resolution.sessionId).toBe("explicit-session-123"); - }); - }); - it("persists explicit session-id-only runs with the synthetic session key", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); @@ -426,93 +388,6 @@ describe("agentCommand", () => { }); }); - it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => { - await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => { - const resolution = resolveSession({ cfg, sessionId }); - expect(resolution.sessionKey).toBe(sessionKey); - const agentId = resolveSessionAgentId({ sessionKey: resolution.sessionKey, config: cfg }); - expect(agentId).toBe("exec"); - expect(resolveAgentDir(cfg, agentId)).toContain( - `${path.sep}agents${path.sep}exec${path.sep}agent`, - ); - }); - }); - - it("resolves duplicate cross-agent sessionIds deterministically", async () => { - await withTempHome(async (home) => { - const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); - const otherStore = path.join(home, "sessions", "other", "sessions.json"); - const retiredStore = path.join(home, "sessions", "retired", "sessions.json"); - writeSessionStoreSeed(otherStore, { - "agent:other:main": { - sessionId: "run-dup", - updatedAt: Date.now() + 1_000, - }, - }); - writeSessionStoreSeed(retiredStore, { - "agent:retired:acp:run-dup": { - sessionId: "run-dup", - updatedAt: Date.now(), - }, - }); - const cfg = mockConfig(home, storePattern, undefined, undefined, [ - { id: "other" }, - { id: "retired", default: true }, - ]); - - const resolution = resolveSession({ cfg, sessionId: "run-dup" }); - - expect(resolution.sessionKey).toBe("agent:retired:acp:run-dup"); - expect(resolution.storePath).toBe(retiredStore); - }); - }); - - it("uses origin.provider for channel-specific session reset overrides", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - main: { - sessionId: "origin-provider-reset", - updatedAt: Date.now() - 30 * 60_000, - origin: { provider: "discord" }, - }, - }); - const cfg = mockConfig(home, store); - cfg.session = { - ...cfg.session, - reset: { mode: "idle", idleMinutes: 10 }, - resetByChannel: { - discord: { mode: "idle", idleMinutes: 120 }, - }, - }; - - const resolution = resolveSession({ cfg, sessionKey: "main" }); - - expect(resolution.sessionId).toBe("origin-provider-reset"); - expect(resolution.isNewSession).toBe(false); - }); - }); - - it("forwards resolved outbound session context when resuming by sessionId", async () => { - await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => { - const resolution = resolveSession({ cfg, sessionId }); - expect(resolution.sessionKey).toBe(sessionKey); - const agentId = resolveSessionAgentId({ sessionKey: resolution.sessionKey, config: cfg }); - expect( - buildOutboundSessionContext({ - cfg, - sessionKey: resolution.sessionKey, - agentId, - }), - ).toEqual( - expect.objectContaining({ - key: sessionKey, - agentId: "exec", - }), - ); - }); - }); - it("resolves resumed session transcript path from custom session store directory", async () => { await withTempHome(async (home) => { const customStoreDir = path.join(home, "custom-state");