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:
parent
30333857a1
commit
7aadd02313
212
apps/web/app/api/profiles/route.test.ts
Normal file
212
apps/web/app/api/profiles/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
219
apps/web/app/api/workspace/init/route.test.ts
Normal file
219
apps/web/app/api/workspace/init/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
505
apps/web/lib/subagent-runs.test.ts
Normal file
505
apps/web/lib/subagent-runs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
apps/web/lib/subagent-streaming.live.test.ts
Normal file
191
apps/web/lib/subagent-streaming.live.test.ts
Normal 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);
|
||||
173
apps/web/lib/workspace-chat-isolation.test.ts
Normal file
173
apps/web/lib/workspace-chat-isolation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
515
apps/web/lib/workspace-profiles.test.ts
Normal file
515
apps/web/lib/workspace-profiles.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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}"
|
||||
|
||||
136
src/agents/workspace-context-awareness.live.test.ts
Normal file
136
src/agents/workspace-context-awareness.live.test.ts
Normal 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,
|
||||
);
|
||||
255
src/agents/workspace-context-awareness.test.ts
Normal file
255
src/agents/workspace-context-awareness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user