mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
{
|
||||
"plugin": ["@username/opencode-multi-model"]
|
||||
"plugin": ["opencode-multi-model"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -21,10 +21,10 @@ Add to your OpenCode config (`opencode.json`):
|
||||
|
||||
```bash
|
||||
# Using bunx (no install)
|
||||
bunx @username/opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
|
||||
bunx opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
|
||||
|
||||
# Or install globally
|
||||
bun install -g @username/opencode-multi-model
|
||||
bun install -g opencode-multi-model
|
||||
opencode-multi-model open my-session -m openai/gpt-5.2
|
||||
```
|
||||
|
||||
@@ -34,7 +34,7 @@ If you installed it via bun and want to use it as a project tool, import it from
|
||||
|
||||
```typescript
|
||||
// .opencode/tools/multi-model.ts
|
||||
import { openTool as open, closeTool as close } from "@username/opencode-multi-model"
|
||||
import { openTool as open, closeTool as close } from "opencode-multi-model"
|
||||
export { open, close }
|
||||
```
|
||||
|
||||
@@ -153,7 +153,7 @@ bun dist/cli.js close test-session
|
||||
│ ├── types.ts # Shared TypeScript types
|
||||
│ ├── core/
|
||||
│ │ ├── index.ts # Core exports
|
||||
│ │ ├── launch.ts # Launch session logic
|
||||
│ │ ├── open.ts # Launch session logic
|
||||
│ │ ├── close.ts # Close session logic
|
||||
│ │ └── utils.ts # Helper functions
|
||||
│ └── tools/
|
||||
@@ -166,6 +166,19 @@ bun dist/cli.js close test-session
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Local code coverage
|
||||
|
||||
The test suite can generate a coverage report locally.
|
||||
|
||||
```bash
|
||||
# Run tests with coverage (HTML report generated in ./coverage)
|
||||
npm run coverage # or: bun run coverage
|
||||
# Open the HTML report
|
||||
open coverage/index.html # macOS
|
||||
```
|
||||
|
||||
The `coverage` directory is ignored by Git but can be inspected to see which lines are exercised.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "@username/opencode-multi-model",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.27",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,6 +26,8 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
+7
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@username/opencode-multi-model",
|
||||
"version": "1.0.0",
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.0.5",
|
||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"bin": {
|
||||
"opencode-multi-model": "./dist/cli.js"
|
||||
"opencode-multi-model": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
@@ -19,9 +19,12 @@
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bun run build"
|
||||
"coverage": "bun test --coverage",
|
||||
"prepublishOnly": "bun run build",
|
||||
"release": "bash ./scripts/release.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"@opencode-ai/plugin": "^1.2.27",
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
|
||||
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."
|
||||
+4
-16
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Command } from "commander";
|
||||
import { launchMultiModel } from "./core/launch";
|
||||
import { openMultiModel } from "./core/open";
|
||||
import { closeMultiModel } from "./core/close";
|
||||
import { getBinaryName } from "./core/utils";
|
||||
|
||||
@@ -21,7 +21,7 @@ program
|
||||
try {
|
||||
const binaryName = options.binary || getBinaryName();
|
||||
|
||||
const result = await launchMultiModel({
|
||||
const result = await openMultiModel({
|
||||
sessionName,
|
||||
models: options.models,
|
||||
binaryName,
|
||||
@@ -54,20 +54,8 @@ program
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Closed session: ${result.sessionName}`);
|
||||
if (result.cleanupPerformed) {
|
||||
console.log(` Removed ${result.worktreesRemoved?.length || 0} worktrees`);
|
||||
if (result.tagsCreated && result.tagsCreated.length > 0) {
|
||||
console.log(` Created backup tags: ${result.tagsCreated.join(", ")}`);
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
console.log(` Warnings: ${result.warnings.join("; ")}`);
|
||||
}
|
||||
if (result.branchesDeleted && result.branchesDeleted.length > 0) {
|
||||
console.log(` Deleted ${result.branchesDeleted.length} branches`);
|
||||
}
|
||||
}
|
||||
if (result.success) {
|
||||
console.log(result.instructions);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
+35
-20
@@ -1,6 +1,7 @@
|
||||
import * as readline from "node:readline";
|
||||
import chalk from 'chalk'; // removed console usage
|
||||
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
||||
import { runCommand, getSessionPath, getWorktreesForSession } from "./utils";
|
||||
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
|
||||
|
||||
/**
|
||||
* Prompts the user for input on the terminal.
|
||||
@@ -45,33 +46,42 @@ async function promptUser(question: string): Promise<string> {
|
||||
*/
|
||||
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
|
||||
const worktreesRemoved: string[] = [];
|
||||
const instructionsArr: string[] = [];
|
||||
const branchesDeleted: string[] = [];
|
||||
const tagsCreated: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
|
||||
const safeSessionName = sanitizeName(options.sessionName);
|
||||
try {
|
||||
// Check if session exists
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", safeSessionName]);
|
||||
|
||||
// Kill tmux session if it exists
|
||||
if (sessionExists) {
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
|
||||
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
|
||||
} else {
|
||||
instructionsArr.push(chalk.yellow(`Session '${safeSessionName}' does not exist`));
|
||||
}
|
||||
|
||||
// Get list of worktrees for this session before killing tmux
|
||||
const worktrees = await getWorktreesForSession(options.sessionName);
|
||||
const worktrees = await getWorktreesForSession(safeSessionName);
|
||||
|
||||
// If cleanup requested and not forced, ask for confirmation
|
||||
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
|
||||
console.log(`\nThe following worktrees and branches will be removed:`);
|
||||
worktrees.forEach((wt) => console.log(` - ${wt.path} (branch: ${wt.branch})`));
|
||||
instructionsArr.push(chalk.red('The following worktrees and branches will be removed:'));
|
||||
worktrees.forEach((wt) => {
|
||||
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
|
||||
instructionsArr.push(msg.trim());
|
||||
});
|
||||
|
||||
const answer = await promptUser("\nDo you want to proceed? (y/N): ");
|
||||
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
|
||||
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
|
||||
return {
|
||||
success: false,
|
||||
sessionName: options.sessionName,
|
||||
sessionName: safeSessionName,
|
||||
error: "Cleanup cancelled by user",
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -94,20 +104,19 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
} else {
|
||||
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
|
||||
warnings.push(warningMsg);
|
||||
console.warn(`Warning: ${warningMsg}`);
|
||||
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
|
||||
instructionsArr.push(warningMsg);
|
||||
}
|
||||
|
||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||
branchesDeleted.push(worktree.branch);
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
|
||||
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove the base directory
|
||||
const worktreeBase = getSessionPath(options.sessionName);
|
||||
const worktreeBase = getSessionPath(safeSessionName);
|
||||
try {
|
||||
await runCommand(["rm", "-rf", worktreeBase]);
|
||||
} catch {
|
||||
@@ -115,26 +124,32 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
}
|
||||
|
||||
cleanupPerformed = worktreesRemoved.length > 0;
|
||||
if (cleanupPerformed) {
|
||||
instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`));
|
||||
if (tagsCreated.length > 0) {
|
||||
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return {
|
||||
success: true,
|
||||
sessionName: options.sessionName,
|
||||
sessionName: safeSessionName,
|
||||
cleanupPerformed,
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
warnings,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
return {
|
||||
success: false,
|
||||
sessionName: options.sessionName,
|
||||
sessionName: safeSessionName,
|
||||
error: String(error),
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
warnings,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export { launchMultiModel } from "./launch";
|
||||
export { openMultiModel } from "./open";
|
||||
export { closeMultiModel } from "./close";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs";
|
||||
import chalk from 'chalk';
|
||||
import type { MultiModelOptions, MultiModelResult } from "../types";
|
||||
import {
|
||||
runCommand,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await launchMultiModel({
|
||||
* const result = await openMultiModel({
|
||||
* sessionName: "compare",
|
||||
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
|
||||
* binaryName: "opencode",
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function launchMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
|
||||
export async function openMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
|
||||
const sessionName = options.sessionName.trim();
|
||||
const safeSessionName = sanitizeName(sessionName);
|
||||
const models = normalizeModels(options.models);
|
||||
@@ -153,7 +154,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
|
||||
if (!sessionCreateResult.ok) {
|
||||
const undoErrors = await undoWorktree(worktreePath, branchName);
|
||||
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
|
||||
if (undoErrors.length > 0) errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
|
||||
if (undoErrors.length > 0) {
|
||||
errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
|
||||
}
|
||||
return { success: false, sessionName, error: errorMsg };
|
||||
}
|
||||
} else {
|
||||
@@ -172,7 +175,9 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
|
||||
if (!windowCreateResult.ok) {
|
||||
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
|
||||
const undoErrors = await undoWorktree(worktreePath, branchName);
|
||||
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
|
||||
if (undoErrors.length > 0) {
|
||||
failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -201,13 +206,17 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
|
||||
sessionName,
|
||||
windows: windowNames,
|
||||
instructions: [
|
||||
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
|
||||
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
|
||||
`Failed: ${failedModels.join(", ")}.`,
|
||||
`Attach with \`${attachCommand}\` to inspect the session.`,
|
||||
`Cleanup when done using either:`,
|
||||
`1. ${primaryCleanup}`,
|
||||
`2. Or run manually: \`${cleanupCommand}\``,
|
||||
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
|
||||
chalk.red(`Failed: ${failedModels.join(", ")}.`),
|
||||
chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`),
|
||||
"",
|
||||
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
|
||||
"",
|
||||
chalk.dim(`When finished, clean up using:`),
|
||||
primaryCleanup,
|
||||
"",
|
||||
chalk.dim(`Alternate manual cleanup - `),
|
||||
chalk.dim(cleanupCommand)
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -217,10 +226,13 @@ export async function launchMultiModel(options: MultiModelOptions): Promise<Mult
|
||||
sessionName,
|
||||
windows: windowNames,
|
||||
instructions: [
|
||||
`Use \`${attachCommand}\` to join session.`,
|
||||
`When finished, clean up using either:`,
|
||||
`1. ${primaryCleanup}`,
|
||||
`2. Or run manually: \`${cleanupCommand}\``,
|
||||
chalk.bold(`Use \`${attachCommand}\` to join session.`),
|
||||
"",
|
||||
chalk.dim(`When finished, clean up using:`),
|
||||
primaryCleanup,
|
||||
"",
|
||||
chalk.dim(`Alternate manual cleanup - `),
|
||||
chalk.dim(cleanupCommand)
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -332,7 +332,7 @@ export async function getWorktreesForSession(sessionName: string): Promise<Workt
|
||||
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
|
||||
const sessionPath = getSessionPath(sessionName);
|
||||
const sessionPath = getSessionPath(sanitizeName(sessionName));
|
||||
|
||||
let currentWorktree: Partial<WorktreeInfo> = {};
|
||||
|
||||
|
||||
+1
-12
@@ -25,17 +25,6 @@ export const closeTool = tool({
|
||||
return result.error!;
|
||||
}
|
||||
|
||||
let details = "";
|
||||
if (result.cleanupPerformed) {
|
||||
details += ` Removed ${result.worktreesRemoved?.length || 0} worktrees.`;
|
||||
if (result.tagsCreated && result.tagsCreated.length > 0) {
|
||||
details += ` Created backup tags: ${result.tagsCreated.join(", ")}.`;
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
details += ` Warnings: ${result.warnings.join("; ")}.`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Session "${result.sessionName}" has been closed.${details}`;
|
||||
return result.instructions;
|
||||
},
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { launchMultiModel } from "../core/launch";
|
||||
import { openMultiModel } from "../core/open";
|
||||
import { getBinaryName } from "../core/utils";
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ export const openTool = tool({
|
||||
async execute(args, context) {
|
||||
const binaryName = getBinaryName();
|
||||
|
||||
const result = await launchMultiModel({
|
||||
const result = await openMultiModel({
|
||||
sessionName: args.sessionName,
|
||||
models: args.models,
|
||||
binaryName,
|
||||
|
||||
+3
-2
@@ -44,6 +44,8 @@ export interface CloseSessionOptions {
|
||||
* Result returned after attempting to close a multi-model session.
|
||||
*/
|
||||
export interface CloseSessionResult {
|
||||
/** Console messages and instructions. */
|
||||
instructions: string;
|
||||
/** Whether the close succeeded. */
|
||||
success: boolean;
|
||||
/** The session name that was closed. */
|
||||
@@ -58,8 +60,7 @@ export interface CloseSessionResult {
|
||||
branchesDeleted?: string[];
|
||||
/** Archive tags that were created before branch deletion. */
|
||||
tagsCreated?: string[];
|
||||
/** Warning messages. */
|
||||
warnings?: string[];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import * as readline from "node:readline";
|
||||
import { closeMultiModel } from "../src/core/close";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
executedCommands.push(commandSignature);
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => ({
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
stderr: { toString: () => response.stderr },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
(globalThis as any).Bun.$ = mockFn;
|
||||
return mockFn;
|
||||
}
|
||||
|
||||
function restoreOriginalBun$() {
|
||||
(globalThis as any).Bun.$ = originalBun$;
|
||||
}
|
||||
|
||||
describe("closeMultiModel", () => {
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
mockCommandResponses = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalBun$();
|
||||
});
|
||||
|
||||
test("kills existing session without cleanup", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
// No worktrees
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(false);
|
||||
expect(result.worktreesRemoved).toEqual([]);
|
||||
expect(executedCommands).toContain("tmux has-session -t test-session");
|
||||
expect(executedCommands).toContain("tmux kill-session -t test-session");
|
||||
});
|
||||
|
||||
test("no session exists, no cleanup", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(false);
|
||||
expect(result.worktreesRemoved).toEqual([]);
|
||||
expect(executedCommands).toContain("tmux has-session -t test-session");
|
||||
// No kill-session should be issued
|
||||
expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false);
|
||||
});
|
||||
|
||||
test("cleanup works with worktrees and force, all succeeds", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" };
|
||||
// Tag command will default to ok:true via fallback
|
||||
mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(true);
|
||||
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
||||
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
|
||||
// Ensure removal commands were executed
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
|
||||
});
|
||||
|
||||
test("user cancels cleanup when not forced", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
|
||||
// Stub readline to return "n"
|
||||
const mockInterface = {
|
||||
question: (q: string, cb: (a: string) => void) => cb("n"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Cleanup cancelled by user");
|
||||
});
|
||||
|
||||
test("user confirms cleanup when not forced", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
|
||||
// Stub readline to simulate user confirming cleanup (answers "y")
|
||||
const mockInterface = {
|
||||
question: (q: string, cb: (a: string) => void) => cb("y"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
|
||||
|
||||
// Restore Date after invocation
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
// Verify result indicates successful cleanup
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(true);
|
||||
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
||||
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
|
||||
|
||||
// Verify the expected git commands were executed
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
|
||||
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
|
||||
expect(executedCommands).toContain(expectedTagCmd);
|
||||
});
|
||||
|
||||
test("warning when tag creation fails", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" };
|
||||
// Tag command will fail
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
|
||||
mockCommandResponses[tagCommand] = { ok: false, stdout: "", stderr: "tag error" };
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Expect the warning message to be captured in instructions
|
||||
expect(result.instructions.includes('Failed to create tag')).toBe(true);
|
||||
expect(result.tagsCreated).toEqual([]);
|
||||
});
|
||||
|
||||
test("cleanup works with unsanitized session name", async () => {
|
||||
const unsanitized = "test session!";
|
||||
const sanitized = "test-session"; // result of sanitizeName
|
||||
// tmux lookup uses unsanitized name
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true });
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(true);
|
||||
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
||||
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`);
|
||||
});
|
||||
});
|
||||
@@ -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,357 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { openMultiModel } from "../src/core/open";
|
||||
import {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowBaseName,
|
||||
createWindowPlans,
|
||||
levenshtein,
|
||||
suggestModels,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
runCommand,
|
||||
getWorktreePath,
|
||||
getSessionPath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
getWorktreesForSession,
|
||||
getBinaryName,
|
||||
} from "../src/core/utils";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
|
||||
executedCommands.push(commandSignature);
|
||||
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => {
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
return {
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
stderr: { toString: () => response.stderr },
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
(globalThis as any).Bun.$ = mockFn;
|
||||
return mockFn;
|
||||
}
|
||||
|
||||
function restoreOriginalBun$() {
|
||||
(globalThis as any).Bun.$ = originalBun$;
|
||||
}
|
||||
|
||||
describe("multi-model launch", () => {
|
||||
let existsSyncMock: ReturnType<typeof spyOn>;
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/mock/home");
|
||||
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
|
||||
mockCommandResponses = {
|
||||
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
|
||||
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
|
||||
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
|
||||
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
|
||||
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalBun$();
|
||||
});
|
||||
|
||||
test("fails if session name is empty", async () => {
|
||||
const result = await openMultiModel({
|
||||
sessionName: "",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `sessionName` must not be empty.");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if no models are provided", async () => {
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model names must not be empty.");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if duplicate models are provided", async () => {
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Duplicate model names are not allowed");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if not inside a git repository", async () => {
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
|
||||
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
|
||||
});
|
||||
|
||||
test("fails if tmux is not installed", async () => {
|
||||
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if opencode is not installed", async () => {
|
||||
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if model name is not in allowlist", async () => {
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["invalid/model"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if session already exists", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Session already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
]);
|
||||
});
|
||||
|
||||
test("successfully plans and executes tmux launch with single model", async () => {
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("successfully launches multiple models", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sanitizes session name", async () => {
|
||||
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test session!",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
|
||||
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to create tmux session");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"git branch -D opencode/test-session/gpt-5-2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if worktree path exists for first model", async () => {
|
||||
existsSyncMock.mockImplementation((path: string) => {
|
||||
return path.includes("test-session/gpt-5-2");
|
||||
});
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Worktree path already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if git branch exists for first model", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if git worktree add fails for first model", async () => {
|
||||
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to create worktree");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("model name collision creates unique window names", async () => {
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("long model name is truncated in window name", async () => {
|
||||
const longModel = "verylongmodelfrontexampleprovider";
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: [longModel],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
|
||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------
|
||||
// Core utils tests remain in a separate file.
|
||||
@@ -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 * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { launchMultiModel } from "../src/core/launch";
|
||||
import {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
@@ -29,9 +28,7 @@ function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
|
||||
executedCommands.push(commandSignature);
|
||||
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => {
|
||||
@@ -45,7 +42,6 @@ function setupMockBun$() {
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
(globalThis as any).Bun.$ = mockFn;
|
||||
return mockFn;
|
||||
}
|
||||
@@ -54,305 +50,6 @@ function restoreOriginalBun$() {
|
||||
(globalThis as any).Bun.$ = originalBun$;
|
||||
}
|
||||
|
||||
describe("multi-model launch", () => {
|
||||
let existsSyncMock: ReturnType<typeof spyOn>;
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/mock/home");
|
||||
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
|
||||
mockCommandResponses = {
|
||||
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
|
||||
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
|
||||
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
|
||||
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
|
||||
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalBun$();
|
||||
});
|
||||
|
||||
test("fails if session name is empty", async () => {
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `sessionName` must not be empty.");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if no models are provided", async () => {
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model names must not be empty.");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if duplicate models are provided", async () => {
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Duplicate model names are not allowed");
|
||||
expect(executedCommands).toEqual([]);
|
||||
});
|
||||
|
||||
test("fails if not inside a git repository", async () => {
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
|
||||
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
|
||||
});
|
||||
|
||||
test("fails if tmux is not installed", async () => {
|
||||
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if opencode is not installed", async () => {
|
||||
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if model name is not in allowlist", async () => {
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["invalid/model"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if session already exists", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Session already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session"
|
||||
]);
|
||||
});
|
||||
|
||||
test("successfully plans and executes tmux launch with single model", async () => {
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("successfully launches multiple models", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sanitizes session name", async () => {
|
||||
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test session!",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
|
||||
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to create tmux session");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
|
||||
"git branch -D opencode/test-session/gpt-5-2"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if worktree path exists for first model", async () => {
|
||||
existsSyncMock.mockImplementation((path: string) => {
|
||||
return path.includes("test-session/gpt-5-2");
|
||||
});
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Worktree path already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if git branch exists for first model", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails if git worktree add fails for first model", async () => {
|
||||
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to create worktree");
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
"command -v opencode",
|
||||
"opencode models",
|
||||
"tmux has-session -t test-session",
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
]);
|
||||
});
|
||||
|
||||
test("model name collision creates unique window names", async () => {
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("long model name is truncated in window name", async () => {
|
||||
const longModel = "verylongmodelfrontexampleprovider";
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchMultiModel({
|
||||
sessionName: "test-session",
|
||||
models: [longModel],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
|
||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("core utils", () => {
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
@@ -371,19 +68,15 @@ describe("core utils", () => {
|
||||
test("normal string without special characters", () => {
|
||||
expect(shellQuote("test")).toBe("'test'");
|
||||
});
|
||||
|
||||
test("string with spaces", () => {
|
||||
expect(shellQuote("test value")).toBe("'test value'");
|
||||
});
|
||||
|
||||
test("string with single quotes", () => {
|
||||
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
|
||||
});
|
||||
|
||||
test("string with multiple single quotes", () => {
|
||||
expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'");
|
||||
});
|
||||
|
||||
test("empty string", () => {
|
||||
expect(shellQuote("")).toBe("''");
|
||||
});
|
||||
@@ -393,15 +86,12 @@ describe("core utils", () => {
|
||||
test("empty array", () => {
|
||||
expect(normalizeModels([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("undefined input", () => {
|
||||
expect(normalizeModels(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test("array with whitespace-only strings", () => {
|
||||
expect(normalizeModels([" ", " "])).toEqual([]);
|
||||
});
|
||||
|
||||
test("array with mixed valid models, padded models, and empty strings", () => {
|
||||
expect(normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
|
||||
});
|
||||
@@ -411,19 +101,15 @@ describe("core utils", () => {
|
||||
test("array with no duplicates", () => {
|
||||
expect(findDuplicates(["a", "b", "c"])).toEqual([]);
|
||||
});
|
||||
|
||||
test("array with one duplicate pair", () => {
|
||||
expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
test("array with multiple different duplicates", () => {
|
||||
expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("array with a single value repeated more than twice", () => {
|
||||
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
test("verifying first-repeated-occurrence order", () => {
|
||||
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]);
|
||||
});
|
||||
@@ -433,20 +119,16 @@ describe("core utils", () => {
|
||||
test("normal model name without slashes", () => {
|
||||
expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2");
|
||||
});
|
||||
|
||||
test("model with slashes", () => {
|
||||
expect(createWindowBaseName("vendor/namespace/model")).toBe("model");
|
||||
});
|
||||
|
||||
test("model exceeding WINDOW_NAME_LIMIT", () => {
|
||||
const longName = "a".repeat(30);
|
||||
expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
test("model with special characters", () => {
|
||||
expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
|
||||
});
|
||||
|
||||
test("model made entirely of special characters", () => {
|
||||
expect(createWindowBaseName("!@#$%^&*()")).toBe("model");
|
||||
});
|
||||
@@ -456,21 +138,18 @@ describe("core utils", () => {
|
||||
test("single model", () => {
|
||||
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]);
|
||||
});
|
||||
|
||||
test("two models with the same base name", () => {
|
||||
expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([
|
||||
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
|
||||
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" }
|
||||
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("multiple collisions", () => {
|
||||
const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]);
|
||||
expect(result[0]?.windowName).toBe("a");
|
||||
expect(result[3]?.windowName).toBe("a-2");
|
||||
expect(result[5]?.windowName).toBe("a-3");
|
||||
});
|
||||
|
||||
test("truncation to accommodate suffix", () => {
|
||||
const longBase = "a".repeat(23);
|
||||
const result = createWindowPlans([longBase, "b"]);
|
||||
@@ -482,27 +161,21 @@ describe("core utils", () => {
|
||||
test("identical strings", () => {
|
||||
expect(levenshtein("test", "test")).toBe(0);
|
||||
});
|
||||
|
||||
test("one substitution", () => {
|
||||
expect(levenshtein("test", "tent")).toBe(1);
|
||||
});
|
||||
|
||||
test("one insertion", () => {
|
||||
expect(levenshtein("test", "tests")).toBe(1);
|
||||
});
|
||||
|
||||
test("one deletion", () => {
|
||||
expect(levenshtein("tests", "test")).toBe(1);
|
||||
});
|
||||
|
||||
test("completely different strings", () => {
|
||||
expect(levenshtein("abc", "xyz")).toBe(3);
|
||||
});
|
||||
|
||||
test("one empty string", () => {
|
||||
expect(levenshtein("", "test")).toBe(4);
|
||||
});
|
||||
|
||||
test("both empty strings", () => {
|
||||
expect(levenshtein("", "")).toBe(0);
|
||||
});
|
||||
@@ -513,21 +186,17 @@ describe("core utils", () => {
|
||||
const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
|
||||
expect(result[0]).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
test("fuzzy match/typo", () => {
|
||||
expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
test("fuzzy match with contains boost", () => {
|
||||
const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]);
|
||||
expect(suggestions[0]).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
test("maximum of 3 suggestions", () => {
|
||||
const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"];
|
||||
expect(suggestModels("gpt4o", allowlist).length).toBe(3);
|
||||
});
|
||||
|
||||
test("sorting logic", () => {
|
||||
const suggestions = suggestModels("abc", ["abc", "abd", "abe"]);
|
||||
expect(suggestions).toEqual(["abc", "abd", "abe"]);
|
||||
@@ -540,13 +209,11 @@ describe("core utils", () => {
|
||||
expect(error).toContain("Model name 'gpt5.4' not found");
|
||||
expect(error).toContain("Did you mean");
|
||||
});
|
||||
|
||||
test("single invalid model without close matches", () => {
|
||||
const error = formatInvalidModelError(["xyz"], ["abc", "def"]);
|
||||
expect(error).toContain("Model name 'xyz' not found");
|
||||
expect(error).toContain("Did you mean");
|
||||
});
|
||||
|
||||
test("multiple invalid models", () => {
|
||||
const error = formatInvalidModelError(["xyz", "abc"], ["def"]);
|
||||
expect(error).toContain("Model names not found");
|
||||
@@ -559,15 +226,12 @@ describe("core utils", () => {
|
||||
test("normal alphanumeric name", () => {
|
||||
expect(sanitizeName("test-session")).toBe("test-session");
|
||||
});
|
||||
|
||||
test("name with spaces and special chars", () => {
|
||||
expect(sanitizeName("test session!")).toBe("test-session");
|
||||
});
|
||||
|
||||
test("name with consecutive hyphens", () => {
|
||||
expect(sanitizeName("test--session")).toBe("test-session");
|
||||
});
|
||||
|
||||
test("name with leading/trailing hyphens", () => {
|
||||
expect(sanitizeName("-test-session-")).toBe("test-session");
|
||||
});
|
||||
@@ -581,7 +245,6 @@ describe("core utils", () => {
|
||||
expect(result.stdout).toBe("hello");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
test("returns ok false for failed command", async () => {
|
||||
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" };
|
||||
const result = await runCommand(["false"]);
|
||||
@@ -596,7 +259,6 @@ describe("core utils", () => {
|
||||
const result = getWorktreePath("my-session", "gpt-5.2");
|
||||
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
});
|
||||
|
||||
test("handles nested session names", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
const result = getWorktreePath("parent/child", "window");
|
||||
@@ -620,7 +282,6 @@ describe("core utils", () => {
|
||||
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns error when worktree remove fails", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
|
||||
@@ -629,7 +290,6 @@ describe("core utils", () => {
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("Failed to remove worktree");
|
||||
});
|
||||
|
||||
test("returns error when branch delete fails", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
|
||||
@@ -638,7 +298,6 @@ describe("core utils", () => {
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("Failed to delete branch");
|
||||
});
|
||||
|
||||
test("returns both errors when both operations fail", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
|
||||
@@ -656,7 +315,6 @@ describe("core utils", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m");
|
||||
});
|
||||
|
||||
test("uses custom binary name", async () => {
|
||||
mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
@@ -680,7 +338,18 @@ describe("core utils", () => {
|
||||
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
|
||||
});
|
||||
test("sanitizes session name when filtering worktrees", async () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const worktrees = await getWorktreesForSession("my session!");
|
||||
expect(worktrees.length).toBe(1);
|
||||
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
});
|
||||
test("returns empty array when no matching worktrees", async () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
@@ -699,7 +368,6 @@ describe("core utils", () => {
|
||||
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
|
||||
expect(getBinaryName()).toBe("opencode");
|
||||
});
|
||||
|
||||
test("returns env var value when set", () => {
|
||||
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
|
||||
expect(getBinaryName()).toBe("kilo");
|
||||
Reference in New Issue
Block a user