From 9f2b25426b03ffeb296b31620b7d8cbc005a1599 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 14:06:11 +0000 Subject: [PATCH] test(core): increase coverage for sessions, auth choice, and model listing --- src/acp/client.test.ts | 30 ++-- .../auth-choice.apply.huggingface.test.ts | 63 ++++--- .../auth-choice.apply.minimax.test.ts | 106 +++++++----- src/commands/models.list.test.ts | 51 +++--- src/config/model-alias-defaults.test.ts | 23 +-- src/config/sessions.test.ts | 123 ++++++++------ src/docker-setup.test.ts | 83 +++++----- .../outbound/bound-delivery-router.test.ts | 155 +++++++++++------- src/version.test.ts | 14 +- 9 files changed, 360 insertions(+), 288 deletions(-) diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index b254060802a..90fad779619 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -74,27 +74,29 @@ describe("resolvePermissionRequest", () => { expect(prompt).not.toHaveBeenCalled(); }); - it("prompts for fetch even when tool name is known", async () => { + it.each([ + { + caseName: "prompts for fetch even when tool name is known", + toolCallId: "tool-f", + title: "fetch: https://example.com", + expectedToolName: "fetch", + }, + { + caseName: "prompts when tool name contains read/search substrings but isn't a safe kind", + toolCallId: "tool-t", + title: "thread: reply", + expectedToolName: "thread", + }, + ])("$caseName", async ({ toolCallId, title, expectedToolName }) => { const prompt = vi.fn(async () => false); const res = await resolvePermissionRequest( makePermissionRequest({ - toolCall: { toolCallId: "tool-f", title: "fetch: https://example.com", status: "pending" }, - }), - { prompt, log: () => {} }, - ); - expect(prompt).toHaveBeenCalledTimes(1); - expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } }); - }); - - it("prompts when tool name contains read/search substrings but isn't a safe kind", async () => { - const prompt = vi.fn(async () => false); - const res = await resolvePermissionRequest( - makePermissionRequest({ - toolCall: { toolCallId: "tool-t", title: "thread: reply", status: "pending" }, + toolCall: { toolCallId, title, status: "pending" }, }), { prompt, log: () => {} }, ); expect(prompt).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledWith(expectedToolName, title); expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } }); }); diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 4090b5473fc..0758d84b0fb 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -13,6 +13,7 @@ function createHuggingfacePrompter(params: { text: WizardPrompter["text"]; select: WizardPrompter["select"]; confirm?: WizardPrompter["confirm"]; + note?: WizardPrompter["note"]; }): WizardPrompter { const overrides: Partial = { text: params.text, @@ -21,6 +22,9 @@ function createHuggingfacePrompter(params: { if (params.confirm) { overrides.confirm = params.confirm; } + if (params.note) { + overrides.note = params.note; + } return createWizardPrompter(overrides, { defaultSelect: "" }); } @@ -95,9 +99,26 @@ describe("applyAuthChoiceHuggingface", () => { expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-test-token"); }); - it("does not prompt to reuse env token when opts.token already provided", async () => { + it.each([ + { + caseName: "does not prompt to reuse env token when opts.token already provided", + tokenProvider: "huggingface", + token: "hf-opts-token", + envToken: "hf-env-token", + }, + { + caseName: "accepts mixed-case tokenProvider from opts without prompting", + tokenProvider: " HuGgInGfAcE ", + token: "hf-opts-mixed", + envToken: undefined, + }, + ])("$caseName", async ({ tokenProvider, token, envToken }) => { const agentDir = await setupTempState(); - process.env.HF_TOKEN = "hf-env-token"; + if (envToken) { + process.env.HF_TOKEN = envToken; + } else { + delete process.env.HF_TOKEN; + } delete process.env.HUGGINGFACE_HUB_TOKEN; const text = vi.fn().mockResolvedValue("hf-text-token"); @@ -115,8 +136,8 @@ describe("applyAuthChoiceHuggingface", () => { runtime, setDefaultModel: true, opts: { - tokenProvider: "huggingface", - token: "hf-opts-token", + tokenProvider, + token, }, }); @@ -125,20 +146,22 @@ describe("applyAuthChoiceHuggingface", () => { expect(text).not.toHaveBeenCalled(); const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-token"); + expect(parsed.profiles?.["huggingface:default"]?.key).toBe(token); }); - it("accepts mixed-case tokenProvider from opts without prompting", async () => { - const agentDir = await setupTempState(); + it("notes when selected Hugging Face model uses a locked router policy", async () => { + await setupTempState(); delete process.env.HF_TOKEN; delete process.env.HUGGINGFACE_HUB_TOKEN; - const text = vi.fn().mockResolvedValue("hf-text-token"); - const select: WizardPrompter["select"] = vi.fn( - async (params) => params.options?.[0]?.value as never, - ); - const confirm = vi.fn(async () => true); - const prompter = createHuggingfacePrompter({ text, select, confirm }); + const text = vi.fn().mockResolvedValue("hf-test-token"); + const select: WizardPrompter["select"] = vi.fn(async (params) => { + const options = (params.options ?? []) as Array<{ value: string }>; + const cheapest = options.find((option) => option.value.endsWith(":cheapest")); + return (cheapest?.value ?? options[0]?.value ?? "") as never; + }); + const note: WizardPrompter["note"] = vi.fn(async () => {}); + const prompter = createHuggingfacePrompter({ text, select, note }); const runtime = createExitThrowingRuntime(); const result = await applyAuthChoiceHuggingface({ @@ -147,17 +170,13 @@ describe("applyAuthChoiceHuggingface", () => { prompter, runtime, setDefaultModel: true, - opts: { - tokenProvider: " HuGgInGfAcE ", - token: "hf-opts-mixed", - }, }); expect(result).not.toBeNull(); - expect(confirm).not.toHaveBeenCalled(); - expect(text).not.toHaveBeenCalled(); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-mixed"); + expect(String(result?.config.agents?.defaults?.model?.primary)).toContain(":cheapest"); + expect(note).toHaveBeenCalledWith( + "Provider locked — router will choose backend by cost or speed.", + "Hugging Face", + ); }); }); diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index ba17cd4766d..43677529a7a 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -47,6 +47,11 @@ describe("applyAuthChoiceMiniMax", () => { }>(agentDir); } + function resetMiniMaxEnv(): void { + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + } + afterEach(async () => { await lifecycle.cleanup(); }); @@ -63,38 +68,60 @@ describe("applyAuthChoiceMiniMax", () => { expect(result).toBeNull(); }); - it("uses opts token for minimax-api without prompt", async () => { - const agentDir = await setupTempState(); - delete process.env.MINIMAX_API_KEY; - delete process.env.MINIMAX_OAUTH_TOKEN; - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - - const result = await applyAuthChoiceMiniMax({ - authChoice: "minimax-api", - config: {}, - prompter: createMinimaxPrompter({ text, confirm }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - opts: { - tokenProvider: "minimax", - token: "mm-opts-token", - }, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + it.each([ + { + caseName: "uses opts token for minimax-api without prompt", + authChoice: "minimax-api" as const, + tokenProvider: "minimax", + token: "mm-opts-token", + profileId: "minimax:default", provider: "minimax", - mode: "api_key", - }); - expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); - expect(text).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); + expectedModel: "minimax/MiniMax-M2.5", + }, + { + caseName: + "uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", + authChoice: "minimax-api-key-cn" as const, + tokenProvider: " MINIMAX-CN ", + token: "mm-cn-opts-token", + profileId: "minimax-cn:default", + provider: "minimax-cn", + expectedModel: "minimax-cn/MiniMax-M2.5", + }, + ])( + "$caseName", + async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => { + const agentDir = await setupTempState(); + resetMiniMaxEnv(); - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-opts-token"); - }); + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice, + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + tokenProvider, + token, + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.[profileId]).toMatchObject({ + provider, + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe(expectedModel); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.[profileId]?.key).toBe(token); + }, + ); it("uses env token for minimax-api-key-cn when confirmed", async () => { const agentDir = await setupTempState(); @@ -125,36 +152,35 @@ describe("applyAuthChoiceMiniMax", () => { expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token"); }); - it("uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", async () => { + it("uses minimax-api-lightning default model", async () => { const agentDir = await setupTempState(); - delete process.env.MINIMAX_API_KEY; - delete process.env.MINIMAX_OAUTH_TOKEN; + resetMiniMaxEnv(); const text = vi.fn(async () => "should-not-be-used"); const confirm = vi.fn(async () => true); const result = await applyAuthChoiceMiniMax({ - authChoice: "minimax-api-key-cn", + authChoice: "minimax-api-lightning", config: {}, prompter: createMinimaxPrompter({ text, confirm }), runtime: createExitThrowingRuntime(), setDefaultModel: true, opts: { - tokenProvider: " MINIMAX-CN ", - token: "mm-cn-opts-token", + tokenProvider: "minimax", + token: "mm-lightning-token", }, }); expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ - provider: "minimax-cn", + expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + provider: "minimax", mode: "api_key", }); - expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5-Lightning"); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-cn-opts-token"); + expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-lightning-token"); }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 9cdaac1d7de..b46d0a4a18b 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -260,6 +260,23 @@ describe("models list/status", () => { return parseJsonLog(runtime); } + const GOOGLE_ANTIGRAVITY_OPUS_46_CASES = [ + { + name: "thinking", + configuredModelId: "claude-opus-4-6-thinking", + templateId: "claude-opus-4-5-thinking", + templateName: "Claude Opus 4.5 Thinking", + expectedKey: "google-antigravity/claude-opus-4-6-thinking", + }, + { + name: "non-thinking", + configuredModelId: "claude-opus-4-6", + templateId: "claude-opus-4-5", + templateName: "Claude Opus 4.5", + expectedKey: "google-antigravity/claude-opus-4-6", + }, + ] as const; + function expectAntigravityModel( payload: Record, params: { key: string; available: boolean; includesTags?: boolean }, @@ -329,22 +346,7 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(false); }); - it.each([ - { - name: "thinking", - configuredModelId: "claude-opus-4-6-thinking", - templateId: "claude-opus-4-5-thinking", - templateName: "Claude Opus 4.5 Thinking", - expectedKey: "google-antigravity/claude-opus-4-6-thinking", - }, - { - name: "non-thinking", - configuredModelId: "claude-opus-4-6", - templateId: "claude-opus-4-5", - templateName: "Claude Opus 4.5", - expectedKey: "google-antigravity/claude-opus-4-6", - }, - ] as const)( + it.each(GOOGLE_ANTIGRAVITY_OPUS_46_CASES)( "models list resolves antigravity opus 4.6 $name from 4.5 template", async ({ configuredModelId, templateId, templateName, expectedKey }) => { const payload = await runGoogleAntigravityListCase({ @@ -360,22 +362,7 @@ describe("models list/status", () => { }, ); - it.each([ - { - name: "thinking", - configuredModelId: "claude-opus-4-6-thinking", - templateId: "claude-opus-4-5-thinking", - templateName: "Claude Opus 4.5 Thinking", - expectedKey: "google-antigravity/claude-opus-4-6-thinking", - }, - { - name: "non-thinking", - configuredModelId: "claude-opus-4-6", - templateId: "claude-opus-4-5", - templateName: "Claude Opus 4.5", - expectedKey: "google-antigravity/claude-opus-4-6", - }, - ] as const)( + it.each(GOOGLE_ANTIGRAVITY_OPUS_46_CASES)( "models list marks synthesized antigravity opus 4.6 $name as available when template is available", async ({ configuredModelId, templateId, templateName, expectedKey }) => { const payload = await runGoogleAntigravityListCase({ diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index 04d26683d2a..d6728858af8 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -137,28 +137,7 @@ describe("applyModelDefaults", () => { }); it("propagates provider api to models when model api is missing", () => { - const cfg = { - models: { - providers: { - myproxy: { - baseUrl: "https://proxy.example/v1", - apiKey: "sk-test", - api: "openai-completions", - models: [ - { - id: "gpt-5.2", - name: "GPT-5.2", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8192, - }, - ], - }, - }, - }, - } satisfies OpenClawConfig; + const cfg = buildProxyProviderConfig(); const next = applyModelDefaults(cfg); const model = next.models?.providers?.myproxy?.models?.[0]; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index a9ecbf37143..cd4ae0f4a92 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -37,6 +37,16 @@ describe("sessions", () => { const withStateDir = (stateDir: string, fn: () => T): T => withEnv({ OPENCLAW_STATE_DIR: stateDir }, fn); + async function createSessionStoreFixture(params: { + prefix: string; + entries: Record>; + }): Promise<{ storePath: string }> { + const dir = await createCaseDir(params.prefix); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, JSON.stringify(params.entries, null, 2), "utf-8"); + return { storePath }; + } + const deriveSessionKeyCases = [ { name: "returns normalized per-sender key", @@ -307,23 +317,16 @@ describe("sessions", () => { it("updateSessionStoreEntry preserves existing fields when patching", async () => { const sessionKey = "agent:main:main"; - const dir = await createCaseDir("updateSessionStoreEntry"); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sess-1", - updatedAt: 100, - reasoningLevel: "on", - }, + const { storePath } = await createSessionStoreFixture({ + prefix: "updateSessionStoreEntry", + entries: { + [sessionKey]: { + sessionId: "sess-1", + updatedAt: 100, + reasoningLevel: "on", }, - null, - 2, - ), - "utf-8", - ); + }, + }); await updateSessionStoreEntry({ storePath, @@ -336,6 +339,44 @@ describe("sessions", () => { expect(store[sessionKey]?.reasoningLevel).toBe("on"); }); + it("updateSessionStoreEntry returns null when session key does not exist", async () => { + const { storePath } = await createSessionStoreFixture({ + prefix: "updateSessionStoreEntry-missing", + entries: {}, + }); + const update = async () => ({ thinkingLevel: "high" as const }); + const result = await updateSessionStoreEntry({ + storePath, + sessionKey: "agent:main:missing", + update, + }); + expect(result).toBeNull(); + }); + + it("updateSessionStoreEntry keeps existing entry when patch callback returns null", async () => { + const sessionKey = "agent:main:main"; + const { storePath } = await createSessionStoreFixture({ + prefix: "updateSessionStoreEntry-noop", + entries: { + [sessionKey]: { + sessionId: "sess-1", + updatedAt: 123, + thinkingLevel: "low", + }, + }, + }); + + const result = await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => null, + }); + expect(result).toEqual(expect.objectContaining({ sessionId: "sess-1", thinkingLevel: "low" })); + + const store = loadSessionStore(storePath); + expect(store[sessionKey]?.thinkingLevel).toBe("low"); + }); + it("updateSessionStore preserves concurrent additions", async () => { const dir = await createCaseDir("updateSessionStore"); const storePath = path.join(dir, "sessions.json"); @@ -534,23 +575,16 @@ describe("sessions", () => { it("updateSessionStoreEntry merges concurrent patches", async () => { const mainSessionKey = "agent:main:main"; - const dir = await createCaseDir("updateSessionStoreEntry"); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - [mainSessionKey]: { - sessionId: "sess-1", - updatedAt: 123, - thinkingLevel: "low", - }, + const { storePath } = await createSessionStoreFixture({ + prefix: "updateSessionStoreEntry", + entries: { + [mainSessionKey]: { + sessionId: "sess-1", + updatedAt: 123, + thinkingLevel: "low", }, - null, - 2, - ), - "utf-8", - ); + }, + }); const createDeferred = () => { let resolve!: (value: T) => void; @@ -594,23 +628,16 @@ describe("sessions", () => { it("updateSessionStoreEntry re-reads disk inside lock instead of using stale cache", async () => { const mainSessionKey = "agent:main:main"; - const dir = await createCaseDir("updateSessionStoreEntry-cache-bypass"); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - [mainSessionKey]: { - sessionId: "sess-1", - updatedAt: 123, - thinkingLevel: "low", - }, + const { storePath } = await createSessionStoreFixture({ + prefix: "updateSessionStoreEntry-cache-bypass", + entries: { + [mainSessionKey]: { + sessionId: "sess-1", + updatedAt: 123, + thinkingLevel: "low", }, - null, - 2, - ), - "utf-8", - ); + }, + }); // Prime the in-process cache with the original entry. expect(loadSessionStore(storePath)[mainSessionKey]?.thinkingLevel).toBe("low"); diff --git a/src/docker-setup.test.ts b/src/docker-setup.test.ts index 14dcd72b815..710e920fe61 100644 --- a/src/docker-setup.test.ts +++ b/src/docker-setup.test.ts @@ -84,6 +84,25 @@ function createEnv( return env; } +function requireSandbox(sandbox: DockerSetupSandbox | null): DockerSetupSandbox { + if (!sandbox) { + throw new Error("sandbox missing"); + } + return sandbox; +} + +function runDockerSetup( + sandbox: DockerSetupSandbox, + overrides: Record = {}, +) { + return spawnSync("bash", [sandbox.scriptPath], { + cwd: sandbox.rootDir, + env: createEnv(sandbox, overrides), + encoding: "utf8", + stdio: ["ignore", "ignore", "pipe"], + }); +} + function resolveBashForCompatCheck(): string | null { for (const candidate of ["/bin/bash", "bash"]) { const probe = spawnSync(candidate, ["-c", "exit 0"], { encoding: "utf8" }); @@ -111,44 +130,34 @@ describe("docker-setup.sh", () => { }); it("handles env defaults, home-volume mounts, and apt build args", async () => { - if (!sandbox) { - throw new Error("sandbox missing"); - } + const activeSandbox = requireSandbox(sandbox); - const result = spawnSync("bash", [sandbox.scriptPath], { - cwd: sandbox.rootDir, - env: createEnv(sandbox, { - OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential", - OPENCLAW_EXTRA_MOUNTS: undefined, - OPENCLAW_HOME_VOLUME: "openclaw-home", - }), - stdio: ["ignore", "ignore", "pipe"], + const result = runDockerSetup(activeSandbox, { + OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential", + OPENCLAW_EXTRA_MOUNTS: undefined, + OPENCLAW_HOME_VOLUME: "openclaw-home", }); expect(result.status).toBe(0); - const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8"); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential"); expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS="); expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); - const extraCompose = await readFile(join(sandbox.rootDir, "docker-compose.extra.yml"), "utf8"); + const extraCompose = await readFile( + join(activeSandbox.rootDir, "docker-compose.extra.yml"), + "utf8", + ); expect(extraCompose).toContain("openclaw-home:/home/node"); expect(extraCompose).toContain("volumes:"); expect(extraCompose).toContain("openclaw-home:"); - const log = await readFile(sandbox.logPath, "utf8"); + const log = await readFile(activeSandbox.logPath, "utf8"); expect(log).toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential"); }); it("rejects injected multiline OPENCLAW_EXTRA_MOUNTS values", async () => { - if (!sandbox) { - throw new Error("sandbox missing"); - } + const activeSandbox = requireSandbox(sandbox); - const result = spawnSync("bash", [sandbox.scriptPath], { - cwd: sandbox.rootDir, - env: createEnv(sandbox, { - OPENCLAW_EXTRA_MOUNTS: "/tmp:/tmp\n evil-service:\n image: alpine", - }), - encoding: "utf8", - stdio: ["ignore", "ignore", "pipe"], + const result = runDockerSetup(activeSandbox, { + OPENCLAW_EXTRA_MOUNTS: "/tmp:/tmp\n evil-service:\n image: alpine", }); expect(result.status).not.toBe(0); @@ -156,17 +165,10 @@ describe("docker-setup.sh", () => { }); it("rejects invalid OPENCLAW_EXTRA_MOUNTS mount format", async () => { - if (!sandbox) { - throw new Error("sandbox missing"); - } + const activeSandbox = requireSandbox(sandbox); - const result = spawnSync("bash", [sandbox.scriptPath], { - cwd: sandbox.rootDir, - env: createEnv(sandbox, { - OPENCLAW_EXTRA_MOUNTS: "bad mount spec", - }), - encoding: "utf8", - stdio: ["ignore", "ignore", "pipe"], + const result = runDockerSetup(activeSandbox, { + OPENCLAW_EXTRA_MOUNTS: "bad mount spec", }); expect(result.status).not.toBe(0); @@ -174,17 +176,10 @@ describe("docker-setup.sh", () => { }); it("rejects invalid OPENCLAW_HOME_VOLUME names", async () => { - if (!sandbox) { - throw new Error("sandbox missing"); - } + const activeSandbox = requireSandbox(sandbox); - const result = spawnSync("bash", [sandbox.scriptPath], { - cwd: sandbox.rootDir, - env: createEnv(sandbox, { - OPENCLAW_HOME_VOLUME: "bad name", - }), - encoding: "utf8", - stdio: ["ignore", "ignore", "pipe"], + const result = runDockerSetup(activeSandbox, { + OPENCLAW_HOME_VOLUME: "bad name", }); expect(result.status).not.toBe(0); diff --git a/src/infra/outbound/bound-delivery-router.test.ts b/src/infra/outbound/bound-delivery-router.test.ts index 00eb151148d..d50fc417f61 100644 --- a/src/infra/outbound/bound-delivery-router.test.ts +++ b/src/infra/outbound/bound-delivery-router.test.ts @@ -1,6 +1,46 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBoundDeliveryRouter } from "./bound-delivery-router.js"; -import { __testing, registerSessionBindingAdapter } from "./session-binding-service.js"; +import { + __testing, + registerSessionBindingAdapter, + type SessionBindingRecord, +} from "./session-binding-service.js"; + +const TARGET_SESSION_KEY = "agent:main:subagent:child"; + +function createDiscordBinding( + targetSessionKey: string, + conversationId: string, + boundAt: number, + parentConversationId?: string, +): SessionBindingRecord { + return { + bindingId: `runtime:${conversationId}`, + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "discord", + accountId: "runtime", + conversationId, + parentConversationId, + }, + status: "active", + boundAt, + }; +} + +function registerDiscordSessionBindings( + targetSessionKey: string, + bindings: SessionBindingRecord[], +): void { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "runtime", + listBySession: (requestedSessionKey) => + requestedSessionKey === targetSessionKey ? bindings : [], + resolveByConversation: () => null, + }); +} describe("bound delivery router", () => { beforeEach(() => { @@ -8,33 +48,13 @@ describe("bound delivery router", () => { }); it("resolves to a bound destination when a single active binding exists", () => { - registerSessionBindingAdapter({ - channel: "discord", - accountId: "runtime", - listBySession: (targetSessionKey) => - targetSessionKey === "agent:main:subagent:child" - ? [ - { - bindingId: "runtime:thread-1", - targetSessionKey, - targetKind: "subagent", - conversation: { - channel: "discord", - accountId: "runtime", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - status: "active", - boundAt: 1, - }, - ] - : [], - resolveByConversation: () => null, - }); + registerDiscordSessionBindings(TARGET_SESSION_KEY, [ + createDiscordBinding(TARGET_SESSION_KEY, "thread-1", 1, "parent-1"), + ]); const route = createBoundDeliveryRouter().resolveDestination({ eventKind: "task_completion", - targetSessionKey: "agent:main:subagent:child", + targetSessionKey: TARGET_SESSION_KEY, requester: { channel: "discord", accountId: "runtime", @@ -67,44 +87,14 @@ describe("bound delivery router", () => { }); it("fails closed when multiple bindings exist without requester signal", () => { - registerSessionBindingAdapter({ - channel: "discord", - accountId: "runtime", - listBySession: (targetSessionKey) => - targetSessionKey === "agent:main:subagent:child" - ? [ - { - bindingId: "runtime:thread-1", - targetSessionKey, - targetKind: "subagent", - conversation: { - channel: "discord", - accountId: "runtime", - conversationId: "thread-1", - }, - status: "active", - boundAt: 1, - }, - { - bindingId: "runtime:thread-2", - targetSessionKey, - targetKind: "subagent", - conversation: { - channel: "discord", - accountId: "runtime", - conversationId: "thread-2", - }, - status: "active", - boundAt: 2, - }, - ] - : [], - resolveByConversation: () => null, - }); + registerDiscordSessionBindings(TARGET_SESSION_KEY, [ + createDiscordBinding(TARGET_SESSION_KEY, "thread-1", 1), + createDiscordBinding(TARGET_SESSION_KEY, "thread-2", 2), + ]); const route = createBoundDeliveryRouter().resolveDestination({ eventKind: "task_completion", - targetSessionKey: "agent:main:subagent:child", + targetSessionKey: TARGET_SESSION_KEY, failClosed: true, }); @@ -114,4 +104,49 @@ describe("bound delivery router", () => { reason: "ambiguous-without-requester", }); }); + + it("selects requester-matching conversation when multiple bindings exist", () => { + registerDiscordSessionBindings(TARGET_SESSION_KEY, [ + createDiscordBinding(TARGET_SESSION_KEY, "thread-1", 1), + createDiscordBinding(TARGET_SESSION_KEY, "thread-2", 2), + ]); + + const route = createBoundDeliveryRouter().resolveDestination({ + eventKind: "task_completion", + targetSessionKey: TARGET_SESSION_KEY, + requester: { + channel: "discord", + accountId: "runtime", + conversationId: "thread-2", + }, + failClosed: true, + }); + + expect(route.mode).toBe("bound"); + expect(route.reason).toBe("requester-match"); + expect(route.binding?.conversation.conversationId).toBe("thread-2"); + }); + + it("falls back for invalid requester conversation values", () => { + registerDiscordSessionBindings(TARGET_SESSION_KEY, [ + createDiscordBinding(TARGET_SESSION_KEY, "thread-1", 1), + ]); + + const route = createBoundDeliveryRouter().resolveDestination({ + eventKind: "task_completion", + targetSessionKey: TARGET_SESSION_KEY, + requester: { + channel: "discord", + accountId: "runtime", + conversationId: " ", + }, + failClosed: true, + }); + + expect(route).toEqual({ + binding: null, + mode: "fallback", + reason: "invalid-requester", + }); + }); }); diff --git a/src/version.test.ts b/src/version.test.ts index c6bc5acf04e..856e4a908b8 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -34,6 +34,12 @@ async function writeJsonFixture(root: string, relativePath: string, value: unkno await fs.writeFile(filePath, JSON.stringify(value), "utf-8"); } +function expectVersionMetadataToBeMissing(moduleUrl: string) { + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBeNull(); + expect(readVersionFromBuildInfoForModuleUrl(moduleUrl)).toBeNull(); + expect(resolveVersionFromModuleUrl(moduleUrl)).toBeNull(); +} + describe("version resolution", () => { it("resolves package version from nested dist/plugin-sdk module URL", async () => { await withTempDir(async (root) => { @@ -69,9 +75,7 @@ describe("version resolution", () => { it("returns null when no version metadata exists", async () => { await withTempDir(async (root) => { const moduleUrl = await ensureModuleFixture(root); - expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBeNull(); - expect(readVersionFromBuildInfoForModuleUrl(moduleUrl)).toBeNull(); - expect(resolveVersionFromModuleUrl(moduleUrl)).toBeNull(); + expectVersionMetadataToBeMissing(moduleUrl); }); }); @@ -80,9 +84,7 @@ describe("version resolution", () => { await writeJsonFixture(root, "package.json", { name: "other-package", version: "9.9.9" }); await writeJsonFixture(root, "build-info.json", { version: " " }); const moduleUrl = await ensureModuleFixture(root); - expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBeNull(); - expect(readVersionFromBuildInfoForModuleUrl(moduleUrl)).toBeNull(); - expect(resolveVersionFromModuleUrl(moduleUrl)).toBeNull(); + expectVersionMetadataToBeMissing(moduleUrl); }); });