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 <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-21 15:37:59 -08:00
parent 30333857a1
commit 7aadd02313
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
10 changed files with 2237 additions and 0 deletions

View File

@ -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-<name> 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<string, unknown>) {
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);
});
});
});

View File

@ -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<string, unknown>) {
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");
});
});

View File

@ -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<string, unknown>)[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<string, unknown>)[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<string, unknown>).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<string, unknown>).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);
});
});
});

View File

@ -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<string, unknown>)[
"__openclaw_subagentRuns"
];
});
afterEach(async () => {
process.env = { ...originalEnv };
delete (globalThis as Record<string, unknown>)[
"__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<Record<string, unknown>> = [];
subscribeToSubagent(
"sub:p:realtime",
(event) => {
if (event) {received.push(event as Record<string, unknown>);}
},
{ 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<Record<string, unknown>> = [];
subscribeToSubagent("sub:p:replay", (event) => {
if (event) {received.push(event as Record<string, unknown>);}
});
expect(received.length).toBeGreaterThanOrEqual(2);
}, 10_000);
}, 60_000);

View File

@ -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-<name> 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);
});
});

View File

@ -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-<name> 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-<name> 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-<name> 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");
});
});
});

View File

@ -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",

View File

@ -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}"

View File

@ -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,
);

View File

@ -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-<profile> 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);
});
});
});