From 7aadd023135c6a965d199b636deef3559ccbc008 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sat, 21 Feb 2026 15:37:59 -0800 Subject: [PATCH] test: add comprehensive workspace test suite and deploy pre-flight checks - Profile management: discoverProfiles, getEffectiveProfile precedence, setUIActiveProfile, resolveWebChatDir, workspace registry (32 tests) - Workspace init API: creation, bootstrap seeding, custom paths, validation, idempotency (13 tests) - Profile switch API: GET/POST profiles, validation, default reset (10 tests) - Chat isolation: profile-scoped chat dirs, session isolation (7 tests) - LLM context awareness: bootstrap loading, subagent filtering, resolveBootstrapContextForRun content isolation (15 unit + 5 live) - Subagent streaming: registerSubagent, event replay, persistence, ensureRegisteredFromDisk, fan-out (24 unit + 5 live) - deploy.sh: add --skip-tests flag, pnpm test + web:build pre-flight, auto git commit/push of version bump after publish - package.json: add test:workspace and test:workspace:live scripts Co-authored-by: Cursor --- apps/web/app/api/profiles/route.test.ts | 212 +++++++ apps/web/app/api/workspace/init/route.test.ts | 219 ++++++++ apps/web/lib/subagent-runs.test.ts | 505 +++++++++++++++++ apps/web/lib/subagent-streaming.live.test.ts | 191 +++++++ apps/web/lib/workspace-chat-isolation.test.ts | 173 ++++++ apps/web/lib/workspace-profiles.test.ts | 515 ++++++++++++++++++ package.json | 2 + scripts/deploy.sh | 29 + .../workspace-context-awareness.live.test.ts | 136 +++++ .../workspace-context-awareness.test.ts | 255 +++++++++ 10 files changed, 2237 insertions(+) create mode 100644 apps/web/app/api/profiles/route.test.ts create mode 100644 apps/web/app/api/workspace/init/route.test.ts create mode 100644 apps/web/lib/subagent-runs.test.ts create mode 100644 apps/web/lib/subagent-streaming.live.test.ts create mode 100644 apps/web/lib/workspace-chat-isolation.test.ts create mode 100644 apps/web/lib/workspace-profiles.test.ts create mode 100644 src/agents/workspace-context-awareness.live.test.ts create mode 100644 src/agents/workspace-context-awareness.test.ts diff --git a/apps/web/app/api/profiles/route.test.ts b/apps/web/app/api/profiles/route.test.ts new file mode 100644 index 00000000000..c64108838d6 --- /dev/null +++ b/apps/web/app/api/profiles/route.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Dirent } from "node:fs"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +import { join } from "node:path"; + +function makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + path: "", + parentPath: "", + } as Dirent; +} + +describe("profiles API", () => { + const originalEnv = { ...process.env }; + const STATE_DIR = join("/home/testuser", ".openclaw"); + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_WORKSPACE; + delete process.env.OPENCLAW_STATE_DIR; + + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + })); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + // ─── GET /api/profiles ──────────────────────────────────────────── + + describe("GET /api/profiles", () => { + async function callGet() { + const { GET } = await import("./route.js"); + return GET(); + } + + it("returns profiles list with default profile", async () => { + const response = await callGet(); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.profiles).toBeDefined(); + expect(json.profiles.length).toBeGreaterThanOrEqual(1); + expect(json.profiles[0].name).toBe("default"); + }); + + it("returns activeProfile", async () => { + const response = await callGet(); + const json = await response.json(); + expect(json.activeProfile).toBe("default"); + }); + + it("returns stateDir", async () => { + const response = await callGet(); + const json = await response.json(); + expect(json.stateDir).toBe(STATE_DIR); + }); + + it("discovers workspace- directories", async () => { + const { existsSync: es, readdirSync: rds } = await import("node:fs"); + vi.mocked(es).mockImplementation((p) => { + const s = String(p); + return ( + s === STATE_DIR || + s === join(STATE_DIR, "workspace-dev") + ); + }); + vi.mocked(rds).mockReturnValue([ + makeDirent("workspace-dev", true), + ] as unknown as Dirent[]); + + const response = await callGet(); + const json = await response.json(); + const names = json.profiles.map((p: { name: string }) => p.name); + expect(names).toContain("dev"); + }); + }); + + // ─── POST /api/profiles/switch ──────────────────────────────────── + + describe("POST /api/profiles/switch", () => { + async function callSwitch(body: Record) { + const { POST } = await import("./switch/route.js"); + const req = new Request("http://localhost/api/profiles/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return POST(req); + } + + it("switches to named profile", async () => { + const { writeFileSync: wfs } = await import("node:fs"); + const { existsSync: es } = await import("node:fs"); + vi.mocked(es).mockReturnValue(true); + + const response = await callSwitch({ profile: "work" }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.activeProfile).toBe("work"); + + const writeCalls = vi.mocked(wfs).mock.calls; + const stateWrite = writeCalls.find((c) => + (c[0] as string).includes(".ironclaw-ui-state.json"), + ); + expect(stateWrite).toBeDefined(); + }); + + it("'default' clears the override", async () => { + const { existsSync: es } = await import("node:fs"); + vi.mocked(es).mockReturnValue(true); + + const response = await callSwitch({ profile: "default" }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.activeProfile).toBe("default"); + }); + + it("rejects missing profile name", async () => { + const response = await callSwitch({}); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.error).toContain("Missing profile name"); + }); + + it("rejects invalid profile name characters", async () => { + const response = await callSwitch({ profile: "bad name!" }); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.error).toContain("Invalid profile name"); + }); + + it("returns workspace root after switching", async () => { + const { existsSync: es } = await import("node:fs"); + const wsDir = join(STATE_DIR, "workspace-dev"); + vi.mocked(es).mockImplementation((p) => { + const s = String(p); + return s === wsDir || s.includes(".openclaw"); + }); + + const response = await callSwitch({ profile: "dev" }); + const json = await response.json(); + expect(json.workspaceRoot).toBeDefined(); + }); + + it("returns stateDir in response", async () => { + const { existsSync: es } = await import("node:fs"); + vi.mocked(es).mockReturnValue(true); + + const response = await callSwitch({ profile: "test" }); + const json = await response.json(); + expect(json.stateDir).toBe(STATE_DIR); + }); + }); +}); diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts new file mode 100644 index 00000000000..4829de2cfbc --- /dev/null +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +describe("POST /api/workspace/init", () => { + const originalEnv = { ...process.env }; + const STATE_DIR = join("/home/testuser", ".openclaw"); + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_WORKSPACE; + delete process.env.OPENCLAW_STATE_DIR; + + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + })); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function callInit(body: Record) { + const { POST } = await import("./route.js"); + const req = new Request("http://localhost/api/workspace/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return POST(req); + } + + it("creates default workspace directory", async () => { + const mockMkdir = vi.mocked(mkdirSync); + const response = await callInit({}); + expect(response.status).toBe(200); + expect(mockMkdir).toHaveBeenCalledWith( + join(STATE_DIR, "workspace"), + { recursive: true }, + ); + const json = await response.json(); + expect(json.profile).toBe("default"); + expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace")); + }); + + it("creates profile-specific workspace directory", async () => { + const mockMkdir = vi.mocked(mkdirSync); + const response = await callInit({ profile: "work" }); + expect(response.status).toBe(200); + expect(mockMkdir).toHaveBeenCalledWith( + join(STATE_DIR, "workspace-work"), + { recursive: true }, + ); + const json = await response.json(); + expect(json.profile).toBe("work"); + }); + + it("rejects invalid profile names", async () => { + const response = await callInit({ profile: "invalid profile!" }); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.error).toContain("Invalid profile name"); + }); + + it("allows alphanumeric, hyphens, and underscores in profile names", async () => { + const response = await callInit({ profile: "my-work_1" }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.profile).toBe("my-work_1"); + }); + + it("accepts 'default' as profile name", async () => { + const response = await callInit({ profile: "default" }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace")); + }); + + it("seeds bootstrap files when seedBootstrap is not false", async () => { + const mockWrite = vi.mocked(writeFileSync); + await callInit({}); + const writtenPaths = mockWrite.mock.calls.map((c) => c[0] as string); + const bootstrapFiles = writtenPaths.filter( + (p) => + p.endsWith("AGENTS.md") || + p.endsWith("SOUL.md") || + p.endsWith("TOOLS.md") || + p.endsWith("IDENTITY.md") || + p.endsWith("USER.md") || + p.endsWith("HEARTBEAT.md") || + p.endsWith("BOOTSTRAP.md"), + ); + expect(bootstrapFiles.length).toBeGreaterThan(0); + }); + + it("returns seeded files list", async () => { + const response = await callInit({}); + const json = await response.json(); + expect(Array.isArray(json.seededFiles)).toBe(true); + }); + + it("skips bootstrap seeding when seedBootstrap is false", async () => { + const mockWrite = vi.mocked(writeFileSync); + const callsBefore = mockWrite.mock.calls.length; + await callInit({ seedBootstrap: false }); + const bootstrapWrites = mockWrite.mock.calls + .slice(callsBefore) + .filter((c) => { + const p = c[0] as string; + return p.endsWith(".md") && !p.endsWith("workspace-state.json"); + }); + expect(bootstrapWrites).toHaveLength(0); + }); + + it("does not overwrite existing bootstrap files (idempotent)", async () => { + const mockExist = vi.mocked(existsSync); + const wsDir = join(STATE_DIR, "workspace"); + mockExist.mockImplementation((p) => { + const s = String(p); + return s === join(wsDir, "AGENTS.md") || s === join(wsDir, "SOUL.md"); + }); + + const response = await callInit({}); + const json = await response.json(); + expect(json.seededFiles).not.toContain("AGENTS.md"); + expect(json.seededFiles).not.toContain("SOUL.md"); + }); + + it("handles custom workspace path", async () => { + const mockMkdir = vi.mocked(mkdirSync); + const response = await callInit({ + profile: "custom", + path: "/my/custom/workspace", + }); + expect(response.status).toBe(200); + expect(mockMkdir).toHaveBeenCalledWith("/my/custom/workspace", { + recursive: true, + }); + const json = await response.json(); + expect(json.workspaceDir).toBe("/my/custom/workspace"); + }); + + it("resolves tilde in custom path", async () => { + const mockMkdir = vi.mocked(mkdirSync); + await callInit({ profile: "tilde", path: "~/my-workspace" }); + expect(mockMkdir).toHaveBeenCalledWith( + join("/home/testuser", "my-workspace"), + { recursive: true }, + ); + }); + + it("auto-switches to new profile after creation", async () => { + const response = await callInit({ profile: "newprofile" }); + const json = await response.json(); + expect(json.activeProfile).toBe("newprofile"); + }); + + it("handles mkdir failure with 500", async () => { + const mockMkdir = vi.mocked(mkdirSync); + mockMkdir.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + const response = await callInit({ profile: "fail" }); + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.error).toContain("Failed to create workspace directory"); + }); +}); diff --git a/apps/web/lib/subagent-runs.test.ts b/apps/web/lib/subagent-runs.test.ts new file mode 100644 index 00000000000..1baf4912733 --- /dev/null +++ b/apps/web/lib/subagent-runs.test.ts @@ -0,0 +1,505 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + appendFileSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const proc = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn(), + unref: vi.fn(), + pid: 12345, + }; + return proc; + }), + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +vi.mock("node:readline", () => ({ + createInterface: vi.fn(() => ({ + on: vi.fn(), + close: vi.fn(), + })), +})); + +import { appendFileSync } from "node:fs"; + +// Shared global key used by subagent-runs.ts for its singleton registry +const GLOBAL_KEY = "__openclaw_subagentRuns"; + +describe("subagent runs", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_WORKSPACE; + delete process.env.OPENCLAW_STATE_DIR; + + // Reset the global singleton between tests + delete (globalThis as Record)[GLOBAL_KEY]; + + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + appendFileSync: vi.fn(), + })); + vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const proc = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn(), + unref: vi.fn(), + pid: 12345, + }; + return proc; + }), + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + vi.mock("node:readline", () => ({ + createInterface: vi.fn(() => ({ + on: vi.fn(), + close: vi.fn(), + })), + })); + }); + + afterEach(() => { + process.env = originalEnv; + delete (globalThis as Record)[GLOBAL_KEY]; + }); + + async function importSubagentRuns() { + return import("./subagent-runs.js"); + } + + // ─── registerSubagent ───────────────────────────────────────────── + + describe("registerSubagent", () => { + it("registers a new subagent run", async () => { + const { registerSubagent, hasActiveSubagent } = + await importSubagentRuns(); + registerSubagent("parent-session-1", { + sessionKey: "sub:parent:child1", + runId: "run-123", + task: "test task", + }); + expect(hasActiveSubagent("sub:parent:child1")).toBe(true); + }); + + it("prevents duplicate registration", async () => { + const { registerSubagent, getSubagentsForSession } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "run-1", + task: "task 1", + }); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "run-2", + task: "task 2", + }); + const subs = getSubagentsForSession("parent-1"); + expect(subs).toHaveLength(1); + expect(subs[0].runId).toBe("run-1"); + }); + + it("sets initial status to running", async () => { + const { registerSubagent, getSubagentsForSession } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "run-1", + task: "task", + }); + const subs = getSubagentsForSession("parent-1"); + expect(subs[0].status).toBe("running"); + }); + + it("persists subagent info to index file", async () => { + const { writeFileSync: wfs } = await import("node:fs"); + const mockWrite = vi.mocked(wfs); + const { registerSubagent } = await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "run-1", + task: "my task", + label: "my label", + }); + const indexWrites = mockWrite.mock.calls.filter((c) => + (c[0] as string).includes("subagent-index.json"), + ); + expect(indexWrites.length).toBeGreaterThan(0); + const written = JSON.parse(indexWrites[indexWrites.length - 1][1] as string); + expect(written["sub:p:c1"]).toBeDefined(); + expect(written["sub:p:c1"].task).toBe("my task"); + }); + + it("stores label when provided", async () => { + const { registerSubagent, getSubagentsForSession } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "run-1", + task: "task", + label: "custom label", + }); + const subs = getSubagentsForSession("parent-1"); + expect(subs[0].label).toBe("custom label"); + }); + }); + + // ─── getSubagentsForSession ─────────────────────────────────────── + + describe("getSubagentsForSession", () => { + it("returns empty array for unknown parent", async () => { + const { getSubagentsForSession } = await importSubagentRuns(); + expect(getSubagentsForSession("unknown")).toEqual([]); + }); + + it("returns all subagents for a parent session", async () => { + const { registerSubagent, getSubagentsForSession } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t1", + }); + registerSubagent("parent-1", { + sessionKey: "sub:p:c2", + runId: "r2", + task: "t2", + }); + const subs = getSubagentsForSession("parent-1"); + expect(subs).toHaveLength(2); + }); + + it("does not return subagents from other parents", async () => { + const { registerSubagent, getSubagentsForSession } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p1:c1", + runId: "r1", + task: "t1", + }); + registerSubagent("parent-2", { + sessionKey: "sub:p2:c1", + runId: "r2", + task: "t2", + }); + const subs1 = getSubagentsForSession("parent-1"); + const subs2 = getSubagentsForSession("parent-2"); + expect(subs1).toHaveLength(1); + expect(subs1[0].sessionKey).toBe("sub:p1:c1"); + expect(subs2).toHaveLength(1); + expect(subs2[0].sessionKey).toBe("sub:p2:c1"); + }); + }); + + // ─── subscribeToSubagent ────────────────────────────────────────── + + describe("subscribeToSubagent", () => { + it("returns null for unknown subagent", async () => { + const { subscribeToSubagent } = await importSubagentRuns(); + const unsub = subscribeToSubagent("unknown-key", () => {}); + expect(unsub).toBeNull(); + }); + + it("replays buffered events by default", async () => { + const { registerSubagent, subscribeToSubagent } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + + // Manually push events into the buffer by using persistUserMessage + const { persistUserMessage } = await importSubagentRuns(); + persistUserMessage("sub:p:c1", { text: "hello" }); + + const received: unknown[] = []; + subscribeToSubagent("sub:p:c1", (event) => { + if (event) {received.push(event);} + }); + + expect(received.length).toBeGreaterThanOrEqual(1); + const userMsg = received.find( + (e) => (e as Record).type === "user-message", + ); + expect(userMsg).toBeDefined(); + }); + + it("skips replay when replay=false", async () => { + const { registerSubagent, persistUserMessage, subscribeToSubagent } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + persistUserMessage("sub:p:c1", { text: "hello" }); + + const received: unknown[] = []; + subscribeToSubagent( + "sub:p:c1", + (event) => { + if (event) {received.push(event);} + }, + { replay: false }, + ); + + expect(received).toHaveLength(0); + }); + + it("returns unsubscribe function", async () => { + const { registerSubagent, subscribeToSubagent } = + await importSubagentRuns(); + registerSubagent("parent-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + const unsub = subscribeToSubagent("sub:p:c1", () => {}); + expect(typeof unsub).toBe("function"); + }); + }); + + // ─── isSubagentRunning / hasActiveSubagent ──────────────────────── + + describe("isSubagentRunning / hasActiveSubagent", () => { + it("reports running after registration", async () => { + const { registerSubagent, isSubagentRunning, hasActiveSubagent } = + await importSubagentRuns(); + registerSubagent("p-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + expect(isSubagentRunning("sub:p:c1")).toBe(true); + expect(hasActiveSubagent("sub:p:c1")).toBe(true); + }); + + it("reports not running for unknown keys", async () => { + const { isSubagentRunning, hasActiveSubagent } = + await importSubagentRuns(); + expect(isSubagentRunning("unknown")).toBe(false); + expect(hasActiveSubagent("unknown")).toBe(false); + }); + }); + + // ─── persistUserMessage ─────────────────────────────────────────── + + describe("persistUserMessage", () => { + it("appends user message event to buffer and disk", async () => { + const mockAppend = vi.mocked(appendFileSync); + const { registerSubagent, persistUserMessage } = + await importSubagentRuns(); + registerSubagent("p-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + const result = persistUserMessage("sub:p:c1", { text: "hello" }); + expect(result).toBe(true); + + const appendCalls = mockAppend.mock.calls.filter((c) => + (c[0] as string).includes("subagent-events"), + ); + expect(appendCalls.length).toBeGreaterThan(0); + }); + + it("returns false for unknown subagent", async () => { + const { persistUserMessage } = await importSubagentRuns(); + expect(persistUserMessage("unknown", { text: "hello" })).toBe(false); + }); + + it("fans out to subscribers", async () => { + const { + registerSubagent, + subscribeToSubagent, + persistUserMessage, + } = await importSubagentRuns(); + registerSubagent("p-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + + const received: unknown[] = []; + subscribeToSubagent( + "sub:p:c1", + (event) => { + if (event) {received.push(event);} + }, + { replay: false }, + ); + + persistUserMessage("sub:p:c1", { text: "live msg" }); + const userMsg = received.find( + (e) => (e as Record).type === "user-message", + ); + expect(userMsg).toBeDefined(); + }); + }); + + // ─── getRunningSubagentKeys ─────────────────────────────────────── + + describe("getRunningSubagentKeys", () => { + it("returns keys of running subagents", async () => { + const { registerSubagent, getRunningSubagentKeys } = + await importSubagentRuns(); + registerSubagent("p-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t1", + }); + registerSubagent("p-1", { + sessionKey: "sub:p:c2", + runId: "r2", + task: "t2", + }); + const keys = getRunningSubagentKeys(); + expect(keys).toContain("sub:p:c1"); + expect(keys).toContain("sub:p:c2"); + }); + + it("returns empty when no subagents registered", async () => { + const { getRunningSubagentKeys } = await importSubagentRuns(); + expect(getRunningSubagentKeys()).toEqual([]); + }); + }); + + // ─── ensureRegisteredFromDisk ───────────────────────────────────── + + describe("ensureRegisteredFromDisk", () => { + it("returns true if already registered in memory", async () => { + const { registerSubagent, ensureRegisteredFromDisk } = + await importSubagentRuns(); + registerSubagent("p-1", { + sessionKey: "sub:p:c1", + runId: "r1", + task: "t", + }); + expect(ensureRegisteredFromDisk("sub:p:c1", "p-1")).toBe(true); + }); + + it("registers from profile-scoped index file", async () => { + const { readFileSync: rfs, existsSync: es } = await import("node:fs"); + vi.mocked(es).mockImplementation((p) => { + const s = String(p); + return s.includes("subagent-index.json"); + }); + vi.mocked(rfs).mockImplementation((p) => { + const s = String(p); + if (s.includes("subagent-index.json")) { + return JSON.stringify({ + "sub:p:disk1": { + runId: "r-disk", + parentWebSessionId: "p-disk", + task: "disk task", + status: "completed", + startedAt: 1000, + }, + }) as never; + } + return "" as never; + }); + + const { ensureRegisteredFromDisk, hasActiveSubagent } = + await importSubagentRuns(); + const result = ensureRegisteredFromDisk("sub:p:disk1", "p-disk"); + expect(result).toBe(true); + expect(hasActiveSubagent("sub:p:disk1")).toBe(true); + }); + + it("returns false when not found anywhere", async () => { + const { ensureRegisteredFromDisk } = await importSubagentRuns(); + expect(ensureRegisteredFromDisk("sub:nonexistent", "p-1")).toBe(false); + }); + + it("registers from shared gateway registry as fallback", async () => { + const { readFileSync: rfs, existsSync: es } = await import("node:fs"); + vi.mocked(es).mockImplementation((p) => { + const s = String(p); + return s.includes("subagents/runs.json") || s.includes(".openclaw"); + }); + vi.mocked(rfs).mockImplementation((p) => { + const s = String(p); + if (s.includes("runs.json")) { + return JSON.stringify({ + runs: { + "run-gw": { + childSessionKey: "sub:gw:c1", + runId: "r-gw", + task: "gateway task", + }, + }, + }) as never; + } + if (s.includes("subagent-index.json")) { + return "{}" as never; + } + return "" as never; + }); + + const { ensureRegisteredFromDisk, hasActiveSubagent } = + await importSubagentRuns(); + const result = ensureRegisteredFromDisk("sub:gw:c1", "p-gw"); + expect(result).toBe(true); + expect(hasActiveSubagent("sub:gw:c1")).toBe(true); + }); + }); + + // ─── abortSubagent ──────────────────────────────────────────────── + + describe("abortSubagent", () => { + it("returns false for unknown subagent", async () => { + const { abortSubagent } = await importSubagentRuns(); + expect(abortSubagent("unknown")).toBe(false); + }); + }); +}); diff --git a/apps/web/lib/subagent-streaming.live.test.ts b/apps/web/lib/subagent-streaming.live.test.ts new file mode 100644 index 00000000000..81da2352507 --- /dev/null +++ b/apps/web/lib/subagent-streaming.live.test.ts @@ -0,0 +1,191 @@ +/** + * Live E2E tests for subagent streaming. + * + * These tests verify that: + * - Subagent registration works with real disk persistence + * - Events can be persisted and reloaded from disk + * - The profile-scoped subagent index works end-to-end + * + * Requires: LIVE=1 or OPENCLAW_LIVE_TEST=1 + * Does NOT require a running gateway — tests the subagent run manager directly. + */ +import fs from "node:fs/promises"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +const LIVE = + process.env.LIVE === "1" || + process.env.OPENCLAW_LIVE_TEST === "1" || + process.env.CLAWDBOT_LIVE_TEST === "1"; + +const describeLive = LIVE ? describe : describe.skip; + +describeLive("subagent streaming (live)", () => { + let tempDir: string; + const originalEnv = { ...process.env }; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "subagent-live-")); + process.env.OPENCLAW_HOME = tempDir; + process.env.OPENCLAW_STATE_DIR = path.join(tempDir, ".openclaw"); + mkdirSync(path.join(tempDir, ".openclaw"), { recursive: true }); + // Reset subagent singleton + delete (globalThis as Record)[ + "__openclaw_subagentRuns" + ]; + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + delete (globalThis as Record)[ + "__openclaw_subagentRuns" + ]; + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + }); + + it("persists subagent index to disk on registration", async () => { + const webChatDir = path.join(tempDir, ".openclaw", "web-chat"); + mkdirSync(webChatDir, { recursive: true }); + + const { + registerSubagent, + } = await import("./subagent-runs.js"); + + registerSubagent("parent-session", { + sessionKey: "sub:p:live1", + runId: "run-live-1", + task: "live test task", + label: "live label", + }); + + const indexPath = path.join(webChatDir, "subagent-index.json"); + expect(existsSync(indexPath)).toBe(true); + + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + expect(index["sub:p:live1"]).toBeDefined(); + expect(index["sub:p:live1"].task).toBe("live test task"); + expect(index["sub:p:live1"].status).toBe("running"); + }, 10_000); + + it("persists user messages to event JSONL file", async () => { + const webChatDir = path.join(tempDir, ".openclaw", "web-chat"); + mkdirSync(webChatDir, { recursive: true }); + + const { + registerSubagent, + persistUserMessage, + } = await import("./subagent-runs.js"); + + registerSubagent("parent-session", { + sessionKey: "sub:p:live2", + runId: "run-live-2", + task: "msg persistence test", + }); + + persistUserMessage("sub:p:live2", { text: "hello from live test" }); + + const eventsDir = path.join(webChatDir, "subagent-events"); + expect(existsSync(eventsDir)).toBe(true); + + const eventFile = path.join(eventsDir, "sub_p_live2.jsonl"); + expect(existsSync(eventFile)).toBe(true); + + const lines = readFileSync(eventFile, "utf-8") + .split("\n") + .filter(Boolean); + expect(lines.length).toBeGreaterThan(0); + + const event = JSON.parse(lines[0]); + expect(event.type).toBe("user-message"); + expect(event.text).toBe("hello from live test"); + }, 10_000); + + it("multiple subagents for same parent are tracked independently", async () => { + const webChatDir = path.join(tempDir, ".openclaw", "web-chat"); + mkdirSync(webChatDir, { recursive: true }); + + const { + registerSubagent, + getSubagentsForSession, + } = await import("./subagent-runs.js"); + + registerSubagent("parent-multi", { + sessionKey: "sub:p:multi1", + runId: "r-m1", + task: "task 1", + }); + registerSubagent("parent-multi", { + sessionKey: "sub:p:multi2", + runId: "r-m2", + task: "task 2", + }); + + const subs = getSubagentsForSession("parent-multi"); + expect(subs).toHaveLength(2); + const keys = subs.map((s) => s.sessionKey); + expect(keys).toContain("sub:p:multi1"); + expect(keys).toContain("sub:p:multi2"); + }, 10_000); + + it("subscriber receives events in real-time", async () => { + const webChatDir = path.join(tempDir, ".openclaw", "web-chat"); + mkdirSync(webChatDir, { recursive: true }); + + const { + registerSubagent, + subscribeToSubagent, + persistUserMessage, + } = await import("./subagent-runs.js"); + + registerSubagent("parent-sub", { + sessionKey: "sub:p:realtime", + runId: "r-rt", + task: "realtime test", + }); + + const received: Array> = []; + subscribeToSubagent( + "sub:p:realtime", + (event) => { + if (event) {received.push(event as Record);} + }, + { replay: false }, + ); + + persistUserMessage("sub:p:realtime", { text: "msg 1" }); + persistUserMessage("sub:p:realtime", { text: "msg 2" }); + + expect(received).toHaveLength(2); + expect(received[0].text).toBe("msg 1"); + expect(received[1].text).toBe("msg 2"); + }, 10_000); + + it("replay delivers buffered events on subscribe", async () => { + const webChatDir = path.join(tempDir, ".openclaw", "web-chat"); + mkdirSync(webChatDir, { recursive: true }); + + const { + registerSubagent, + persistUserMessage, + subscribeToSubagent, + } = await import("./subagent-runs.js"); + + registerSubagent("parent-replay", { + sessionKey: "sub:p:replay", + runId: "r-rp", + task: "replay test", + }); + + persistUserMessage("sub:p:replay", { text: "buffered 1" }); + persistUserMessage("sub:p:replay", { text: "buffered 2" }); + + const received: Array> = []; + subscribeToSubagent("sub:p:replay", (event) => { + if (event) {received.push(event as Record);} + }); + + expect(received.length).toBeGreaterThanOrEqual(2); + }, 10_000); +}, 60_000); diff --git a/apps/web/lib/workspace-chat-isolation.test.ts b/apps/web/lib/workspace-chat-isolation.test.ts new file mode 100644 index 00000000000..04a5a2ca10e --- /dev/null +++ b/apps/web/lib/workspace-chat-isolation.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +import { join } from "node:path"; + +describe("profile-scoped chat session isolation", () => { + const originalEnv = { ...process.env }; + const STATE_DIR = join("/home/testuser", ".openclaw"); + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_WORKSPACE; + delete process.env.OPENCLAW_STATE_DIR; + + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + })); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function importWorkspace() { + const { readFileSync: rfs, writeFileSync: wfs, existsSync: es } = + await import("node:fs"); + const mod = await import("./workspace.js"); + return { + ...mod, + mockReadFile: vi.mocked(rfs), + mockWriteFile: vi.mocked(wfs), + mockExists: vi.mocked(es), + }; + } + + it("default profile uses web-chat directory", async () => { + const { resolveWebChatDir, mockReadFile } = await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + }); + + it("named profile uses web-chat- directory", async () => { + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("work"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat-work")); + }); + + it("different profiles produce different chat directories", async () => { + const { resolveWebChatDir, setUIActiveProfile, clearUIActiveProfileCache, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + + setUIActiveProfile("alpha"); + const dirAlpha = resolveWebChatDir(); + + clearUIActiveProfileCache(); + setUIActiveProfile("beta"); + const dirBeta = resolveWebChatDir(); + + expect(dirAlpha).not.toBe(dirBeta); + expect(dirAlpha).toBe(join(STATE_DIR, "web-chat-alpha")); + expect(dirBeta).toBe(join(STATE_DIR, "web-chat-beta")); + }); + + it("switching to default after named profile reverts to base dir", async () => { + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + + setUIActiveProfile("work"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat-work")); + + setUIActiveProfile(null); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + }); + + it("'default' profile name uses base web-chat dir (case-insensitive)", async () => { + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + + setUIActiveProfile("Default"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + + setUIActiveProfile("DEFAULT"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + }); + + it("OPENCLAW_STATE_DIR override changes base for chat dirs", async () => { + process.env.OPENCLAW_STATE_DIR = "/custom/state"; + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat")); + + setUIActiveProfile("test"); + expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat-test")); + }); + + it("workspace roots are isolated per profile too", async () => { + const { resolveWorkspaceRoot, setUIActiveProfile, clearUIActiveProfileCache, mockExists, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + + const defaultWs = join(STATE_DIR, "workspace"); + const workWs = join(STATE_DIR, "workspace-work"); + + mockExists.mockImplementation((p) => { + const s = String(p); + return s === defaultWs || s === workWs; + }); + + clearUIActiveProfileCache(); + setUIActiveProfile(null); + expect(resolveWorkspaceRoot()).toBe(defaultWs); + + setUIActiveProfile("work"); + expect(resolveWorkspaceRoot()).toBe(workWs); + }); +}); diff --git a/apps/web/lib/workspace-profiles.test.ts b/apps/web/lib/workspace-profiles.test.ts new file mode 100644 index 00000000000..e5eebf58af5 --- /dev/null +++ b/apps/web/lib/workspace-profiles.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Dirent } from "node:fs"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})); + +import { join } from "node:path"; + +function makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + path: "", + parentPath: "", + } as Dirent; +} + +describe("workspace profiles", () => { + const originalEnv = { ...process.env }; + const STATE_DIR = join("/home/testuser", ".openclaw"); + const UI_STATE_PATH = join(STATE_DIR, ".ironclaw-ui-state.json"); + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_WORKSPACE; + delete process.env.OPENCLAW_STATE_DIR; + + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + readdirSync: vi.fn(() => []), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + })); + vi.mock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, result: { stdout: string }) => void, + ) => { + cb(null, { stdout: "" }); + }, + ), + })); + vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/home/testuser"), + })); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function importWorkspace() { + const { + existsSync: es, + readFileSync: rfs, + readdirSync: rds, + writeFileSync: wfs, + } = await import("node:fs"); + const mod = await import("./workspace.js"); + return { + ...mod, + mockExists: vi.mocked(es), + mockReadFile: vi.mocked(rfs), + mockReaddir: vi.mocked(rds), + mockWriteFile: vi.mocked(wfs), + }; + } + + // ─── getEffectiveProfile ────────────────────────────────────────── + + describe("getEffectiveProfile", () => { + it("returns env var when OPENCLAW_PROFILE is set", async () => { + process.env.OPENCLAW_PROFILE = "work"; + const { getEffectiveProfile } = await importWorkspace(); + expect(getEffectiveProfile()).toBe("work"); + }); + + it("returns null when nothing is set", async () => { + const { getEffectiveProfile, mockReadFile } = await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(getEffectiveProfile()).toBeNull(); + }); + + it("returns persisted profile from state file", async () => { + const { getEffectiveProfile, mockReadFile } = await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ activeProfile: "personal" }) as never, + ); + expect(getEffectiveProfile()).toBe("personal"); + }); + + it("env var takes precedence over persisted file", async () => { + process.env.OPENCLAW_PROFILE = "env-profile"; + const { getEffectiveProfile, mockReadFile } = await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ activeProfile: "file-profile" }) as never, + ); + expect(getEffectiveProfile()).toBe("env-profile"); + }); + + it("in-memory override takes precedence over persisted file", async () => { + const { getEffectiveProfile, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ activeProfile: "file-profile" }) as never, + ); + setUIActiveProfile("memory-profile"); + expect(getEffectiveProfile()).toBe("memory-profile"); + }); + + it("env var takes precedence over in-memory override", async () => { + process.env.OPENCLAW_PROFILE = "env-wins"; + const { getEffectiveProfile, setUIActiveProfile } = + await importWorkspace(); + setUIActiveProfile("memory-profile"); + expect(getEffectiveProfile()).toBe("env-wins"); + }); + + it("trims whitespace from env var", async () => { + process.env.OPENCLAW_PROFILE = " padded "; + const { getEffectiveProfile } = await importWorkspace(); + expect(getEffectiveProfile()).toBe("padded"); + }); + + it("trims whitespace from persisted profile", async () => { + const { getEffectiveProfile, mockReadFile } = await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ activeProfile: " trimme " }) as never, + ); + expect(getEffectiveProfile()).toBe("trimme"); + }); + }); + + // ─── setUIActiveProfile ────────────────────────────────────────── + + describe("setUIActiveProfile", () => { + it("persists profile to state file", async () => { + const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + mockExists.mockReturnValue(true); + setUIActiveProfile("work"); + expect(mockWriteFile).toHaveBeenCalledWith( + UI_STATE_PATH, + expect.stringContaining('"activeProfile": "work"'), + ); + }); + + it("null clears the override", async () => { + const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + mockExists.mockReturnValue(true); + setUIActiveProfile(null); + expect(mockWriteFile).toHaveBeenCalledWith( + UI_STATE_PATH, + expect.stringContaining('"activeProfile": null'), + ); + }); + + it("preserves existing state keys", async () => { + const { setUIActiveProfile, mockReadFile, mockWriteFile, mockExists } = + await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { other: "/path" }, + }) as never, + ); + mockExists.mockReturnValue(true); + setUIActiveProfile("new"); + const stateWrites = mockWriteFile.mock.calls.filter((c) => + (c[0] as string).includes(".ironclaw-ui-state.json"), + ); + expect(stateWrites.length).toBeGreaterThan(0); + const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string); + expect(parsed.workspaceRegistry).toEqual({ other: "/path" }); + expect(parsed.activeProfile).toBe("new"); + }); + }); + + // ─── clearUIActiveProfileCache ──────────────────────────────────── + + describe("clearUIActiveProfileCache", () => { + it("re-reads from file after clearing", async () => { + const { + getEffectiveProfile, + setUIActiveProfile, + clearUIActiveProfileCache, + mockReadFile, + } = await importWorkspace(); + + mockReadFile.mockReturnValue( + JSON.stringify({ activeProfile: "from-file" }) as never, + ); + setUIActiveProfile("in-memory"); + expect(getEffectiveProfile()).toBe("in-memory"); + + clearUIActiveProfileCache(); + expect(getEffectiveProfile()).toBe("from-file"); + }); + }); + + // ─── discoverProfiles ───────────────────────────────────────────── + + describe("discoverProfiles", () => { + it("always includes default profile", async () => { + const { discoverProfiles, mockExists } = await importWorkspace(); + mockExists.mockReturnValue(false); + const profiles = discoverProfiles(); + expect(profiles).toHaveLength(1); + expect(profiles[0].name).toBe("default"); + }); + + it("default profile is active when no profile set", async () => { + const { discoverProfiles, clearUIActiveProfileCache, mockExists, mockReadFile } = + await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + mockExists.mockReturnValue(false); + clearUIActiveProfileCache(); + const profiles = discoverProfiles(); + expect(profiles[0].isActive).toBe(true); + }); + + it("discovers workspace- directories", async () => { + const { discoverProfiles, mockExists, mockReaddir } = + await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return ( + s === STATE_DIR || + s === join(STATE_DIR, "workspace-work") || + s === join(STATE_DIR, "workspace-personal") + ); + }); + mockReaddir.mockReturnValue([ + makeDirent("workspace-work", true), + makeDirent("workspace-personal", true), + makeDirent("sessions", true), + makeDirent("config.json", false), + ] as unknown as Dirent[]); + + const profiles = discoverProfiles(); + const names = profiles.map((p) => p.name); + expect(names).toContain("default"); + expect(names).toContain("work"); + expect(names).toContain("personal"); + expect(names).not.toContain("sessions"); + }); + + it("marks active profile correctly", async () => { + const { discoverProfiles, setUIActiveProfile, mockExists, mockReaddir } = + await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === STATE_DIR || s === join(STATE_DIR, "workspace-work"); + }); + mockReaddir.mockReturnValue([ + makeDirent("workspace-work", true), + ] as unknown as Dirent[]); + + setUIActiveProfile("work"); + const profiles = discoverProfiles(); + const defaultProfile = profiles.find((p) => p.name === "default"); + const workProfile = profiles.find((p) => p.name === "work"); + expect(defaultProfile?.isActive).toBe(false); + expect(workProfile?.isActive).toBe(true); + }); + + it("merges registry entries for custom-path workspaces", async () => { + const { discoverProfiles, mockExists, mockReadFile } = + await importWorkspace(); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === "/custom/workspace" || s === STATE_DIR; + }); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { custom: "/custom/workspace" }, + }) as never, + ); + + const profiles = discoverProfiles(); + const custom = profiles.find((p) => p.name === "custom"); + expect(custom).toBeDefined(); + expect(custom!.workspaceDir).toBe("/custom/workspace"); + }); + + it("does not duplicate profiles seen via directory and registry", async () => { + const { discoverProfiles, mockExists, mockReaddir, mockReadFile } = + await importWorkspace(); + const wsDir = join(STATE_DIR, "workspace-shared"); + mockExists.mockImplementation((p) => { + const s = String(p); + return s === STATE_DIR || s === wsDir; + }); + mockReaddir.mockReturnValue([ + makeDirent("workspace-shared", true), + ] as unknown as Dirent[]); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { shared: wsDir }, + }) as never, + ); + + const profiles = discoverProfiles(); + const sharedProfiles = profiles.filter((p) => p.name === "shared"); + expect(sharedProfiles).toHaveLength(1); + }); + + it("handles unreadable state directory gracefully", async () => { + const { discoverProfiles, mockExists, mockReaddir } = + await importWorkspace(); + mockExists.mockReturnValue(true); + mockReaddir.mockImplementation(() => { + throw new Error("EACCES"); + }); + const profiles = discoverProfiles(); + expect(profiles.length).toBeGreaterThanOrEqual(1); + expect(profiles[0].name).toBe("default"); + }); + }); + + // ─── resolveWebChatDir ──────────────────────────────────────────── + + describe("resolveWebChatDir", () => { + it("returns web-chat for default profile", async () => { + const { resolveWebChatDir, mockReadFile } = await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + }); + + it("returns web-chat- for named profile", async () => { + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("work"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat-work")); + }); + + it("returns web-chat when profile is 'default'", async () => { + const { resolveWebChatDir, setUIActiveProfile, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("default"); + expect(resolveWebChatDir()).toBe(join(STATE_DIR, "web-chat")); + }); + + it("respects OPENCLAW_STATE_DIR override", async () => { + process.env.OPENCLAW_STATE_DIR = "/custom/state"; + const { resolveWebChatDir, mockReadFile } = await importWorkspace(); + mockReadFile.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(resolveWebChatDir()).toBe(join("/custom/state", "web-chat")); + }); + }); + + // ─── resolveWorkspaceRoot (profile-aware) ───────────────────────── + + describe("resolveWorkspaceRoot (profile-aware)", () => { + it("returns workspace- for named profile", async () => { + const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("work"); + const workDir = join(STATE_DIR, "workspace-work"); + mockExists.mockImplementation((p) => String(p) === workDir); + expect(resolveWorkspaceRoot()).toBe(workDir); + }); + + it("prefers registry path over directory convention", async () => { + const { + resolveWorkspaceRoot, + setUIActiveProfile, + mockExists, + mockReadFile, + } = await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { work: "/custom/work" }, + }) as never, + ); + setUIActiveProfile("work"); + mockExists.mockImplementation((p) => { + const s = String(p); + return ( + s === "/custom/work" || s === join(STATE_DIR, "workspace-work") + ); + }); + expect(resolveWorkspaceRoot()).toBe("/custom/work"); + }); + + it("OPENCLAW_WORKSPACE env takes top priority", async () => { + process.env.OPENCLAW_WORKSPACE = "/env/workspace"; + const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("work"); + mockExists.mockImplementation((p) => String(p) === "/env/workspace"); + expect(resolveWorkspaceRoot()).toBe("/env/workspace"); + }); + + it("falls back to default workspace when named profile dir missing", async () => { + const { resolveWorkspaceRoot, setUIActiveProfile, mockExists, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + setUIActiveProfile("missing"); + const defaultDir = join(STATE_DIR, "workspace"); + mockExists.mockImplementation((p) => String(p) === defaultDir); + expect(resolveWorkspaceRoot()).toBe(defaultDir); + }); + }); + + // ─── registerWorkspacePath / getRegisteredWorkspacePath ──────────── + + describe("workspace registry", () => { + it("registerWorkspacePath persists to state file", async () => { + const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + mockExists.mockReturnValue(true); + registerWorkspacePath("myprofile", "/my/workspace"); + const stateWrites = mockWriteFile.mock.calls.filter((c) => + (c[0] as string).includes(".ironclaw-ui-state.json"), + ); + expect(stateWrites.length).toBeGreaterThan(0); + const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string); + expect(parsed.workspaceRegistry.myprofile).toBe("/my/workspace"); + }); + + it("getRegisteredWorkspacePath returns null for unknown profile", async () => { + const { getRegisteredWorkspacePath, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue(JSON.stringify({}) as never); + expect(getRegisteredWorkspacePath("unknown")).toBeNull(); + }); + + it("getRegisteredWorkspacePath returns null for null profile", async () => { + const { getRegisteredWorkspacePath } = await importWorkspace(); + expect(getRegisteredWorkspacePath(null)).toBeNull(); + }); + + it("getRegisteredWorkspacePath returns path for registered profile", async () => { + const { getRegisteredWorkspacePath, mockReadFile } = + await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { test: "/test/workspace" }, + }) as never, + ); + expect(getRegisteredWorkspacePath("test")).toBe("/test/workspace"); + }); + + it("registerWorkspacePath preserves existing registry entries", async () => { + const { registerWorkspacePath, mockReadFile, mockWriteFile, mockExists } = + await importWorkspace(); + mockReadFile.mockReturnValue( + JSON.stringify({ + workspaceRegistry: { existing: "/existing" }, + }) as never, + ); + mockExists.mockReturnValue(true); + registerWorkspacePath("new", "/new/path"); + const stateWrites = mockWriteFile.mock.calls.filter((c) => + (c[0] as string).includes(".ironclaw-ui-state.json"), + ); + expect(stateWrites.length).toBeGreaterThan(0); + const parsed = JSON.parse(stateWrites[stateWrites.length - 1][1] as string); + expect(parsed.workspaceRegistry.existing).toBe("/existing"); + expect(parsed.workspaceRegistry.new).toBe("/new/path"); + }); + }); +}); diff --git a/package.json b/package.json index 05c25f361c6..6f26ef610c6 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,8 @@ "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", "test:ui": "pnpm --dir ui test", "test:watch": "vitest", + "test:workspace": "vitest run --config vitest.unit.config.ts -- workspace-profiles workspace-chat-isolation workspace-context-awareness subagent-runs && pnpm --dir apps/web vitest run -- workspace-profiles workspace-chat-isolation subagent-runs route.test", + "test:workspace:live": "LIVE=1 vitest run --config vitest.live.config.ts -- workspace-context-awareness && LIVE=1 pnpm --dir apps/web vitest run -- subagent-streaming.live", "tsgo:test": "tsgo -p tsconfig.test.json", "tui": "node scripts/run-node.mjs tui", "tui:dev": "OPENCLAW_PROFILE=dev CLAWDBOT_PROFILE=dev node scripts/run-node.mjs --dev tui", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9478d255bd4..24ba4719aa4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -11,6 +11,9 @@ # 2026.2.7 → 2026.2.7-1 # (no flag) Publish whatever version is already in package.json. # +# Flags: +# --skip-tests Skip running tests before build/publish. +# # Environment: # NPM_TOKEN Required. npm auth token for publishing. @@ -96,6 +99,7 @@ MODE="" UPSTREAM_VERSION="" DRY_RUN=false SKIP_BUILD=false +SKIP_TESTS=false while [[ $# -gt 0 ]]; do case $1 in @@ -116,6 +120,10 @@ while [[ $# -gt 0 ]]; do SKIP_BUILD=true shift ;; + --skip-tests) + SKIP_TESTS=true + shift + ;; --help|-h) sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0" exit 0 @@ -171,6 +179,13 @@ fi npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@]}" +# ── pre-flight: tests ──────────────────────────────────────────────────────── + +if [[ "$SKIP_TESTS" != true ]] && [[ "$SKIP_BUILD" != true ]]; then + echo "running tests..." + pnpm test +fi + # ── build ──────────────────────────────────────────────────────────────────── # The `prepack` script (triggered by `npm publish`) runs the full build chain: @@ -180,6 +195,9 @@ npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@] if [[ "$SKIP_BUILD" != true ]]; then echo "building..." pnpm build + + echo "building web app (standalone verification)..." + pnpm web:build fi # ── publish ────────────────────────────────────────────────────────────────── @@ -199,6 +217,17 @@ if [[ ! -f "$STANDALONE_SERVER" ]]; then echo " users may not get a working Web UI — check the prepack step" fi +# ── post-publish: commit + push version bump ───────────────────────────────── + +if git diff --quiet package.json 2>/dev/null; then + echo "package.json unchanged — skipping git commit" +else + echo "committing version bump..." + git add package.json + git commit -m "release: v${VERSION}" + git push +fi + echo "" echo "published ${PACKAGE_NAME}@${VERSION}" echo "install: npm i -g ${PACKAGE_NAME}" diff --git a/src/agents/workspace-context-awareness.live.test.ts b/src/agents/workspace-context-awareness.live.test.ts new file mode 100644 index 00000000000..30cd2c09d0c --- /dev/null +++ b/src/agents/workspace-context-awareness.live.test.ts @@ -0,0 +1,136 @@ +/** + * Live E2E tests for workspace context awareness. + * + * Requires: + * - A running gateway (openclaw gateway run) + * - LIVE=1 or OPENCLAW_LIVE_TEST=1 env var + * + * These tests verify that the agent actually knows about workspace context + * by creating temporary workspaces and inspecting bootstrap file loading. + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; +import { resolveBootstrapContextForRun } from "./bootstrap-files.js"; +import { + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + ensureAgentWorkspace, + loadWorkspaceBootstrapFiles, + resolveDefaultAgentWorkspaceDir, +} from "./workspace.js"; + +const LIVE = + isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST) || + isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST) || + isTruthyEnvValue(process.env.LIVE); + +const describeLive = LIVE ? describe : describe.skip; + +describeLive( + "workspace context awareness (live)", + () => { + it("agent workspace resolves profile-specific directory", () => { + const workDir = resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: "live-test", + HOME: "/home/liveuser", + } as NodeJS.ProcessEnv); + expect(workDir).toContain("workspace-live-test"); + expect(workDir).not.toContain("workspace-default"); + }); + + it("bootstrap files from workspace A are distinct from workspace B", async () => { + const wsA = await makeTempWorkspace("live-ws-a-"); + const wsB = await makeTempWorkspace("live-ws-b-"); + + await writeWorkspaceFile({ + dir: wsA, + name: DEFAULT_AGENTS_FILENAME, + content: "# Agent Profile Alpha\nYou are the Alpha agent.", + }); + await writeWorkspaceFile({ + dir: wsB, + name: DEFAULT_AGENTS_FILENAME, + content: "# Agent Profile Beta\nYou are the Beta agent.", + }); + + const ctxA = await resolveBootstrapContextForRun({ workspaceDir: wsA }); + const ctxB = await resolveBootstrapContextForRun({ workspaceDir: wsB }); + + const textA = ctxA.contextFiles.map((f) => f.content).join(" "); + const textB = ctxB.contextFiles.map((f) => f.content).join(" "); + + expect(textA).toContain("Alpha"); + expect(textA).not.toContain("Beta"); + expect(textB).toContain("Beta"); + expect(textB).not.toContain("Alpha"); + }, 15_000); + + it("workspace seeding creates all expected bootstrap files", async () => { + const tempDir = await makeTempWorkspace("live-seed-"); + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + + const expectedFiles = [ + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + ]; + + for (const file of expectedFiles) { + const filePath = path.join(tempDir, file); + const stat = await fs.stat(filePath).catch(() => null); + expect(stat, `${file} should exist after seeding`).not.toBeNull(); + } + }, 15_000); + + it("workspace bootstrap files include workspace path metadata", async () => { + const tempDir = await makeTempWorkspace("live-meta-"); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_AGENTS_FILENAME, + content: "# Test Agent", + }); + + const files = await loadWorkspaceBootstrapFiles(tempDir); + const agents = files.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + + expect(agents).toBeDefined(); + expect(agents!.path).toContain(tempDir); + expect(agents!.missing).toBe(false); + }); + + it("context files from different workspaces contain correct file paths", async () => { + const wsA = await makeTempWorkspace("live-path-a-"); + const wsB = await makeTempWorkspace("live-path-b-"); + + await writeWorkspaceFile({ + dir: wsA, + name: DEFAULT_AGENTS_FILENAME, + content: "# Agent A", + }); + await writeWorkspaceFile({ + dir: wsB, + name: DEFAULT_AGENTS_FILENAME, + content: "# Agent B", + }); + + const filesA = await loadWorkspaceBootstrapFiles(wsA); + const filesB = await loadWorkspaceBootstrapFiles(wsB); + + const agentA = filesA.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + const agentB = filesB.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + + expect(agentA!.path).toContain(wsA); + expect(agentB!.path).toContain(wsB); + expect(agentA!.path).not.toContain(wsB); + }); + }, + 30_000, +); diff --git a/src/agents/workspace-context-awareness.test.ts b/src/agents/workspace-context-awareness.test.ts new file mode 100644 index 00000000000..68a4c72e3f4 --- /dev/null +++ b/src/agents/workspace-context-awareness.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; +import { resolveBootstrapFilesForRun, resolveBootstrapContextForRun } from "./bootstrap-files.js"; +import { + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + loadWorkspaceBootstrapFiles, + filterBootstrapFilesForSession, + resolveDefaultAgentWorkspaceDir, +} from "./workspace.js"; + +describe("workspace context awareness", () => { + // ─── resolveDefaultAgentWorkspaceDir profile awareness ──────────── + + describe("resolveDefaultAgentWorkspaceDir respects OPENCLAW_PROFILE", () => { + it("returns workspace- for named profile", () => { + const dir = resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: "work", + HOME: "/home/user", + } as NodeJS.ProcessEnv); + expect(dir).toContain("workspace-work"); + }); + + it("returns default workspace when profile is 'default'", () => { + const dir = resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: "default", + HOME: "/home/user", + } as NodeJS.ProcessEnv); + expect(dir).toMatch(/workspace$/); + expect(dir).not.toContain("workspace-default"); + }); + + it("returns default workspace when no profile set", () => { + const dir = resolveDefaultAgentWorkspaceDir({ + HOME: "/home/user", + } as NodeJS.ProcessEnv); + expect(dir).toMatch(/workspace$/); + }); + + it("trims whitespace from profile name", () => { + const dir = resolveDefaultAgentWorkspaceDir({ + OPENCLAW_PROFILE: " padded ", + HOME: "/home/user", + } as NodeJS.ProcessEnv); + expect(dir).toContain("workspace-padded"); + }); + }); + + // ─── loadWorkspaceBootstrapFiles ────────────────────────────────── + + describe("loadWorkspaceBootstrapFiles loads from correct workspace", () => { + it("loads all standard bootstrap files from a workspace directory", async () => { + const tempDir = await makeTempWorkspace("ctx-awareness-"); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_AGENTS_FILENAME, + content: "# Custom Agent", + }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_SOUL_FILENAME, + content: "# Custom Soul", + }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_TOOLS_FILENAME, + content: "# Custom Tools", + }); + + const files = await loadWorkspaceBootstrapFiles(tempDir); + const agents = files.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + const soul = files.find((f) => f.name === DEFAULT_SOUL_FILENAME); + const tools = files.find((f) => f.name === DEFAULT_TOOLS_FILENAME); + + expect(agents).toBeDefined(); + expect(agents!.missing).toBe(false); + expect(agents!.content).toBe("# Custom Agent"); + + expect(soul).toBeDefined(); + expect(soul!.content).toBe("# Custom Soul"); + + expect(tools).toBeDefined(); + expect(tools!.content).toBe("# Custom Tools"); + }); + + it("marks missing files correctly", async () => { + const tempDir = await makeTempWorkspace("ctx-missing-"); + const files = await loadWorkspaceBootstrapFiles(tempDir); + + for (const f of files) { + expect(f.missing).toBe(true); + } + }); + + it("loads from the specific workspace dir, not a different one", async () => { + const wsA = await makeTempWorkspace("ctx-ws-a-"); + const wsB = await makeTempWorkspace("ctx-ws-b-"); + + await writeWorkspaceFile({ dir: wsA, name: DEFAULT_AGENTS_FILENAME, content: "Workspace A" }); + await writeWorkspaceFile({ dir: wsB, name: DEFAULT_AGENTS_FILENAME, content: "Workspace B" }); + + const filesA = await loadWorkspaceBootstrapFiles(wsA); + const filesB = await loadWorkspaceBootstrapFiles(wsB); + + const agentsA = filesA.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + const agentsB = filesB.find((f) => f.name === DEFAULT_AGENTS_FILENAME); + + expect(agentsA!.content).toBe("Workspace A"); + expect(agentsB!.content).toBe("Workspace B"); + }); + }); + + // ─── filterBootstrapFilesForSession ─────────────────────────────── + + describe("filterBootstrapFilesForSession", () => { + it("returns all files for a regular session key", async () => { + const tempDir = await makeTempWorkspace("ctx-filter-"); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "agents" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_SOUL_FILENAME, content: "soul" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_TOOLS_FILENAME, content: "tools" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "user" }); + + const files = await loadWorkspaceBootstrapFiles(tempDir); + const filtered = filterBootstrapFilesForSession(files, "regular-session-key"); + expect(filtered.length).toBe(files.length); + }); + + it("returns only AGENTS.md and TOOLS.md for subagent sessions", async () => { + const tempDir = await makeTempWorkspace("ctx-subagent-"); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "agents" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_SOUL_FILENAME, content: "soul" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_TOOLS_FILENAME, content: "tools" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "user" }); + + const files = await loadWorkspaceBootstrapFiles(tempDir); + const filtered = filterBootstrapFilesForSession(files, "subagent:parent:child"); + const names = filtered.map((f) => f.name); + expect(names).toContain(DEFAULT_AGENTS_FILENAME); + expect(names).toContain(DEFAULT_TOOLS_FILENAME); + expect(names).not.toContain(DEFAULT_SOUL_FILENAME); + expect(names).not.toContain(DEFAULT_USER_FILENAME); + }); + + it("returns all files when no session key provided", async () => { + const tempDir = await makeTempWorkspace("ctx-no-key-"); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "a" }); + const files = await loadWorkspaceBootstrapFiles(tempDir); + const filtered = filterBootstrapFilesForSession(files); + expect(filtered.length).toBe(files.length); + }); + }); + + // ─── resolveBootstrapContextForRun ──────────────────────────────── + + describe("resolveBootstrapContextForRun", () => { + it("produces context files from workspace bootstrap files", async () => { + const tempDir = await makeTempWorkspace("ctx-resolve-"); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_AGENTS_FILENAME, + content: "# My Agent", + }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_SOUL_FILENAME, content: "# My Soul" }); + + const result = await resolveBootstrapContextForRun({ + workspaceDir: tempDir, + }); + + expect(result.bootstrapFiles.length).toBeGreaterThan(0); + expect(result.contextFiles.length).toBeGreaterThan(0); + + const agentsCtx = result.contextFiles.find((f) => f.path.includes(DEFAULT_AGENTS_FILENAME)); + expect(agentsCtx).toBeDefined(); + }); + + it("context files reflect workspace-specific content", async () => { + const wsA = await makeTempWorkspace("ctx-a-"); + const wsB = await makeTempWorkspace("ctx-b-"); + + await writeWorkspaceFile({ + dir: wsA, + name: DEFAULT_AGENTS_FILENAME, + content: "Profile A instructions", + }); + await writeWorkspaceFile({ + dir: wsB, + name: DEFAULT_AGENTS_FILENAME, + content: "Profile B instructions", + }); + + const resultA = await resolveBootstrapContextForRun({ workspaceDir: wsA }); + const resultB = await resolveBootstrapContextForRun({ workspaceDir: wsB }); + + const contentA = resultA.contextFiles.map((f) => f.content).join(" "); + const contentB = resultB.contextFiles.map((f) => f.content).join(" "); + + expect(contentA).toContain("Profile A instructions"); + expect(contentB).toContain("Profile B instructions"); + expect(contentA).not.toContain("Profile B instructions"); + }); + + it("handles empty workspace gracefully", async () => { + const emptyDir = await makeTempWorkspace("ctx-empty-"); + const result = await resolveBootstrapContextForRun({ + workspaceDir: emptyDir, + }); + expect(result.bootstrapFiles).toBeDefined(); + expect(result.contextFiles).toBeDefined(); + }); + }); + + // ─── resolveBootstrapFilesForRun ────────────────────────────────── + + describe("resolveBootstrapFilesForRun", () => { + it("filters files for subagent session keys", async () => { + const tempDir = await makeTempWorkspace("ctx-run-sub-"); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "agents" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_SOUL_FILENAME, content: "soul" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_TOOLS_FILENAME, content: "tools" }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_IDENTITY_FILENAME, + content: "identity", + }); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir: tempDir, + sessionKey: "subagent:parent:child", + }); + + const names = files.map((f) => f.name); + expect(names).toContain(DEFAULT_AGENTS_FILENAME); + expect(names).toContain(DEFAULT_TOOLS_FILENAME); + expect(names).not.toContain(DEFAULT_SOUL_FILENAME); + expect(names).not.toContain(DEFAULT_IDENTITY_FILENAME); + }); + + it("returns all files for regular session keys", async () => { + const tempDir = await makeTempWorkspace("ctx-run-reg-"); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "agents" }); + await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_SOUL_FILENAME, content: "soul" }); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir: tempDir, + sessionKey: "regular-session", + }); + + const nonMissing = files.filter((f) => !f.missing); + expect(nonMissing.length).toBeGreaterThanOrEqual(2); + }); + }); +});