build: add biome formatter and linter

This commit is contained in:
2026-03-23 08:17:27 +05:30
parent 068db04355
commit 7a164c705c
21 changed files with 1620 additions and 510 deletions
+4 -3
View File
@@ -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 };
+1 -3
View File
@@ -18,9 +18,7 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": [
"bun-types"
]
"types": ["bun-types"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
+1 -1
View File
@@ -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",
"bun.test.customScript": "bun test"
}
+50
View File
@@ -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"
}
}
}
}
+19
View File
@@ -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=="],
+5 -1
View File
@@ -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"
},
+32 -9
View File
@@ -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,9 +15,20 @@ program
.command("open")
.description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session")
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", [])
.option("-b, --binary <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 }) => {
.option(
"-m, --models <models...>",
"Model IDs to launch (space-separated)",
[],
)
.option(
"-b, --binary <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();
@@ -29,7 +40,9 @@ program
});
if (result.success) {
console.log(result.instructions || `Created session: ${result.sessionName}`);
console.log(
result.instructions || `Created session: ${result.sessionName}`,
);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
@@ -38,16 +51,25 @@ program
console.error(`Error: ${error}`);
process.exit(1);
}
});
},
);
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "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 }) => {
.action(
async (
sessionName: string,
options: { keepWorktrees: boolean; force: boolean; tags: boolean },
) => {
try {
const result = await closeMultiModel({
sessionName,
@@ -66,6 +88,7 @@ if (result.success) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
},
);
program.parse();
+48 -14
View File
@@ -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<string> {
* }
* ```
*/
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
export async function closeMultiModel(
options: CloseSessionOptions,
): Promise<CloseSessionResult> {
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<Clo
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 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,
@@ -100,12 +119,19 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
if (options.createArchiveTags !== false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
const tagResult = await runCommand([
"git",
"tag",
archiveTag,
worktree.branch,
]);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
const warningMsg = chalk.red(
`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`,
);
instructionsArr.push(warningMsg);
}
}
@@ -113,7 +139,9 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
instructionsArr.push(
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
);
}
}
@@ -127,9 +155,15 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
cleanupPerformed = worktreesRemoved.length > 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(", ")}.`),
);
}
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { openMultiModel } from "./open";
export { closeMultiModel } from "./close";
export { openMultiModel } from "./open";
export * from "./utils";
+121 -34
View File
@@ -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<MultiModelResult> {
export async function openMultiModel(
options: MultiModelOptions,
): Promise<MultiModelResult> {
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<MultiM
const mode = options.mode || "cli";
if (!sessionName) {
return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty." };
return {
success: false,
sessionName: "",
error: "Error: `sessionName` must not be empty.",
};
}
if (models.length === 0) {
return { success: false, sessionName, error: "Error: Model names must not be empty." };
return {
success: false,
sessionName,
error: "Error: Model names must not be empty.",
};
}
const duplicateModels = findDuplicates(models);
@@ -59,19 +69,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
};
}
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]);
const gitRepoCheck = await runCommand([
"git",
"rev-parse",
"--is-inside-work-tree",
]);
if (!gitRepoCheck.ok) {
return { success: false, sessionName, error: "Error: `multi-model` requires a git repository." };
return {
success: false,
sessionName,
error: "Error: `multi-model` requires a git repository.",
};
}
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) {
return { success: false, sessionName, error: "Error: `tmux` is not installed or not on `PATH`." };
return {
success: false,
sessionName,
error: "Error: `tmux` is not installed or not on `PATH`.",
};
}
const binaryExists = await runCommand(["command", "-v", binaryName]);
if (!binaryExists.ok) {
return { success: false, sessionName, error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.` };
return {
success: false,
sessionName,
error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.`,
};
}
const modelListResult = await runCommand([binaryName, "models"]);
@@ -90,12 +116,25 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
const invalidModels = models.filter((model) => !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<MultiM
if (fs.existsSync(worktreePath)) {
if (!isFirst) {
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`);
failedModels.push(
`${plan.model} (Worktree path already exists at ${worktreePath})`,
);
continue;
} else {
return { success: false, sessionName, error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.` };
return {
success: false,
sessionName,
error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`,
};
}
}
const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
const branchExists = await runCommand([
"git",
"show-ref",
"--verify",
"--quiet",
`refs/heads/${branchName}`,
]);
if (branchExists.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Branch '${branchName}' already exists)`);
failedModels.push(
`${plan.model} (Branch '${branchName}' already exists)`,
);
continue;
} else {
return { success: false, sessionName, error: `Error: Branch '${branchName}' already exists.` };
return {
success: false,
sessionName,
error: `Error: Branch '${branchName}' already exists.`,
};
}
}
const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]);
const worktreeResult = await runCommand([
"git",
"worktree",
"add",
"-b",
branchName,
worktreePath,
]);
if (!worktreeResult.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`);
failedModels.push(
`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`,
);
continue;
} else {
return { success: false, sessionName, error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}` };
return {
success: false,
sessionName,
error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`,
};
}
}
@@ -173,22 +243,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
]);
if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
failedModels.push(
`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`,
);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 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<MultiM
sessionName,
windows: windowNames,
instructions: [
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
chalk.red(
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
),
chalk.red(`Failed: ${failedModels.join(", ")}.`),
chalk.dim(`Succeeded: ${succeededModels.length > 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<MultiM
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
chalk.dim(cleanupCommand),
].join("\n"),
};
}
@@ -232,7 +319,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
chalk.dim(cleanupCommand),
].join("\n"),
};
}
+67 -14
View File
@@ -136,7 +136,10 @@ export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
const trimmedBase = baseName.slice(
0,
Math.max(1, WINDOW_NAME_LIMIT - suffix.length),
);
return {
model,
@@ -194,7 +197,10 @@ export function levenshtein(left: string, right: string): number {
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
export function suggestModels(requested: string, allowlist: string[]): string[] {
export function suggestModels(
requested: string,
allowlist: string[],
): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
@@ -212,7 +218,11 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
score: distance + containsBoost,
};
})
.sort((left, right) => 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<CommandResult> {
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<WorktreeInfo[]> {
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
export async function getWorktreesForSession(
sessionName: string,
): Promise<WorktreeInfo[]> {
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<Workt
* if (errors.length > 0) console.error(errors);
* ```
*/
export async function undoWorktree(worktreePath: string, branchName: string): Promise<string[]> {
export async function undoWorktree(
worktreePath: string,
branchName: string,
): Promise<string[]> {
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;
}
+2 -2
View File
@@ -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 };
+18 -4
View File
@@ -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
+8 -2
View File
@@ -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`
);
},
});
-1
View File
@@ -62,7 +62,6 @@ export interface CloseSessionResult {
branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */
tagsCreated?: string[];
}
/**
+301 -74
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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);
@@ -100,15 +176,28 @@ describe("closeMultiModel", () => {
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
@@ -154,37 +268,73 @@ describe("closeMultiModel", () => {
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,
);
});
});
+24 -5
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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");
});
+228 -66
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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);
});
});
+398 -95
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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,37 +59,104 @@ 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");
});
@@ -84,22 +166,59 @@ describe("openMultiModel error paths", () => {
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: "" };
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: "" };
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" });
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");
@@ -110,30 +229,76 @@ describe("openMultiModel error paths", () => {
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: "" };
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: "" };
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" });
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.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");
});
@@ -142,31 +307,79 @@ const result = await openMultiModel({ sessionName: session, models, binaryName:
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: "" };
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: "" };
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" });
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.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");
});
@@ -175,30 +388,82 @@ const result = await openMultiModel({ sessionName: session, models, binaryName:
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: "" };
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: "" };
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" });
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.instructions).toContain(
"Failed: anthropic/gpt-5-2 (launch err",
);
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
@@ -207,30 +472,68 @@ const result = await openMultiModel({ sessionName: session, models, binaryName:
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: "" };
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: "" };
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: "" };
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" });
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.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");
});
});
+160 -49
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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");