mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
mock,
|
|
spyOn,
|
|
test,
|
|
} from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import { openMultiModel } from "../src/core/open";
|
|
|
|
const 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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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.
|