Compare commits
22 Commits
fix/issue-
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f8286c561 | ||
|
|
35edcecd8f | ||
|
|
58201220cc | ||
|
|
4efad491e7 | ||
|
|
4df69c58bf | ||
|
|
cc8ef7fe39 | ||
|
|
2ece7c3d0a | ||
|
|
decff3152a | ||
|
|
0526bac873 | ||
|
|
0c62656cc6 | ||
|
|
aff43bfc77 | ||
|
|
6865cee8ca | ||
|
|
8721ba471c | ||
|
|
d425f9bb80 | ||
|
|
cc5e9d1e9b | ||
|
|
269f37af1c | ||
|
|
ccb789e5df | ||
|
|
a6617d93c0 | ||
|
|
2295161022 | ||
|
|
0516f2febc | ||
|
|
07e8d965a8 | ||
|
|
718884210b |
18
.github/workflows/publish-platform.yml
vendored
18
.github/workflows/publish-platform.yml
vendored
@@ -35,15 +35,15 @@ jobs:
|
||||
# - Uploads compressed artifacts for the publish job
|
||||
# =============================================================================
|
||||
build:
|
||||
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ startsWith(matrix.platform, 'windows-') && 'windows-latest' || 'ubuntu-latest' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 7
|
||||
max-parallel: 11
|
||||
matrix:
|
||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
||||
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -95,14 +95,18 @@ jobs:
|
||||
case "$PLATFORM" in
|
||||
darwin-arm64) TARGET="bun-darwin-arm64" ;;
|
||||
darwin-x64) TARGET="bun-darwin-x64" ;;
|
||||
darwin-x64-baseline) TARGET="bun-darwin-x64-baseline" ;;
|
||||
linux-x64) TARGET="bun-linux-x64" ;;
|
||||
linux-x64-baseline) TARGET="bun-linux-x64-baseline" ;;
|
||||
linux-arm64) TARGET="bun-linux-arm64" ;;
|
||||
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
|
||||
linux-x64-musl-baseline) TARGET="bun-linux-x64-musl-baseline" ;;
|
||||
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
|
||||
windows-x64) TARGET="bun-windows-x64" ;;
|
||||
windows-x64-baseline) TARGET="bun-windows-x64-baseline" ;;
|
||||
esac
|
||||
|
||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
||||
if [[ "$PLATFORM" == windows-* ]]; then
|
||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
|
||||
else
|
||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
|
||||
@@ -119,7 +123,7 @@ jobs:
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
cd packages/${PLATFORM}
|
||||
|
||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
||||
if [[ "$PLATFORM" == windows-* ]]; then
|
||||
# Windows: use 7z (pre-installed on windows-latest)
|
||||
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
||||
else
|
||||
@@ -155,7 +159,7 @@ jobs:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
||||
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||
steps:
|
||||
- name: Check if already published
|
||||
id: check
|
||||
@@ -184,7 +188,7 @@ jobs:
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
mkdir -p packages/${PLATFORM}
|
||||
|
||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
||||
if [[ "$PLATFORM" == windows-* ]]; then
|
||||
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
||||
else
|
||||
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -189,7 +189,7 @@ jobs:
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64; do
|
||||
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
|
||||
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
|
||||
mv tmp.json "packages/${platform}/package.json"
|
||||
done
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// Wrapper script that detects platform and spawns the correct binary
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -26,55 +27,116 @@ function getLibcFamily() {
|
||||
}
|
||||
}
|
||||
|
||||
function supportsAvx2() {
|
||||
if (process.arch !== "x64") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.env.OH_MY_OPENCODE_FORCE_BASELINE === "1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
const cpuInfo = readFileSync("/proc/cpuinfo", "utf8").toLowerCase();
|
||||
return cpuInfo.includes("avx2");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const probe = spawnSync("sysctl", ["-n", "machdep.cpu.leaf7_features"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (probe.error || probe.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return probe.stdout.toUpperCase().includes("AVX2");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignalExitCode(signal) {
|
||||
const signalCodeByName = {
|
||||
SIGINT: 2,
|
||||
SIGILL: 4,
|
||||
SIGKILL: 9,
|
||||
SIGTERM: 15,
|
||||
};
|
||||
|
||||
return 128 + (signalCodeByName[signal] ?? 1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
const avx2Supported = supportsAvx2();
|
||||
|
||||
// Get platform package name
|
||||
let pkg;
|
||||
let packageCandidates;
|
||||
try {
|
||||
pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
packageCandidates = getPlatformPackageCandidates({
|
||||
platform,
|
||||
arch,
|
||||
libcFamily,
|
||||
preferBaseline: avx2Supported === false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`\noh-my-opencode: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve binary path
|
||||
const binRelPath = getBinaryPath(pkg, platform);
|
||||
|
||||
let binPath;
|
||||
try {
|
||||
binPath = require.resolve(binRelPath);
|
||||
} catch {
|
||||
|
||||
const resolvedBinaries = packageCandidates
|
||||
.map((pkg) => {
|
||||
try {
|
||||
return { pkg, binPath: require.resolve(getBinaryPath(pkg, platform)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
if (resolvedBinaries.length === 0) {
|
||||
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
||||
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
||||
console.error(`Expected package: ${pkg}`);
|
||||
console.error(`Expected packages (in order): ${packageCandidates.join(", ")}`);
|
||||
console.error(`\nTo fix, run:`);
|
||||
console.error(` npm install ${pkg}\n`);
|
||||
console.error(` npm install ${packageCandidates[0]}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Spawn the binary
|
||||
const result = spawnSync(binPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
if (result.error) {
|
||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||
console.error(`Error: ${result.error.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Handle signals
|
||||
if (result.signal) {
|
||||
const signalNum = result.signal === "SIGTERM" ? 15 :
|
||||
result.signal === "SIGKILL" ? 9 :
|
||||
result.signal === "SIGINT" ? 2 : 1;
|
||||
process.exit(128 + signalNum);
|
||||
|
||||
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
||||
const currentBinary = resolvedBinaries[index];
|
||||
const hasFallback = index < resolvedBinaries.length - 1;
|
||||
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (hasFallback) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||
console.error(`Error: ${result.error.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (result.signal === "SIGILL" && hasFallback) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
process.exit(getSignalExitCode(result.signal));
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
14
bin/platform.d.ts
vendored
Normal file
14
bin/platform.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
export declare function getPlatformPackage(options: {
|
||||
platform: string;
|
||||
arch: string;
|
||||
libcFamily?: string | null;
|
||||
}): string;
|
||||
|
||||
export declare function getPlatformPackageCandidates(options: {
|
||||
platform: string;
|
||||
arch: string;
|
||||
libcFamily?: string | null;
|
||||
preferBaseline?: boolean;
|
||||
}): string[];
|
||||
|
||||
export declare function getBinaryPath(pkg: string, platform: string): string;
|
||||
@@ -26,6 +26,50 @@ export function getPlatformPackage({ platform, arch, libcFamily }) {
|
||||
return `oh-my-opencode-${os}-${arch}${suffix}`;
|
||||
}
|
||||
|
||||
/** @param {{ platform: string, arch: string, libcFamily?: string | null, preferBaseline?: boolean }} options */
|
||||
export function getPlatformPackageCandidates({ platform, arch, libcFamily, preferBaseline = false }) {
|
||||
const primaryPackage = getPlatformPackage({ platform, arch, libcFamily });
|
||||
const baselinePackage = getBaselinePlatformPackage({ platform, arch, libcFamily });
|
||||
|
||||
if (!baselinePackage) {
|
||||
return [primaryPackage];
|
||||
}
|
||||
|
||||
return preferBaseline ? [baselinePackage, primaryPackage] : [primaryPackage, baselinePackage];
|
||||
}
|
||||
|
||||
/** @param {{ platform: string, arch: string, libcFamily?: string | null }} options */
|
||||
function getBaselinePlatformPackage({ platform, arch, libcFamily }) {
|
||||
if (arch !== "x64") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
return "oh-my-opencode-darwin-x64-baseline";
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return "oh-my-opencode-windows-x64-baseline";
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
if (libcFamily === null || libcFamily === undefined) {
|
||||
throw new Error(
|
||||
"Could not detect libc on Linux. " +
|
||||
"Please ensure detect-libc is installed or report this issue."
|
||||
);
|
||||
}
|
||||
|
||||
if (libcFamily === "musl") {
|
||||
return "oh-my-opencode-linux-x64-musl-baseline";
|
||||
}
|
||||
|
||||
return "oh-my-opencode-linux-x64-baseline";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the binary within a platform package
|
||||
* @param {string} pkg Package name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// bin/platform.test.ts
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
|
||||
|
||||
describe("getPlatformPackage", () => {
|
||||
// #region Darwin platforms
|
||||
@@ -146,3 +146,58 @@ describe("getBinaryPath", () => {
|
||||
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformPackageCandidates", () => {
|
||||
test("returns x64 and baseline candidates for Linux glibc", () => {
|
||||
// #given Linux x64 with glibc
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting package candidates
|
||||
const result = getPlatformPackageCandidates(input);
|
||||
|
||||
// #then returns modern first then baseline fallback
|
||||
expect(result).toEqual([
|
||||
"oh-my-opencode-linux-x64",
|
||||
"oh-my-opencode-linux-x64-baseline",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns x64 musl and baseline candidates for Linux musl", () => {
|
||||
// #given Linux x64 with musl
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
|
||||
|
||||
// #when getting package candidates
|
||||
const result = getPlatformPackageCandidates(input);
|
||||
|
||||
// #then returns musl modern first then musl baseline fallback
|
||||
expect(result).toEqual([
|
||||
"oh-my-opencode-linux-x64-musl",
|
||||
"oh-my-opencode-linux-x64-musl-baseline",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns baseline first when preferBaseline is true", () => {
|
||||
// #given Windows x64 and baseline preference
|
||||
const input = { platform: "win32", arch: "x64", preferBaseline: true };
|
||||
|
||||
// #when getting package candidates
|
||||
const result = getPlatformPackageCandidates(input);
|
||||
|
||||
// #then baseline package is preferred first
|
||||
expect(result).toEqual([
|
||||
"oh-my-opencode-windows-x64-baseline",
|
||||
"oh-my-opencode-windows-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns only one candidate for ARM64", () => {
|
||||
// #given non-x64 platform
|
||||
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting package candidates
|
||||
const result = getPlatformPackageCandidates(input);
|
||||
|
||||
// #then baseline fallback is not included
|
||||
expect(result).toEqual(["oh-my-opencode-linux-arm64"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,11 +77,15 @@
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||
"oh-my-opencode-darwin-x64": "3.8.5",
|
||||
"oh-my-opencode-darwin-x64-baseline": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
||||
"oh-my-opencode-linux-x64": "3.8.5",
|
||||
"oh-my-opencode-linux-x64-baseline": "3.8.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
||||
"oh-my-opencode-windows-x64": "3.8.5"
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "3.8.5",
|
||||
"oh-my-opencode-windows-x64": "3.8.5",
|
||||
"oh-my-opencode-windows-x64-baseline": "3.8.5"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Runs after npm install to verify platform binary is available
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
|
||||
import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -27,12 +27,28 @@ function main() {
|
||||
const libcFamily = getLibcFamily();
|
||||
|
||||
try {
|
||||
const pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
const binPath = getBinaryPath(pkg, platform);
|
||||
|
||||
// Try to resolve the binary
|
||||
require.resolve(binPath);
|
||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
|
||||
const packageCandidates = getPlatformPackageCandidates({
|
||||
platform,
|
||||
arch,
|
||||
libcFamily,
|
||||
});
|
||||
|
||||
const resolvedPackage = packageCandidates.find((pkg) => {
|
||||
try {
|
||||
require.resolve(getBinaryPath(pkg, platform));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!resolvedPackage) {
|
||||
throw new Error(
|
||||
`No platform binary package installed. Tried: ${packageCandidates.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch} (${resolvedPackage})`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
||||
console.warn(` The CLI may not work on this platform.`);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
buildDecisionMatrix,
|
||||
} from "./prompt-section-builder"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
const MODE: AgentMode = "all"
|
||||
|
||||
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
categorizeTools,
|
||||
} from "./dynamic-agent-prompt-builder";
|
||||
|
||||
const MODE: AgentMode = "primary";
|
||||
const MODE: AgentMode = "all";
|
||||
|
||||
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
||||
if (useTaskSystem) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
buildGeminiIntentGateEnforcement,
|
||||
} from "./sisyphus-gemini-overlays";
|
||||
|
||||
const MODE: AgentMode = "primary";
|
||||
const MODE: AgentMode = "all";
|
||||
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "utility",
|
||||
cost: "EXPENSIVE",
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("isGptModel", () => {
|
||||
});
|
||||
|
||||
test("gemini models are not gpt", () => {
|
||||
expect(isGptModel("google/gemini-3-pro")).toBe(false);
|
||||
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
|
||||
expect(isGptModel("google/gemini-3.1-pro")).toBe(false);
|
||||
expect(isGptModel("litellm/gemini-3.1-pro")).toBe(false);
|
||||
});
|
||||
|
||||
test("opencode provider is not gpt", () => {
|
||||
@@ -58,29 +58,29 @@ describe("isGptModel", () => {
|
||||
|
||||
describe("isGeminiModel", () => {
|
||||
test("#given google provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given google-vertex provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google-vertex/gemini-3.1-pro")).toBe(true);
|
||||
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given github copilot gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("github-copilot/gemini-3.1-pro")).toBe(true);
|
||||
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given litellm proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-3.1-pro")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given other proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("custom-provider/gemini-3.1-pro")).toBe(true);
|
||||
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -988,7 +988,7 @@ describe("buildAgent with category and skills", () => {
|
||||
const agent = buildAgent(source["test-agent"], TEST_MODEL)
|
||||
|
||||
// #then - category's built-in model is applied
|
||||
expect(agent.model).toBe("google/gemini-3-pro")
|
||||
expect(agent.model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
|
||||
test("agent with category and existing model keeps existing model", () => {
|
||||
|
||||
@@ -325,7 +325,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -334,34 +334,34 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -371,7 +371,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -386,7 +386,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -395,44 +395,44 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -506,7 +506,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -581,7 +581,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -634,7 +634,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -655,7 +655,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"model": "opencode/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -708,7 +708,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -730,7 +730,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"model": "opencode/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -779,14 +779,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -845,14 +845,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -863,7 +863,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1026,7 +1026,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -1047,7 +1047,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"model": "opencode/gemini-3.1-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1100,7 +1100,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -1121,7 +1121,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1217,7 +1217,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
@@ -1231,14 +1231,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1248,7 +1248,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1301,7 +1301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -1322,7 +1322,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1375,7 +1375,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -1396,7 +1396,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1449,7 +1449,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
@@ -1471,7 +1471,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
expect(models).toBeTruthy()
|
||||
|
||||
const required = [
|
||||
"antigravity-gemini-3-pro",
|
||||
"antigravity-gemini-3.1-pro",
|
||||
"antigravity-gemini-3-flash",
|
||||
"antigravity-claude-sonnet-4-6",
|
||||
"antigravity-claude-sonnet-4-6-thinking",
|
||||
@@ -206,7 +206,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
|
||||
|
||||
// #when checking Gemini Pro variants
|
||||
const pro = models["antigravity-gemini-3-pro"]
|
||||
const pro = models["antigravity-gemini-3.1-pro"]
|
||||
// #then should have low and high variants
|
||||
expect(pro.variants).toBeTruthy()
|
||||
expect(pro.variants.low).toBeTruthy()
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
||||
*
|
||||
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
|
||||
* - `antigravity-gemini-3-pro` with variants: low, high
|
||||
* - `antigravity-gemini-3.1-pro` with variants: low, high
|
||||
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
|
||||
*
|
||||
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
|
||||
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3.1-pro-high`) still work
|
||||
* but variants are the recommended approach.
|
||||
*
|
||||
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
|
||||
@@ -16,7 +16,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
google: {
|
||||
name: "Google",
|
||||
models: {
|
||||
"antigravity-gemini-3-pro": {
|
||||
"antigravity-gemini-3.1-pro": {
|
||||
name: "Gemini 3 Pro (Antigravity)",
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||
@@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||
cwd: getConfigDir(),
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
import { initConfigContext } from "./config-context"
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
|
||||
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], {
|
||||
const proc = spawnWithWindowsHide([binary, "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
import type { DependencyInfo } from "../types"
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
||||
|
||||
async function getBinaryVersion(binary: string): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
|
||||
path: resolvedPath,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("model-resolution check", () => {
|
||||
// then: Should have category entries
|
||||
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
||||
expect(visual).toBeDefined()
|
||||
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro")
|
||||
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3.1-pro")
|
||||
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
import { OPENCODE_BINARIES } from "../constants"
|
||||
|
||||
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const command = buildVersionCommand(binaryPath, platform)
|
||||
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
|
||||
const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
||||
|
||||
async function getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
if (processResult.exitCode !== 0) return null
|
||||
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "auth", "status"], {
|
||||
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
@@ -59,7 +59,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
metis: {
|
||||
@@ -68,14 +68,14 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
atlas: {
|
||||
@@ -84,7 +84,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
@@ -101,7 +101,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
||||
ultrabrain: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
@@ -109,17 +109,17 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
requiresModel: "gpt-5.3-codex",
|
||||
},
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
requiresModel: "gemini-3-pro",
|
||||
requiresModel: "gemini-3.1-pro",
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
@@ -139,7 +139,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
writing: {
|
||||
|
||||
@@ -40,16 +40,16 @@ describe("transformModelForProvider", () => {
|
||||
expect(result).toBe("claude-haiku-4.5")
|
||||
})
|
||||
|
||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-pro model
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro model
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-pro"
|
||||
const model = "gemini-3.1-pro"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3-pro-preview
|
||||
expect(result).toBe("gemini-3-pro-preview")
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||
@@ -64,16 +64,16 @@ describe("transformModelForProvider", () => {
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "github-copilot"
|
||||
const model = "gemini-3-pro-preview"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3-pro-preview-preview
|
||||
expect(result).toBe("gemini-3-pro-preview")
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||
@@ -102,16 +102,16 @@ describe("transformModelForProvider", () => {
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
||||
// #given google provider and gemini-3-pro model
|
||||
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro model
|
||||
const provider = "google"
|
||||
const model = "gemini-3-pro"
|
||||
const model = "gemini-3.1-pro"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to gemini-3-pro-preview
|
||||
expect(result).toBe("gemini-3-pro-preview")
|
||||
// #then should transform to gemini-3.1-pro-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("passes through other gemini models unchanged", () => {
|
||||
@@ -138,16 +138,16 @@ describe("transformModelForProvider", () => {
|
||||
expect(result).toBe("gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
||||
// #given google provider and gemini-3-pro-preview model (already transformed)
|
||||
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||
// #given google provider and gemini-3.1-pro-preview model (already transformed)
|
||||
const provider = "google"
|
||||
const model = "gemini-3-pro-preview"
|
||||
const model = "gemini-3.1-pro-preview"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should NOT become gemini-3-pro-preview-preview
|
||||
expect(result).toBe("gemini-3-pro-preview")
|
||||
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result).toBe("gemini-3.1-pro-preview")
|
||||
})
|
||||
|
||||
test("does not transform claude models for google provider", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
|
||||
import { createJsonOutputManager } from "./json-output"
|
||||
import { resolveSession } from "./session-resolver"
|
||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||
import type { OpencodeClient } from "./types"
|
||||
import * as originalSdk from "@opencode-ai/sdk"
|
||||
import * as originalPortUtils from "../../shared/port-utils"
|
||||
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
|
||||
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||
expect(mockClient.session.get).toHaveBeenCalledWith({
|
||||
path: { id: sessionId },
|
||||
query: { directory: "/test" },
|
||||
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||
exited: Promise.resolve(0),
|
||||
exitCode: 0,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
|
||||
|
||||
// then
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
mockStdout = createMockWriteStream()
|
||||
mockStderr = createMockWriteStream()
|
||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||
exited: Promise.resolve(0),
|
||||
exitCode: 0,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
|
||||
const emitted = mockStdout.writes[0]!
|
||||
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(args).toEqual(["sh", "-c", "echo done"])
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||
|
||||
describe("executeOnCompleteHook", () => {
|
||||
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
|
||||
return {
|
||||
exited: Promise.resolve(exitCode),
|
||||
exitCode,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
}
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
||||
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("executes command with correct env vars", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
// then
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
|
||||
expect(args).toEqual(["sh", "-c", "echo test"])
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("env var values are strings", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
|
||||
expect(options?.env?.EXIT_CODE).toBe("1")
|
||||
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("empty command string is no-op", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("whitespace-only command is no-op", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("command failure logs warning but does not throw", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
|
||||
|
||||
try {
|
||||
// when
|
||||
await expect(
|
||||
expect(
|
||||
executeOnCompleteHook({
|
||||
command: "false",
|
||||
sessionId: "session-123",
|
||||
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
|
||||
it("spawn error logs warning but does not throw", async () => {
|
||||
// given
|
||||
const spawnError = new Error("Command not found")
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
|
||||
throw spawnError
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
await expect(
|
||||
expect(
|
||||
executeOnCompleteHook({
|
||||
command: "nonexistent-command",
|
||||
sessionId: "session-123",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pc from "picocolors"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
export async function executeOnCompleteHook(options: {
|
||||
command: string
|
||||
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
|
||||
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
|
||||
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
|
||||
env: {
|
||||
...process.env,
|
||||
SESSION_ID: sessionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { delimiter, dirname, join } from "node:path"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
||||
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
|
||||
@@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
|
||||
|
||||
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([binaryPath, "--version"], {
|
||||
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||
default_run_agent: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_agents: z.array(z.string()).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(z.string()).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
modelConcurrency: { "google/gemini-3.1-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
@@ -95,7 +95,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
// when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
|
||||
|
||||
// then
|
||||
expect(modelLimit).toBe(10)
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("TaskToastManager", () => {
|
||||
description: "Task with category default model",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
|
||||
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
import { isOrchestratorAgent } from "../../shared/orchestrator-agents";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -60,6 +61,11 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
|
||||
if (!isOrchestratorAgent(sessionID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { isOrchestratorAgent } from "../../shared/orchestrator-agents"
|
||||
import { buildReminderMessage } from "./formatter"
|
||||
|
||||
/**
|
||||
* Target agents that should receive category+skill reminders.
|
||||
* These are orchestrator agents that delegate work to specialized agents.
|
||||
*/
|
||||
const TARGET_AGENTS = new Set([
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that indicate the agent is doing work that could potentially be delegated.
|
||||
* When these tools are used, we remind the agent about the category+skill system.
|
||||
@@ -73,22 +62,11 @@ export function createCategorySkillReminderHook(
|
||||
return sessionStates.get(sessionID)!
|
||||
}
|
||||
|
||||
function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
|
||||
const agent = getSessionAgent(sessionID) ?? inputAgent
|
||||
if (!agent) return false
|
||||
const agentKey = getAgentConfigKey(agent)
|
||||
return (
|
||||
TARGET_AGENTS.has(agentKey) ||
|
||||
agentKey.includes("sisyphus") ||
|
||||
agentKey.includes("atlas")
|
||||
)
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (!isTargetAgent(sessionID, input.agent)) {
|
||||
if (!isOrchestratorAgent(sessionID, input.agent)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
||||
import type { InteractiveBashSessionState } from "./types";
|
||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||
|
||||
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
||||
|
||||
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
|
||||
): Promise<void> {
|
||||
for (const sessionName of state.tmuxSessions) {
|
||||
try {
|
||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
||||
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InteractiveBashSessionState } from "./types";
|
||||
import { loadInteractiveBashSessionState } from "./storage";
|
||||
import { OMO_SESSION_PREFIX } from "./constants";
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||
|
||||
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
|
||||
): Promise<void> {
|
||||
for (const sessionName of state.tmuxSessions) {
|
||||
try {
|
||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
||||
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
|
||||
@@ -79,8 +79,8 @@ export async function detectCompletionInSessionMessages(
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const pattern = buildPromisePattern(options.promise)
|
||||
const recentAssistants = assistantMessages.slice(-3)
|
||||
for (const assistant of recentAssistants) {
|
||||
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
|
||||
const assistant = assistantMessages[index]
|
||||
if (!assistant.parts) continue
|
||||
|
||||
let responseText = ""
|
||||
|
||||
@@ -494,6 +494,7 @@ describe("ralph-loop", () => {
|
||||
config: {
|
||||
enabled: true,
|
||||
default_max_iterations: 200,
|
||||
default_strategy: "continue",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -708,6 +709,57 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
|
||||
test("should skip concurrent idle events for same session when handler is in flight", async () => {
|
||||
// given - active loop with delayed prompt injection
|
||||
let releasePromptAsync: (() => void) | undefined
|
||||
const promptAsyncBlocked = new Promise<void>((resolve) => {
|
||||
releasePromptAsync = resolve
|
||||
})
|
||||
let firstPromptStartedResolve: (() => void) | undefined
|
||||
const firstPromptStarted = new Promise<void>((resolve) => {
|
||||
firstPromptStartedResolve = resolve
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput() as {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalPromptAsync = mockInput.client.session.promptAsync
|
||||
let promptAsyncCalls = 0
|
||||
mockInput.client.session.promptAsync = async (opts) => {
|
||||
promptAsyncCalls += 1
|
||||
if (promptAsyncCalls === 1) {
|
||||
firstPromptStartedResolve?.()
|
||||
}
|
||||
await promptAsyncBlocked
|
||||
return originalPromptAsync(opts)
|
||||
}
|
||||
|
||||
const hook = createRalphLoopHook(mockInput as Parameters<typeof createRalphLoopHook>[0])
|
||||
hook.startLoop("session-123", "Build feature", { maxIterations: 10 })
|
||||
|
||||
// when - second idle arrives while first idle processing is still in flight
|
||||
const firstIdle = hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await firstPromptStarted
|
||||
const secondIdle = hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
releasePromptAsync?.()
|
||||
await Promise.all([firstIdle, secondIdle])
|
||||
|
||||
// then - only one continuation should be injected
|
||||
expect(promptAsyncCalls).toBe(1)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||
// given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
@@ -782,8 +834,8 @@ describe("ralph-loop", () => {
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
|
||||
// given - promise appears in an assistant message older than last 3
|
||||
test("should detect completion even when promise is older than previous narrow window", async () => {
|
||||
// given - promise appears in an older assistant message with additional assistant output after it
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
|
||||
@@ -801,9 +853,40 @@ describe("ralph-loop", () => {
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - loop should continue (promise is older than last 3 assistant messages)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
// then - loop should complete because all assistant messages are scanned
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should detect completion when many assistant messages are emitted after promise", async () => {
|
||||
// given - completion promise followed by long assistant output sequence
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done now <promise>DONE</promise>" }] },
|
||||
]
|
||||
|
||||
for (let index = 1; index <= 25; index += 1) {
|
||||
mockSessionMessages.push({
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: `Post-completion assistant output ${index}` }],
|
||||
})
|
||||
}
|
||||
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - loop should complete despite large trailing output
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ export function createRalphLoopEventHandler(
|
||||
ctx: PluginInput,
|
||||
options: RalphLoopEventHandlerOptions,
|
||||
) {
|
||||
const inFlightSessions = new Set<string>()
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
@@ -32,115 +34,127 @@ export function createRalphLoopEventHandler(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (options.sessionRecovery.isRecovering(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
if (inFlightSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = options.loopState.getState()
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
if (options.checkSessionExists) {
|
||||
try {
|
||||
const exists = await options.checkSessionExists(state.session_id)
|
||||
if (!exists) {
|
||||
options.loopState.clear()
|
||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||
orphanedSessionId: state.session_id,
|
||||
currentSessionId: sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||
sessionId: state.session_id,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const transcriptPath = options.getTranscriptPath(sessionID)
|
||||
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
|
||||
const completionViaApi = completionViaTranscript
|
||||
? false
|
||||
: await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
})
|
||||
|
||||
if (completionViaTranscript || completionViaApi) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionViaTranscript
|
||||
? "transcript_file"
|
||||
: "session_messages_api",
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
||||
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
||||
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (state.iteration >= state.max_iterations) {
|
||||
log(`[${HOOK_NAME}] Max iterations reached`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
max: state.max_iterations,
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const newState = options.loopState.incrementIteration()
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||
sessionID,
|
||||
iteration: newState.iteration,
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
inFlightSessions.add(sessionID)
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||
|
||||
if (options.sessionRecovery.isRecovering(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = options.loopState.getState()
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
if (options.checkSessionExists) {
|
||||
try {
|
||||
const exists = await options.checkSessionExists(state.session_id)
|
||||
if (!exists) {
|
||||
options.loopState.clear()
|
||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||
orphanedSessionId: state.session_id,
|
||||
currentSessionId: sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||
sessionId: state.session_id,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const transcriptPath = options.getTranscriptPath(sessionID)
|
||||
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
|
||||
const completionViaApi = completionViaTranscript
|
||||
? false
|
||||
: await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
})
|
||||
|
||||
if (completionViaTranscript || completionViaApi) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionViaTranscript
|
||||
? "transcript_file"
|
||||
: "session_messages_api",
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
||||
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
||||
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (state.iteration >= state.max_iterations) {
|
||||
log(`[${HOOK_NAME}] Max iterations reached`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
max: state.max_iterations,
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const newState = options.loopState.incrementIteration()
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
iteration: newState.iteration,
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
|
||||
@@ -36,7 +36,7 @@ async function waitUntil(condition: () => boolean): Promise<void> {
|
||||
}
|
||||
|
||||
describe("ralph-loop reset strategy race condition", () => {
|
||||
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
|
||||
test("should skip duplicate idle while reset iteration handling is in flight", async () => {
|
||||
// given - reset strategy loop with blocked TUI session switch
|
||||
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
||||
const createSessionCalls: Array<{ parentID?: string }> = []
|
||||
@@ -85,7 +85,7 @@ describe("ralph-loop reset strategy race condition", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createRalphLoopHook>[0])
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0])
|
||||
|
||||
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
||||
|
||||
@@ -100,14 +100,12 @@ describe("ralph-loop reset strategy race condition", () => {
|
||||
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
||||
})
|
||||
|
||||
await waitUntil(() => selectSessionCalls > 1)
|
||||
|
||||
selectSessionDeferred.resolve()
|
||||
await Promise.all([firstIdleEvent, secondIdleEvent])
|
||||
|
||||
// then - second idle should not be skipped during reset transition
|
||||
expect(createSessionCalls.length).toBe(2)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
// then - duplicate idle should be skipped to prevent concurrent continuation injection
|
||||
expect(createSessionCalls.length).toBe(1)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("runtime-fallback", () => {
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-3-pro" } },
|
||||
properties: { info: { id: sessionID, model: "google/gemini-3.1-pro" } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1841,7 +1841,7 @@ describe("runtime-fallback", () => {
|
||||
test("should apply fallback model on next chat.message after error", async () => {
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3-pro"]),
|
||||
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3.1-pro"]),
|
||||
})
|
||||
const sessionID = "test-session-switch"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
@@ -1916,7 +1916,7 @@ describe("runtime-fallback", () => {
|
||||
const input = createMockPluginInput()
|
||||
const hook = createRuntimeFallbackHook(input, {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3-pro"]),
|
||||
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3.1-pro"]),
|
||||
})
|
||||
const sessionID = "test-agent-fallback"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
import {
|
||||
clearContinuationMarker,
|
||||
@@ -8,6 +9,11 @@ import { log } from "../../shared/logger"
|
||||
|
||||
const HOOK_NAME = "stop-continuation-guard"
|
||||
|
||||
type StopContinuationBackgroundManager = Pick<
|
||||
BackgroundManager,
|
||||
"getAllDescendantTasks" | "cancelTask"
|
||||
>
|
||||
|
||||
export interface StopContinuationGuard {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
||||
@@ -17,7 +23,10 @@ export interface StopContinuationGuard {
|
||||
}
|
||||
|
||||
export function createStopContinuationGuardHook(
|
||||
ctx: PluginInput
|
||||
ctx: PluginInput,
|
||||
options?: {
|
||||
backgroundManager?: StopContinuationBackgroundManager
|
||||
}
|
||||
): StopContinuationGuard {
|
||||
const stoppedSessions = new Set<string>()
|
||||
|
||||
@@ -25,6 +34,38 @@ export function createStopContinuationGuardHook(
|
||||
stoppedSessions.add(sessionID)
|
||||
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
|
||||
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
||||
|
||||
const backgroundManager = options?.backgroundManager
|
||||
if (!backgroundManager) {
|
||||
return
|
||||
}
|
||||
|
||||
const cancellableTasks = backgroundManager
|
||||
.getAllDescendantTasks(sessionID)
|
||||
.filter((task) => task.status === "running" || task.status === "pending")
|
||||
|
||||
if (cancellableTasks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.allSettled(
|
||||
cancellableTasks.map(async (task) => {
|
||||
await backgroundManager.cancelTask(task.id, {
|
||||
source: "stop-continuation",
|
||||
reason: "Continuation stopped via /stop-continuation",
|
||||
abortSession: task.status === "running",
|
||||
skipNotification: true,
|
||||
})
|
||||
})
|
||||
).then((results) => {
|
||||
const cancelledCount = results.filter((result) => result.status === "fulfilled").length
|
||||
const failedCount = results.length - cancelledCount
|
||||
log(`[${HOOK_NAME}] Cancelled background tasks for stopped session`, {
|
||||
sessionID,
|
||||
cancelledCount,
|
||||
failedCount,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isStopped = (sessionID: string): boolean => {
|
||||
|
||||
@@ -2,9 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||
import { readContinuationMarker } from "../../features/run-continuation-state"
|
||||
import { createStopContinuationGuardHook } from "./index"
|
||||
|
||||
type CancelCall = {
|
||||
taskId: string
|
||||
options?: Parameters<BackgroundManager["cancelTask"]>[1]
|
||||
}
|
||||
|
||||
describe("stop-continuation-guard", () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
@@ -34,6 +40,33 @@ describe("stop-continuation-guard", () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
description: `${id} description`,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
prompt: "prompt",
|
||||
agent: "sisyphus-junior",
|
||||
}
|
||||
}
|
||||
|
||||
function createMockBackgroundManager(tasks: BackgroundTask[], cancelCalls: CancelCall[]): Pick<BackgroundManager, "getAllDescendantTasks" | "cancelTask"> {
|
||||
return {
|
||||
getAllDescendantTasks: () => tasks,
|
||||
cancelTask: async (taskId: string, options?: Parameters<BackgroundManager["cancelTask"]>[1]) => {
|
||||
cancelCalls.push({ taskId, options })
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
test("should mark session as stopped", () => {
|
||||
// given - a guard hook with no stopped sessions
|
||||
const input = createMockPluginInput()
|
||||
@@ -166,4 +199,31 @@ describe("stop-continuation-guard", () => {
|
||||
// then - should not throw and stopped session remains stopped
|
||||
expect(guard.isStopped("some-session")).toBe(true)
|
||||
})
|
||||
|
||||
test("should cancel only running and pending background tasks on stop", async () => {
|
||||
// given - a background manager with mixed task statuses
|
||||
const cancelCalls: CancelCall[] = []
|
||||
const backgroundManager = createMockBackgroundManager(
|
||||
[
|
||||
createBackgroundTask("running", "task-running"),
|
||||
createBackgroundTask("pending", "task-pending"),
|
||||
createBackgroundTask("completed", "task-completed"),
|
||||
],
|
||||
cancelCalls,
|
||||
)
|
||||
const guard = createStopContinuationGuardHook(createMockPluginInput(), {
|
||||
backgroundManager,
|
||||
})
|
||||
|
||||
// when - stop continuation is triggered
|
||||
guard.stop("test-session-bg")
|
||||
await flushMicrotasks()
|
||||
|
||||
// then - only running and pending tasks are cancelled
|
||||
expect(cancelCalls).toHaveLength(2)
|
||||
expect(cancelCalls[0]?.taskId).toBe("task-running")
|
||||
expect(cancelCalls[0]?.options?.abortSession).toBe(true)
|
||||
expect(cancelCalls[1]?.taskId).toBe("task-pending")
|
||||
expect(cancelCalls[1]?.options?.abortSession).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("createThinkModeHook", () => {
|
||||
const input = createHookInput({
|
||||
sessionID,
|
||||
providerID: "google",
|
||||
modelID: "gemini-3-pro",
|
||||
modelID: "gemini-3.1-pro",
|
||||
})
|
||||
const output = createHookOutput("Please solve this directly")
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should handle Gemini preview variants", () => {
|
||||
// given Gemini preview model IDs
|
||||
expect(getHighVariant("gemini-3-pro")).toBe(
|
||||
"gemini-3-pro-high"
|
||||
expect(getHighVariant("gemini-3.1-pro")).toBe(
|
||||
"gemini-3-1-pro-high"
|
||||
)
|
||||
expect(getHighVariant("gemini-3-flash")).toBe(
|
||||
"gemini-3-flash-high"
|
||||
@@ -61,7 +61,7 @@ describe("think-mode switcher", () => {
|
||||
// given model IDs that are already high variants
|
||||
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
||||
expect(getHighVariant("gpt-5-2-high")).toBeNull()
|
||||
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
|
||||
expect(getHighVariant("gemini-3-1-pro-high")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for unknown models", () => {
|
||||
@@ -77,7 +77,7 @@ describe("think-mode switcher", () => {
|
||||
// given model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gemini-3.1-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect -high suffix after normalization", () => {
|
||||
@@ -90,7 +90,7 @@ describe("think-mode switcher", () => {
|
||||
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
|
||||
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for models with 'high' in name but not suffix", () => {
|
||||
@@ -129,7 +129,7 @@ describe("think-mode switcher", () => {
|
||||
// given various custom prefixes
|
||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
||||
expect(getHighVariant("bedrock/claude-sonnet-4-6")).toBe("bedrock/claude-sonnet-4-6-high")
|
||||
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
|
||||
expect(getHighVariant("custom-llm/gemini-3.1-pro")).toBe("custom-llm/gemini-3-1-pro-high")
|
||||
})
|
||||
|
||||
it("should return null for prefixed models without high variant mapping", () => {
|
||||
@@ -150,7 +150,7 @@ describe("think-mode switcher", () => {
|
||||
// given prefixed model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("custom/gemini-3.1-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for prefixed base models", () => {
|
||||
@@ -167,4 +167,3 @@ describe("think-mode switcher", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6-high",
|
||||
"claude-opus-4-6": "claude-opus-4-6-high",
|
||||
// Gemini
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
"gemini-3-1-pro": "gemini-3-1-pro-high",
|
||||
"gemini-3-1-pro-low": "gemini-3-1-pro-high",
|
||||
"gemini-3-flash": "gemini-3-flash-high",
|
||||
// GPT-5
|
||||
"gpt-5": "gpt-5-high",
|
||||
@@ -82,7 +82,7 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
||||
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
||||
// Antigravity (Google)
|
||||
"antigravity-gemini-3-pro": "antigravity-gemini-3-pro-high",
|
||||
"antigravity-gemini-3-1-pro": "antigravity-gemini-3-1-pro-high",
|
||||
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
|
||||
}
|
||||
|
||||
@@ -114,4 +114,3 @@ export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MAX_CONSECUTIVE_FAILURES,
|
||||
} from "./constants"
|
||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||
import { getIncompleteCount } from "./todo"
|
||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
@@ -74,6 +75,10 @@ export async function handleSessionIdle(args: {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (hasUnansweredQuestion(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||
|
||||
describe("hasUnansweredQuestion", () => {
|
||||
test("given empty messages, returns false", () => {
|
||||
expect(hasUnansweredQuestion([])).toBe(false)
|
||||
})
|
||||
|
||||
test("given null-ish input, returns false", () => {
|
||||
expect(hasUnansweredQuestion(undefined as never)).toBe(false)
|
||||
})
|
||||
|
||||
test("given last assistant message with question tool_use, returns true", () => {
|
||||
const messages = [
|
||||
{ info: { role: "user" } },
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_use", name: "question" },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||
})
|
||||
|
||||
test("given last assistant message with question tool-invocation, returns true", () => {
|
||||
const messages = [
|
||||
{ info: { role: "user" } },
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool-invocation", toolName: "question" },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||
})
|
||||
|
||||
test("given user message after question (answered), returns false", () => {
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_use", name: "question" },
|
||||
],
|
||||
},
|
||||
{ info: { role: "user" } },
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||
})
|
||||
|
||||
test("given assistant message with non-question tool, returns false", () => {
|
||||
const messages = [
|
||||
{ info: { role: "user" } },
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_use", name: "bash" },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||
})
|
||||
|
||||
test("given assistant message with no parts, returns false", () => {
|
||||
const messages = [
|
||||
{ info: { role: "user" } },
|
||||
{ info: { role: "assistant" } },
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||
})
|
||||
|
||||
test("given role on message directly (not in info), returns true for question", () => {
|
||||
const messages = [
|
||||
{ role: "user" },
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "tool_use", name: "question" },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||
})
|
||||
|
||||
test("given mixed tools including question, returns true", () => {
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_use", name: "bash" },
|
||||
{ type: "tool_use", name: "question" },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
name?: string
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
info?: { role?: string }
|
||||
role?: string
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
export function hasUnansweredQuestion(messages: Message[]): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
const role = msg.info?.role ?? msg.role
|
||||
|
||||
if (role === "user") return false
|
||||
|
||||
if (role === "assistant" && msg.parts) {
|
||||
const hasQuestion = msg.parts.some(
|
||||
(part) =>
|
||||
(part.type === "tool_use" || part.type === "tool-invocation") &&
|
||||
(part.name === "question" || part.toolName === "question"),
|
||||
)
|
||||
if (hasQuestion) {
|
||||
log(`[${HOOK_NAME}] Detected pending question tool in last assistant message`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -297,6 +297,31 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when remaining todos are blocked or deleted", async () => {
|
||||
// given - session where non-completed todos are only blocked/deleted
|
||||
const sessionID = "main-blocked-deleted"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({ data: [
|
||||
{ id: "1", content: "Blocked task", status: "blocked", priority: "high" },
|
||||
{ id: "2", content: "Deleted task", status: "deleted", priority: "medium" },
|
||||
{ id: "3", content: "Done task", status: "completed", priority: "low" },
|
||||
]})
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
// when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when background tasks are running", async () => {
|
||||
// given - session with running background tasks
|
||||
const sessionID = "main-789"
|
||||
@@ -1663,7 +1688,6 @@ describe("todo-continuation-enforcer", () => {
|
||||
test("should cancel all countdowns via cancelAllCountdowns", async () => {
|
||||
// given - multiple sessions with running countdowns
|
||||
const session1 = "main-cancel-all-1"
|
||||
const session2 = "main-cancel-all-2"
|
||||
setMainSession(session1)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Todo } from "./types"
|
||||
|
||||
export function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length
|
||||
return todos.filter(
|
||||
(todo) =>
|
||||
todo.status !== "completed"
|
||||
&& todo.status !== "cancelled"
|
||||
&& todo.status !== "blocked"
|
||||
&& todo.status !== "deleted",
|
||||
).length
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("mergeConfigs", () => {
|
||||
temperature: 0.3,
|
||||
},
|
||||
visual: {
|
||||
model: "google/gemini-3-pro",
|
||||
model: "google/gemini-3.1-pro",
|
||||
},
|
||||
},
|
||||
} as unknown as OhMyOpenCodeConfig;
|
||||
@@ -41,7 +41,7 @@ describe("mergeConfigs", () => {
|
||||
// then quick should be preserved from base
|
||||
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
|
||||
// then visual should be added from override
|
||||
expect(result.categories?.visual?.model).toBe("google/gemini-3-pro");
|
||||
expect(result.categories?.visual?.model).toBe("google/gemini-3.1-pro");
|
||||
});
|
||||
|
||||
it("should preserve base categories when override has no categories", () => {
|
||||
|
||||
@@ -106,6 +106,15 @@ export async function applyAgentConfig(params: {
|
||||
]),
|
||||
);
|
||||
|
||||
const disabledAgentNames = new Set(
|
||||
(migratedDisabledAgents ?? []).map(a => a.toLowerCase())
|
||||
);
|
||||
|
||||
const filterDisabledAgents = (agents: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase()))
|
||||
);
|
||||
|
||||
const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
@@ -194,9 +203,9 @@ export async function applyAgentConfig(params: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
|
||||
),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filterDisabledAgents(userAgents),
|
||||
...filterDisabledAgents(projectAgents),
|
||||
...filterDisabledAgents(pluginAgents),
|
||||
...filteredConfigAgents,
|
||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||
@@ -204,9 +213,9 @@ export async function applyAgentConfig(params: {
|
||||
} else {
|
||||
params.config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filterDisabledAgents(userAgents),
|
||||
...filterDisabledAgents(projectAgents),
|
||||
...filterDisabledAgents(pluginAgents),
|
||||
...configAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ describe("Prometheus category config resolution", () => {
|
||||
|
||||
// then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.model).toBe("google/gemini-3-pro")
|
||||
expect(config?.model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
|
||||
test("user categories override default categories", () => {
|
||||
|
||||
@@ -49,7 +49,10 @@ export function createContinuationHooks(args: {
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
|
||||
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
|
||||
? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx))
|
||||
? safeHook("stop-continuation-guard", () =>
|
||||
createStopContinuationGuardHook(ctx, {
|
||||
backgroundManager,
|
||||
}))
|
||||
: null
|
||||
|
||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||
|
||||
@@ -279,6 +279,30 @@ describe("applyUltraworkModelOverrideOnMessage", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("should override keyword-detector variant with configured ultrawork variant on deferred path", () => {
|
||||
//#given
|
||||
const config = createConfig("sisyphus", {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "extended",
|
||||
})
|
||||
const output = createOutput("ultrawork do something", { messageId: "msg_123" })
|
||||
output.message["variant"] = "max"
|
||||
output.message["thinking"] = "max"
|
||||
const tui = createMockTui()
|
||||
|
||||
//#when
|
||||
applyUltraworkModelOverrideOnMessage(config, "sisyphus", output, tui)
|
||||
|
||||
//#then
|
||||
expect(dbOverrideSpy).toHaveBeenCalledWith(
|
||||
"msg_123",
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
"extended",
|
||||
)
|
||||
expect(output.message["variant"]).toBe("extended")
|
||||
expect(output.message["thinking"]).toBe("extended")
|
||||
})
|
||||
|
||||
test("should NOT mutate output.message.model when message ID present", () => {
|
||||
//#given
|
||||
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
|
||||
|
||||
@@ -114,10 +114,12 @@ export function applyUltraworkModelOverrideOnMessage(
|
||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||
if (!override) return
|
||||
|
||||
if (override.variant) {
|
||||
output.message["variant"] = override.variant
|
||||
output.message["thinking"] = override.variant
|
||||
}
|
||||
|
||||
if (!override.providerID || !override.modelID) {
|
||||
if (override.variant) {
|
||||
output.message["variant"] = override.variant
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,10 +133,8 @@ export function applyUltraworkModelOverrideOnMessage(
|
||||
if (!messageId) {
|
||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||
output.message.model = targetModel
|
||||
if (override.variant) {
|
||||
output.message["variant"] = override.variant
|
||||
}
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from "./session-cursor"
|
||||
export * from "./shell-env"
|
||||
export * from "./system-directive"
|
||||
export * from "./agent-tool-restrictions"
|
||||
export * from "./orchestrator-agents"
|
||||
export * from "./model-requirements"
|
||||
export * from "./model-resolver"
|
||||
export { normalizeFallbackModels } from "./model-resolver"
|
||||
|
||||
@@ -774,7 +774,7 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
test("migrates model to category when mapping exists", () => {
|
||||
// given: Config with a model that has a category mapping
|
||||
const config = {
|
||||
model: "google/gemini-3-pro",
|
||||
model: "google/gemini-3.1-pro",
|
||||
temperature: 0.5,
|
||||
top_p: 0.9,
|
||||
}
|
||||
@@ -823,7 +823,7 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
test("handles all mapped models correctly", () => {
|
||||
// given: Configs for each mapped model
|
||||
const configs = [
|
||||
{ model: "google/gemini-3-pro" },
|
||||
{ model: "google/gemini-3.1-pro" },
|
||||
{ model: "google/gemini-3-flash" },
|
||||
{ model: "openai/gpt-5.2" },
|
||||
{ model: "anthropic/claude-haiku-4-5" },
|
||||
@@ -893,7 +893,7 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
// given: Config with fields matching category defaults
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "google/gemini-3-pro",
|
||||
model: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when: Check if config should be deleted
|
||||
@@ -1021,7 +1021,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
agents: {
|
||||
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
"my-custom-agent": { model: "google/gemini-3-pro" },
|
||||
"my-custom-agent": { model: "google/gemini-3.1-pro" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1037,7 +1037,7 @@ describe("migrateConfigFile with backup", () => {
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro")
|
||||
expect(agents["my-custom-agent"].model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
|
||||
test("preserves category setting when explicitly set", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* This map will be removed in a future major version once migration period ends.
|
||||
*/
|
||||
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
||||
"google/gemini-3-pro": "visual-engineering",
|
||||
"google/gemini-3.1-pro": "visual-engineering",
|
||||
"google/gemini-3-flash": "writing",
|
||||
"openai/gpt-5.2": "ultrabrain",
|
||||
"anthropic/claude-haiku-4-5": "quick",
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("fetchAvailableModels", () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
google: { id: "google", models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -74,7 +74,7 @@ describe("fetchAvailableModels", () => {
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
||||
@@ -97,7 +97,7 @@ describe("fetchAvailableModels", () => {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
{ id: "gemini-3.1-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -107,7 +107,7 @@ describe("fetchAvailableModels", () => {
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||
@@ -126,7 +126,7 @@ describe("fetchAvailableModels", () => {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
{ id: "gemini-3.1-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -136,7 +136,7 @@ describe("fetchAvailableModels", () => {
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||
@@ -515,7 +515,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -525,7 +525,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
// given cache with multiple providers
|
||||
@@ -535,7 +535,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -544,7 +544,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -759,7 +759,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
models: {
|
||||
opencode: ["big-pickle"],
|
||||
anthropic: ["claude-opus-4-6"],
|
||||
google: ["gemini-3-pro"]
|
||||
google: ["gemini-3.1-pro"]
|
||||
},
|
||||
connected: ["opencode", "anthropic", "google"]
|
||||
})
|
||||
@@ -771,7 +771,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("opencode/big-pickle")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle object[] format with metadata (Ollama-style)", async () => {
|
||||
@@ -953,7 +953,7 @@ describe("fallback model availability", () => {
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
]
|
||||
const availableModels = new Set(["google/gemini-3-pro"])
|
||||
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||
|
||||
// when
|
||||
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)
|
||||
|
||||
@@ -248,19 +248,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("openai")
|
||||
})
|
||||
|
||||
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
|
||||
test("visual-engineering has valid fallbackChain with gemini-3.1-pro high as primary", () => {
|
||||
// given - visual-engineering category requirement
|
||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||
|
||||
// when - accessing visual-engineering requirement
|
||||
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max)
|
||||
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max)
|
||||
expect(visualEngineering).toBeDefined()
|
||||
expect(visualEngineering.fallbackChain).toBeArray()
|
||||
expect(visualEngineering.fallbackChain).toHaveLength(3)
|
||||
|
||||
const primary = visualEngineering.fallbackChain[0]
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
expect(primary.model).toBe("gemini-3-pro")
|
||||
expect(primary.model).toBe("gemini-3.1-pro")
|
||||
expect(primary.variant).toBe("high")
|
||||
|
||||
const second = visualEngineering.fallbackChain[1]
|
||||
@@ -319,39 +319,43 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
})
|
||||
|
||||
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
|
||||
test("artistry has valid fallbackChain with gemini-3.1-pro as primary", () => {
|
||||
// given - artistry category requirement
|
||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||
|
||||
// when - accessing artistry requirement
|
||||
// then - fallbackChain exists with gemini-3-pro as first entry
|
||||
// then - fallbackChain exists with gemini-3.1-pro as first entry
|
||||
expect(artistry).toBeDefined()
|
||||
expect(artistry.fallbackChain).toBeArray()
|
||||
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
||||
|
||||
const primary = artistry.fallbackChain[0]
|
||||
expect(primary.model).toBe("gemini-3-pro")
|
||||
expect(primary.model).toBe("gemini-3.1-pro")
|
||||
expect(primary.variant).toBe("high")
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
})
|
||||
|
||||
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
|
||||
test("writing has valid fallbackChain with kimi-k2.5-free as primary", () => {
|
||||
// given - writing category requirement
|
||||
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
||||
|
||||
// when - accessing writing requirement
|
||||
// then - fallbackChain: gemini-3-flash → claude-sonnet-4-6
|
||||
// then - fallbackChain: kimi-k2.5-free -> gemini-3-flash -> claude-sonnet-4-6
|
||||
expect(writing).toBeDefined()
|
||||
expect(writing.fallbackChain).toBeArray()
|
||||
expect(writing.fallbackChain).toHaveLength(2)
|
||||
expect(writing.fallbackChain).toHaveLength(3)
|
||||
|
||||
const primary = writing.fallbackChain[0]
|
||||
expect(primary.model).toBe("gemini-3-flash")
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
expect(primary.model).toBe("kimi-k2.5-free")
|
||||
expect(primary.providers[0]).toBe("opencode")
|
||||
|
||||
const second = writing.fallbackChain[1]
|
||||
expect(second.model).toBe("claude-sonnet-4-6")
|
||||
expect(second.providers[0]).toBe("anthropic")
|
||||
expect(second.model).toBe("gemini-3-flash")
|
||||
expect(second.providers[0]).toBe("google")
|
||||
|
||||
const third = writing.fallbackChain[2]
|
||||
expect(third.model).toBe("claude-sonnet-4-6")
|
||||
expect(third.providers[0]).toBe("anthropic")
|
||||
})
|
||||
|
||||
test("all 8 categories have valid fallbackChain arrays", () => {
|
||||
@@ -489,12 +493,12 @@ describe("requiresModel field in categories", () => {
|
||||
expect(deep.requiresModel).toBe("gpt-5.3-codex")
|
||||
})
|
||||
|
||||
test("artistry category has requiresModel set to gemini-3-pro", () => {
|
||||
test("artistry category has requiresModel set to gemini-3.1-pro", () => {
|
||||
// given
|
||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||
|
||||
// when / #then
|
||||
expect(artistry.requiresModel).toBe("gemini-3-pro")
|
||||
expect(artistry.requiresModel).toBe("gemini-3.1-pro")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
metis: {
|
||||
@@ -73,14 +73,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
atlas: {
|
||||
@@ -95,7 +95,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
@@ -103,7 +103,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
ultrabrain: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
@@ -111,17 +111,17 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
],
|
||||
requiresModel: "gpt-5.3-codex",
|
||||
},
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
requiresModel: "gemini-3-pro",
|
||||
requiresModel: "gemini-3.1-pro",
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
@@ -141,11 +141,12 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
writing: {
|
||||
fallbackChain: [
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -25,7 +25,7 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -40,14 +40,14 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: undefined,
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// then
|
||||
expect(result).toBe("google/gemini-3-pro")
|
||||
expect(result).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -72,14 +72,14 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: " ",
|
||||
inheritedModel: "",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModel(input)
|
||||
|
||||
// then
|
||||
expect(result).toBe("google/gemini-3-pro")
|
||||
expect(result).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("resolveModel", () => {
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
systemDefault: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -123,7 +123,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -141,7 +141,7 @@ describe("resolveModelWithFallback", () => {
|
||||
uiSelectedModel: "opencode/big-pickle",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -158,7 +158,7 @@ describe("resolveModelWithFallback", () => {
|
||||
uiSelectedModel: " ",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -175,7 +175,7 @@ describe("resolveModelWithFallback", () => {
|
||||
uiSelectedModel: "",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -195,7 +195,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -215,7 +215,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -234,7 +234,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -252,7 +252,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -271,7 +271,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -294,8 +294,8 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3-pro"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3.1-pro"]),
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -313,7 +313,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
||||
],
|
||||
availableModels: new Set(["opencode/gpt-5-nano"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -331,7 +331,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -346,7 +346,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -361,7 +361,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -378,7 +378,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -397,7 +397,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -420,7 +420,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||
],
|
||||
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -438,7 +438,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
||||
],
|
||||
availableModels: new Set(["opencode/glm-5"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -457,7 +457,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -477,14 +477,14 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||
})
|
||||
@@ -516,7 +516,7 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -533,7 +533,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
@@ -544,7 +544,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
// then - should use github-copilot (second provider) since google not connected
|
||||
// model name is transformed to preview variant for github-copilot provider
|
||||
expect(result!.model).toBe("github-copilot/gemini-3-pro-preview")
|
||||
expect(result!.model).toBe("github-copilot/gemini-3.1-pro-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -577,14 +577,14 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should fall through to system default
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -593,14 +593,14 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
})
|
||||
@@ -627,20 +627,20 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
test("tries all providers in first entry before moving to second entry", () => {
|
||||
// given
|
||||
const availableModels = new Set(["google/gemini-3-pro"])
|
||||
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
@@ -675,7 +675,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
@@ -693,7 +693,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
systemDefaultModel: "google/gemini-3.1-pro",
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -708,32 +708,32 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
||||
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
||||
// given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available
|
||||
// given - gemini-3.1-pro is the category default, but only gemini-3.1-pro-preview is available
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
|
||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
||||
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||
expect(result!.source).toBe("category-default")
|
||||
})
|
||||
|
||||
test("categoryDefaultModel uses exact match when available", () => {
|
||||
// given - exact match exists
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3-pro", "google/gemini-3-pro-preview"]),
|
||||
availableModels: new Set(["google/gemini-3.1-pro", "google/gemini-3.1-pro-preview"]),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
}
|
||||
|
||||
@@ -741,14 +741,14 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should use exact match
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.source).toBe("category-default")
|
||||
})
|
||||
|
||||
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
||||
// given - categoryDefaultModel has no match, but fallbackChain does
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
@@ -768,11 +768,11 @@ describe("resolveModelWithFallback", () => {
|
||||
// given - both userModel and categoryDefaultModel provided
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
@@ -788,7 +788,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// given - no availableModels but connected provider cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
}
|
||||
@@ -797,7 +797,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should use transformed categoryDefaultModel since google is connected
|
||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||
expect(result!.source).toBe("category-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -824,7 +824,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// given - category default already has -preview suffix
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro-preview",
|
||||
categoryDefaultModel: "google/gemini-3.1-pro-preview",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
@@ -832,18 +832,18 @@ describe("resolveModelWithFallback", () => {
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should NOT become gemini-3-pro-preview-preview
|
||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
||||
// then - should NOT become gemini-3.1-pro-preview-preview
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||
expect(result!.source).toBe("category-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("transforms gemini-3-pro in fallback chain for google connected provider", () => {
|
||||
// given - google connected, fallback chain has gemini-3-pro
|
||||
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
|
||||
// given - google connected, fallback chain has gemini-3.1-pro
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
@@ -853,7 +853,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should transform to preview variant for google provider
|
||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
||||
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
21
src/shared/orchestrator-agents.ts
Normal file
21
src/shared/orchestrator-agents.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getSessionAgent } from "../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "./agent-display-names"
|
||||
|
||||
export const ORCHESTRATOR_AGENTS = new Set([
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"atlas",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
])
|
||||
|
||||
export function isOrchestratorAgent(sessionID: string, inputAgent?: string): boolean {
|
||||
const agent = getSessionAgent(sessionID) ?? inputAgent
|
||||
if (!agent) return true
|
||||
const agentKey = getAgentConfigKey(agent)
|
||||
return (
|
||||
ORCHESTRATOR_AGENTS.has(agentKey) ||
|
||||
agentKey.includes("sisyphus") ||
|
||||
agentKey.includes("atlas")
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,12 @@ export function transformModelForProvider(provider: string, model: string): stri
|
||||
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
|
||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||
.replace("claude-sonnet-4", "claude-sonnet-4")
|
||||
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
|
||||
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||
}
|
||||
if (provider === "google") {
|
||||
return model
|
||||
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
|
||||
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||
}
|
||||
return model
|
||||
|
||||
84
src/shared/spawn-with-windows-hide.ts
Normal file
84
src/shared/spawn-with-windows-hide.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { spawn as bunSpawn } from "bun"
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||
import { Readable } from "node:stream"
|
||||
|
||||
export interface SpawnOptions {
|
||||
cwd?: string
|
||||
env?: Record<string, string | undefined>
|
||||
stdin?: "pipe" | "inherit" | "ignore"
|
||||
stdout?: "pipe" | "inherit" | "ignore"
|
||||
stderr?: "pipe" | "inherit" | "ignore"
|
||||
}
|
||||
|
||||
export interface SpawnedProcess {
|
||||
readonly exitCode: number | null
|
||||
readonly exited: Promise<number>
|
||||
readonly stdout: ReadableStream<Uint8Array> | undefined
|
||||
readonly stderr: ReadableStream<Uint8Array> | undefined
|
||||
kill(signal?: NodeJS.Signals): void
|
||||
}
|
||||
|
||||
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
|
||||
if (!stream) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
|
||||
let resolveExited: (exitCode: number) => void
|
||||
let exitCode: number | null = null
|
||||
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
resolveExited = resolve
|
||||
})
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
exitCode = code ?? 1
|
||||
resolveExited(exitCode)
|
||||
})
|
||||
|
||||
proc.on("error", () => {
|
||||
if (exitCode === null) {
|
||||
exitCode = 1
|
||||
resolveExited(1)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
get exitCode() {
|
||||
return exitCode
|
||||
},
|
||||
exited,
|
||||
stdout: toReadableStream(proc.stdout),
|
||||
stderr: toReadableStream(proc.stderr),
|
||||
kill(signal?: NodeJS.Signals): void {
|
||||
try {
|
||||
if (!signal) {
|
||||
proc.kill()
|
||||
return
|
||||
}
|
||||
|
||||
proc.kill(signal)
|
||||
} catch {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
|
||||
if (process.platform !== "win32") {
|
||||
return bunSpawn(command, options)
|
||||
}
|
||||
|
||||
const [cmd, ...args] = command
|
||||
const proc = nodeSpawn(cmd, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
})
|
||||
|
||||
return wrapNodeProcess(proc)
|
||||
}
|
||||
@@ -208,10 +208,10 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
|
||||
|
||||
|
||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
|
||||
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
|
||||
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
||||
artistry: { model: "google/gemini-3-pro", variant: "high" },
|
||||
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
quick: { model: "anthropic/claude-haiku-4-5" },
|
||||
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
||||
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||
|
||||
@@ -17,7 +17,7 @@ const TEST_AVAILABLE_MODELS = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
"google/gemini-3-pro",
|
||||
"google/gemini-3.1-pro",
|
||||
"google/gemini-3-flash",
|
||||
"openai/gpt-5.2",
|
||||
"openai/gpt-5.3-codex",
|
||||
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
|
||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: {
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
||||
google: ["gemini-3-pro", "gemini-3-flash"],
|
||||
google: ["gemini-3.1-pro", "gemini-3-flash"],
|
||||
openai: ["gpt-5.2", "gpt-5.3-codex"],
|
||||
},
|
||||
connected: ["anthropic", "google", "openai"],
|
||||
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
// when / #then
|
||||
expect(category).toBeDefined()
|
||||
expect(category.model).toBe("google/gemini-3-pro")
|
||||
expect(category.model).toBe("google/gemini-3.1-pro")
|
||||
expect(category.variant).toBe("high")
|
||||
})
|
||||
|
||||
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||
expect(result!.promptAppend).toContain("VISUAL/UI")
|
||||
})
|
||||
|
||||
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": {
|
||||
model: "google/gemini-3-pro",
|
||||
model: "google/gemini-3.1-pro",
|
||||
prompt_append: "Custom instructions here",
|
||||
},
|
||||
}
|
||||
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": {
|
||||
model: "google/gemini-3-pro",
|
||||
model: "google/gemini-3.1-pro",
|
||||
temperature: 0.3,
|
||||
},
|
||||
}
|
||||
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
// then - category's built-in model wins over inheritedModel
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
|
||||
test("systemDefaultModel is used as fallback when custom category has no model", () => {
|
||||
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1738,7 +1738,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
|
||||
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
||||
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
|
||||
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
||||
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - artistry category (gemini-3-pro with high variant)
|
||||
// when - artistry category (gemini-3.1-pro with high variant)
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test artistry forced background",
|
||||
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
|
||||
// when resolveCategoryConfig is called
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
||||
|
||||
// then should use category's built-in model (gemini-3-pro for visual-engineering)
|
||||
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBe("google/gemini-3-pro")
|
||||
expect(resolved!.model).toBe("google/gemini-3.1-pro")
|
||||
})
|
||||
|
||||
test("systemDefaultModel is used when no other model is available", () => {
|
||||
@@ -3522,7 +3522,7 @@ describe("sisyphus-task", () => {
|
||||
)
|
||||
|
||||
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
|
||||
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
|
||||
// oracle fallback chain: gpt-5.2 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
|
||||
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
|
||||
expect(promptBody.model).toBeDefined()
|
||||
expect(promptBody.model.providerID).toBe("openai")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
||||
|
||||
@@ -89,7 +90,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
|
||||
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([tmuxPath, ...parts], {
|
||||
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user