mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bcdc0a90f | ||
|
|
132a15dfc4 | ||
|
|
8bbd741bfa | ||
|
|
0503c474a8 | ||
|
|
d92c9623a8 | ||
|
|
4364910fca | ||
|
|
f956406e9d | ||
|
|
b92fdc692b | ||
|
|
81b25bd348 | ||
|
|
b2621a10de | ||
|
|
fc7feb2095 | ||
|
|
7180569b87 | ||
|
|
f0cc07f513 | ||
|
|
6dd4ee4b4f | ||
|
|
dd7ac4dc2d | ||
|
|
e8dbcff73e | ||
|
|
787fa75df4 | ||
|
|
17bb1130c8 | ||
|
|
c642148cac | ||
|
|
7abfd6bf64 | ||
|
|
93478294aa | ||
|
|
5d50038845 | ||
|
|
0b88ea0763 | ||
|
|
ffdc5451ab | ||
|
|
8a087275b3 | ||
|
|
962c9a474f | ||
|
|
71523f2144 | ||
|
|
1c2f203a34 | ||
|
|
536e30c5d0 | ||
|
|
da0ed90192 | ||
|
|
49da7c4219 | ||
|
|
41757e712f | ||
|
|
96e3922c42 | ||
|
|
d6a2811ec4 | ||
|
|
c179491aed | ||
|
|
1b8f54d955 | ||
|
|
7cecbede6d |
Generated
-52
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+25
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+16
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@username/opencode-multi-model",
|
"name": "opencode-multi-model",
|
||||||
"version": "1.0.0",
|
"version": "0.0.9",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+39
@@ -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."
|
||||||
+9
-19
@@ -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,30 +44,20 @@ 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) {
|
|
||||||
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 {
|
} else {
|
||||||
console.error(`Failed: ${result.error}`);
|
console.error(`Failed: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+36
-19
@@ -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,7 +96,8 @@ 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
|
||||||
|
if (options.createArchiveTags !== false) {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||||
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
||||||
@@ -94,20 +105,20 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
|||||||
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
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user