refactor(agents): dedupe plugin hooks and test helpers

This commit is contained in:
Peter Steinberger
2026-02-22 07:38:24 +00:00
parent 75c1bfbae8
commit 185fba1d22
16 changed files with 661 additions and 579 deletions

View File

@@ -16,15 +16,43 @@ vi.mock("./tools/gateway.js", () => ({
readGatewayCallOptions: vi.fn(() => ({})),
}));
function requireGatewayTool(agentSessionKey?: string) {
const tool = createOpenClawTools({
...(agentSessionKey ? { agentSessionKey } : {}),
config: { commands: { restart: true } },
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
return tool;
}
function expectConfigMutationCall(params: {
callGatewayTool: {
mock: {
calls: Array<[string, unknown, Record<string, unknown>]>;
};
};
action: "config.apply" | "config.patch";
raw: string;
sessionKey: string;
}) {
expect(params.callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(params.callGatewayTool).toHaveBeenCalledWith(
params.action,
expect.any(Object),
expect.objectContaining({
raw: params.raw.trim(),
baseHash: "hash-1",
sessionKey: params.sessionKey,
}),
);
}
describe("gateway tool", () => {
it("marks gateway as owner-only", async () => {
const tool = createOpenClawTools({
config: { commands: { restart: true } },
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
const tool = requireGatewayTool();
expect(tool.ownerOnly).toBe(true);
});
@@ -37,13 +65,7 @@ describe("gateway tool", () => {
await withEnvAsync(
{ OPENCLAW_STATE_DIR: stateDir, OPENCLAW_PROFILE: "isolated" },
async () => {
const tool = createOpenClawTools({
config: { commands: { restart: true } },
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
const tool = requireGatewayTool();
const result = await tool.execute("call1", {
action: "restart",
@@ -80,13 +102,8 @@ describe("gateway tool", () => {
it("passes config.apply through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createOpenClawTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
const raw = '{\n agents: { defaults: { workspace: "~/openclaw" } }\n}\n';
await tool.execute("call2", {
@@ -94,27 +111,18 @@ describe("gateway tool", () => {
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.apply",
expect.any(Object),
expect.objectContaining({
raw: raw.trim(),
baseHash: "hash-1",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
expectConfigMutationCall({
callGatewayTool: vi.mocked(callGatewayTool),
action: "config.apply",
raw,
sessionKey,
});
});
it("passes config.patch through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createOpenClawTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
const raw = '{\n channels: { telegram: { groups: { "*": { requireMention: false } } } }\n}\n';
await tool.execute("call4", {
@@ -122,27 +130,18 @@ describe("gateway tool", () => {
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.objectContaining({
raw: raw.trim(),
baseHash: "hash-1",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
expectConfigMutationCall({
callGatewayTool: vi.mocked(callGatewayTool),
action: "config.patch",
raw,
sessionKey,
});
});
it("passes update.run through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createOpenClawTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing gateway tool");
}
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
await tool.execute("call3", {
action: "update.run",
@@ -154,7 +153,7 @@ describe("gateway tool", () => {
expect.any(Object),
expect.objectContaining({
note: "test update",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
sessionKey,
}),
);
const updateCall = vi

View File

@@ -3,67 +3,82 @@ import { describe, expect, it } from "vitest";
import { sanitizeToolsForGoogle } from "./google.js";
describe("sanitizeToolsForGoogle", () => {
it("strips unsupported schema keywords for Google providers", () => {
const tool = {
const createTool = (parameters: Record<string, unknown>) =>
({
name: "test",
description: "test",
parameters: {
type: "object",
additionalProperties: false,
properties: {
foo: {
type: "string",
format: "uuid",
},
parameters,
execute: async () => ({ ok: true, content: [] }),
}) as unknown as AgentTool;
const expectFormatRemoved = (
sanitized: AgentTool,
key: "additionalProperties" | "patternProperties",
) => {
const params = sanitized.parameters as {
additionalProperties?: unknown;
patternProperties?: unknown;
properties?: Record<string, { format?: unknown }>;
};
expect(params[key]).toBeUndefined();
expect(params.properties?.foo?.format).toBeUndefined();
};
it("strips unsupported schema keywords for Google providers", () => {
const tool = createTool({
type: "object",
additionalProperties: false,
properties: {
foo: {
type: "string",
format: "uuid",
},
},
execute: async () => ({ ok: true, content: [] }),
} as unknown as AgentTool;
});
const [sanitized] = sanitizeToolsForGoogle({
tools: [tool],
provider: "google-gemini-cli",
});
const params = sanitized.parameters as {
additionalProperties?: unknown;
properties?: Record<string, { format?: unknown }>;
};
expect(params.additionalProperties).toBeUndefined();
expect(params.properties?.foo?.format).toBeUndefined();
expectFormatRemoved(sanitized, "additionalProperties");
});
it("strips unsupported schema keywords for google-antigravity", () => {
const tool = {
name: "test",
description: "test",
parameters: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
properties: {
foo: {
type: "string",
format: "uuid",
},
const tool = createTool({
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
properties: {
foo: {
type: "string",
format: "uuid",
},
},
execute: async () => ({ ok: true, content: [] }),
} as unknown as AgentTool;
});
const [sanitized] = sanitizeToolsForGoogle({
tools: [tool],
provider: "google-antigravity",
});
expectFormatRemoved(sanitized, "patternProperties");
});
const params = sanitized.parameters as {
patternProperties?: unknown;
properties?: Record<string, { format?: unknown }>;
};
it("returns original tools for non-google providers", () => {
const tool = createTool({
type: "object",
additionalProperties: false,
properties: {
foo: {
type: "string",
format: "uuid",
},
},
});
const sanitized = sanitizeToolsForGoogle({
tools: [tool],
provider: "openai",
});
expect(params.patternProperties).toBeUndefined();
expect(params.properties?.foo?.format).toBeUndefined();
expect(sanitized).toEqual([tool]);
expect(sanitized[0]).toBe(tool);
});
});

View File

@@ -26,6 +26,21 @@ function createRunEntry(): SubagentRunRecord {
}
describe("emitSubagentEndedHookOnce", () => {
const createEmitParams = (
overrides?: Partial<Parameters<typeof emitSubagentEndedHookOnce>[0]>,
) => {
const entry = overrides?.entry ?? createRunEntry();
return {
entry,
reason: SUBAGENT_ENDED_REASON_COMPLETE,
sendFarewell: true,
accountId: "acct-1",
inFlightRunIds: new Set<string>(),
persist: vi.fn(),
...overrides,
};
};
beforeEach(() => {
lifecycleMocks.getGlobalHookRunner.mockReset();
lifecycleMocks.runSubagentEnded.mockClear();
@@ -37,21 +52,13 @@ describe("emitSubagentEndedHookOnce", () => {
runSubagentEnded: lifecycleMocks.runSubagentEnded,
});
const entry = createRunEntry();
const persist = vi.fn();
const emitted = await emitSubagentEndedHookOnce({
entry,
reason: SUBAGENT_ENDED_REASON_COMPLETE,
sendFarewell: true,
accountId: "acct-1",
inFlightRunIds: new Set<string>(),
persist,
});
const params = createEmitParams();
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(true);
expect(lifecycleMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(typeof entry.endedHookEmittedAt).toBe("number");
expect(persist).toHaveBeenCalledTimes(1);
expect(typeof params.entry.endedHookEmittedAt).toBe("number");
expect(params.persist).toHaveBeenCalledTimes(1);
});
it("runs subagent_ended hooks when available", async () => {
@@ -60,20 +67,60 @@ describe("emitSubagentEndedHookOnce", () => {
runSubagentEnded: lifecycleMocks.runSubagentEnded,
});
const entry = createRunEntry();
const persist = vi.fn();
const emitted = await emitSubagentEndedHookOnce({
entry,
reason: SUBAGENT_ENDED_REASON_COMPLETE,
sendFarewell: true,
accountId: "acct-1",
inFlightRunIds: new Set<string>(),
persist,
});
const params = createEmitParams();
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(true);
expect(lifecycleMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
expect(typeof entry.endedHookEmittedAt).toBe("number");
expect(persist).toHaveBeenCalledTimes(1);
expect(typeof params.entry.endedHookEmittedAt).toBe("number");
expect(params.persist).toHaveBeenCalledTimes(1);
});
it("returns false when runId is blank", async () => {
const params = createEmitParams({
entry: { ...createRunEntry(), runId: " " },
});
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(false);
expect(params.persist).not.toHaveBeenCalled();
expect(lifecycleMocks.runSubagentEnded).not.toHaveBeenCalled();
});
it("returns false when ended hook marker already exists", async () => {
const params = createEmitParams({
entry: { ...createRunEntry(), endedHookEmittedAt: Date.now() },
});
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(false);
expect(params.persist).not.toHaveBeenCalled();
expect(lifecycleMocks.runSubagentEnded).not.toHaveBeenCalled();
});
it("returns false when runId is already in flight", async () => {
const entry = createRunEntry();
const inFlightRunIds = new Set<string>([entry.runId]);
const params = createEmitParams({ entry, inFlightRunIds });
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(false);
expect(params.persist).not.toHaveBeenCalled();
expect(lifecycleMocks.runSubagentEnded).not.toHaveBeenCalled();
});
it("returns false when subagent hook execution throws", async () => {
lifecycleMocks.runSubagentEnded.mockRejectedValueOnce(new Error("boom"));
lifecycleMocks.getGlobalHookRunner.mockReturnValue({
hasHooks: () => true,
runSubagentEnded: lifecycleMocks.runSubagentEnded,
});
const entry = createRunEntry();
const inFlightRunIds = new Set<string>();
const params = createEmitParams({ entry, inFlightRunIds });
const emitted = await emitSubagentEndedHookOnce(params);
expect(emitted).toBe(false);
expect(params.persist).not.toHaveBeenCalled();
expect(inFlightRunIds.has(entry.runId)).toBe(false);
expect(entry.endedHookEmittedAt).toBeUndefined();
});
});

View File

@@ -13,33 +13,42 @@ function makeBootstrapFile(overrides: Partial<WorkspaceBootstrapFile>): Workspac
}
describe("buildSystemPromptReport", () => {
it("counts injected chars when injected file paths are absolute", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = buildSystemPromptReport({
const makeReport = (params: {
file: WorkspaceBootstrapFile;
injectedPath: string;
injectedContent: string;
bootstrapMaxChars?: number;
bootstrapTotalMaxChars?: number;
}) =>
buildSystemPromptReport({
source: "run",
generatedAt: 0,
bootstrapMaxChars: 20_000,
bootstrapMaxChars: params.bootstrapMaxChars ?? 20_000,
bootstrapTotalMaxChars: params.bootstrapTotalMaxChars,
systemPrompt: "system",
bootstrapFiles: [file],
injectedFiles: [{ path: "/tmp/workspace/policies/AGENTS.md", content: "trimmed" }],
bootstrapFiles: [params.file],
injectedFiles: [{ path: params.injectedPath, content: params.injectedContent }],
skillsPrompt: "",
tools: [],
});
it("counts injected chars when injected file paths are absolute", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = makeReport({
file,
injectedPath: "/tmp/workspace/policies/AGENTS.md",
injectedContent: "trimmed",
});
expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe("trimmed".length);
});
it("keeps legacy basename matching for injected files", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = buildSystemPromptReport({
source: "run",
generatedAt: 0,
bootstrapMaxChars: 20_000,
systemPrompt: "system",
bootstrapFiles: [file],
injectedFiles: [{ path: "AGENTS.md", content: "trimmed" }],
skillsPrompt: "",
tools: [],
const report = makeReport({
file,
injectedPath: "AGENTS.md",
injectedContent: "trimmed",
});
expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe("trimmed".length);
@@ -50,15 +59,10 @@ describe("buildSystemPromptReport", () => {
path: "/tmp/workspace/policies/AGENTS.md",
content: "abcdefghijklmnopqrstuvwxyz",
});
const report = buildSystemPromptReport({
source: "run",
generatedAt: 0,
bootstrapMaxChars: 20_000,
systemPrompt: "system",
bootstrapFiles: [file],
injectedFiles: [{ path: "/tmp/workspace/policies/AGENTS.md", content: "trimmed" }],
skillsPrompt: "",
tools: [],
const report = makeReport({
file,
injectedPath: "/tmp/workspace/policies/AGENTS.md",
injectedContent: "trimmed",
});
expect(report.injectedWorkspaceFiles[0]?.truncated).toBe(true);
@@ -66,19 +70,27 @@ describe("buildSystemPromptReport", () => {
it("includes both bootstrap caps in the report payload", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = buildSystemPromptReport({
source: "run",
generatedAt: 0,
const report = makeReport({
file,
injectedPath: "AGENTS.md",
injectedContent: "trimmed",
bootstrapMaxChars: 11_111,
bootstrapTotalMaxChars: 22_222,
systemPrompt: "system",
bootstrapFiles: [file],
injectedFiles: [{ path: "AGENTS.md", content: "trimmed" }],
skillsPrompt: "",
tools: [],
});
expect(report.bootstrapMaxChars).toBe(11_111);
expect(report.bootstrapTotalMaxChars).toBe(22_222);
});
it("reports injectedChars=0 when injected file does not match by path or basename", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = makeReport({
file,
injectedPath: "/tmp/workspace/policies/OTHER.md",
injectedContent: "trimmed",
});
expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe(0);
expect(report.injectedWorkspaceFiles[0]?.truncated).toBe(true);
});
});

View File

@@ -11,6 +11,19 @@ describe("workspace bootstrap file caching", () => {
workspaceDir = await makeTempWorkspace("openclaw-bootstrap-cache-test-");
});
const loadAgentsFile = async (dir: string) => {
const result = await loadWorkspaceBootstrapFiles(dir);
return result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
};
const expectAgentsContent = (
agentsFile: Awaited<ReturnType<typeof loadAgentsFile>>,
content: string,
) => {
expect(agentsFile?.content).toBe(content);
expect(agentsFile?.missing).toBe(false);
};
it("returns cached content when mtime unchanged", async () => {
const content1 = "# Initial content";
await writeWorkspaceFile({
@@ -20,16 +33,12 @@ describe("workspace bootstrap file caching", () => {
});
// First load
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile1?.content).toBe(content1);
expect(agentsFile1?.missing).toBe(false);
const agentsFile1 = await loadAgentsFile(workspaceDir);
expectAgentsContent(agentsFile1, content1);
// Second load should use cached content (same mtime)
const result2 = await loadWorkspaceBootstrapFiles(workspaceDir);
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile2?.content).toBe(content1);
expect(agentsFile2?.missing).toBe(false);
const agentsFile2 = await loadAgentsFile(workspaceDir);
expectAgentsContent(agentsFile2, content1);
// Verify both calls returned the same content without re-reading
expect(agentsFile1?.content).toBe(agentsFile2?.content);
@@ -46,9 +55,8 @@ describe("workspace bootstrap file caching", () => {
});
// First load
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile1?.content).toBe(content1);
const agentsFile1 = await loadAgentsFile(workspaceDir);
expectAgentsContent(agentsFile1, content1);
// Wait a bit to ensure mtime will be different
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -61,10 +69,8 @@ describe("workspace bootstrap file caching", () => {
});
// Second load should detect the change and return new content
const result2 = await loadWorkspaceBootstrapFiles(workspaceDir);
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile2?.content).toBe(content2);
expect(agentsFile2?.missing).toBe(false);
const agentsFile2 = await loadAgentsFile(workspaceDir);
expectAgentsContent(agentsFile2, content2);
});
it("handles file deletion gracefully", async () => {
@@ -74,10 +80,8 @@ describe("workspace bootstrap file caching", () => {
await writeWorkspaceFile({ dir: workspaceDir, name: DEFAULT_AGENTS_FILENAME, content });
// First load
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile1?.content).toBe(content);
expect(agentsFile1?.missing).toBe(false);
const agentsFile1 = await loadAgentsFile(workspaceDir);
expectAgentsContent(agentsFile1, content);
// Delete the file
await fs.unlink(filePath);
@@ -101,8 +105,7 @@ describe("workspace bootstrap file caching", () => {
// All results should be identical
for (const result of results) {
const agentsFile = result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
expect(agentsFile?.content).toBe(content);
expect(agentsFile?.missing).toBe(false);
expectAgentsContent(agentsFile, content);
}
});
@@ -127,4 +130,10 @@ describe("workspace bootstrap file caching", () => {
expect(agentsFile1?.content).toBe(content1);
expect(agentsFile2?.content).toBe(content2);
});
it("returns missing=true when bootstrap file never existed", async () => {
const agentsFile = await loadAgentsFile(workspaceDir);
expect(agentsFile?.missing).toBe(true);
expect(agentsFile?.content).toBeUndefined();
});
});

View File

@@ -37,6 +37,15 @@ function makeEvent(overrides?: Partial<InternalHookEvent>): InternalHookEvent {
}
describe("boot-md handler", () => {
function setupTwoAgentBootConfig() {
const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } };
listAgentIds.mockReturnValue(["main", "ops"]);
resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) =>
id === "main" ? MAIN_WORKSPACE_DIR : OPS_WORKSPACE_DIR,
);
return cfg;
}
beforeEach(() => {
vi.clearAllMocks();
logWarn.mockReset();
@@ -59,11 +68,7 @@ describe("boot-md handler", () => {
});
it("runs boot for each agent", async () => {
const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } };
listAgentIds.mockReturnValue(["main", "ops"]);
resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) =>
id === "main" ? MAIN_WORKSPACE_DIR : OPS_WORKSPACE_DIR,
);
const cfg = setupTwoAgentBootConfig();
runBootOnce.mockResolvedValue({ status: "ran" });
await runBootChecklist(makeEvent({ context: { cfg } }));
@@ -93,11 +98,7 @@ describe("boot-md handler", () => {
});
it("logs warning details when a per-agent boot run fails", async () => {
const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } };
listAgentIds.mockReturnValue(["main", "ops"]);
resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) =>
id === "main" ? MAIN_WORKSPACE_DIR : OPS_WORKSPACE_DIR,
);
const cfg = setupTwoAgentBootConfig();
runBootOnce
.mockResolvedValueOnce({ status: "ran" })
.mockResolvedValueOnce({ status: "failed", reason: "agent failed" });

View File

@@ -114,6 +114,25 @@ function makeSessionMemoryConfig(tempDir: string, messages?: number): OpenClawCo
} satisfies OpenClawConfig;
}
async function createSessionMemoryWorkspace(params?: {
activeSession?: { name: string; content: string };
}): Promise<{ tempDir: string; sessionsDir: string; activeSessionFile?: string }> {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
if (!params?.activeSession) {
return { tempDir, sessionsDir };
}
const activeSessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: params.activeSession.name,
content: params.activeSession.content,
});
return { tempDir, sessionsDir, activeSessionFile };
}
describe("session-memory hook", () => {
it("skips non-command events", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
@@ -289,14 +308,8 @@ describe("session-memory hook", () => {
});
it("falls back to latest .jsonl.reset.* transcript when active file is empty", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const activeSessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({
activeSession: { name: "test-session.jsonl", content: "" },
});
// Simulate /new rotation where useful content is now in .reset.* file
@@ -314,7 +327,7 @@ describe("session-memory hook", () => {
tempDir,
previousSessionEntry: {
sessionId: "test-123",
sessionFile: activeSessionFile,
sessionFile: activeSessionFile!,
},
});
@@ -323,9 +336,7 @@ describe("session-memory hook", () => {
});
it("handles reset-path session pointers from previousSessionEntry", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const { tempDir, sessionsDir } = await createSessionMemoryWorkspace();
const sessionId = "reset-pointer-session";
const resetSessionFile = await writeWorkspaceFile({
@@ -352,9 +363,7 @@ describe("session-memory hook", () => {
});
it("recovers transcript when previousSessionEntry.sessionFile is missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const { tempDir, sessionsDir } = await createSessionMemoryWorkspace();
const sessionId = "missing-session-file";
await writeWorkspaceFile({
@@ -385,14 +394,8 @@ describe("session-memory hook", () => {
});
it("prefers the newest reset transcript when multiple reset candidates exist", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const activeSessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({
activeSession: { name: "test-session.jsonl", content: "" },
});
await writeWorkspaceFile({
@@ -416,7 +419,7 @@ describe("session-memory hook", () => {
tempDir,
previousSessionEntry: {
sessionId: "test-123",
sessionFile: activeSessionFile,
sessionFile: activeSessionFile!,
},
});
@@ -425,6 +428,39 @@ describe("session-memory hook", () => {
expect(memoryContent).not.toContain("Older rotated transcript");
});
it("prefers active transcript when it is non-empty even with reset candidates", async () => {
const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({
activeSession: {
name: "test-session.jsonl",
content: createMockSessionContent([
{ role: "user", content: "Active transcript message" },
{ role: "assistant", content: "Active transcript summary" },
]),
},
});
await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl.reset.2026-02-16T22-26-34.000Z",
content: createMockSessionContent([
{ role: "user", content: "Reset fallback message" },
{ role: "assistant", content: "Reset fallback summary" },
]),
});
const { memoryContent } = await runNewWithPreviousSessionEntry({
tempDir,
previousSessionEntry: {
sessionId: "test-123",
sessionFile: activeSessionFile!,
},
});
expect(memoryContent).toContain("user: Active transcript message");
expect(memoryContent).toContain("assistant: Active transcript summary");
expect(memoryContent).not.toContain("Reset fallback message");
});
it("handles empty session files gracefully", async () => {
// Should not throw
const { files } = await runNewWithPreviousSession({ sessionContent: "" });

View File

@@ -39,25 +39,26 @@ describe("before_agent_start hook merger", () => {
registry = createEmptyPluginRegistry();
});
it("returns modelOverride from a single plugin", async () => {
addBeforeAgentStartHook(registry, "plugin-a", () => ({
modelOverride: "llama3.3:8b",
}));
const runWithSingleHook = async (result: PluginHookBeforeAgentStartResult, priority?: number) => {
addBeforeAgentStartHook(registry, "plugin-a", () => result, priority);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
return await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
};
expect(result?.modelOverride).toBe("llama3.3:8b");
const expectSingleModelOverride = async (modelOverride: string) => {
const result = await runWithSingleHook({ modelOverride });
expect(result?.modelOverride).toBe(modelOverride);
return result;
};
it("returns modelOverride from a single plugin", async () => {
await expectSingleModelOverride("llama3.3:8b");
});
it("returns providerOverride from a single plugin", async () => {
addBeforeAgentStartHook(registry, "plugin-a", () => ({
const result = await runWithSingleHook({
providerOverride: "ollama",
}));
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
});
expect(result?.providerOverride).toBe("ollama");
});
@@ -153,14 +154,7 @@ describe("before_agent_start hook merger", () => {
});
it("modelOverride without providerOverride leaves provider undefined", async () => {
addBeforeAgentStartHook(registry, "plugin-a", () => ({
modelOverride: "llama3.3:8b",
}));
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
expect(result?.modelOverride).toBe("llama3.3:8b");
const result = await expectSingleModelOverride("llama3.3:8b");
expect(result?.providerOverride).toBeUndefined();
});

View File

@@ -3,20 +3,23 @@ import type { OpenClawConfig } from "../config/config.js";
import { applyExclusiveSlotSelection } from "./slots.js";
describe("applyExclusiveSlotSelection", () => {
it("selects the slot and disables other entries for the same kind", () => {
const config: OpenClawConfig = {
plugins: {
slots: { memory: "memory-core" },
entries: {
"memory-core": { enabled: true },
memory: { enabled: true },
const createMemoryConfig = (plugins?: OpenClawConfig["plugins"]): OpenClawConfig => ({
plugins: {
...plugins,
entries: {
...plugins?.entries,
memory: {
enabled: true,
...plugins?.entries?.memory,
},
},
};
},
});
const result = applyExclusiveSlotSelection({
const runMemorySelection = (config: OpenClawConfig, selectedId = "memory") =>
applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedId,
selectedKind: "memory",
registry: {
plugins: [
@@ -26,6 +29,13 @@ describe("applyExclusiveSlotSelection", () => {
},
});
it("selects the slot and disables other entries for the same kind", () => {
const config = createMemoryConfig({
slots: { memory: "memory-core" },
entries: { "memory-core": { enabled: true } },
});
const result = runMemorySelection(config);
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("memory");
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
@@ -36,15 +46,9 @@ describe("applyExclusiveSlotSelection", () => {
});
it("does nothing when the slot already matches", () => {
const config: OpenClawConfig = {
plugins: {
slots: { memory: "memory" },
entries: {
memory: { enabled: true },
},
},
};
const config = createMemoryConfig({
slots: { memory: "memory" },
});
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
@@ -58,14 +62,7 @@ describe("applyExclusiveSlotSelection", () => {
});
it("warns when the slot falls back to a default", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
memory: { enabled: true },
},
},
};
const config = createMemoryConfig();
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
@@ -79,6 +76,22 @@ describe("applyExclusiveSlotSelection", () => {
);
});
it("keeps disabled competing plugins disabled without adding disable warnings", () => {
const config = createMemoryConfig({
entries: {
"memory-core": { enabled: false },
},
});
const result = runMemorySelection(config);
expect(result.changed).toBe(true);
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
expect(result.warnings).not.toContain('Disabled other "memory" slot plugins: memory-core.');
});
it("skips changes when no exclusive slot applies", () => {
const config: OpenClawConfig = {};
const result = applyExclusiveSlotSelection({

View File

@@ -6,50 +6,39 @@ import { createHookRunner } from "./hooks.js";
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
describe("subagent hook runner methods", () => {
const baseRequester = {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
};
const baseSubagentCtx = {
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
};
it("runSubagentSpawning invokes registered subagent_spawning hooks", async () => {
const handler = vi.fn(async () => ({ status: "ok", threadBindingReady: true as const }));
const registry = createMockPluginRegistry([{ hookName: "subagent_spawning", handler }]);
const runner = createHookRunner(registry);
const event = {
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "session" as const,
requester: baseRequester,
threadRequested: true,
};
const ctx = {
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
};
const result = await runner.runSubagentSpawning(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
const result = await runner.runSubagentSpawning(event, ctx);
expect(handler).toHaveBeenCalledWith(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
expect(handler).toHaveBeenCalledWith(event, ctx);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
@@ -57,50 +46,19 @@ describe("subagent hook runner methods", () => {
const handler = vi.fn();
const registry = createMockPluginRegistry([{ hookName: "subagent_spawned", handler }]);
const runner = createHookRunner(registry);
const event = {
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "run" as const,
requester: baseRequester,
threadRequested: true,
};
await runner.runSubagentSpawned(
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "run",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
await runner.runSubagentSpawned(event, baseSubagentCtx);
expect(handler).toHaveBeenCalledWith(
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "research",
mode: "run",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
});
it("runSubagentDeliveryTarget invokes registered subagent_delivery_target hooks", async () => {
@@ -114,48 +72,18 @@ describe("subagent hook runner methods", () => {
}));
const registry = createMockPluginRegistry([{ hookName: "subagent_delivery_target", handler }]);
const runner = createHookRunner(registry);
const event = {
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: baseRequester,
childRunId: "run-1",
spawnMode: "session" as const,
expectsCompletionMessage: true,
};
const result = await runner.runSubagentDeliveryTarget(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
const result = await runner.runSubagentDeliveryTarget(event, baseSubagentCtx);
expect(handler).toHaveBeenCalledWith(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
expect(result).toEqual({
origin: {
channel: "discord",
@@ -166,44 +94,40 @@ describe("subagent hook runner methods", () => {
});
});
it("runSubagentDeliveryTarget returns undefined when no matching hooks are registered", async () => {
const registry = createMockPluginRegistry([]);
const runner = createHookRunner(registry);
const result = await runner.runSubagentDeliveryTarget(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: baseRequester,
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
baseSubagentCtx,
);
expect(result).toBeUndefined();
});
it("runSubagentEnded invokes registered subagent_ended hooks", async () => {
const handler = vi.fn();
const registry = createMockPluginRegistry([{ hookName: "subagent_ended", handler }]);
const runner = createHookRunner(registry);
const event = {
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent" as const,
reason: "subagent-complete",
sendFarewell: true,
accountId: "work",
runId: "run-1",
outcome: "ok" as const,
};
await runner.runSubagentEnded(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
accountId: "work",
runId: "run-1",
outcome: "ok",
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
await runner.runSubagentEnded(event, baseSubagentCtx);
expect(handler).toHaveBeenCalledWith(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
accountId: "work",
runId: "run-1",
outcome: "ok",
},
{
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
},
);
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
});
it("hasHooks returns true for registered subagent hooks", () => {

View File

@@ -28,6 +28,28 @@ import {
waitForActiveTasks,
} from "./command-queue.js";
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
function enqueueBlockedMainTask<T = void>(
onRelease?: () => Promise<T> | T,
): {
task: Promise<T>;
release: () => void;
} {
const deferred = createDeferred();
const task = enqueueCommand(async () => {
await deferred.promise;
return (await onRelease?.()) as T;
});
return { task, release: deferred.resolve };
}
describe("command queue", () => {
beforeEach(() => {
diagnosticMocks.logLaneEnqueue.mockClear();
@@ -113,18 +135,11 @@ describe("command queue", () => {
});
it("getActiveTaskCount returns count of currently executing tasks", async () => {
let resolve1!: () => void;
const blocker = new Promise<void>((r) => {
resolve1 = r;
});
const task = enqueueCommand(async () => {
await blocker;
});
const { task, release } = enqueueBlockedMainTask();
expect(getActiveTaskCount()).toBe(1);
resolve1();
release();
await task;
expect(getActiveTaskCount()).toBe(0);
});
@@ -135,21 +150,14 @@ describe("command queue", () => {
});
it("waitForActiveTasks waits for active tasks to finish", async () => {
let resolve1!: () => void;
const blocker = new Promise<void>((r) => {
resolve1 = r;
});
const task = enqueueCommand(async () => {
await blocker;
});
const { task, release } = enqueueBlockedMainTask();
vi.useFakeTimers();
try {
const drainPromise = waitForActiveTasks(5000);
await vi.advanceTimersByTimeAsync(50);
resolve1();
release();
await vi.advanceTimersByTimeAsync(50);
const { drained } = await drainPromise;
@@ -161,15 +169,18 @@ describe("command queue", () => {
}
});
it("waitForActiveTasks returns drained=false on timeout", async () => {
let resolve1!: () => void;
const blocker = new Promise<void>((r) => {
resolve1 = r;
});
it("waitForActiveTasks returns drained=false when timeout is zero and tasks are active", async () => {
const { task, release } = enqueueBlockedMainTask();
const task = enqueueCommand(async () => {
await blocker;
});
const { drained } = await waitForActiveTasks(0);
expect(drained).toBe(false);
release();
await task;
});
it("waitForActiveTasks returns drained=false on timeout", async () => {
const { task, release } = enqueueBlockedMainTask();
vi.useFakeTimers();
try {
@@ -178,7 +189,7 @@ describe("command queue", () => {
const { drained } = await waitPromise;
expect(drained).toBe(false);
resolve1();
release();
await task;
} finally {
vi.useRealTimers();
@@ -261,16 +272,8 @@ describe("command queue", () => {
});
it("clearCommandLane rejects pending promises", async () => {
let resolve1!: () => void;
const blocker = new Promise<void>((r) => {
resolve1 = r;
});
// First task blocks the lane.
const first = enqueueCommand(async () => {
await blocker;
return "first";
});
const { task: first, release } = enqueueBlockedMainTask(async () => "first");
// Second task is queued behind the first.
const second = enqueueCommand(async () => "second");
@@ -282,7 +285,7 @@ describe("command queue", () => {
await expect(second).rejects.toBeInstanceOf(CommandLaneClearedError);
// Let the active task finish normally.
resolve1();
release();
await expect(first).resolves.toBe("first");
});
});

View File

@@ -9,8 +9,22 @@ afterEach(() => {
});
describe("refreshQwenPortalCredentials", () => {
const expiredCredentials = () => ({
access: "old-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
});
const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials());
const stubFetchResponse = (response: unknown) => {
const fetchSpy = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchSpy);
return fetchSpy;
};
it("refreshes tokens with a new access token", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
const fetchSpy = stubFetchResponse({
ok: true,
status: 200,
json: async () => ({
@@ -19,13 +33,8 @@ describe("refreshQwenPortalCredentials", () => {
expires_in: 3600,
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await refreshQwenPortalCredentials({
access: "old-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
});
const result = await runRefresh();
expect(fetchSpy).toHaveBeenCalledWith(
"https://chat.qwen.ai/api/v1/oauth2/token",
@@ -39,7 +48,7 @@ describe("refreshQwenPortalCredentials", () => {
});
it("keeps refresh token when refresh response omits it", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
stubFetchResponse({
ok: true,
status: 200,
json: async () => ({
@@ -47,19 +56,14 @@ describe("refreshQwenPortalCredentials", () => {
expires_in: 1800,
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await refreshQwenPortalCredentials({
access: "old-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
});
const result = await runRefresh();
expect(result.refresh).toBe("old-refresh");
});
it("keeps refresh token when response sends an empty refresh token", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
stubFetchResponse({
ok: true,
status: 200,
json: async () => ({
@@ -68,19 +72,14 @@ describe("refreshQwenPortalCredentials", () => {
expires_in: 1800,
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await refreshQwenPortalCredentials({
access: "old-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
});
const result = await runRefresh();
expect(result.refresh).toBe("old-refresh");
});
it("errors when refresh response has invalid expires_in", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
stubFetchResponse({
ok: true,
status: 200,
json: async () => ({
@@ -89,31 +88,53 @@ describe("refreshQwenPortalCredentials", () => {
expires_in: 0,
}),
});
vi.stubGlobal("fetch", fetchSpy);
await expect(
refreshQwenPortalCredentials({
access: "old-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
}),
).rejects.toThrow("Qwen OAuth refresh response missing or invalid expires_in");
await expect(runRefresh()).rejects.toThrow(
"Qwen OAuth refresh response missing or invalid expires_in",
);
});
it("errors when refresh token is invalid", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
stubFetchResponse({
ok: false,
status: 400,
text: async () => "invalid_grant",
});
vi.stubGlobal("fetch", fetchSpy);
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid");
});
it("errors when refresh token is missing before any request", async () => {
await expect(
refreshQwenPortalCredentials({
access: "old-access",
refresh: "old-refresh",
refresh: " ",
expires: Date.now() - 1000,
}),
).rejects.toThrow("Qwen OAuth refresh token expired or invalid");
).rejects.toThrow("Qwen OAuth refresh token missing");
});
it("errors when refresh response omits access token", async () => {
stubFetchResponse({
ok: true,
status: 200,
json: async () => ({
refresh_token: "new-refresh",
expires_in: 1800,
}),
});
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token");
});
it("errors with server payload text for non-400 status", async () => {
stubFetchResponse({
ok: false,
status: 500,
statusText: "Server Error",
text: async () => "gateway down",
});
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down");
});
});

View File

@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { captureEnv, captureFullEnv, withEnv, withEnvAsync } from "./env.js";
function restoreEnvKey(key: string, previous: string | undefined): void {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
describe("env test utils", () => {
it("captureEnv restores mutated keys", () => {
const keyA = "OPENCLAW_ENV_TEST_A";
@@ -63,11 +71,7 @@ describe("env test utils", () => {
expect(seen).toBeUndefined();
expect(process.env[key]).toBe("outer");
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restoreEnvKey(key, prev);
});
it("withEnvAsync restores values when callback throws", async () => {
@@ -103,10 +107,6 @@ describe("env test utils", () => {
expect(seen).toBeUndefined();
expect(process.env[key]).toBe("outer");
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restoreEnvKey(key, prev);
});
});

View File

@@ -17,6 +17,16 @@ export function captureEnv(keys: string[]) {
};
}
function applyEnvValues(env: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
export function captureFullEnv() {
const snapshot: Record<string, string | undefined> = { ...process.env };
@@ -41,13 +51,7 @@ export function captureFullEnv() {
export function withEnv<T>(env: Record<string, string | undefined>, fn: () => T): T {
const snapshot = captureEnv(Object.keys(env));
try {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
applyEnvValues(env);
return fn();
} finally {
snapshot.restore();
@@ -60,13 +64,7 @@ export async function withEnvAsync<T>(
): Promise<T> {
const snapshot = captureEnv(Object.keys(env));
try {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
applyEnvValues(env);
return await fn();
} finally {
snapshot.restore();

View File

@@ -41,6 +41,22 @@ const testItems = [
];
describe("SearchableSelectList", () => {
function expectDescriptionVisibilityAtWidth(width: number, shouldContainDescription: boolean) {
const items = [
{ value: "one", label: "one", description: "desc" },
{ value: "two", label: "two", description: "desc" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
// Ensure first row is non-selected so description styling path is exercised.
list.setSelectedIndex(1);
const output = list.render(width).join("\n");
if (shouldContainDescription) {
expect(output).toContain("(desc)");
} else {
expect(output).not.toContain("(desc)");
}
}
it("renders all items when no filter is applied", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
const output = list.render(80);
@@ -61,27 +77,11 @@ describe("SearchableSelectList", () => {
});
it("does not show description layout at width 40 (boundary)", () => {
const items = [
{ value: "one", label: "one", description: "desc" },
{ value: "two", label: "two", description: "desc" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
list.setSelectedIndex(1); // ensure first row is not selected so description styling is applied
const output = list.render(40).join("\n");
expect(output).not.toContain("(desc)");
expectDescriptionVisibilityAtWidth(40, false);
});
it("shows description layout at width 41 (boundary)", () => {
const items = [
{ value: "one", label: "one", description: "desc" },
{ value: "two", label: "two", description: "desc" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
list.setSelectedIndex(1); // ensure first row is not selected so description styling is applied
const output = list.render(41).join("\n");
expect(output).toContain("(desc)");
expectDescriptionVisibilityAtWidth(41, true);
});
it("keeps ANSI-highlighted description rows within terminal width", () => {

View File

@@ -1,6 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { createCommandHandlers } from "./tui-command-handlers.js";
function createHarness(params?: {
sendChat?: ReturnType<typeof vi.fn>;
resetSession?: ReturnType<typeof vi.fn>;
loadHistory?: ReturnType<typeof vi.fn>;
setActivityStatus?: ReturnType<typeof vi.fn>;
}) {
const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" });
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
const addUser = vi.fn();
const addSystem = vi.fn();
const requestRender = vi.fn();
const loadHistory = params?.loadHistory ?? vi.fn().mockResolvedValue(undefined);
const setActivityStatus = params?.setActivityStatus ?? vi.fn();
const { handleCommand } = createCommandHandlers({
client: { sendChat, resetSession } as never,
chatLog: { addUser, addSystem } as never,
tui: { requestRender } as never,
opts: {},
state: {
currentSessionKey: "agent:main:main",
activeChatRunId: null,
sessionInfo: {},
} as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory,
setSession: vi.fn(),
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
forgetLocalRunId: vi.fn(),
});
return {
handleCommand,
sendChat,
resetSession,
addUser,
addSystem,
requestRender,
loadHistory,
setActivityStatus,
};
}
describe("tui command handlers", () => {
it("renders the sending indicator before chat.send resolves", async () => {
let resolveSend: ((value: { runId: string }) => void) | null = null;
@@ -55,35 +106,7 @@ describe("tui command handlers", () => {
});
it("forwards unknown slash commands to the gateway", async () => {
const sendChat = vi.fn().mockResolvedValue({ runId: "r1" });
const addUser = vi.fn();
const addSystem = vi.fn();
const requestRender = vi.fn();
const setActivityStatus = vi.fn();
const { handleCommand } = createCommandHandlers({
client: { sendChat } as never,
chatLog: { addUser, addSystem } as never,
tui: { requestRender } as never,
opts: {},
state: {
currentSessionKey: "agent:main:main",
activeChatRunId: null,
sessionInfo: {},
} as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory: vi.fn(),
setSession: vi.fn(),
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
});
const { handleCommand, sendChat, addUser, addSystem, requestRender } = createHarness();
await handleCommand("/context");
@@ -99,34 +122,8 @@ describe("tui command handlers", () => {
});
it("passes reset reason when handling /new and /reset", async () => {
const resetSession = vi.fn().mockResolvedValue({ ok: true });
const addSystem = vi.fn();
const requestRender = vi.fn();
const loadHistory = vi.fn().mockResolvedValue(undefined);
const { handleCommand } = createCommandHandlers({
client: { resetSession } as never,
chatLog: { addSystem } as never,
tui: { requestRender } as never,
opts: {},
state: {
currentSessionKey: "agent:main:main",
activeChatRunId: null,
sessionInfo: {},
} as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory,
setSession: vi.fn(),
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus: vi.fn(),
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
});
const { handleCommand, resetSession } = createHarness({ loadHistory });
await handleCommand("/new");
await handleCommand("/reset");
@@ -135,4 +132,17 @@ describe("tui command handlers", () => {
expect(resetSession).toHaveBeenNthCalledWith(2, "agent:main:main", "reset");
expect(loadHistory).toHaveBeenCalledTimes(2);
});
it("reports send failures and marks activity status as error", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, addSystem } = createHarness({
sendChat: vi.fn().mockRejectedValue(new Error("gateway down")),
setActivityStatus,
});
await handleCommand("/context");
expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down");
expect(setActivityStatus).toHaveBeenLastCalledWith("error");
});
});