Compare commits

..
Author SHA1 Message Date
bendtherules f0cc07f513 chore(release): 0.0.5 2026-03-21 01:11:36 +05:30
bendtherules 6dd4ee4b4f fix: correct path for cli.js in package.json 2026-03-21 01:11:30 +05:30
bendtherules dd7ac4dc2d chore(release): 0.0.4 2026-03-21 01:10:27 +05:30
bendtherules e8dbcff73e fix test 2026-03-21 01:10:21 +05:30
bendtherules 787fa75df4 build: color-code open and close outputs 2026-03-21 01:05:17 +05:30
bendtherules 17bb1130c8 build: color-code open output 2026-03-21 00:22:31 +05:30
bendtherules c642148cac test: add user confirmation handling for cleanup in closeMultiModel 2026-03-20 14:56:29 +05:30
bendtherules 7abfd6bf64 test: fix tests 2026-03-20 14:46:00 +05:30
bendtherules 93478294aa test: add error handling tests for closeMultiModel and openMultiModel 2026-03-20 14:20:34 +05:30
bendtherules 5d50038845 docs: update test coverage command in README 2026-03-20 14:08:00 +05:30
bendtherules 0b88ea0763 docs: add local code coverage instructions 2026-03-20 14:02:41 +05:30
bendtherules ffdc5451ab refactor: tests and add close.test.ts 2026-03-20 13:51:28 +05:30
bendtherules 8a087275b3 refactor: rename launchMultiModel to openMultiModel 2026-03-20 13:38:46 +05:30
bendtherules 962c9a474f refactor: rename src/core/launch.ts to open.ts 2026-03-20 13:33:30 +05:30
bendtherules 71523f2144 feat: add launch configuration for multi-model sessions in VSCode 2026-03-20 13:29:36 +05:30
bendtherules 1c2f203a34 feat: add initial VSCode settings for Bun configuration 2026-03-20 13:22:00 +05:30
bendtherules 536e30c5d0 test: add session name sanitization when fetching worktrees 2026-03-20 13:21:51 +05:30
bendtherules da0ed90192 fix: sanitize session name in getWorktreesForSession function 2026-03-20 13:17:10 +05:30
bendtherules 49da7c4219 chore(release): 0.0.3 2026-03-20 10:42:09 +05:30
bendtherules 41757e712f fix: update package name to remove username prefix 2026-03-20 10:41:58 +05:30
bendtherules 96e3922c42 chore(release): 0.0.2 2026-03-20 10:39:56 +05:30
bendtherules d6a2811ec4 fix: update release script permissions to executable 2026-03-20 10:39:48 +05:30
bendtherules c179491aed build: add release script 2026-03-20 10:38:07 +05:30
bendtherules 1b8f54d955 fix: revert version number to 0.0.1 in package.json 2026-03-20 10:35:36 +05:30
bendtherules 7cecbede6d fix: update npm @username to @bendtherules 2026-03-20 10:33:39 +05:30
19 changed files with 1076 additions and 435 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Open Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["open", "test/session1", "--models", "openai/gpt-5.2"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Close Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["close", "test/session1"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
// The path to the `bun` executable.
"bun.runtime": "/path/to/bun",
// If support for Bun should be added to the default "JavaScript Debug Terminal".
"bun.debugTerminal.enabled": true,
// If the debugger should stop on the first line of the program.
"bun.debugTerminal.stopOnEntry": false,
// Glob pattern to find test files. Defaults to the value shown below.
"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",
}
+18 -5
View File
@@ -13,7 +13,7 @@ Add to your OpenCode config (`opencode.json`):
```json
{
"plugin": ["@username/opencode-multi-model"]
"plugin": ["opencode-multi-model"]
}
```
@@ -21,10 +21,10 @@ Add to your OpenCode config (`opencode.json`):
```bash
# Using bunx (no install)
bunx @username/opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
bunx opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
# Or install globally
bun install -g @username/opencode-multi-model
bun install -g opencode-multi-model
opencode-multi-model open my-session -m openai/gpt-5.2
```
@@ -34,7 +34,7 @@ If you installed it via bun and want to use it as a project tool, import it from
```typescript
// .opencode/tools/multi-model.ts
import { openTool as open, closeTool as close } from "@username/opencode-multi-model"
import { openTool as open, closeTool as close } from "opencode-multi-model"
export { open, close }
```
@@ -153,7 +153,7 @@ bun dist/cli.js close test-session
│ ├── types.ts # Shared TypeScript types
│ ├── core/
│ │ ├── index.ts # Core exports
│ │ ├── launch.ts # Launch session logic
│ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ └── utils.ts # Helper functions
│ └── tools/
@@ -166,6 +166,19 @@ bun dist/cli.js close test-session
└── README.md
```
## Local code coverage
The test suite can generate a coverage report locally.
```bash
# Run tests with coverage (HTML report generated in ./coverage)
npm run coverage # or: bun run coverage
# Open the HTML report
open coverage/index.html # macOS
```
The `coverage` directory is ignored by Git but can be inspected to see which lines are exercised.
## License
MIT
+3
View File
@@ -6,6 +6,7 @@
"name": "@username/opencode-multi-model",
"dependencies": {
"@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0",
"commander": "^12.0.0",
},
"devDependencies": {
@@ -25,6 +26,8 @@
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+7 -4
View File
@@ -1,11 +1,11 @@
{
"name": "@username/opencode-multi-model",
"version": "1.0.0",
"name": "opencode-multi-model",
"version": "0.0.5",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module",
"main": "./dist/index.js",
"bin": {
"opencode-multi-model": "./dist/cli.js"
"opencode-multi-model": "dist/cli.js"
},
"exports": {
".": "./dist/index.js",
@@ -19,9 +19,12 @@
"scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
"test": "bun test",
"prepublishOnly": "bun run build"
"coverage": "bun test --coverage",
"prepublishOnly": "bun run build",
"release": "bash ./scripts/release.sh"
},
"dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0"
},
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -e
# Prompt for version bump type
read -p "Select version bump (major/minor/patch): " bump
if [[ ! "$bump" =~ ^(major|minor|patch)$ ]]; then
echo "Invalid choice. Must be 'major', 'minor', or 'patch'."
exit 1
fi
# Run tests
echo "Running test suite..."
npm test
# Build the project
echo "Building the project..."
npm run build
# Bump version
echo "Bumping version ($bump)..."
npm version "$bump" -m "chore(release): %s"
# Show git status
git status
# Confirm publishing
read -p "Proceed with npm publish? (y/N): " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Publish aborted."
exit 0
fi
# Publish to npm
npm publish
# Push git commits and tags
git push && git push --tags
echo "Release completed successfully."
+4 -16
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { launchMultiModel } from "./core/launch";
import { openMultiModel } from "./core/open";
import { closeMultiModel } from "./core/close";
import { getBinaryName } from "./core/utils";
@@ -21,7 +21,7 @@ program
try {
const binaryName = options.binary || getBinaryName();
const result = await launchMultiModel({
const result = await openMultiModel({
sessionName,
models: options.models,
binaryName,
@@ -54,20 +54,8 @@ program
force: options.force,
});
if (result.success) {
console.log(`Closed session: ${result.sessionName}`);
if (result.cleanupPerformed) {
console.log(` Removed ${result.worktreesRemoved?.length || 0} worktrees`);
if (result.tagsCreated && result.tagsCreated.length > 0) {
console.log(` Created backup tags: ${result.tagsCreated.join(", ")}`);
}
if (result.warnings && result.warnings.length > 0) {
console.log(` Warnings: ${result.warnings.join("; ")}`);
}
if (result.branchesDeleted && result.branchesDeleted.length > 0) {
console.log(` Deleted ${result.branchesDeleted.length} branches`);
}
}
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
+35 -20
View File
@@ -1,6 +1,7 @@
import * as readline from "node:readline";
import chalk from 'chalk'; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession } from "./utils";
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
/**
* Prompts the user for input on the terminal.
@@ -45,33 +46,42 @@ async function promptUser(question: string): Promise<string> {
*/
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const instructionsArr: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const warnings: 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", safeSessionName]);
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
} else {
instructionsArr.push(chalk.yellow(`Session '${safeSessionName}' does not exist`));
}
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(options.sessionName);
const worktrees = await getWorktreesForSession(safeSessionName);
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(`\nThe following worktrees and branches will be removed:`);
worktrees.forEach((wt) => console.log(` - ${wt.path} (branch: ${wt.branch})`));
instructionsArr.push(chalk.red('The following worktrees and branches will be removed:'));
worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
instructionsArr.push(msg.trim());
});
const answer = await promptUser("\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,
sessionName: options.sessionName,
sessionName: safeSessionName,
error: "Cleanup cancelled by user",
instructions: instructionsArr.join("\n"),
};
}
}
@@ -94,20 +104,19 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
warnings.push(warningMsg);
console.warn(`Warning: ${warningMsg}`);
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
instructionsArr.push(warningMsg);
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
}
}
// Also remove the base directory
const worktreeBase = getSessionPath(options.sessionName);
const worktreeBase = getSessionPath(safeSessionName);
try {
await runCommand(["rm", "-rf", worktreeBase]);
} catch {
@@ -115,26 +124,32 @@ 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.`));
if (tagsCreated.length > 0) {
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`));
}
}
}
return {
return {
success: true,
sessionName: options.sessionName,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
instructions: instructionsArr.join("\n"),
};
} catch (error) {
return {
return {
success: false,
sessionName: options.sessionName,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings,
instructions: instructionsArr.join("\n"),
};
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { launchMultiModel } from "./launch";
export { openMultiModel } from "./open";
export { closeMultiModel } from "./close";
export * from "./utils";
+27 -15
View File
@@ -1,4 +1,5 @@
import * as fs from "node:fs";
import chalk from 'chalk';
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand,
@@ -24,7 +25,7 @@ import {
*
* @example
* ```ts
* const result = await launchMultiModel({
* const result = await openMultiModel({
* sessionName: "compare",
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode",
@@ -34,7 +35,7 @@ import {
* }
* ```
*/
export async function launchMultiModel(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);
@@ -153,7 +154,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName);
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
if (undoErrors.length > 0) errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
if (undoErrors.length > 0) {
errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
}
return { success: false, sessionName, error: errorMsg };
}
} else {
@@ -172,7 +175,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
if (!windowCreateResult.ok) {
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(", ")}`);
if (undoErrors.length > 0) {
failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
}
continue;
}
}
@@ -201,13 +206,17 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
sessionName,
windows: windowNames,
instructions: [
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
`Failed: ${failedModels.join(", ")}.`,
`Attach with \`${attachCommand}\` to inspect the session.`,
`Cleanup when done using either:`,
`1. ${primaryCleanup}`,
`2. Or run manually: \`${cleanupCommand}\``,
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.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"),
};
}
@@ -217,10 +226,13 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
sessionName,
windows: windowNames,
instructions: [
`Use \`${attachCommand}\` to join session.`,
`When finished, clean up using either:`,
`1. ${primaryCleanup}`,
`2. Or run manually: \`${cleanupCommand}\``,
chalk.bold(`Use \`${attachCommand}\` to join session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"),
};
}
+1 -1
View File
@@ -332,7 +332,7 @@ export async function getWorktreesForSession(sessionName: string): Promise<Workt
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sessionName);
const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {};
+1 -12
View File
@@ -25,17 +25,6 @@ export const closeTool = tool({
return result.error!;
}
let details = "";
if (result.cleanupPerformed) {
details += ` Removed ${result.worktreesRemoved?.length || 0} worktrees.`;
if (result.tagsCreated && result.tagsCreated.length > 0) {
details += ` Created backup tags: ${result.tagsCreated.join(", ")}.`;
}
if (result.warnings && result.warnings.length > 0) {
details += ` Warnings: ${result.warnings.join("; ")}.`;
}
}
return `Session "${result.sessionName}" has been closed.${details}`;
return result.instructions;
},
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { tool } from "@opencode-ai/plugin";
import { launchMultiModel } from "../core/launch";
import { openMultiModel } from "../core/open";
import { getBinaryName } from "../core/utils";
/**
@@ -20,7 +20,7 @@ export const openTool = tool({
async execute(args, context) {
const binaryName = getBinaryName();
const result = await launchMultiModel({
const result = await openMultiModel({
sessionName: args.sessionName,
models: args.models,
binaryName,
+3 -2
View File
@@ -44,6 +44,8 @@ export interface CloseSessionOptions {
* Result returned after attempting to close a multi-model session.
*/
export interface CloseSessionResult {
/** Console messages and instructions. */
instructions: string;
/** Whether the close succeeded. */
success: boolean;
/** The session name that was closed. */
@@ -58,8 +60,7 @@ export interface CloseSessionResult {
branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */
tagsCreated?: string[];
/** Warning messages. */
warnings?: string[];
}
/**
+223
View File
@@ -0,0 +1,223 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } 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 }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel", () => {
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
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: "" };
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
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");
expect(executedCommands).toContain("tmux kill-session -t test-session");
});
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: "" };
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);
});
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";
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: "" };
// 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: "" };
// 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;
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Ensure removal commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
});
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";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
// Stub readline to return "n"
const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
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";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
// Stub readline to simulate user confirming cleanup (answers "y")
const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
// 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;
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
// Restore Date after invocation
// @ts-ignore
global.Date = OriginalDate;
// Verify result indicates successful cleanup
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Verify the expected git commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
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";
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: "" };
// 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;
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" };
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
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.tagsCreated).toEqual([]);
});
test("cleanup works with unsanitized session name", async () => {
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: "" };
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: "" };
// 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;
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
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`);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } 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 }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
// Simulate throwing error for a specific command
if (cmd === "git worktree list --porcelain") {
throw new Error("unexpected error");
}
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel unexpected error handling", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("catches thrown error and returns failure", async () => {
const result = await closeMultiModel({ sessionName: "any-session", cleanupWorktrees: false, force: false });
expect(result.success).toBe(false);
expect(result.error).toContain("unexpected error");
});
});
+357
View File
@@ -0,0 +1,357 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
import {
shellQuote,
normalizeModels,
findDuplicates,
createWindowBaseName,
createWindowPlans,
levenshtein,
suggestModels,
formatInvalidModelError,
sanitizeName,
runCommand,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
getWorktreesForSession,
getBinaryName,
} from "../src/core/utils";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model launch", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await openMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: [],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["invalid/model"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
});
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2",
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: [longModel],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
// ----------
// Core utils tests remain in a separate file.
+236
View File
@@ -0,0 +1,236 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test";
import * as os from "node:os";
import * as fs from "node:fs";
import { openMultiModel } from "../src/core/open";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("openMultiModel error paths", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
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" };
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: "" };
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: "" };
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
expect(result.success).toBe(false);
expect(result.error).toContain("Session already exists");
});
test("second model fails because branch already exists", async () => {
const session = "sess-add";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
// common mocks
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model branch exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree creation fails", async () => {
const session = "sess-wt";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model fails worktree
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: false, stdout: "", stderr: "wt error" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model window creation fails", async () => {
const session = "sess-win";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model window failure
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: false, stdout: "", stderr: "win err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model launch command fails", async () => {
const session = "sess-launch";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model launch fails
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`] = { ok: false, stdout: "", stderr: "launch err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (launch err");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree path already exists", async () => {
const session = "sess-wtpath";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model path exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
// mock fs existsSync to return true for secondPath
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Worktree path already exists at");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
});
+12 -344
View File
@@ -1,7 +1,6 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { launchMultiModel } from "../src/core/launch";
import {
shellQuote,
normalizeModels,
@@ -29,9 +28,7 @@ function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
@@ -45,7 +42,6 @@ function setupMockBun$() {
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
@@ -54,305 +50,6 @@ function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model launch", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await launchMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await launchMultiModel({
sessionName: "test-session",
models: [],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux"
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode"
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await launchMultiModel({
sessionName: "test-session",
models: ["invalid/model"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models"
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
});
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2"
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
const result = await launchMultiModel({
sessionName: "test-session",
models: [longModel],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
describe("core utils", () => {
let bunMock: ReturnType<typeof setupMockBun$>;
@@ -371,19 +68,15 @@ describe("core utils", () => {
test("normal string without special characters", () => {
expect(shellQuote("test")).toBe("'test'");
});
test("string with spaces", () => {
expect(shellQuote("test value")).toBe("'test value'");
});
test("string with single quotes", () => {
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'");
});
test("empty string", () => {
expect(shellQuote("")).toBe("''");
});
@@ -393,15 +86,12 @@ describe("core utils", () => {
test("empty array", () => {
expect(normalizeModels([])).toEqual([]);
});
test("undefined input", () => {
expect(normalizeModels(undefined)).toEqual([]);
});
test("array with whitespace-only strings", () => {
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"]);
});
@@ -411,19 +101,15 @@ describe("core utils", () => {
test("array with no duplicates", () => {
expect(findDuplicates(["a", "b", "c"])).toEqual([]);
});
test("array with one duplicate pair", () => {
expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]);
});
test("array with multiple different duplicates", () => {
expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
});
test("array with a single value repeated more than twice", () => {
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"]);
});
@@ -433,20 +119,16 @@ describe("core utils", () => {
test("normal model name without slashes", () => {
expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2");
});
test("model with slashes", () => {
expect(createWindowBaseName("vendor/namespace/model")).toBe("model");
});
test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30);
expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
});
test("model with special characters", () => {
expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
});
test("model made entirely of special characters", () => {
expect(createWindowBaseName("!@#$%^&*()")).toBe("model");
});
@@ -456,21 +138,18 @@ describe("core utils", () => {
test("single model", () => {
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([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" }
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
]);
});
test("multiple collisions", () => {
const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3");
});
test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23);
const result = createWindowPlans([longBase, "b"]);
@@ -482,27 +161,21 @@ describe("core utils", () => {
test("identical strings", () => {
expect(levenshtein("test", "test")).toBe(0);
});
test("one substitution", () => {
expect(levenshtein("test", "tent")).toBe(1);
});
test("one insertion", () => {
expect(levenshtein("test", "tests")).toBe(1);
});
test("one deletion", () => {
expect(levenshtein("tests", "test")).toBe(1);
});
test("completely different strings", () => {
expect(levenshtein("abc", "xyz")).toBe(3);
});
test("one empty string", () => {
expect(levenshtein("", "test")).toBe(4);
});
test("both empty strings", () => {
expect(levenshtein("", "")).toBe(0);
});
@@ -513,21 +186,17 @@ describe("core utils", () => {
const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2");
});
test("fuzzy match/typo", () => {
expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
});
test("fuzzy match with contains boost", () => {
const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]);
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"];
expect(suggestModels("gpt4o", allowlist).length).toBe(3);
});
test("sorting logic", () => {
const suggestions = suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["abc", "abd", "abe"]);
@@ -540,13 +209,11 @@ describe("core utils", () => {
expect(error).toContain("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean");
});
test("single invalid model without close matches", () => {
const error = formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean");
});
test("multiple invalid models", () => {
const error = formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found");
@@ -559,15 +226,12 @@ describe("core utils", () => {
test("normal alphanumeric name", () => {
expect(sanitizeName("test-session")).toBe("test-session");
});
test("name with spaces and special chars", () => {
expect(sanitizeName("test session!")).toBe("test-session");
});
test("name with consecutive hyphens", () => {
expect(sanitizeName("test--session")).toBe("test-session");
});
test("name with leading/trailing hyphens", () => {
expect(sanitizeName("-test-session-")).toBe("test-session");
});
@@ -581,7 +245,6 @@ describe("core utils", () => {
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
});
test("returns ok false for failed command", async () => {
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" };
const result = await runCommand(["false"]);
@@ -596,7 +259,6 @@ describe("core utils", () => {
const result = getWorktreePath("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");
@@ -620,7 +282,6 @@ describe("core utils", () => {
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: "" };
@@ -629,7 +290,6 @@ describe("core utils", () => {
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" };
@@ -638,7 +298,6 @@ describe("core utils", () => {
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" };
@@ -656,7 +315,6 @@ describe("core utils", () => {
expect(result.ok).toBe(true);
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: "" };
@@ -680,7 +338,18 @@ describe("core utils", () => {
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",
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");
});
test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
@@ -699,7 +368,6 @@ describe("core utils", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode");
});
test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo");