diff --git a/.opencode/plans/1773330460043-shiny-wolf.md b/.opencode/plans/archive/1773330460043-shiny-wolf.md similarity index 100% rename from .opencode/plans/1773330460043-shiny-wolf.md rename to .opencode/plans/archive/1773330460043-shiny-wolf.md diff --git a/.opencode/tools/multi-model.test.ts b/.opencode/tools/multi-model.test.ts new file mode 100644 index 0000000..7d58d99 --- /dev/null +++ b/.opencode/tools/multi-model.test.ts @@ -0,0 +1,181 @@ +import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import multiModelTool from "./multi-model"; + +let originalBun$: typeof Bun.$; +let mockCommandResponses: Record = {}; + +function setupMockBun$() { + const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { + const parts = values[0] as string[]; + const commandSignature = parts.join(" "); + + return { + quiet: () => ({ + nothrow: async () => { + const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + return { + exitCode: response.ok ? 0 : 1, + stdout: { toString: () => response.stdout }, + stderr: { toString: () => response.stderr }, + }; + }, + }), + }; + }); + + (globalThis as any).Bun.$ = mockFn; + return mockFn; +} + +function restoreOriginalBun$() { + (globalThis as any).Bun.$ = originalBun$; +} + +describe("multi-model tool", () => { + let existsSyncMock: ReturnType; + let bunMock: ReturnType; + + beforeEach(() => { + spyOn(os, "homedir").mockReturnValue("/mock/home"); + existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); + bunMock = setupMockBun$(); + + mockCommandResponses = { + "git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, + "command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" }, + "command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" }, + "opencode models": { ok: true, stdout: "openai/gpt-4o\nanthropic/claude-3-5-sonnet", stderr: "" }, + "tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" }, + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o": { ok: false, stdout: "", stderr: "" }, + }; + }); + + afterEach(() => { + restoreOriginalBun$(); + }); + + test("fails if session name is empty", async () => { + const result = await multiModelTool.execute( + { sessionName: "", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: `sessionName` must not be empty."); + }); + + test("fails if no models are provided", async () => { + const result = await multiModelTool.execute( + { sessionName: "test-session", models: [] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: Model names must not be empty."); + }); + + test("fails if duplicate models are provided", async () => { + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o", "openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Duplicate model names are not allowed"); + }); + + test("fails if not inside a git repository", async () => { + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" }; + + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: `multi-model` requires a git repository."); + }); + + test("fails if tmux is not installed", async () => { + mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; + + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: `tmux` is not installed or not on `PATH`."); + }); + + test("fails if opencode is not installed", async () => { + mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; + + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: `opencode` is not installed or not on `PATH`."); + }); + + test("fails if model name is not in allowlist", async () => { + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["invalid/model"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: Model name 'invalid/model' not found"); + }); + + test("fails if session already exists", async () => { + mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; + + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Error: Session already exists"); + }); + + test("successfully plans and executes tmux launch with single model", async () => { + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Use `tmux attach -t test-session` to join session"); + }); + + test("successfully launches multiple models", async () => { + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" }; + + const result = await multiModelTool.execute( + { sessionName: "test-session", models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Use `tmux attach -t test-session` to join session"); + }); + + test("sanitizes session name", async () => { + mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; + + const result = await multiModelTool.execute( + { sessionName: "test session!", models: ["openai/gpt-4o"] }, + { metadata: mock() } as any + ); + + expect(result).toContain("Use `tmux attach -t test session!` to join session"); + }); +}); + +describe("unit functions", () => { + test("shellQuote escapes single quotes", () => { + const value = "test'value"; + const quoted = "'" + value.replace(/'/g, `'"'"'`) + "'"; + expect(quoted).toBe("'test'\"'\"'value'"); + }); + + test("normalizeModels trims and filters empty", () => { + expect([" openai/gpt-4o ", ""].map(m => m.trim()).filter(Boolean)).toEqual(["openai/gpt-4o"]); + }); +});