diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 9b5e706f8..768f0e9ca 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -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]>; + }; + }; + 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 diff --git a/src/agents/pi-embedded-runner/google.e2e.test.ts b/src/agents/pi-embedded-runner/google.e2e.test.ts index f5e331b14..76e067a37 100644 --- a/src/agents/pi-embedded-runner/google.e2e.test.ts +++ b/src/agents/pi-embedded-runner/google.e2e.test.ts @@ -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) => + ({ 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; + }; + 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; - }; - - 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; - }; + 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); }); }); diff --git a/src/agents/subagent-registry-completion.test.ts b/src/agents/subagent-registry-completion.test.ts index d885d99df..4c3faa771 100644 --- a/src/agents/subagent-registry-completion.test.ts +++ b/src/agents/subagent-registry-completion.test.ts @@ -26,6 +26,21 @@ function createRunEntry(): SubagentRunRecord { } describe("emitSubagentEndedHookOnce", () => { + const createEmitParams = ( + overrides?: Partial[0]>, + ) => { + const entry = overrides?.entry ?? createRunEntry(); + return { + entry, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: "acct-1", + inFlightRunIds: new Set(), + 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(), - 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(), - 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([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(); + 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(); }); }); diff --git a/src/agents/system-prompt-report.test.ts b/src/agents/system-prompt-report.test.ts index ad758b27b..a3eb95e07 100644 --- a/src/agents/system-prompt-report.test.ts +++ b/src/agents/system-prompt-report.test.ts @@ -13,33 +13,42 @@ function makeBootstrapFile(overrides: Partial): 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); + }); }); diff --git a/src/agents/workspace.bootstrap-cache.test.ts b/src/agents/workspace.bootstrap-cache.test.ts index e9ae4b682..c08f74fa3 100644 --- a/src/agents/workspace.bootstrap-cache.test.ts +++ b/src/agents/workspace.bootstrap-cache.test.ts @@ -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>, + 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(); + }); }); diff --git a/src/hooks/bundled/boot-md/handler.test.ts b/src/hooks/bundled/boot-md/handler.test.ts index 62fdc9901..6308d4085 100644 --- a/src/hooks/bundled/boot-md/handler.test.ts +++ b/src/hooks/bundled/boot-md/handler.test.ts @@ -37,6 +37,15 @@ function makeEvent(overrides?: Partial): 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" }); diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 4ddec40ac..1d7aa63ba 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -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: "" }); diff --git a/src/plugins/hooks.before-agent-start.test.ts b/src/plugins/hooks.before-agent-start.test.ts index 060147f07..7a0785823 100644 --- a/src/plugins/hooks.before-agent-start.test.ts +++ b/src/plugins/hooks.before-agent-start.test.ts @@ -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(); }); diff --git a/src/plugins/slots.test.ts b/src/plugins/slots.test.ts index bc1cca8d9..56f18e039 100644 --- a/src/plugins/slots.test.ts +++ b/src/plugins/slots.test.ts @@ -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({ diff --git a/src/plugins/wired-hooks-subagent.test.ts b/src/plugins/wired-hooks-subagent.test.ts index af9c6b5e3..a1c050a0d 100644 --- a/src/plugins/wired-hooks-subagent.test.ts +++ b/src/plugins/wired-hooks-subagent.test.ts @@ -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", () => { diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 3460875bf..6c0a1f57f 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -28,6 +28,28 @@ import { waitForActiveTasks, } from "./command-queue.js"; +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function enqueueBlockedMainTask( + onRelease?: () => Promise | T, +): { + task: Promise; + 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((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((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((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((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"); }); }); diff --git a/src/providers/qwen-portal-oauth.test.ts b/src/providers/qwen-portal-oauth.test.ts index 78b25b583..4e73062d8 100644 --- a/src/providers/qwen-portal-oauth.test.ts +++ b/src/providers/qwen-portal-oauth.test.ts @@ -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"); }); }); diff --git a/src/test-utils/env.test.ts b/src/test-utils/env.test.ts index cf080e171..514eb9783 100644 --- a/src/test-utils/env.test.ts +++ b/src/test-utils/env.test.ts @@ -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); }); }); diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts index 36c9b137f..fab379c7a 100644 --- a/src/test-utils/env.ts +++ b/src/test-utils/env.ts @@ -17,6 +17,16 @@ export function captureEnv(keys: string[]) { }; } +function applyEnvValues(env: Record): 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 = { ...process.env }; @@ -41,13 +51,7 @@ export function captureFullEnv() { export function withEnv(env: Record, 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( ): Promise { 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(); diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index aeff61195..4e39fa200 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -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", () => { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 2fb1f4d57..c4e3d1ae3 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -1,6 +1,57 @@ import { describe, expect, it, vi } from "vitest"; import { createCommandHandlers } from "./tui-command-handlers.js"; +function createHarness(params?: { + sendChat?: ReturnType; + resetSession?: ReturnType; + loadHistory?: ReturnType; + setActivityStatus?: ReturnType; +}) { + 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"); + }); });