- 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>
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|