refactor(agents): dedupe plugin hooks and test helpers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user