Compare commits

..
Author SHA1 Message Date
bendtherules 8bbd741bfa chore(release): 0.0.7 2026-03-21 17:55:26 +05:30
bendtherules 0503c474a8 fix: use sessionName instead of safeSessionName in tmux commands 2026-03-21 17:42:38 +05:30
bendtherules d92c9623a8 fix: remove package-lock.json to streamline dependencies 2026-03-21 17:33:37 +05:30
bendtherules 4364910fca Merge branch 'opencode/build-readme-improve/glm-5' 2026-03-21 17:31:34 +05:30
bendtherules f956406e9d docs: add CLI reference and How it works to Readme 2026-03-21 17:31:07 +05:30
bendtherules b92fdc692b fix: remove default value for --no-tags option 2026-03-21 17:29:09 +05:30
bendtherules 81b25bd348 fix: close cli options - improve negative options 2026-03-21 16:42:24 +05:30
bendtherules b2621a10de fix: console logs branch names directly for close confirmation 2026-03-21 16:41:54 +05:30
bendtherules fc7feb2095 chore(release): 0.0.6 2026-03-21 01:28:15 +05:30
bendtherules 7180569b87 feat: add option to disable archive tags 2026-03-21 01:27:43 +05:30
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
20 changed files with 1229 additions and 496 deletions
-52
View File
@@ -1,52 +0,0 @@
{
"name": "opencode-multi-model-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencode-multi-model-tools",
"dependencies": {
"@opencode-ai/plugin": "1.2.24"
},
"devDependencies": {
"@types/node": "^25.5.0"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.24",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.24",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.24",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+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",
}
+91 -5
View File
@@ -13,7 +13,7 @@ Add to your OpenCode config (`opencode.json`):
```json ```json
{ {
"plugin": ["@username/opencode-multi-model"] "plugin": ["opencode-multi-model"]
} }
``` ```
@@ -21,10 +21,10 @@ Add to your OpenCode config (`opencode.json`):
```bash ```bash
# Using bunx (no install) # 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 # 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 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 ```typescript
// .opencode/tools/multi-model.ts // .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 } export { open, close }
``` ```
@@ -118,6 +118,79 @@ opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
Priority: CLI flag > Environment variable > Default ("opencode") Priority: CLI flag > Environment variable > Default ("opencode")
## CLI Reference
### `open` - Create a new multi-model tmux session
```bash
opencode-multi-model open <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name for the tmux session (required)
**Options:**
| Option | Description |
|--------|-------------|
| `-m, --models <models...>` | Model IDs to launch (space-separated) |
| `-b, --binary <binary>` | Binary to use (opencode or kilo). Defaults to env var `OPENCODE_MULTI_MODEL_BINARY` or 'opencode' |
### `close` - Close a multi-model tmux session
```bash
opencode-multi-model close <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name of the tmux session to close (required)
**Options:**
| Option | Description |
|--------|-------------|
| `--keep-worktrees` | Do not remove worktrees and delete branches |
| `--no-tags` | Do not create archive tags before deleting branches |
| `-f, --force` | Skip confirmation prompts |
### Global Options
| Option | Description |
|--------|-------------|
| `-h, --help` | Display help information |
| `-V, --version` | Display version number |
## How it works
### `open` command
The `open` command performs the following steps:
1. **Validation**: Checks that session name andmodels are provided, no duplicate models exist, and verifies git repo, tmux, and binary are available
2. **Model verification**: Validates models against `opencode models` output to ensure only valid model IDs are used
3. **Git worktree creation**: For each model, creates:
- A git worktree at `~/.local/share/opencode/multi-model/<session>/<model>/`
- A branch named `opencode/<session>/<model>`
4. **Tmux session**: Creates a tmux session with one window per model, each window's working directory set to its corresponding worktree
5. **Launch**: Sends the launch command to each window to start the binary with the specified model
### `close` command
The `close` command performs the following steps:
1. **Kill tmux session**: Terminates the tmux session if it exists
2. **Worktree discovery**: Finds all worktrees under `~/.local/share/opencode/multi-model/<session>/`
3. **Worktree cleanup** (unless `--keep-worktrees`):
- Removes each gitworktree
- Creates an archive tag (unless `--no-tags`): `archive/<branch>-<timestamp>` for recovery
- Deletes the associated branches
4. **Directory cleanup**: Removes the base session directory
## Requirements ## Requirements
- tmux - tmux
@@ -153,7 +226,7 @@ bun dist/cli.js close test-session
│ ├── types.ts # Shared TypeScript types │ ├── types.ts # Shared TypeScript types
│ ├── core/ │ ├── core/
│ │ ├── index.ts # Core exports │ │ ├── index.ts # Core exports
│ │ ├── launch.ts # Launch session logic │ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic │ │ ├── close.ts # Close session logic
│ │ └── utils.ts # Helper functions │ │ └── utils.ts # Helper functions
│ └── tools/ │ └── tools/
@@ -166,6 +239,19 @@ bun dist/cli.js close test-session
└── README.md └── 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 ## License
MIT MIT
+3
View File
@@ -6,6 +6,7 @@
"name": "@username/opencode-multi-model", "name": "@username/opencode-multi-model",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "^1.2.27", "@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +26,8 @@
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "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=="], "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+7 -4
View File
@@ -1,11 +1,11 @@
{ {
"name": "@username/opencode-multi-model", "name": "opencode-multi-model",
"version": "1.0.0", "version": "0.0.7",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool", "description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"bin": { "bin": {
"opencode-multi-model": "./dist/cli.js" "opencode-multi-model": "dist/cli.js"
}, },
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
@@ -19,9 +19,12 @@
"scripts": { "scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm", "build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
"test": "bun test", "test": "bun test",
"prepublishOnly": "bun run build" "coverage": "bun test --coverage",
"prepublishOnly": "bun run build",
"release": "bash ./scripts/release.sh"
}, },
"dependencies": { "dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27", "@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0" "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."
+10 -20
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander"; import { Command } from "commander";
import { launchMultiModel } from "./core/launch"; import { openMultiModel } from "./core/open";
import { closeMultiModel } from "./core/close"; import { closeMultiModel } from "./core/close";
import { getBinaryName } from "./core/utils"; import { getBinaryName } from "./core/utils";
@@ -21,7 +21,7 @@ program
try { try {
const binaryName = options.binary || getBinaryName(); const binaryName = options.binary || getBinaryName();
const result = await launchMultiModel({ const result = await openMultiModel({
sessionName, sessionName,
models: options.models, models: options.models,
binaryName, binaryName,
@@ -44,31 +44,21 @@ program
.command("close") .command("close")
.description("Close a multi-model tmux session") .description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close") .argument("<session-name>", "Name of the tmux session to close")
.option("-c, --cleanup-worktrees", "Remove worktrees and delete branches", true) .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) .option("-f, --force", "Skip confirmation prompts", false)
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => { .action(async (sessionName: string, options: { keepWorktrees: boolean; force: boolean; tags: boolean }) => {
try { try {
const result = await closeMultiModel({ const result = await closeMultiModel({
sessionName, sessionName,
cleanupWorktrees: options.cleanupWorktrees, cleanupWorktrees: !options.keepWorktrees,
force: options.force, force: options.force,
createArchiveTags: options.tags,
}); });
if (result.success) { if (result.success) {
console.log(`Closed session: ${result.sessionName}`); console.log(result.instructions);
if (result.cleanupPerformed) { } else {
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`);
}
}
} else {
console.error(`Failed: ${result.error}`); console.error(`Failed: ${result.error}`);
process.exit(1); process.exit(1);
} }
+55 -38
View File
@@ -1,6 +1,7 @@
import * as readline from "node:readline"; import * as readline from "node:readline";
import chalk from 'chalk'; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types"; 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. * Prompts the user for input on the terminal.
@@ -45,10 +46,12 @@ 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 worktreesRemoved: string[] = [];
const instructionsArr: string[] = [];
const branchesDeleted: string[] = []; const branchesDeleted: string[] = [];
const tagsCreated: string[] = []; const tagsCreated: string[] = [];
const warnings: string[] = [];
const safeSessionName = sanitizeName(options.sessionName);
try { try {
// Check if session exists // 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]);
@@ -56,22 +59,29 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
// Kill tmux session if it exists // Kill tmux session if it exists
if (sessionExists) { if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]); await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`));
} else {
instructionsArr.push(chalk.yellow(`Session '${options.sessionName}' does not exist`));
} }
// Get list of worktrees for this session before killing tmux // 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 cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) { if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(`\nThe following worktrees and branches will be removed:`); console.log(chalk.red('The following worktrees and branches will be removed:'));
worktrees.forEach((wt) => console.log(` - ${wt.path} (branch: ${wt.branch})`)); worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
console.log(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") { if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return { return {
success: false, success: false,
sessionName: options.sessionName, sessionName: safeSessionName,
error: "Cleanup cancelled by user", error: "Cleanup cancelled by user",
instructions: instructionsArr.join("\n"),
}; };
} }
} }
@@ -86,28 +96,29 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
await runCommand(["git", "worktree", "remove", "-f", worktree.path]); await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path); worktreesRemoved.push(worktree.path);
// Create an archive tag before deleting the branch for safe recovery // Optionally create an archive tag before deleting the branch for safe recovery
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); if (options.createArchiveTags !== false) {
const archiveTag = `archive/${worktree.branch}-${timestamp}`; const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]); const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
if (tagResult.ok) { if (tagResult.ok) {
tagsCreated.push(archiveTag); tagsCreated.push(archiveTag);
} else { } else {
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`; const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
warnings.push(warningMsg); instructionsArr.push(warningMsg);
console.warn(`Warning: ${warningMsg}`); }
} }
await runCommand(["git", "branch", "-D", worktree.branch]); await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch); branchesDeleted.push(worktree.branch);
} catch (err) { } 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 // Also remove the base directory
const worktreeBase = getSessionPath(options.sessionName); const worktreeBase = getSessionPath(safeSessionName);
try { try {
await runCommand(["rm", "-rf", worktreeBase]); await runCommand(["rm", "-rf", worktreeBase]);
} catch { } catch {
@@ -115,26 +126,32 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
} }
cleanupPerformed = worktreesRemoved.length > 0; 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, success: true,
sessionName: options.sessionName, sessionName: safeSessionName,
cleanupPerformed, cleanupPerformed,
worktreesRemoved, worktreesRemoved,
branchesDeleted, branchesDeleted,
tagsCreated, tagsCreated,
warnings, instructions: instructionsArr.join("\n"),
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
sessionName: options.sessionName, sessionName: safeSessionName,
error: String(error), error: String(error),
worktreesRemoved, worktreesRemoved,
branchesDeleted, branchesDeleted,
tagsCreated, 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 { closeMultiModel } from "./close";
export * from "./utils"; export * from "./utils";
+27 -15
View File
@@ -1,4 +1,5 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import chalk from 'chalk';
import type { MultiModelOptions, MultiModelResult } from "../types"; import type { MultiModelOptions, MultiModelResult } from "../types";
import { import {
runCommand, runCommand,
@@ -24,7 +25,7 @@ import {
* *
* @example * @example
* ```ts * ```ts
* const result = await launchMultiModel({ * const result = await openMultiModel({
* sessionName: "compare", * sessionName: "compare",
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], * models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode", * 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 sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName); const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models); const models = normalizeModels(options.models);
@@ -153,7 +154,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
if (!sessionCreateResult.ok) { if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName); const undoErrors = await undoWorktree(worktreePath, branchName);
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; 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 }; return { success: false, sessionName, error: errorMsg };
} }
} else { } else {
@@ -172,7 +175,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
if (!windowCreateResult.ok) { 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); 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; continue;
} }
} }
@@ -201,13 +206,17 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
sessionName, sessionName,
windows: windowNames, windows: windowNames,
instructions: [ instructions: [
`Error: Created tmux session '${sessionName}', but some model launches failed.`, chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`, chalk.red(`Failed: ${failedModels.join(", ")}.`),
`Failed: ${failedModels.join(", ")}.`, chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`),
`Attach with \`${attachCommand}\` to inspect the session.`, "",
`Cleanup when done using either:`, chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
`1. ${primaryCleanup}`, "",
`2. Or run manually: \`${cleanupCommand}\``, chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"), ].join("\n"),
}; };
} }
@@ -217,10 +226,13 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
sessionName, sessionName,
windows: windowNames, windows: windowNames,
instructions: [ instructions: [
`Use \`${attachCommand}\` to join session.`, chalk.bold(`Use \`${attachCommand}\` to join session.`),
`When finished, clean up using either:`, "",
`1. ${primaryCleanup}`, chalk.dim(`When finished, clean up using:`),
`2. Or run manually: \`${cleanupCommand}\``, primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"), ].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 { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
const worktrees: WorktreeInfo[] = []; const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sessionName); const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {}; let currentWorktree: Partial<WorktreeInfo> = {};
+3 -12
View File
@@ -12,12 +12,14 @@ export const closeTool = tool({
args: { args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to close"), 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)"), 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) { async execute(args, context) {
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal // In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
const result = await closeMultiModel({ const result = await closeMultiModel({
sessionName: args.sessionName, sessionName: args.sessionName,
cleanupWorktrees: args.cleanupWorktrees, cleanupWorktrees: args.cleanupWorktrees,
createArchiveTags: args.createArchiveTags,
force: true, force: true,
}); });
@@ -25,17 +27,6 @@ export const closeTool = tool({
return result.error!; return result.error!;
} }
let details = ""; return result.instructions;
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}`;
}, },
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { launchMultiModel } from "../core/launch"; import { openMultiModel } from "../core/open";
import { getBinaryName } from "../core/utils"; import { getBinaryName } from "../core/utils";
/** /**
@@ -20,7 +20,7 @@ export const openTool = tool({
async execute(args, context) { async execute(args, context) {
const binaryName = getBinaryName(); const binaryName = getBinaryName();
const result = await launchMultiModel({ const result = await openMultiModel({
sessionName: args.sessionName, sessionName: args.sessionName,
models: args.models, models: args.models,
binaryName, binaryName,
+5 -2
View File
@@ -32,6 +32,8 @@ export interface MultiModelResult {
* Options for closing a multi-model tmux session. * Options for closing a multi-model tmux session.
*/ */
export interface CloseSessionOptions { export interface CloseSessionOptions {
/** Whether to create archive tags before deleting branches. */
createArchiveTags?: boolean;
/** Name of the tmux session to close. */ /** Name of the tmux session to close. */
sessionName: string; sessionName: string;
/** Whether to remove worktrees and delete branches. */ /** Whether to remove worktrees and delete branches. */
@@ -44,6 +46,8 @@ export interface CloseSessionOptions {
* Result returned after attempting to close a multi-model session. * Result returned after attempting to close a multi-model session.
*/ */
export interface CloseSessionResult { export interface CloseSessionResult {
/** Console messages and instructions. */
instructions: string;
/** Whether the close succeeded. */ /** Whether the close succeeded. */
success: boolean; success: boolean;
/** The session name that was closed. */ /** The session name that was closed. */
@@ -58,8 +62,7 @@ export interface CloseSessionResult {
branchesDeleted?: string[]; branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */ /** Archive tags that were created before branch deletion. */
tagsCreated?: string[]; tagsCreated?: string[];
/** Warning messages. */
warnings?: string[];
} }
/** /**
+261
View File
@@ -0,0 +1,261 @@
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`);
});
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: "" };
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
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);
});
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";
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: "" };
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true, createArchiveTags: false });
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 ?? 0).toBe(0);
// Ensure no tag command was executed
expect(executedCommands.some(cmd => cmd.startsWith("git tag"))).toBe(false);
});
});
+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");
});
});
+382
View File
@@ -0,0 +1,382 @@
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("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: "" };
const result = await openMultiModel({
sessionName: unsanitized,
models: ["openai/gpt-5.2"],
});
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);
// sanitized name should NOT appear in tmux commands
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);
});
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 { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import { launchMultiModel } from "../src/core/launch";
import { import {
shellQuote, shellQuote,
normalizeModels, normalizeModels,
@@ -29,9 +28,7 @@ function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[]; const parts = values[0] as string[];
const commandSignature = parts.join(" "); const commandSignature = parts.join(" ");
executedCommands.push(commandSignature); executedCommands.push(commandSignature);
return { return {
quiet: () => ({ quiet: () => ({
nothrow: async () => { nothrow: async () => {
@@ -45,7 +42,6 @@ function setupMockBun$() {
}), }),
}; };
}); });
(globalThis as any).Bun.$ = mockFn; (globalThis as any).Bun.$ = mockFn;
return mockFn; return mockFn;
} }
@@ -54,305 +50,6 @@ function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$; (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", () => { describe("core utils", () => {
let bunMock: ReturnType<typeof setupMockBun$>; let bunMock: ReturnType<typeof setupMockBun$>;
@@ -371,19 +68,15 @@ describe("core utils", () => {
test("normal string without special characters", () => { test("normal string without special characters", () => {
expect(shellQuote("test")).toBe("'test'"); expect(shellQuote("test")).toBe("'test'");
}); });
test("string with spaces", () => { test("string with spaces", () => {
expect(shellQuote("test value")).toBe("'test value'"); expect(shellQuote("test value")).toBe("'test value'");
}); });
test("string with single quotes", () => { test("string with single quotes", () => {
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'"); expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
}); });
test("string with multiple single quotes", () => { 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", () => { test("empty string", () => {
expect(shellQuote("")).toBe("''"); expect(shellQuote("")).toBe("''");
}); });
@@ -393,15 +86,12 @@ describe("core utils", () => {
test("empty array", () => { test("empty array", () => {
expect(normalizeModels([])).toEqual([]); expect(normalizeModels([])).toEqual([]);
}); });
test("undefined input", () => { test("undefined input", () => {
expect(normalizeModels(undefined)).toEqual([]); expect(normalizeModels(undefined)).toEqual([]);
}); });
test("array with whitespace-only strings", () => { test("array with whitespace-only strings", () => {
expect(normalizeModels([" ", " "])).toEqual([]); expect(normalizeModels([" ", " "])).toEqual([]);
}); });
test("array with mixed valid models, padded models, and empty strings", () => { 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"]);
}); });
@@ -411,19 +101,15 @@ describe("core utils", () => {
test("array with no duplicates", () => { test("array with no duplicates", () => {
expect(findDuplicates(["a", "b", "c"])).toEqual([]); expect(findDuplicates(["a", "b", "c"])).toEqual([]);
}); });
test("array with one duplicate pair", () => { test("array with one duplicate pair", () => {
expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]); expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]);
}); });
test("array with multiple different duplicates", () => { test("array with multiple different duplicates", () => {
expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]); expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
}); });
test("array with a single value repeated more than twice", () => { test("array with a single value repeated more than twice", () => {
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]); expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
}); });
test("verifying first-repeated-occurrence order", () => { 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"]);
}); });
@@ -433,20 +119,16 @@ describe("core utils", () => {
test("normal model name without slashes", () => { test("normal model name without slashes", () => {
expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2"); expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2");
}); });
test("model with slashes", () => { test("model with slashes", () => {
expect(createWindowBaseName("vendor/namespace/model")).toBe("model"); expect(createWindowBaseName("vendor/namespace/model")).toBe("model");
}); });
test("model exceeding WINDOW_NAME_LIMIT", () => { test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30); const longName = "a".repeat(30);
expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa"); expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
}); });
test("model with special characters", () => { test("model with special characters", () => {
expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0"); expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
}); });
test("model made entirely of special characters", () => { test("model made entirely of special characters", () => {
expect(createWindowBaseName("!@#$%^&*()")).toBe("model"); expect(createWindowBaseName("!@#$%^&*()")).toBe("model");
}); });
@@ -456,21 +138,18 @@ describe("core utils", () => {
test("single model", () => { 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", () => { 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: "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", () => { test("multiple collisions", () => {
const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]); const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a"); expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2"); expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3"); expect(result[5]?.windowName).toBe("a-3");
}); });
test("truncation to accommodate suffix", () => { test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23); const longBase = "a".repeat(23);
const result = createWindowPlans([longBase, "b"]); const result = createWindowPlans([longBase, "b"]);
@@ -482,27 +161,21 @@ describe("core utils", () => {
test("identical strings", () => { test("identical strings", () => {
expect(levenshtein("test", "test")).toBe(0); expect(levenshtein("test", "test")).toBe(0);
}); });
test("one substitution", () => { test("one substitution", () => {
expect(levenshtein("test", "tent")).toBe(1); expect(levenshtein("test", "tent")).toBe(1);
}); });
test("one insertion", () => { test("one insertion", () => {
expect(levenshtein("test", "tests")).toBe(1); expect(levenshtein("test", "tests")).toBe(1);
}); });
test("one deletion", () => { test("one deletion", () => {
expect(levenshtein("tests", "test")).toBe(1); expect(levenshtein("tests", "test")).toBe(1);
}); });
test("completely different strings", () => { test("completely different strings", () => {
expect(levenshtein("abc", "xyz")).toBe(3); expect(levenshtein("abc", "xyz")).toBe(3);
}); });
test("one empty string", () => { test("one empty string", () => {
expect(levenshtein("", "test")).toBe(4); expect(levenshtein("", "test")).toBe(4);
}); });
test("both empty strings", () => { test("both empty strings", () => {
expect(levenshtein("", "")).toBe(0); expect(levenshtein("", "")).toBe(0);
}); });
@@ -513,21 +186,17 @@ describe("core utils", () => {
const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]); const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2"); expect(result[0]).toBe("gpt-5.2");
}); });
test("fuzzy match/typo", () => { test("fuzzy match/typo", () => {
expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]); expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
}); });
test("fuzzy match with contains boost", () => { test("fuzzy match with contains boost", () => {
const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]); const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]);
expect(suggestions[0]).toBe("gpt-5.2"); expect(suggestions[0]).toBe("gpt-5.2");
}); });
test("maximum of 3 suggestions", () => { 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); expect(suggestModels("gpt4o", allowlist).length).toBe(3);
}); });
test("sorting logic", () => { test("sorting logic", () => {
const suggestions = suggestModels("abc", ["abc", "abd", "abe"]); const suggestions = suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["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("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean"); expect(error).toContain("Did you mean");
}); });
test("single invalid model without close matches", () => { test("single invalid model without close matches", () => {
const error = formatInvalidModelError(["xyz"], ["abc", "def"]); const error = formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found"); expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean"); expect(error).toContain("Did you mean");
}); });
test("multiple invalid models", () => { test("multiple invalid models", () => {
const error = formatInvalidModelError(["xyz", "abc"], ["def"]); const error = formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found"); expect(error).toContain("Model names not found");
@@ -559,15 +226,12 @@ describe("core utils", () => {
test("normal alphanumeric name", () => { test("normal alphanumeric name", () => {
expect(sanitizeName("test-session")).toBe("test-session"); expect(sanitizeName("test-session")).toBe("test-session");
}); });
test("name with spaces and special chars", () => { test("name with spaces and special chars", () => {
expect(sanitizeName("test session!")).toBe("test-session"); expect(sanitizeName("test session!")).toBe("test-session");
}); });
test("name with consecutive hyphens", () => { test("name with consecutive hyphens", () => {
expect(sanitizeName("test--session")).toBe("test-session"); expect(sanitizeName("test--session")).toBe("test-session");
}); });
test("name with leading/trailing hyphens", () => { test("name with leading/trailing hyphens", () => {
expect(sanitizeName("-test-session-")).toBe("test-session"); expect(sanitizeName("-test-session-")).toBe("test-session");
}); });
@@ -581,7 +245,6 @@ describe("core utils", () => {
expect(result.stdout).toBe("hello"); expect(result.stdout).toBe("hello");
expect(result.stderr).toBe(""); expect(result.stderr).toBe("");
}); });
test("returns ok false for failed command", async () => { 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"]); const result = await runCommand(["false"]);
@@ -596,7 +259,6 @@ describe("core utils", () => {
const result = getWorktreePath("my-session", "gpt-5.2"); 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", () => { test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window"); const result = getWorktreePath("parent/child", "window");
@@ -620,7 +282,6 @@ describe("core utils", () => {
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
test("returns error when worktree remove fails", async () => { test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; 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 branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
@@ -629,7 +290,6 @@ describe("core utils", () => {
expect(errors.length).toBe(1); expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree"); expect(errors[0]).toContain("Failed to remove worktree");
}); });
test("returns error when branch delete fails", async () => { test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; 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 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.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch"); expect(errors[0]).toContain("Failed to delete branch");
}); });
test("returns both errors when both operations fail", async () => { 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 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 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(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 () => { 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: "" };
@@ -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]?.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"); 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 () => { test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user"); spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = { mockCommandResponses["git worktree list --porcelain"] = {
@@ -699,7 +368,6 @@ describe("core utils", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY; delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode"); expect(getBinaryName()).toBe("opencode");
}); });
test("returns env var value when set", () => { test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo"; process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo"); expect(getBinaryName()).toBe("kilo");