import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import { openMultiModel } from "../src/core/open"; import { shellQuote, normalizeModels, findDuplicates, createWindowBaseName, createWindowPlans, levenshtein, suggestModels, formatInvalidModelError, sanitizeName, runCommand, getWorktreePath, getSessionPath, undoWorktree, launchModelInWindow, getWorktreesForSession, getBinaryName, } from "../src/core/utils"; let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; let mockCommandResponses: Record = {}; let executedCommands: string[] = []; function setupMockBun$() { const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const parts = values[0] as string[]; const commandSignature = parts.join(" "); executedCommands.push(commandSignature); 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 launch", () => { let existsSyncMock: ReturnType; let bunMock: ReturnType; beforeEach(() => { spyOn(os, "homedir").mockReturnValue("/mock/home"); existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); bunMock = setupMockBun$(); executedCommands = []; 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-5.2\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-5-2": { ok: false, stdout: "", stderr: "" }, }; }); afterEach(() => { restoreOriginalBun$(); }); test("fails if session name is empty", async () => { const result = await openMultiModel({ sessionName: "", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: `sessionName` must not be empty."); expect(executedCommands).toEqual([]); }); test("fails if no models are provided", async () => { const result = await openMultiModel({ sessionName: "test-session", models: [], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: Model names must not be empty."); expect(executedCommands).toEqual([]); }); test("fails if duplicate models are provided", async () => { const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2", "openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Duplicate model names are not allowed"); expect(executedCommands).toEqual([]); }); 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 openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: `multi-model` requires a git repository."); expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]); }); test("fails if tmux is not installed", async () => { mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", ]); }); test("fails if opencode is not installed", async () => { mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", ]); }); test("fails if model name is not in allowlist", async () => { const result = await openMultiModel({ sessionName: "test-session", models: ["invalid/model"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: Model name 'invalid/model' not found"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", ]); }); test("fails if session already exists", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Error: Session already exists"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", "tmux has-session -t test-session", ]); }); test("successfully plans and executes tmux launch with single model", async () => { const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(true); expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true); }); 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 openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], }); expect(result.success).toBe(true); expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true); }); test("sanitizes session name", async () => { mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test session!", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(true); expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session"); expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true); }); test("fails if tmux new-session fails for the first model and triggers undo", async () => { mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" }; mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Failed to create tmux session"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", "tmux has-session -t test-session", "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", "tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", "git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", "git branch -D opencode/test-session/gpt-5-2", ]); }); test("fails if worktree path exists for first model", async () => { existsSyncMock.mockImplementation((path: string) => { return path.includes("test-session/gpt-5-2"); }); const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Worktree path already exists"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", "tmux has-session -t test-session", ]); }); test("fails if git branch exists for first model", async () => { mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", "tmux has-session -t test-session", "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", ]); }); test("fails if git worktree add fails for first model", async () => { mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2"], }); expect(result.success).toBe(false); expect(result.error).toContain("Failed to create worktree"); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", "command -v opencode", "opencode models", "tmux has-session -t test-session", "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", ]); }); test("model name collision creates unique window names", async () => { mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" }; mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: ["openai/gpt-5.2", "anthropic/gpt-5.2"], }); expect(result.success).toBe(true); expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true); }); test("long model name is truncated in window name", async () => { const longModel = "verylongmodelfrontexampleprovider"; mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" }; mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: "test-session", models: [longModel], }); expect(result.success).toBe(true); expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1]; expect(windowName?.length).toBeLessThanOrEqual(24); }); }); // ---------- // Core utils tests remain in a separate file.