From 7a164c705c66d8a9d6ff528e683c18d4df749035 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Mon, 23 Mar 2026 08:17:27 +0530 Subject: [PATCH] build: add biome formatter and linter --- .opencode/package.json | 2 +- .opencode/tools/multi-model.ts | 7 +- .opencode/tsconfig.json | 6 +- .vscode/settings.json | 4 +- biome.json | 50 +++ bun.lock | 19 + package.json | 6 +- src/cli.ts | 97 +++-- src/core/close.ts | 118 +++--- src/core/index.ts | 2 +- src/core/open.ts | 155 ++++++-- src/core/utils.ts | 81 ++++- src/index.ts | 4 +- src/tools/close.ts | 22 +- src/tools/open.ts | 10 +- src/types.ts | 1 - tests/close.test.ts | 377 +++++++++++++++---- tests/close_error.test.ts | 29 +- tests/open.test.ts | 294 +++++++++++---- tests/open_error.test.ts | 637 ++++++++++++++++++++++++--------- tests/utils.test.ts | 209 ++++++++--- 21 files changed, 1620 insertions(+), 510 deletions(-) create mode 100644 biome.json diff --git a/.opencode/package.json b/.opencode/package.json index 2502893..4aabb36 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -12,4 +12,4 @@ "@types/bun": "^1.3.10", "@types/node": "^25.5.0" } -} \ No newline at end of file +} diff --git a/.opencode/tools/multi-model.ts b/.opencode/tools/multi-model.ts index 4febcca..7a7b3e6 100644 --- a/.opencode/tools/multi-model.ts +++ b/.opencode/tools/multi-model.ts @@ -1,6 +1,7 @@ // Re-export open and close tools for project-level use via relative paths. // For published package usage, change imports to "@username/opencode-multi-model". -import { openTool } from "../../src/tools/open"; -import { closeTool } from "../../src/tools/close"; -export { openTool as open, closeTool as close }; +import { closeTool } from "../../src/tools/close"; +import { openTool } from "../../src/tools/open"; + +export { closeTool as close, openTool as open }; diff --git a/.opencode/tsconfig.json b/.opencode/tsconfig.json index 05af1b4..11fa5fc 100644 --- a/.opencode/tsconfig.json +++ b/.opencode/tsconfig.json @@ -18,9 +18,7 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "types": [ - "bun-types" - ] + "types": ["bun-types"] }, "include": ["**/*.ts", "**/*.tsx"] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index f95a89c..23c2097 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,5 @@ "bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}", // The custom script to call for testing instead of `bun test` - "bun.test.customScript": "bun test", -} \ No newline at end of file + "bun.test.customScript": "bun test" +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f08f460 --- /dev/null +++ b/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off" + }, + "correctness": { + "noUnusedImports": "off", + "noUnusedFunctionParameters": "off", + "noUnusedVariables": "off", + "noConstructorReturn": "off" + }, + "complexity": { + "useLiteralKeys": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock index 68daa8b..9339758 100644 --- a/bun.lock +++ b/bun.lock @@ -10,12 +10,31 @@ "commander": "^12.0.0", }, "devDependencies": { + "@biomejs/biome": "^2.4.8", "@types/bun": "^1.3.10", "@types/node": "^25.5.0", }, }, }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="], diff --git a/package.json b/package.json index 065e5e6..ed100af 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "coverage": "bun test --coverage", "prepublishOnly": "bun run build", "release": "bash ./scripts/release.sh", - "setup": "bash ./scripts/setup.sh" + "setup": "bash ./scripts/setup.sh", + "lint": "biome check .", + "lint:fix": "biome check . --fix", + "format": "biome format . --write" }, "dependencies": { "chalk": "^5.3.0", @@ -30,6 +33,7 @@ "commander": "^12.0.0" }, "devDependencies": { + "@biomejs/biome": "^2.4.8", "@types/bun": "^1.3.10", "@types/node": "^25.5.0" }, diff --git a/src/cli.ts b/src/cli.ts index 292a620..db96303 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Command } from "commander"; -import { openMultiModel } from "./core/open"; import { closeMultiModel } from "./core/close"; +import { openMultiModel } from "./core/open"; import { getBinaryName } from "./core/utils"; const program = new Command(); @@ -15,57 +15,80 @@ program .command("open") .description("Create a new multi-model tmux session") .argument("", "Name for the tmux session") - .option("-m, --models ", "Model IDs to launch (space-separated)", []) - .option("-b, --binary ", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'") - .action(async (sessionName: string, options: { models: string[]; binary?: string }) => { - try { - const binaryName = options.binary || getBinaryName(); + .option( + "-m, --models ", + "Model IDs to launch (space-separated)", + [], + ) + .option( + "-b, --binary ", + "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'", + ) + .action( + async ( + sessionName: string, + options: { models: string[]; binary?: string }, + ) => { + try { + const binaryName = options.binary || getBinaryName(); - const result = await openMultiModel({ - sessionName, - models: options.models, - binaryName, - mode: "cli", - }); + const result = await openMultiModel({ + sessionName, + models: options.models, + binaryName, + mode: "cli", + }); - if (result.success) { - console.log(result.instructions || `Created session: ${result.sessionName}`); - } else { - console.error(`Failed: ${result.error}`); + if (result.success) { + console.log( + result.instructions || `Created session: ${result.sessionName}`, + ); + } else { + console.error(`Failed: ${result.error}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); process.exit(1); } - } catch (error) { - console.error(`Error: ${error}`); - process.exit(1); - } - }); + }, + ); program .command("close") .description("Close a multi-model tmux session") .argument("", "Name of the tmux session to close") - .option("--keep-worktrees", "Do not remove worktrees and delete branches", false) + .option( + "--keep-worktrees", + "Do not remove worktrees and delete branches", + false, + ) .option("--no-tags", "Create archive tags before deleting branches") .option("-f, --force", "Skip confirmation prompts", false) - .action(async (sessionName: string, options: { keepWorktrees: boolean; force: boolean; tags: boolean }) => { - try { - const result = await closeMultiModel({ - sessionName, - cleanupWorktrees: !options.keepWorktrees, - force: options.force, - createArchiveTags: options.tags, - }); + .action( + async ( + sessionName: string, + options: { keepWorktrees: boolean; force: boolean; tags: boolean }, + ) => { + try { + const result = await closeMultiModel({ + sessionName, + cleanupWorktrees: !options.keepWorktrees, + force: options.force, + createArchiveTags: options.tags, + }); -if (result.success) { + if (result.success) { console.log(result.instructions); } else { - console.error(`Failed: ${result.error}`); + console.error(`Failed: ${result.error}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); process.exit(1); } - } catch (error) { - console.error(`Error: ${error}`); - process.exit(1); - } - }); + }, + ); program.parse(); diff --git a/src/core/close.ts b/src/core/close.ts index 20fc663..c8b84ec 100644 --- a/src/core/close.ts +++ b/src/core/close.ts @@ -1,7 +1,12 @@ import * as readline from "node:readline"; -import chalk from 'chalk'; // removed console usage +import chalk from "chalk"; // removed console usage import type { CloseSessionOptions, CloseSessionResult } from "../types"; -import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils"; +import { + getSessionPath, + getWorktreesForSession, + runCommand, + sanitizeName, +} from "./utils"; /** * Prompts the user for input on the terminal. @@ -44,24 +49,34 @@ async function promptUser(question: string): Promise { * } * ``` */ -export async function closeMultiModel(options: CloseSessionOptions): Promise { +export async function closeMultiModel( + options: CloseSessionOptions, +): Promise { const worktreesRemoved: string[] = []; const instructionsArr: string[] = []; const branchesDeleted: string[] = []; const tagsCreated: string[] = []; - const safeSessionName = sanitizeName(options.sessionName); try { // Check if session exists - const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]); + const { ok: sessionExists } = await runCommand([ + "tmux", + "has-session", + "-t", + options.sessionName, + ]); // Kill tmux session if it exists if (sessionExists) { await runCommand(["tmux", "kill-session", "-t", options.sessionName]); - instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`)); + instructionsArr.push( + chalk.green(`Closed session '${options.sessionName}'`), + ); } else { - instructionsArr.push(chalk.yellow(`Session '${options.sessionName}' does not exist`)); + instructionsArr.push( + chalk.yellow(`Session '${options.sessionName}' does not exist`), + ); } // Get list of worktrees for this session before killing tmux @@ -69,13 +84,17 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise 0) { - console.log(chalk.red('The following worktrees and branches will be removed:')); + console.log( + chalk.red("The following worktrees and branches will be removed:"), + ); worktrees.forEach((wt) => { const msg = ` - ${wt.path} (branch: ${wt.branch})`; console.log(msg.trim()); }); - const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): ")); + const answer = await promptUser( + chalk.bold("\nDo you want to proceed? (y/N): "), + ); if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") { return { success: false, @@ -96,24 +115,33 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise 0; if (cleanupPerformed) { - instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`)); + instructionsArr.push( + chalk.dim( + `Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`, + ), + ); if (tagsCreated.length > 0) { - instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`)); + instructionsArr.push( + chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`), + ); } } } -return { - success: true, - sessionName: safeSessionName, - cleanupPerformed, - worktreesRemoved, - branchesDeleted, - tagsCreated, - instructions: instructionsArr.join("\n"), - }; + return { + success: true, + sessionName: safeSessionName, + cleanupPerformed, + worktreesRemoved, + branchesDeleted, + tagsCreated, + instructions: instructionsArr.join("\n"), + }; } catch (error) { -return { - success: false, - sessionName: safeSessionName, - error: String(error), - worktreesRemoved, - branchesDeleted, - tagsCreated, - instructions: instructionsArr.join("\n"), - }; + return { + success: false, + sessionName: safeSessionName, + error: String(error), + worktreesRemoved, + branchesDeleted, + tagsCreated, + instructions: instructionsArr.join("\n"), + }; } } diff --git a/src/core/index.ts b/src/core/index.ts index db81d7b..dc3f42f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,3 @@ -export { openMultiModel } from "./open"; export { closeMultiModel } from "./close"; +export { openMultiModel } from "./open"; export * from "./utils"; diff --git a/src/core/open.ts b/src/core/open.ts index b400530..3e372be 100644 --- a/src/core/open.ts +++ b/src/core/open.ts @@ -1,17 +1,17 @@ import * as fs from "node:fs"; -import chalk from 'chalk'; +import chalk from "chalk"; import type { MultiModelOptions, MultiModelResult } from "../types"; import { - runCommand, - normalizeModels, - findDuplicates, createWindowPlans, + findDuplicates, formatInvalidModelError, - sanitizeName, - getWorktreePath, getSessionPath, - undoWorktree, + getWorktreePath, launchModelInWindow, + normalizeModels, + runCommand, + sanitizeName, + undoWorktree, } from "./utils"; /** @@ -35,7 +35,9 @@ import { * } * ``` */ -export async function openMultiModel(options: MultiModelOptions): Promise { +export async function openMultiModel( + options: MultiModelOptions, +): Promise { const sessionName = options.sessionName.trim(); const safeSessionName = sanitizeName(sessionName); const models = normalizeModels(options.models); @@ -43,11 +45,19 @@ export async function openMultiModel(options: MultiModelOptions): Promise !allowlist.includes(model)); if (invalidModels.length > 0) { - return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist) }; + return { + success: false, + sessionName, + error: formatInvalidModelError(invalidModels, allowlist), + }; } - const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]); + const sessionExists = await runCommand([ + "tmux", + "has-session", + "-t", + sessionName, + ]); if (sessionExists.ok) { - return { success: false, sessionName, error: "Error: Session already exists. Use a different `sessionName`." }; + return { + success: false, + sessionName, + error: "Error: Session already exists. Use a different `sessionName`.", + }; } const windowPlans = createWindowPlans(models); @@ -111,30 +150,61 @@ export async function openMultiModel(options: MultiModelOptions): Promise 0) { - failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); + failedModels.push( + `cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`, + ); } continue; } } - const launchResult = await launchModelInWindow(sessionName, plan, binaryName); + const launchResult = await launchModelInWindow( + sessionName, + plan, + binaryName, + ); if (launchResult.ok) { succeededModels.push(plan.model); } else { - failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`); + failedModels.push( + `${plan.model} (${launchResult.stderr || "failed to send launch command"})`, + ); const undoErrors = await undoWorktree(worktreePath, branchName); - if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); + if (undoErrors.length > 0) + failedModels.push( + `cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`, + ); } } @@ -206,9 +289,13 @@ export async function openMultiModel(options: MultiModelOptions): Promise 0 ? succeededModels.join(", ") : "none"}.`), + chalk.dim( + `Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`, + ), "", chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`), "", @@ -216,7 +303,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise left.score - right.score || left.candidate.localeCompare(right.candidate)) + .sort( + (left, right) => + left.score - right.score || + left.candidate.localeCompare(right.candidate), + ) .slice(0, MAX_SUGGESTIONS) .map(({ candidate }) => candidate); } @@ -229,7 +239,10 @@ export function suggestModels(requested: string, allowlist: string[]): string[] * const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]); * ``` */ -export function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string { +export function formatInvalidModelError( + invalidModels: string[], + allowlist: string[], +): string { const firstInvalidModel = invalidModels[0]!; const suggestions = suggestModels(firstInvalidModel, allowlist); const suggestionText = @@ -264,7 +277,14 @@ export async function launchModelInWindow( ): Promise { const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`; - return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]); + return runCommand([ + "tmux", + "send-keys", + "-t", + `${sessionName}:${plan.windowName}`, + launchCommand, + "C-m", + ]); } /** @@ -280,7 +300,10 @@ export async function launchModelInWindow( * ``` */ export function sanitizeName(name: string): string { - return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return name + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); } /** @@ -297,7 +320,14 @@ export function sanitizeName(name: string): string { */ export function getSessionPath(sessionName: string): string { const homedir = os.homedir(); - return path.join(homedir, ".local", "share", "opencode", "multi-model", sessionName); + return path.join( + homedir, + ".local", + "share", + "opencode", + "multi-model", + sessionName, + ); } /** @@ -313,7 +343,10 @@ export function getSessionPath(sessionName: string): string { * // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2" * ``` */ -export function getWorktreePath(safeSession: string, windowName: string): string { +export function getWorktreePath( + safeSession: string, + windowName: string, +): string { return path.join(getSessionPath(safeSession), windowName); } @@ -328,8 +361,15 @@ export function getWorktreePath(safeSession: string, windowName: string): string * const worktrees = await getWorktreesForSession("my-session"); * ``` */ -export async function getWorktreesForSession(sessionName: string): Promise { - const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]); +export async function getWorktreesForSession( + sessionName: string, +): Promise { + const { stdout } = await runCommand([ + "git", + "worktree", + "list", + "--porcelain", + ]); const worktrees: WorktreeInfo[] = []; const sessionPath = getSessionPath(sanitizeName(sessionName)); @@ -371,13 +411,26 @@ export async function getWorktreesForSession(sessionName: string): Promise 0) console.error(errors); * ``` */ -export async function undoWorktree(worktreePath: string, branchName: string): Promise { +export async function undoWorktree( + worktreePath: string, + branchName: string, +): Promise { const errors: string[] = []; - const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]); - if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`); + const removeRes = await runCommand([ + "git", + "worktree", + "remove", + "-f", + worktreePath, + ]); + if (!removeRes.ok) + errors.push( + `Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`, + ); const branchRes = await runCommand(["git", "branch", "-D", branchName]); - if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`); + if (!branchRes.ok) + errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`); return errors; } diff --git a/src/index.ts b/src/index.ts index 7d55d37..6940b3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { Plugin } from "@opencode-ai/plugin"; -import { openTool } from "./tools/open"; import { closeTool } from "./tools/close"; +import { openTool } from "./tools/open"; /** * OpenCode plugin that provides multi-model tmux session management tools. @@ -27,6 +27,6 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => { }; export default OpenCodeMultiModelPlugin; -export { openTool, closeTool }; export * from "./core/index"; export * from "./types"; +export { closeTool, openTool }; diff --git a/src/tools/close.ts b/src/tools/close.ts index b5018cb..e8d692b 100644 --- a/src/tools/close.ts +++ b/src/tools/close.ts @@ -8,11 +8,25 @@ import { closeMultiModel } from "../core/close"; * creates archive tags, and deletes associated branches. */ export const closeTool = tool({ - description: "Close a multi-model tmux session and optionally cleanup worktrees and branches", + description: + "Close a multi-model tmux session and optionally cleanup worktrees and branches", args: { - sessionName: tool.schema.string().min(1).describe("tmux session name to close"), - cleanupWorktrees: tool.schema.boolean().default(true).describe("whether to remove worktrees and delete branches (default: true)"), - createArchiveTags: tool.schema.boolean().default(true).describe("whether to create archive tags before deleting branches (default: true)"), + sessionName: tool.schema + .string() + .min(1) + .describe("tmux session name to close"), + cleanupWorktrees: tool.schema + .boolean() + .default(true) + .describe( + "whether to remove worktrees and delete branches (default: true)", + ), + createArchiveTags: tool.schema + .boolean() + .default(true) + .describe( + "whether to create archive tags before deleting branches (default: true)", + ), }, async execute(args, context) { // In plugin mode, we skip confirmation (force=true) since there's no interactive terminal diff --git a/src/tools/open.ts b/src/tools/open.ts index dd3a88f..a0ab3b1 100644 --- a/src/tools/open.ts +++ b/src/tools/open.ts @@ -11,7 +11,10 @@ import { getBinaryName } from "../core/utils"; export const openTool = tool({ description: "Launch multiple OpenCode models in tmux", args: { - sessionName: tool.schema.string().min(1).describe("tmux session name to create"), + sessionName: tool.schema + .string() + .min(1) + .describe("tmux session name to create"), models: tool.schema .array(tool.schema.string().min(1)) .min(1) @@ -39,6 +42,9 @@ export const openTool = tool({ return result.error!; } - return result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`; + return ( + result.instructions || + `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows` + ); }, }); diff --git a/src/types.ts b/src/types.ts index 4ec2b41..1338376 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,7 +62,6 @@ export interface CloseSessionResult { branchesDeleted?: string[]; /** Archive tags that were created before branch deletion. */ tagsCreated?: string[]; - } /** diff --git a/tests/close.test.ts b/tests/close.test.ts index 7858d8c..a834473 100644 --- a/tests/close.test.ts +++ b/tests/close.test.ts @@ -1,10 +1,21 @@ -import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import * as os from "node:os"; import * as readline from "node:readline"; import { closeMultiModel } from "../src/core/close"; -let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; -let mockCommandResponses: Record = {}; +const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record< + string, + { ok: boolean; stdout: string; stderr: string } +> = {}; let executedCommands: string[] = []; function setupMockBun$() { @@ -12,7 +23,11 @@ function setupMockBun$() { const parts = values[0] as string[]; const commandSignature = parts.join(" "); executedCommands.push(commandSignature); - const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + const response = mockCommandResponses[commandSignature] ?? { + ok: true, + stdout: "", + stderr: "", + }; return { quiet: () => ({ nothrow: async () => ({ @@ -46,12 +61,28 @@ describe("closeMultiModel", () => { }); test("kills existing session without cleanup", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["tmux kill-session -t test-session"] = { + ok: true, + stdout: "", + stderr: "", + }; // No worktrees - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: "", + stderr: "", + }; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: false, + force: false, + }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(false); expect(result.worktreesRemoved).toEqual([]); @@ -60,37 +91,82 @@ describe("closeMultiModel", () => { }); test("no session exists, no cleanup", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: "", + stderr: "", + }; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: false, + force: false, + }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(false); expect(result.worktreesRemoved).toEqual([]); expect(executedCommands).toContain("tmux has-session -t test-session"); // No kill-session should be issued - expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false); + expect( + executedCommands.some((c) => c.startsWith("tmux kill-session")), + ).toBe(false); }); test("cleanup works with worktrees and force, all succeeds", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + const worktreePath = + "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; - mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; + mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["git branch -D opencode/test-session/model"] = { + ok: true, + stdout: "", + stderr: "", + }; // Tag command will default to ok:true via fallback - mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + "rm -rf /home/user/.local/share/opencode/multi-model/test-session" + ] = { ok: true, stdout: "", stderr: "" }; // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; - // @ts-ignore - global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; + // @ts-expect-error + global.Date = class extends OriginalDate { + constructor() { + super(); + return fixedDate; + } + toISOString() { + return "2023-01-01T00:00:00.000Z"; + } + } as any; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: true, + force: true, + }); // Restore Date - // @ts-ignore + // @ts-expect-error global.Date = OriginalDate; expect(result.success).toBe(true); @@ -98,17 +174,30 @@ describe("closeMultiModel", () => { expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length).toBe(1); - + // Ensure removal commands were executed - expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); - expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); + expect(executedCommands).toContain( + `git worktree remove -f ${worktreePath}`, + ); + expect(executedCommands).toContain( + `git branch -D opencode/test-session/model`, + ); }); test("user cancels cleanup when not forced", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + const worktreePath = + "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; // Stub readline to return "n" const mockInterface = { @@ -117,16 +206,29 @@ describe("closeMultiModel", () => { } as any; spyOn(readline, "createInterface").mockImplementation(() => mockInterface); - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: true, + force: false, + }); expect(result.success).toBe(false); expect(result.error).toBe("Cleanup cancelled by user"); }); test("user confirms cleanup when not forced", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + const worktreePath = + "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; // Stub readline to simulate user confirming cleanup (answers "y") const mockInterface = { @@ -138,13 +240,25 @@ describe("closeMultiModel", () => { // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; - // @ts-ignore - global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; + // @ts-expect-error + global.Date = class extends OriginalDate { + constructor() { + super(); + return fixedDate; + } + toISOString() { + return "2023-01-01T00:00:00.000Z"; + } + } as any; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: true, + force: false, + }); // Restore Date after invocation - // @ts-ignore + // @ts-expect-error global.Date = OriginalDate; // Verify result indicates successful cleanup @@ -153,38 +267,74 @@ describe("closeMultiModel", () => { expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length).toBe(1); - // Verify the expected git commands were executed - expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); - expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); + expect(executedCommands).toContain( + `git worktree remove -f ${worktreePath}`, + ); + expect(executedCommands).toContain( + `git branch -D opencode/test-session/model`, + ); const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`; expect(executedCommands).toContain(expectedTagCmd); }); test("warning when tag creation fails", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + const worktreePath = + "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; - mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; + mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["git branch -D opencode/test-session/model"] = { + ok: true, + stdout: "", + stderr: "", + }; // Tag command will fail const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; - // @ts-ignore - global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; + // @ts-expect-error + global.Date = class extends OriginalDate { + constructor() { + super(); + return fixedDate; + } + toISOString() { + return "2023-01-01T00:00:00.000Z"; + } + } as any; const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`; - mockCommandResponses[tagCommand] = { ok: false, stdout: "", stderr: "tag error" }; + mockCommandResponses[tagCommand] = { + ok: false, + stdout: "", + stderr: "tag error", + }; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: true, + force: true, + }); // Restore Date - // @ts-ignore + // @ts-expect-error global.Date = OriginalDate; expect(result.success).toBe(true); // Expect the warning message to be captured in instructions - expect(result.instructions.includes('Failed to create tag')).toBe(true); + expect(result.instructions.includes("Failed to create tag")).toBe(true); expect(result.tagsCreated).toEqual([]); }); @@ -192,23 +342,53 @@ describe("closeMultiModel", () => { const unsanitized = "test session!"; const sanitized = "test-session"; // result of sanitizeName // tmux lookup uses unsanitized name - mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { + ok: false, + stdout: "", + stderr: "", + }; const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; - mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; + mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}` + ] = { ok: true, stdout: "", stderr: "" }; // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; - // @ts-ignore - global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; + // @ts-expect-error + global.Date = class extends OriginalDate { + constructor() { + super(); + return fixedDate; + } + toISOString() { + return "2023-01-01T00:00:00.000Z"; + } + } as any; - const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true }); + const result = await closeMultiModel({ + sessionName: unsanitized, + cleanupWorktrees: true, + force: true, + }); // Restore Date - // @ts-ignore + // @ts-expect-error global.Date = OriginalDate; expect(result.success).toBe(true); @@ -217,37 +397,83 @@ describe("closeMultiModel", () => { expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]); expect(result.tagsCreated?.length).toBe(1); // Verify that the commands used the sanitized paths for worktree removal and branch deletion - expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); - expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`); + expect(executedCommands).toContain( + `git worktree remove -f ${worktreePath}`, + ); + expect(executedCommands).toContain( + `git branch -D opencode/${sanitized}/model`, + ); }); test("tmux commands use sessionName, not safeSessionName", async () => { const unsanitized = "my session!"; const sanitized = "my-session"; - mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = { + ok: true, + stdout: "", + stderr: "", + }; // No worktrees - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: "", + stderr: "", + }; - const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: false, force: false }); + const result = await closeMultiModel({ + sessionName: unsanitized, + cleanupWorktrees: false, + force: false, + }); expect(result.success).toBe(true); // Verify tmux commands used unsanitized name expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`); expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`); // Ensure sanitized name not used in tmux commands - expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false); + expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe( + false, + ); }); test("skip archive tags when flag disabled", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; - const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: false, + stdout: "", + stderr: "", + }; + const worktreePath = + "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; - mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; - mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree list --porcelain"] = { + ok: true, + stdout: worktreeListOutput, + stderr: "", + }; + mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["git branch -D opencode/test-session/model"] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + "rm -rf /home/user/.local/share/opencode/multi-model/test-session" + ] = { ok: true, stdout: "", stderr: "" }; - const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true, createArchiveTags: false }); + const result = await closeMultiModel({ + sessionName: "test-session", + cleanupWorktrees: true, + force: true, + createArchiveTags: false, + }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(true); @@ -255,7 +481,8 @@ describe("closeMultiModel", () => { expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length ?? 0).toBe(0); // Ensure no tag command was executed - expect(executedCommands.some(cmd => cmd.startsWith("git tag"))).toBe(false); + expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe( + false, + ); }); }); - diff --git a/tests/close_error.test.ts b/tests/close_error.test.ts index 7601e85..99ca933 100644 --- a/tests/close_error.test.ts +++ b/tests/close_error.test.ts @@ -1,9 +1,20 @@ -import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import * as os from "node:os"; import { closeMultiModel } from "../src/core/close"; -let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; -let mockCommandResponses: Record = {}; +const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record< + string, + { ok: boolean; stdout: string; stderr: string } +> = {}; let executedCommands: string[] = []; function setupMockBun$() { @@ -15,7 +26,11 @@ function setupMockBun$() { if (cmd === "git worktree list --porcelain") { throw new Error("unexpected error"); } - const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" }; + const response = mockCommandResponses[cmd] ?? { + ok: true, + stdout: "", + stderr: "", + }; return { quiet: () => ({ nothrow: async () => ({ @@ -46,7 +61,11 @@ describe("closeMultiModel unexpected error handling", () => { }); test("catches thrown error and returns failure", async () => { - const result = await closeMultiModel({ sessionName: "any-session", cleanupWorktrees: false, force: false }); + const result = await closeMultiModel({ + sessionName: "any-session", + cleanupWorktrees: false, + force: false, + }); expect(result.success).toBe(false); expect(result.error).toContain("unexpected error"); }); diff --git a/tests/open.test.ts b/tests/open.test.ts index 0ea6559..8b2782a 100644 --- a/tests/open.test.ts +++ b/tests/open.test.ts @@ -1,28 +1,39 @@ -import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} 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, + findDuplicates, formatInvalidModelError, - sanitizeName, - runCommand, - getWorktreePath, - getSessionPath, - undoWorktree, - launchModelInWindow, - getWorktreesForSession, getBinaryName, + getSessionPath, + getWorktreePath, + getWorktreesForSession, + launchModelInWindow, + levenshtein, + normalizeModels, + runCommand, + sanitizeName, + shellQuote, + suggestModels, + undoWorktree, } from "../src/core/utils"; -let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; -let mockCommandResponses: Record = {}; +const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record< + string, + { ok: boolean; stdout: string; stderr: string } +> = {}; let executedCommands: string[] = []; function setupMockBun$() { @@ -35,7 +46,11 @@ function setupMockBun$() { return { quiet: () => ({ nothrow: async () => { - const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + const response = mockCommandResponses[commandSignature] ?? { + ok: true, + stdout: "", + stderr: "", + }; return { exitCode: response.ok ? 0 : 1, stdout: { toString: () => response.stdout }, @@ -65,12 +80,29 @@ describe("multi-model launch", () => { executedCommands = []; mockCommandResponses = { - "git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, + "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: "" }, + "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: "" }, }; }); @@ -112,7 +144,11 @@ describe("multi-model launch", () => { }); 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" }; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: false, + stdout: "", + stderr: "not a git repo", + }; const result = await openMultiModel({ sessionName: "test-session", @@ -120,12 +156,18 @@ describe("multi-model launch", () => { }); expect(result.success).toBe(false); - expect(result.error).toContain("Error: `multi-model` requires a git repository."); + 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: "" }; + mockCommandResponses["command -v tmux"] = { + ok: false, + stdout: "", + stderr: "", + }; const result = await openMultiModel({ sessionName: "test-session", @@ -133,7 +175,9 @@ describe("multi-model launch", () => { }); expect(result.success).toBe(false); - expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); + 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", @@ -141,7 +185,11 @@ describe("multi-model launch", () => { }); test("fails if opencode is not installed", async () => { - mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses["command -v opencode"] = { + ok: false, + stdout: "", + stderr: "", + }; const result = await openMultiModel({ sessionName: "test-session", @@ -149,7 +197,9 @@ describe("multi-model launch", () => { }); expect(result.success).toBe(false); - expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); + 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", @@ -164,7 +214,9 @@ describe("multi-model launch", () => { }); expect(result.success).toBe(false); - expect(result.error).toContain("Error: Model name 'invalid/model' not found"); + expect(result.error).toContain( + "Error: Model name 'invalid/model' not found", + ); expect(executedCommands).toEqual([ "git rev-parse --is-inside-work-tree", "command -v tmux", @@ -174,7 +226,11 @@ describe("multi-model launch", () => { }); test("fails if session already exists", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["tmux has-session -t test-session"] = { + ok: true, + stdout: "", + stderr: "", + }; const result = await openMultiModel({ sessionName: "test-session", @@ -199,13 +255,25 @@ describe("multi-model launch", () => { }); 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); + 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: "" }; + 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", @@ -213,14 +281,30 @@ describe("multi-model launch", () => { }); 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); + 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: "" }; + 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!", @@ -228,18 +312,36 @@ describe("multi-model launch", () => { }); 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); + 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("uses sessionName for tmux commands, not safeSessionName", async () => { const unsanitized = "my awesome session!!"; const sanitized = "my-awesome-session"; - mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { + ok: false, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[ + `git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; const result = await openMultiModel({ sessionName: unsanitized, @@ -248,19 +350,45 @@ describe("multi-model launch", () => { expect(result.success).toBe(true); // tmux commands should use unsanitized name - expect(executedCommands.some(c => c.startsWith(`tmux has-session -t ${unsanitized}`))).toBe(true); - expect(executedCommands.some(c => c.startsWith(`tmux new-session -d -s ${unsanitized}`))).toBe(true); - expect(executedCommands.some(c => c.startsWith(`tmux send-keys -t ${unsanitized}:`))).toBe(true); + expect( + executedCommands.some((c) => + c.startsWith(`tmux has-session -t ${unsanitized}`), + ), + ).toBe(true); + expect( + executedCommands.some((c) => + c.startsWith(`tmux new-session -d -s ${unsanitized}`), + ), + ).toBe(true); + expect( + executedCommands.some((c) => + c.startsWith(`tmux send-keys -t ${unsanitized}:`), + ), + ).toBe(true); // sanitized name should NOT appear in tmux commands - expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false); + expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe( + false, + ); // git branches and paths should use sanitized name - expect(executedCommands.some(c => c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`))).toBe(true); + expect( + executedCommands.some((c) => + c.startsWith(`git worktree add -b opencode/${sanitized}/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: "" }; + 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", @@ -305,7 +433,9 @@ describe("multi-model launch", () => { }); 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: "" }; + 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", @@ -313,7 +443,9 @@ describe("multi-model launch", () => { }); expect(result.success).toBe(false); - expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists"); + 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", @@ -325,7 +457,9 @@ describe("multi-model launch", () => { }); 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" }; + 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", @@ -346,9 +480,17 @@ describe("multi-model launch", () => { }); 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: "" }; + 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", @@ -356,15 +498,31 @@ describe("multi-model launch", () => { }); 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); + 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: "" }; + 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", @@ -372,8 +530,12 @@ describe("multi-model launch", () => { }); 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(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); }); }); diff --git a/tests/open_error.test.ts b/tests/open_error.test.ts index 97b4a78..cdab159 100644 --- a/tests/open_error.test.ts +++ b/tests/open_error.test.ts @@ -1,10 +1,21 @@ -import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test"; -import * as os from "node:os"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import * as fs from "node:fs"; +import * as os from "node:os"; import { openMultiModel } from "../src/core/open"; -let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; -let mockCommandResponses: Record = {}; +const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record< + string, + { ok: boolean; stdout: string; stderr: string } +> = {}; let executedCommands: string[] = []; function setupMockBun$() { @@ -12,7 +23,11 @@ function setupMockBun$() { const parts = values[0] as string[]; const cmd = parts.join(" "); executedCommands.push(cmd); - const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" }; + const response = mockCommandResponses[cmd] ?? { + ok: true, + stdout: "", + stderr: "", + }; return { quiet: () => ({ nothrow: async () => ({ @@ -44,193 +59,481 @@ describe("openMultiModel error paths", () => { test("fails when model list command fails", async () => { const session = "sess"; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: false, stdout: "", stderr: "model error" }; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: false, + stdout: "", + stderr: "model error", + }; - const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" }); + const result = await openMultiModel({ + sessionName: session, + models: ["openai/gpt-5.2"], + binaryName: "opencode", + mode: "cli", + }); expect(result.success).toBe(false); expect(result.error).toContain("Failed to load valid models"); }); test("fails when duplicate models provided", async () => { const session = "dup"; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" }; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: "openai/gpt-5.2", + stderr: "", + }; - const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2", "openai/gpt-5.2"], binaryName: "opencode", mode: "cli" }); + const result = await openMultiModel({ + sessionName: session, + models: ["openai/gpt-5.2", "openai/gpt-5.2"], + binaryName: "opencode", + mode: "cli", + }); expect(result.success).toBe(false); expect(result.error).toContain("Duplicate model names are not allowed"); }); test("fails when session already exists", async () => { const session = "existing"; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: "openai/gpt-5.2", + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: true, + stdout: "", + stderr: "", + }; - const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" }); + const result = await openMultiModel({ + sessionName: session, + models: ["openai/gpt-5.2"], + binaryName: "opencode", + mode: "cli", + }); expect(result.success).toBe(false); expect(result.error).toContain("Session already exists"); }); - test("second model fails because branch already exists", async () => { - const session = "sess-add"; - const binary = "opencode"; - const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; - // common mocks - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; - // first model success - const firstBranch = `opencode/${session}/gpt-5-2`; - const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; - // second model branch exists - const secondBranch = `opencode/${session}/gpt-5-2-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; - const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); - expect(result.success).toBe(true); - expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch"); - expect(result.windows).toContain("gpt-5-2"); - expect(result.windows).toContain("gpt-5-2-2"); + test("second model fails because branch already exists", async () => { + const session = "sess-add"; + const binary = "opencode"; + const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; + // common mocks + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: models.join("\n"), + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: false, + stdout: "", + stderr: "", + }; + // first model success + const firstBranch = `opencode/${session}/gpt-5-2`; + const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${firstBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; + // second model branch exists + const secondBranch = `opencode/${session}/gpt-5-2-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${secondBranch}` + ] = { ok: true, stdout: "", stderr: "" }; + const result = await openMultiModel({ + sessionName: session, + models, + binaryName: binary, + mode: "cli", + }); + expect(result.success).toBe(true); + expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch"); + expect(result.windows).toContain("gpt-5-2"); + expect(result.windows).toContain("gpt-5-2-2"); + }); + + test("second model worktree creation fails", async () => { + const session = "sess-wt"; + const binary = "opencode"; + const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: models.join("\n"), + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: false, + stdout: "", + stderr: "", + }; + // first model success + const firstBranch = `opencode/${session}/gpt-5-2`; + const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${firstBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; + // second model fails worktree + const secondBranch = `opencode/${session}/gpt-5-2-2`; + const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${secondBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = + { ok: false, stdout: "", stderr: "wt error" }; + mockCommandResponses[`git worktree remove -f ${secondPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[`git branch -D ${secondBranch}`] = { + ok: true, + stdout: "", + stderr: "", + }; + const result = await openMultiModel({ + sessionName: session, + models, + binaryName: binary, + mode: "cli", }); - test("second model worktree creation fails", async () => { - const session = "sess-wt"; - const binary = "opencode"; - const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; - // first model success - const firstBranch = `opencode/${session}/gpt-5-2`; - const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; - // second model fails worktree - const secondBranch = `opencode/${session}/gpt-5-2-2`; - const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: false, stdout: "", stderr: "wt error" }; - mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; -const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); + expect(result.success).toBe(true); + expect(result.instructions).toContain("Error: Created tmux session"); + expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); + expect(result.windows).toContain("gpt-5-2"); + expect(result.windows).toContain("gpt-5-2-2"); + }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); - expect(result.windows).toContain("gpt-5-2"); - expect(result.windows).toContain("gpt-5-2-2"); + test("second model window creation fails", async () => { + const session = "sess-win"; + const binary = "opencode"; + const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: models.join("\n"), + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: false, + stdout: "", + stderr: "", + }; + // first model success + const firstBranch = `opencode/${session}/gpt-5-2`; + const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${firstBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; + // second model window failure + const secondBranch = `opencode/${session}/gpt-5-2-2`; + const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${secondBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = + { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}` + ] = { ok: false, stdout: "", stderr: "win err" }; + mockCommandResponses[`git worktree remove -f ${secondPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[`git branch -D ${secondBranch}`] = { + ok: true, + stdout: "", + stderr: "", + }; + const result = await openMultiModel({ + sessionName: session, + models, + binaryName: binary, + mode: "cli", }); - test("second model window creation fails", async () => { - const session = "sess-win"; - const binary = "opencode"; - const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; - // first model success - const firstBranch = `opencode/${session}/gpt-5-2`; - const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; - // second model window failure - const secondBranch = `opencode/${session}/gpt-5-2-2`; - const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: false, stdout: "", stderr: "win err" }; - mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; -const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); + expect(result.success).toBe(true); + expect(result.instructions).toContain("Error: Created tmux session"); + expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); + expect(result.windows).toContain("gpt-5-2"); + expect(result.windows).toContain("gpt-5-2-2"); + }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2"); - expect(result.windows).toContain("gpt-5-2"); - expect(result.windows).toContain("gpt-5-2-2"); - }); - - test("second model launch command fails", async () => { - const session = "sess-launch"; - const binary = "opencode"; - const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; - // first model success - const firstBranch = `opencode/${session}/gpt-5-2`; - const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; - // second model launch fails - const secondBranch = `opencode/${session}/gpt-5-2-2`; - const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`] = { ok: false, stdout: "", stderr: "launch err" }; - mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" }; - const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); - expect(result.success).toBe(true); - expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (launch err"); - expect(result.windows).toContain("gpt-5-2"); - expect(result.windows).toContain("gpt-5-2-2"); - }); - - test("second model worktree path already exists", async () => { - const session = "sess-wtpath"; - const binary = "opencode"; - const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" }; - mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" }; - mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" }; - mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" }; - mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" }; - // first model success - const firstBranch = `opencode/${session}/gpt-5-2`; - const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" }; - // second model path exists - const secondBranch = `opencode/${session}/gpt-5-2-2`; - const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" }; - // mock fs existsSync to return true for secondPath - spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath); - const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" }); - expect(result.success).toBe(true); - expect(result.instructions).toContain("Error: Created tmux session"); - expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Worktree path already exists at"); - expect(result.windows).toContain("gpt-5-2"); - expect(result.windows).toContain("gpt-5-2-2"); + test("second model launch command fails", async () => { + const session = "sess-launch"; + const binary = "opencode"; + const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: models.join("\n"), + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: false, + stdout: "", + stderr: "", + }; + // first model success + const firstBranch = `opencode/${session}/gpt-5-2`; + const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${firstBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; + // second model launch fails + const secondBranch = `opencode/${session}/gpt-5-2-2`; + const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${secondBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = + { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m` + ] = { ok: false, stdout: "", stderr: "launch err" }; + mockCommandResponses[`git worktree remove -f ${secondPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[`git branch -D ${secondBranch}`] = { + ok: true, + stdout: "", + stderr: "", + }; + const result = await openMultiModel({ + sessionName: session, + models, + binaryName: binary, + mode: "cli", }); + expect(result.success).toBe(true); + expect(result.instructions).toContain( + "Failed: anthropic/gpt-5-2 (launch err", + ); + expect(result.windows).toContain("gpt-5-2"); + expect(result.windows).toContain("gpt-5-2-2"); + }); + test("second model worktree path already exists", async () => { + const session = "sess-wtpath"; + const binary = "opencode"; + const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"]; + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { + ok: true, + stdout: "true", + stderr: "", + }; + mockCommandResponses["command -v tmux"] = { + ok: true, + stdout: "tmux", + stderr: "", + }; + mockCommandResponses["command -v opencode"] = { + ok: true, + stdout: "opencode", + stderr: "", + }; + mockCommandResponses["opencode models"] = { + ok: true, + stdout: models.join("\n"), + stderr: "", + }; + mockCommandResponses[`tmux has-session -t ${session}`] = { + ok: false, + stdout: "", + stderr: "", + }; + // first model success + const firstBranch = `opencode/${session}/gpt-5-2`; + const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${firstBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses[ + `tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}` + ] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + `tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m` + ] = { ok: true, stdout: "", stderr: "" }; + // second model path exists + const secondBranch = `opencode/${session}/gpt-5-2-2`; + const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`; + mockCommandResponses[ + `git show-ref --verify --quiet refs/heads/${secondBranch}` + ] = { ok: false, stdout: "", stderr: "" }; + // mock fs existsSync to return true for secondPath + spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath); + const result = await openMultiModel({ + sessionName: session, + models, + binaryName: binary, + mode: "cli", }); + expect(result.success).toBe(true); + expect(result.instructions).toContain("Error: Created tmux session"); + expect(result.instructions).toContain( + "Failed: anthropic/gpt-5-2 (Worktree path already exists at", + ); + expect(result.windows).toContain("gpt-5-2"); + expect(result.windows).toContain("gpt-5-2-2"); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 4443803..bec562a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,27 +1,38 @@ -import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import { - shellQuote, - normalizeModels, - findDuplicates, createWindowBaseName, createWindowPlans, - levenshtein, - suggestModels, + findDuplicates, formatInvalidModelError, - sanitizeName, - runCommand, - getWorktreePath, - getSessionPath, - undoWorktree, - launchModelInWindow, - getWorktreesForSession, getBinaryName, + getSessionPath, + getWorktreePath, + getWorktreesForSession, + launchModelInWindow, + levenshtein, + normalizeModels, + runCommand, + sanitizeName, + shellQuote, + suggestModels, + undoWorktree, } from "../src/core/utils"; -let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; -let mockCommandResponses: Record = {}; +const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; +let mockCommandResponses: Record< + string, + { ok: boolean; stdout: string; stderr: string } +> = {}; let executedCommands: string[] = []; function setupMockBun$() { @@ -32,7 +43,11 @@ function setupMockBun$() { return { quiet: () => ({ nothrow: async () => { - const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + const response = mockCommandResponses[commandSignature] ?? { + ok: true, + stdout: "", + stderr: "", + }; return { exitCode: response.ok ? 0 : 1, stdout: { toString: () => response.stdout }, @@ -75,7 +90,9 @@ describe("core utils", () => { expect(shellQuote("test'value")).toBe("'test'\"'\"'value'"); }); test("string with multiple single quotes", () => { - expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'"); + expect(shellQuote("te's't'v'alue")).toBe( + "'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'", + ); }); test("empty string", () => { expect(shellQuote("")).toBe("''"); @@ -93,7 +110,9 @@ describe("core utils", () => { expect(normalizeModels([" ", " "])).toEqual([]); }); test("array with mixed valid models, padded models, and empty strings", () => { - expect(normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "anthropic/claude"]); + expect( + normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "]), + ).toEqual(["openai/gpt-5.2", "anthropic/claude"]); }); }); @@ -111,7 +130,11 @@ describe("core utils", () => { expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]); }); test("verifying first-repeated-occurrence order", () => { - expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]); + expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual([ + "a", + "x", + "b", + ]); }); }); @@ -136,10 +159,14 @@ describe("core utils", () => { describe("createWindowPlans", () => { test("single model", () => { - expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]); + expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([ + { model: "openai/gpt-5-2", windowName: "gpt-5-2" }, + ]); }); test("two models with the same base name", () => { - expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([ + expect( + createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"]), + ).toEqual([ { model: "openai/gpt-5-2", windowName: "gpt-5-2" }, { model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" }, ]); @@ -194,7 +221,12 @@ describe("core utils", () => { expect(suggestions[0]).toBe("gpt-5.2"); }); test("maximum of 3 suggestions", () => { - const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"]; + const allowlist = [ + "gpt-5.2", + "gpt-5.2-mini", + "gpt-5.2-pro", + "gpt-5.2-ultra", + ]; expect(suggestModels("gpt4o", allowlist).length).toBe(3); }); test("sorting logic", () => { @@ -239,14 +271,22 @@ describe("core utils", () => { describe("runCommand", () => { test("returns ok true for successful command", async () => { - mockCommandResponses["echo hello"] = { ok: true, stdout: "hello", stderr: "" }; + mockCommandResponses["echo hello"] = { + ok: true, + stdout: "hello", + stderr: "", + }; const result = await runCommand(["echo", "hello"]); expect(result.ok).toBe(true); expect(result.stdout).toBe("hello"); expect(result.stderr).toBe(""); }); test("returns ok false for failed command", async () => { - mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" }; + mockCommandResponses["false"] = { + ok: false, + stdout: "", + stderr: "error", + }; const result = await runCommand(["false"]); expect(result.ok).toBe(false); expect(result.stderr).toBe("error"); @@ -257,12 +297,16 @@ describe("core utils", () => { test("returns correct worktree path", () => { spyOn(os, "homedir").mockReturnValue("/home/user"); const result = getWorktreePath("my-session", "gpt-5.2"); - expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); + expect(result).toBe( + "/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2", + ); }); test("handles nested session names", () => { spyOn(os, "homedir").mockReturnValue("/home/user"); const result = getWorktreePath("parent/child", "window"); - expect(result).toBe("/home/user/.local/share/opencode/multi-model/parent/child/window"); + expect(result).toBe( + "/home/user/.local/share/opencode/multi-model/parent/child/window", + ); }); }); @@ -270,57 +314,118 @@ describe("core utils", () => { test("returns correct session path", () => { spyOn(os, "homedir").mockReturnValue("/home/user"); const result = getSessionPath("my-session"); - expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session"); + expect(result).toBe( + "/home/user/.local/share/opencode/multi-model/my-session", + ); }); }); describe("undoWorktree", () => { test("returns empty array on success", async () => { - mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree remove -f /path/to/worktree"] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["git branch -D opencode/session/window"] = { + ok: true, + stdout: "", + stderr: "", + }; - const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); + const errors = await undoWorktree( + "/path/to/worktree", + "opencode/session/window", + ); expect(errors).toEqual([]); }); test("returns error when worktree remove fails", async () => { - mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; - mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git worktree remove -f /path/to/worktree"] = { + ok: false, + stdout: "", + stderr: "remove failed", + }; + mockCommandResponses["git branch -D opencode/session/window"] = { + ok: true, + stdout: "", + stderr: "", + }; - const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); + const errors = await undoWorktree( + "/path/to/worktree", + "opencode/session/window", + ); expect(errors.length).toBe(1); expect(errors[0]).toContain("Failed to remove worktree"); }); test("returns error when branch delete fails", async () => { - mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; + mockCommandResponses["git worktree remove -f /path/to/worktree"] = { + ok: true, + stdout: "", + stderr: "", + }; + mockCommandResponses["git branch -D opencode/session/window"] = { + ok: false, + stdout: "", + stderr: "branch delete failed", + }; - const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); + const errors = await undoWorktree( + "/path/to/worktree", + "opencode/session/window", + ); expect(errors.length).toBe(1); expect(errors[0]).toContain("Failed to delete branch"); }); test("returns both errors when both operations fail", async () => { - mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; - mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; + mockCommandResponses["git worktree remove -f /path/to/worktree"] = { + ok: false, + stdout: "", + stderr: "remove failed", + }; + mockCommandResponses["git branch -D opencode/session/window"] = { + ok: false, + stdout: "", + stderr: "branch delete failed", + }; - const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); + const errors = await undoWorktree( + "/path/to/worktree", + "opencode/session/window", + ); expect(errors.length).toBe(2); }); }); describe("launchModelInWindow", () => { test("sends correct tmux command", async () => { - mockCommandResponses["tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + "tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m" + ] = { ok: true, stdout: "", stderr: "" }; - const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" }); + const result = await launchModelInWindow("session", { + model: "openai/gpt-5.2", + windowName: "window", + }); expect(result.ok).toBe(true); - expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"); + expect(executedCommands).toContain( + "tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m", + ); }); test("uses custom binary name", async () => { - mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses[ + "tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m" + ] = { ok: true, stdout: "", stderr: "" }; - const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" }, "kilo"); + const result = await launchModelInWindow( + "session", + { model: "openai/gpt-5.2", windowName: "window" }, + "kilo", + ); expect(result.ok).toBe(true); - expect(executedCommands).toContain("tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"); + expect(executedCommands).toContain( + "tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m", + ); }); }); @@ -329,26 +434,32 @@ describe("core utils", () => { spyOn(os, "homedir").mockReturnValue("/home/user"); mockCommandResponses["git worktree list --porcelain"] = { ok: true, - stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main", + stdout: + "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main", stderr: "", }; const worktrees = await getWorktreesForSession("my-session"); expect(worktrees.length).toBe(1); - expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); + expect(worktrees[0]?.path).toBe( + "/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2", + ); expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2"); }); test("sanitizes session name when filtering worktrees", async () => { spyOn(os, "homedir").mockReturnValue("/home/user"); mockCommandResponses["git worktree list --porcelain"] = { ok: true, - stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2", + stdout: + "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2", stderr: "", }; const worktrees = await getWorktreesForSession("my session!"); expect(worktrees.length).toBe(1); - expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); + expect(worktrees[0]?.path).toBe( + "/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2", + ); }); test("returns empty array when no matching worktrees", async () => { spyOn(os, "homedir").mockReturnValue("/home/user");