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
|
# - Uploads compressed artifacts for the publish job
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
|
runs-on: ${{ startsWith(matrix.platform, 'windows-') && 'windows-latest' || 'ubuntu-latest' }}
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 7
|
max-parallel: 11
|
||||||
matrix:
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -95,14 +95,18 @@ jobs:
|
|||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
darwin-arm64) TARGET="bun-darwin-arm64" ;;
|
darwin-arm64) TARGET="bun-darwin-arm64" ;;
|
||||||
darwin-x64) TARGET="bun-darwin-x64" ;;
|
darwin-x64) TARGET="bun-darwin-x64" ;;
|
||||||
|
darwin-x64-baseline) TARGET="bun-darwin-x64-baseline" ;;
|
||||||
linux-x64) TARGET="bun-linux-x64" ;;
|
linux-x64) TARGET="bun-linux-x64" ;;
|
||||||
|
linux-x64-baseline) TARGET="bun-linux-x64-baseline" ;;
|
||||||
linux-arm64) TARGET="bun-linux-arm64" ;;
|
linux-arm64) TARGET="bun-linux-arm64" ;;
|
||||||
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
|
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" ;;
|
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
|
||||||
windows-x64) TARGET="bun-windows-x64" ;;
|
windows-x64) TARGET="bun-windows-x64" ;;
|
||||||
|
windows-x64-baseline) TARGET="bun-windows-x64-baseline" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
|
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
|
||||||
else
|
else
|
||||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
|
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
|
||||||
@@ -119,7 +123,7 @@ jobs:
|
|||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
cd packages/${PLATFORM}
|
cd packages/${PLATFORM}
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
# Windows: use 7z (pre-installed on windows-latest)
|
# Windows: use 7z (pre-installed on windows-latest)
|
||||||
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
||||||
else
|
else
|
||||||
@@ -155,7 +159,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 2
|
max-parallel: 2
|
||||||
matrix:
|
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:
|
steps:
|
||||||
- name: Check if already published
|
- name: Check if already published
|
||||||
id: check
|
id: check
|
||||||
@@ -184,7 +188,7 @@ jobs:
|
|||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
mkdir -p packages/${PLATFORM}
|
mkdir -p packages/${PLATFORM}
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
||||||
else
|
else
|
||||||
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
|
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 }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
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
|
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
|
||||||
mv tmp.json "packages/${platform}/package.json"
|
mv tmp.json "packages/${platform}/package.json"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
// Wrapper script that detects platform and spawns the correct binary
|
// Wrapper script that detects platform and spawns the correct binary
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
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() {
|
function main() {
|
||||||
const { platform, arch } = process;
|
const { platform, arch } = process;
|
||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
|
const avx2Supported = supportsAvx2();
|
||||||
|
|
||||||
// Get platform package name
|
let packageCandidates;
|
||||||
let pkg;
|
|
||||||
try {
|
try {
|
||||||
pkg = getPlatformPackage({ platform, arch, libcFamily });
|
packageCandidates = getPlatformPackageCandidates({
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
libcFamily,
|
||||||
|
preferBaseline: avx2Supported === false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\noh-my-opencode: ${error.message}\n`);
|
console.error(`\noh-my-opencode: ${error.message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve binary path
|
const resolvedBinaries = packageCandidates
|
||||||
const binRelPath = getBinaryPath(pkg, platform);
|
.map((pkg) => {
|
||||||
|
try {
|
||||||
let binPath;
|
return { pkg, binPath: require.resolve(getBinaryPath(pkg, platform)) };
|
||||||
try {
|
} catch {
|
||||||
binPath = require.resolve(binRelPath);
|
return null;
|
||||||
} catch {
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry !== null);
|
||||||
|
|
||||||
|
if (resolvedBinaries.length === 0) {
|
||||||
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
||||||
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
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(`\nTo fix, run:`);
|
||||||
console.error(` npm install ${pkg}\n`);
|
console.error(` npm install ${packageCandidates[0]}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the binary
|
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
||||||
const result = spawnSync(binPath, process.argv.slice(2), {
|
const currentBinary = resolvedBinaries[index];
|
||||||
stdio: "inherit",
|
const hasFallback = index < resolvedBinaries.length - 1;
|
||||||
});
|
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
||||||
|
stdio: "inherit",
|
||||||
// Handle spawn errors
|
});
|
||||||
if (result.error) {
|
|
||||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
if (result.error) {
|
||||||
console.error(`Error: ${result.error.message}\n`);
|
if (hasFallback) {
|
||||||
process.exit(2);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle signals
|
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||||
if (result.signal) {
|
console.error(`Error: ${result.error.message}\n`);
|
||||||
const signalNum = result.signal === "SIGTERM" ? 15 :
|
process.exit(2);
|
||||||
result.signal === "SIGKILL" ? 9 :
|
}
|
||||||
result.signal === "SIGINT" ? 2 : 1;
|
|
||||||
process.exit(128 + signalNum);
|
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();
|
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}`;
|
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
|
* Get the path to the binary within a platform package
|
||||||
* @param {string} pkg Package name
|
* @param {string} pkg Package name
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// bin/platform.test.ts
|
// bin/platform.test.ts
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
|
||||||
|
|
||||||
describe("getPlatformPackage", () => {
|
describe("getPlatformPackage", () => {
|
||||||
// #region Darwin platforms
|
// #region Darwin platforms
|
||||||
@@ -146,3 +146,58 @@ describe("getBinaryPath", () => {
|
|||||||
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
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": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.8.5",
|
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||||
"oh-my-opencode-darwin-x64": "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": "3.8.5",
|
||||||
"oh-my-opencode-linux-arm64-musl": "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": "3.8.5",
|
||||||
|
"oh-my-opencode-linux-x64-baseline": "3.8.5",
|
||||||
"oh-my-opencode-linux-x64-musl": "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": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@ast-grep/cli",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Runs after npm install to verify platform binary is available
|
// Runs after npm install to verify platform binary is available
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
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);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -27,12 +27,28 @@ function main() {
|
|||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pkg = getPlatformPackage({ platform, arch, libcFamily });
|
const packageCandidates = getPlatformPackageCandidates({
|
||||||
const binPath = getBinaryPath(pkg, platform);
|
platform,
|
||||||
|
arch,
|
||||||
// Try to resolve the binary
|
libcFamily,
|
||||||
require.resolve(binPath);
|
});
|
||||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
||||||
console.warn(` The CLI may not work on this platform.`);
|
console.warn(` The CLI may not work on this platform.`);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
buildDecisionMatrix,
|
buildDecisionMatrix,
|
||||||
} from "./prompt-section-builder"
|
} from "./prompt-section-builder"
|
||||||
|
|
||||||
const MODE: AgentMode = "primary"
|
const MODE: AgentMode = "all"
|
||||||
|
|
||||||
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
categorizeTools,
|
categorizeTools,
|
||||||
} from "./dynamic-agent-prompt-builder";
|
} from "./dynamic-agent-prompt-builder";
|
||||||
|
|
||||||
const MODE: AgentMode = "primary";
|
const MODE: AgentMode = "all";
|
||||||
|
|
||||||
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
||||||
if (useTaskSystem) {
|
if (useTaskSystem) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
buildGeminiIntentGateEnforcement,
|
buildGeminiIntentGateEnforcement,
|
||||||
} from "./sisyphus-gemini-overlays";
|
} from "./sisyphus-gemini-overlays";
|
||||||
|
|
||||||
const MODE: AgentMode = "primary";
|
const MODE: AgentMode = "all";
|
||||||
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
||||||
category: "utility",
|
category: "utility",
|
||||||
cost: "EXPENSIVE",
|
cost: "EXPENSIVE",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ describe("isGptModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("gemini models are not gpt", () => {
|
test("gemini models are not gpt", () => {
|
||||||
expect(isGptModel("google/gemini-3-pro")).toBe(false);
|
expect(isGptModel("google/gemini-3.1-pro")).toBe(false);
|
||||||
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
|
expect(isGptModel("litellm/gemini-3.1-pro")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opencode provider is not gpt", () => {
|
test("opencode provider is not gpt", () => {
|
||||||
@@ -58,29 +58,29 @@ describe("isGptModel", () => {
|
|||||||
|
|
||||||
describe("isGeminiModel", () => {
|
describe("isGeminiModel", () => {
|
||||||
test("#given google provider models #then returns true", () => {
|
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-3-flash")).toBe(true);
|
||||||
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given google-vertex provider models #then returns 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);
|
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given github copilot gemini models #then returns 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);
|
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given litellm proxied gemini models #then returns 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-3-flash")).toBe(true);
|
||||||
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given other proxied gemini models #then returns 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);
|
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)
|
const agent = buildAgent(source["test-agent"], TEST_MODEL)
|
||||||
|
|
||||||
// #then - category's built-in model is applied
|
// #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", () => {
|
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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"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",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -371,7 +371,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
|||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"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",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -506,7 +506,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -581,7 +581,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -634,7 +634,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -655,7 +655,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
"model": "opencode/claude-sonnet-4-5",
|
"model": "opencode/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -708,7 +708,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -730,7 +730,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
"model": "opencode/claude-sonnet-4-5",
|
"model": "opencode/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -779,14 +779,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -845,14 +845,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -863,7 +863,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1026,7 +1026,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1047,7 +1047,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1100,7 +1100,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1121,7 +1121,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1217,7 +1217,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
@@ -1231,14 +1231,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -1248,7 +1248,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1301,7 +1301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1322,7 +1322,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1375,7 +1375,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1396,7 +1396,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1449,7 +1449,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1471,7 +1471,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
|||||||
expect(models).toBeTruthy()
|
expect(models).toBeTruthy()
|
||||||
|
|
||||||
const required = [
|
const required = [
|
||||||
"antigravity-gemini-3-pro",
|
"antigravity-gemini-3.1-pro",
|
||||||
"antigravity-gemini-3-flash",
|
"antigravity-gemini-3-flash",
|
||||||
"antigravity-claude-sonnet-4-6",
|
"antigravity-claude-sonnet-4-6",
|
||||||
"antigravity-claude-sonnet-4-6-thinking",
|
"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>
|
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
|
||||||
|
|
||||||
// #when checking Gemini Pro variants
|
// #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
|
// #then should have low and high variants
|
||||||
expect(pro.variants).toBeTruthy()
|
expect(pro.variants).toBeTruthy()
|
||||||
expect(pro.variants.low).toBeTruthy()
|
expect(pro.variants.low).toBeTruthy()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
||||||
*
|
*
|
||||||
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
|
* 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
|
* - `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.
|
* but variants are the recommended approach.
|
||||||
*
|
*
|
||||||
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
|
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
|
||||||
@@ -16,7 +16,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
|||||||
google: {
|
google: {
|
||||||
name: "Google",
|
name: "Google",
|
||||||
models: {
|
models: {
|
||||||
"antigravity-gemini-3-pro": {
|
"antigravity-gemini-3.1-pro": {
|
||||||
name: "Gemini 3 Pro (Antigravity)",
|
name: "Gemini 3 Pro (Antigravity)",
|
||||||
limit: { context: 1048576, output: 65535 },
|
limit: { context: 1048576, output: 65535 },
|
||||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
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> {
|
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["bun", "install"], {
|
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||||
cwd: getConfigDir(),
|
cwd: getConfigDir(),
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
import { initConfigContext } from "./config-context"
|
import { initConfigContext } from "./config-context"
|
||||||
|
|
||||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||||
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
|
|||||||
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
||||||
for (const binary of OPENCODE_BINARIES) {
|
for (const binary of OPENCODE_BINARIES) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([binary, "--version"], {
|
const proc = spawnWithWindowsHide([binary, "--version"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
|
|||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
|
|
||||||
import type { DependencyInfo } from "../types"
|
import type { DependencyInfo } from "../types"
|
||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
|||||||
|
|
||||||
async function getBinaryVersion(binary: string): Promise<string | null> {
|
async function getBinaryVersion(binary: string): Promise<string | null> {
|
||||||
try {
|
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()
|
const output = await new Response(proc.stdout).text()
|
||||||
await proc.exited
|
await proc.exited
|
||||||
if (proc.exitCode === 0) {
|
if (proc.exitCode === 0) {
|
||||||
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
|
|||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("model-resolution check", () => {
|
|||||||
// then: Should have category entries
|
// then: Should have category entries
|
||||||
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
||||||
expect(visual).toBeDefined()
|
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")
|
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
import { OPENCODE_BINARIES } from "../constants"
|
import { OPENCODE_BINARIES } from "../constants"
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const command = buildVersionCommand(binaryPath, platform)
|
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()
|
const output = await new Response(processResult.stdout).text()
|
||||||
await processResult.exited
|
await processResult.exited
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
export interface GhCliInfo {
|
export interface GhCliInfo {
|
||||||
installed: boolean
|
installed: boolean
|
||||||
version: string | null
|
version: string | null
|
||||||
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
|||||||
|
|
||||||
async function getGhVersion(): Promise<string | null> {
|
async function getGhVersion(): Promise<string | null> {
|
||||||
try {
|
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()
|
const output = await new Response(processResult.stdout).text()
|
||||||
await processResult.exited
|
await processResult.exited
|
||||||
if (processResult.exitCode !== 0) return null
|
if (processResult.exitCode !== 0) return null
|
||||||
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
|
|||||||
error: string | null
|
error: string | null
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const processResult = Bun.spawn(["gh", "auth", "status"], {
|
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
oracle: {
|
oracle: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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" },
|
{ 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: ["kimi-for-coding"], model: "k2p5" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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: {
|
metis: {
|
||||||
@@ -68,14 +68,14 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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: {
|
momus: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ 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: {
|
atlas: {
|
||||||
@@ -84,7 +84,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ 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> = {
|
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
fallbackChain: [
|
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: ["zai-coding-plan"], model: "glm-5" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||||
@@ -101,7 +101,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
ultrabrain: {
|
ultrabrain: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
{ 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" },
|
{ 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: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ 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",
|
requiresModel: "gpt-5.3-codex",
|
||||||
},
|
},
|
||||||
artistry: {
|
artistry: {
|
||||||
fallbackChain: [
|
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: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
requiresModel: "gemini-3-pro",
|
requiresModel: "gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
quick: {
|
quick: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
@@ -139,7 +139,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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: {
|
writing: {
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("claude-haiku-4.5")
|
expect(result).toBe("claude-haiku-4.5")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||||
// #given github-copilot provider and gemini-3-pro model
|
// #given github-copilot provider and gemini-3.1-pro model
|
||||||
const provider = "github-copilot"
|
const provider = "github-copilot"
|
||||||
const model = "gemini-3-pro"
|
const model = "gemini-3.1-pro"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should transform to gemini-3-pro-preview
|
// #then should transform to gemini-3.1-pro-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||||
@@ -64,16 +64,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||||
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
|
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
|
||||||
const provider = "github-copilot"
|
const provider = "github-copilot"
|
||||||
const model = "gemini-3-pro-preview"
|
const model = "gemini-3.1-pro-preview"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should NOT become gemini-3-pro-preview-preview
|
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||||
@@ -102,16 +102,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||||
// #given google provider and gemini-3-pro model
|
// #given google provider and gemini-3.1-pro model
|
||||||
const provider = "google"
|
const provider = "google"
|
||||||
const model = "gemini-3-pro"
|
const model = "gemini-3.1-pro"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should transform to gemini-3-pro-preview
|
// #then should transform to gemini-3.1-pro-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through other gemini models unchanged", () => {
|
test("passes through other gemini models unchanged", () => {
|
||||||
@@ -138,16 +138,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||||
// #given google provider and gemini-3-pro-preview model (already transformed)
|
// #given google provider and gemini-3.1-pro-preview model (already transformed)
|
||||||
const provider = "google"
|
const provider = "google"
|
||||||
const model = "gemini-3-pro-preview"
|
const model = "gemini-3.1-pro-preview"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should NOT become gemini-3-pro-preview-preview
|
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not transform claude models for google provider", () => {
|
test("does not transform claude models for google provider", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
|
|||||||
import { createJsonOutputManager } from "./json-output"
|
import { createJsonOutputManager } from "./json-output"
|
||||||
import { resolveSession } from "./session-resolver"
|
import { resolveSession } from "./session-resolver"
|
||||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
import * as originalSdk from "@opencode-ai/sdk"
|
import * as originalSdk from "@opencode-ai/sdk"
|
||||||
import * as originalPortUtils from "../../shared/port-utils"
|
import * as originalPortUtils from "../../shared/port-utils"
|
||||||
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
|
|||||||
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||||
expect(mockClient.session.get).toHaveBeenCalledWith({
|
expect(mockClient.session.get).toHaveBeenCalledWith({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
query: { directory: "/test" },
|
query: { directory: "/test" },
|
||||||
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(console, "error").mockImplementation(() => {})
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||||
exited: Promise.resolve(0),
|
exited: Promise.resolve(0),
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
stdout: undefined,
|
||||||
|
stderr: undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
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?.SESSION_ID).toBe("session-123")
|
||||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
|
|||||||
spyOn(console, "error").mockImplementation(() => {})
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
mockStdout = createMockWriteStream()
|
mockStdout = createMockWriteStream()
|
||||||
mockStderr = createMockWriteStream()
|
mockStderr = createMockWriteStream()
|
||||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||||
exited: Promise.resolve(0),
|
exited: Promise.resolve(0),
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
stdout: undefined,
|
||||||
|
stderr: undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
|
|||||||
const emitted = mockStdout.writes[0]!
|
const emitted = mockStdout.writes[0]!
|
||||||
expect(() => JSON.parse(emitted)).not.toThrow()
|
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
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"])
|
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?.SESSION_ID).toBe("session-123")
|
||||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
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"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
|
||||||
describe("executeOnCompleteHook", () => {
|
describe("executeOnCompleteHook", () => {
|
||||||
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
|
|||||||
return {
|
return {
|
||||||
exited: Promise.resolve(exitCode),
|
exited: Promise.resolve(exitCode),
|
||||||
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">>
|
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
||||||
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("executes command with correct env vars", async () => {
|
it("executes command with correct env vars", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
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(args).toEqual(["sh", "-c", "echo test"])
|
||||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("env var values are strings", async () => {
|
it("env var values are strings", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// 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).toBe("1")
|
||||||
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||||
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("empty command string is no-op", async () => {
|
it("empty command string is no-op", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("whitespace-only command is no-op", async () => {
|
it("whitespace-only command is no-op", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("command failure logs warning but does not throw", async () => {
|
it("command failure logs warning but does not throw", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
await expect(
|
expect(
|
||||||
executeOnCompleteHook({
|
executeOnCompleteHook({
|
||||||
command: "false",
|
command: "false",
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
|
|||||||
it("spawn error logs warning but does not throw", async () => {
|
it("spawn error logs warning but does not throw", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnError = new Error("Command not found")
|
const spawnError = new Error("Command not found")
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
|
||||||
throw spawnError
|
throw spawnError
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
await expect(
|
expect(
|
||||||
executeOnCompleteHook({
|
executeOnCompleteHook({
|
||||||
command: "nonexistent-command",
|
command: "nonexistent-command",
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
export async function executeOnCompleteHook(options: {
|
export async function executeOnCompleteHook(options: {
|
||||||
command: string
|
command: string
|
||||||
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
|
|||||||
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
|
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
SESSION_ID: sessionId,
|
SESSION_ID: sessionId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delimiter, dirname, join } from "node:path"
|
import { delimiter, dirname, join } from "node:path"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
||||||
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] 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> {
|
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([binaryPath, "--version"], {
|
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "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 agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||||
default_run_agent: z.string().optional(),
|
default_run_agent: z.string().optional(),
|
||||||
disabled_mcps: z.array(AnyMcpNameSchema).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_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||||
disabled_hooks: z.array(z.string()).optional(),
|
disabled_hooks: z.array(z.string()).optional(),
|
||||||
disabled_commands: z.array(BuiltinCommandNameSchema).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", () => {
|
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||||
// given
|
// given
|
||||||
const config: BackgroundTaskConfig = {
|
const config: BackgroundTaskConfig = {
|
||||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
modelConcurrency: { "google/gemini-3.1-pro": 5 },
|
||||||
providerConcurrency: { anthropic: 3 }
|
providerConcurrency: { anthropic: 3 }
|
||||||
}
|
}
|
||||||
const manager = new ConcurrencyManager(config)
|
const manager = new ConcurrencyManager(config)
|
||||||
@@ -95,7 +95,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
|||||||
// when
|
// when
|
||||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
||||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-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
|
// then
|
||||||
expect(modelLimit).toBe(10)
|
expect(modelLimit).toBe(10)
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe("TaskToastManager", () => {
|
|||||||
description: "Task with category default model",
|
description: "Task with category default model",
|
||||||
agent: "sisyphus-junior",
|
agent: "sisyphus-junior",
|
||||||
isBackground: false,
|
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
|
// when - addTask is called
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "./storage";
|
} from "./storage";
|
||||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||||
import type { AgentUsageState } from "./types";
|
import type { AgentUsageState } from "./types";
|
||||||
|
import { isOrchestratorAgent } from "../../shared/orchestrator-agents";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -60,6 +61,11 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
|||||||
output: ToolExecuteOutput,
|
output: ToolExecuteOutput,
|
||||||
) => {
|
) => {
|
||||||
const { tool, sessionID } = input;
|
const { tool, sessionID } = input;
|
||||||
|
|
||||||
|
if (!isOrchestratorAgent(sessionID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const toolLower = tool.toLowerCase();
|
const toolLower = tool.toLowerCase();
|
||||||
|
|
||||||
if (AGENT_TOOLS.has(toolLower)) {
|
if (AGENT_TOOLS.has(toolLower)) {
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
import { isOrchestratorAgent } from "../../shared/orchestrator-agents"
|
||||||
import { buildReminderMessage } from "./formatter"
|
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.
|
* 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.
|
* 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)!
|
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 toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||||
const { tool, sessionID } = input
|
const { tool, sessionID } = input
|
||||||
const toolLower = tool.toLowerCase()
|
const toolLower = tool.toLowerCase()
|
||||||
|
|
||||||
if (!isTargetAgent(sessionID, input.agent)) {
|
if (!isOrchestratorAgent(sessionID, input.agent)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
||||||
import type { InteractiveBashSessionState } from "./types";
|
import type { InteractiveBashSessionState } from "./types";
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||||
|
|
||||||
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sessionName of state.tmuxSessions) {
|
for (const sessionName of state.tmuxSessions) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { InteractiveBashSessionState } from "./types";
|
import type { InteractiveBashSessionState } from "./types";
|
||||||
import { loadInteractiveBashSessionState } from "./storage";
|
import { loadInteractiveBashSessionState } from "./storage";
|
||||||
import { OMO_SESSION_PREFIX } from "./constants";
|
import { OMO_SESSION_PREFIX } from "./constants";
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||||
|
|
||||||
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
||||||
if (!sessionStates.has(sessionID)) {
|
if (!sessionStates.has(sessionID)) {
|
||||||
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sessionName of state.tmuxSessions) {
|
for (const sessionName of state.tmuxSessions) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export async function detectCompletionInSessionMessages(
|
|||||||
if (assistantMessages.length === 0) return false
|
if (assistantMessages.length === 0) return false
|
||||||
|
|
||||||
const pattern = buildPromisePattern(options.promise)
|
const pattern = buildPromisePattern(options.promise)
|
||||||
const recentAssistants = assistantMessages.slice(-3)
|
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
|
||||||
for (const assistant of recentAssistants) {
|
const assistant = assistantMessages[index]
|
||||||
if (!assistant.parts) continue
|
if (!assistant.parts) continue
|
||||||
|
|
||||||
let responseText = ""
|
let responseText = ""
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ describe("ralph-loop", () => {
|
|||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
default_max_iterations: 200,
|
default_max_iterations: 200,
|
||||||
|
default_strategy: "continue",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -708,6 +709,57 @@ describe("ralph-loop", () => {
|
|||||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
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 () => {
|
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||||
// given - active loop
|
// given - active loop
|
||||||
const hook = createRalphLoopHook(createMockPluginInput())
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
@@ -782,8 +834,8 @@ describe("ralph-loop", () => {
|
|||||||
expect(hook.getState()).toBeNull()
|
expect(hook.getState()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
|
test("should detect completion even when promise is older than previous narrow window", async () => {
|
||||||
// given - promise appears in an assistant message older than last 3
|
// given - promise appears in an older assistant message with additional assistant output after it
|
||||||
mockSessionMessages = [
|
mockSessionMessages = [
|
||||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
|
{ 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" } },
|
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||||
})
|
})
|
||||||
|
|
||||||
// then - loop should continue (promise is older than last 3 assistant messages)
|
// then - loop should complete because all assistant messages are scanned
|
||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(0)
|
||||||
expect(hook.getState()?.iteration).toBe(2)
|
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 () => {
|
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function createRalphLoopEventHandler(
|
|||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: RalphLoopEventHandlerOptions,
|
options: RalphLoopEventHandlerOptions,
|
||||||
) {
|
) {
|
||||||
|
const inFlightSessions = new Set<string>()
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
@@ -32,115 +34,127 @@ export function createRalphLoopEventHandler(
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (options.sessionRecovery.isRecovering(sessionID)) {
|
if (inFlightSessions.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = options.loopState.getState()
|
inFlightSessions.add(sessionID)
|
||||||
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(() => {})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await continueIteration(ctx, newState, {
|
|
||||||
previousSessionID: sessionID,
|
if (options.sessionRecovery.isRecovering(sessionID)) {
|
||||||
directory: options.directory,
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
apiTimeoutMs: options.apiTimeoutMs,
|
return
|
||||||
loopState: options.loopState,
|
}
|
||||||
})
|
|
||||||
} catch (err) {
|
const state = options.loopState.getState()
|
||||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
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,
|
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") {
|
if (event.type === "session.deleted") {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function waitUntil(condition: () => boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ralph-loop reset strategy race condition", () => {
|
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
|
// given - reset strategy loop with blocked TUI session switch
|
||||||
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
||||||
const createSessionCalls: Array<{ parentID?: 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" })
|
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" } },
|
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitUntil(() => selectSessionCalls > 1)
|
|
||||||
|
|
||||||
selectSessionDeferred.resolve()
|
selectSessionDeferred.resolve()
|
||||||
await Promise.all([firstIdleEvent, secondIdleEvent])
|
await Promise.all([firstIdleEvent, secondIdleEvent])
|
||||||
|
|
||||||
// then - second idle should not be skipped during reset transition
|
// then - duplicate idle should be skipped to prevent concurrent continuation injection
|
||||||
expect(createSessionCalls.length).toBe(2)
|
expect(createSessionCalls.length).toBe(1)
|
||||||
expect(promptCalls.length).toBe(2)
|
expect(promptCalls.length).toBe(1)
|
||||||
expect(hook.getState()?.iteration).toBe(3)
|
expect(hook.getState()?.iteration).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe("runtime-fallback", () => {
|
|||||||
await hook.event({
|
await hook.event({
|
||||||
event: {
|
event: {
|
||||||
type: "session.created",
|
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 () => {
|
test("should apply fallback model on next chat.message after error", async () => {
|
||||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
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"
|
const sessionID = "test-session-switch"
|
||||||
SessionCategoryRegistry.register(sessionID, "test")
|
SessionCategoryRegistry.register(sessionID, "test")
|
||||||
@@ -1916,7 +1916,7 @@ describe("runtime-fallback", () => {
|
|||||||
const input = createMockPluginInput()
|
const input = createMockPluginInput()
|
||||||
const hook = createRuntimeFallbackHook(input, {
|
const hook = createRuntimeFallbackHook(input, {
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
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"
|
const sessionID = "test-agent-fallback"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearContinuationMarker,
|
clearContinuationMarker,
|
||||||
@@ -8,6 +9,11 @@ import { log } from "../../shared/logger"
|
|||||||
|
|
||||||
const HOOK_NAME = "stop-continuation-guard"
|
const HOOK_NAME = "stop-continuation-guard"
|
||||||
|
|
||||||
|
type StopContinuationBackgroundManager = Pick<
|
||||||
|
BackgroundManager,
|
||||||
|
"getAllDescendantTasks" | "cancelTask"
|
||||||
|
>
|
||||||
|
|
||||||
export interface StopContinuationGuard {
|
export interface StopContinuationGuard {
|
||||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||||
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
||||||
@@ -17,7 +23,10 @@ export interface StopContinuationGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStopContinuationGuardHook(
|
export function createStopContinuationGuardHook(
|
||||||
ctx: PluginInput
|
ctx: PluginInput,
|
||||||
|
options?: {
|
||||||
|
backgroundManager?: StopContinuationBackgroundManager
|
||||||
|
}
|
||||||
): StopContinuationGuard {
|
): StopContinuationGuard {
|
||||||
const stoppedSessions = new Set<string>()
|
const stoppedSessions = new Set<string>()
|
||||||
|
|
||||||
@@ -25,6 +34,38 @@ export function createStopContinuationGuardHook(
|
|||||||
stoppedSessions.add(sessionID)
|
stoppedSessions.add(sessionID)
|
||||||
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
|
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
|
||||||
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
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 => {
|
const isStopped = (sessionID: string): boolean => {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import { mkdtempSync, rmSync } from "node:fs"
|
import { mkdtempSync, rmSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||||
import { readContinuationMarker } from "../../features/run-continuation-state"
|
import { readContinuationMarker } from "../../features/run-continuation-state"
|
||||||
import { createStopContinuationGuardHook } from "./index"
|
import { createStopContinuationGuardHook } from "./index"
|
||||||
|
|
||||||
|
type CancelCall = {
|
||||||
|
taskId: string
|
||||||
|
options?: Parameters<BackgroundManager["cancelTask"]>[1]
|
||||||
|
}
|
||||||
|
|
||||||
describe("stop-continuation-guard", () => {
|
describe("stop-continuation-guard", () => {
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
@@ -34,6 +40,33 @@ describe("stop-continuation-guard", () => {
|
|||||||
} as any
|
} 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", () => {
|
test("should mark session as stopped", () => {
|
||||||
// given - a guard hook with no stopped sessions
|
// given - a guard hook with no stopped sessions
|
||||||
const input = createMockPluginInput()
|
const input = createMockPluginInput()
|
||||||
@@ -166,4 +199,31 @@ describe("stop-continuation-guard", () => {
|
|||||||
// then - should not throw and stopped session remains stopped
|
// then - should not throw and stopped session remains stopped
|
||||||
expect(guard.isStopped("some-session")).toBe(true)
|
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({
|
const input = createHookInput({
|
||||||
sessionID,
|
sessionID,
|
||||||
providerID: "google",
|
providerID: "google",
|
||||||
modelID: "gemini-3-pro",
|
modelID: "gemini-3.1-pro",
|
||||||
})
|
})
|
||||||
const output = createHookOutput("Please solve this directly")
|
const output = createHookOutput("Please solve this directly")
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe("think-mode switcher", () => {
|
|||||||
|
|
||||||
it("should handle Gemini preview variants", () => {
|
it("should handle Gemini preview variants", () => {
|
||||||
// given Gemini preview model IDs
|
// given Gemini preview model IDs
|
||||||
expect(getHighVariant("gemini-3-pro")).toBe(
|
expect(getHighVariant("gemini-3.1-pro")).toBe(
|
||||||
"gemini-3-pro-high"
|
"gemini-3-1-pro-high"
|
||||||
)
|
)
|
||||||
expect(getHighVariant("gemini-3-flash")).toBe(
|
expect(getHighVariant("gemini-3-flash")).toBe(
|
||||||
"gemini-3-flash-high"
|
"gemini-3-flash-high"
|
||||||
@@ -61,7 +61,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given model IDs that are already high variants
|
// given model IDs that are already high variants
|
||||||
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
||||||
expect(getHighVariant("gpt-5-2-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", () => {
|
it("should return null for unknown models", () => {
|
||||||
@@ -77,7 +77,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given model IDs with -high suffix
|
// given model IDs with -high suffix
|
||||||
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("gpt-5-2-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", () => {
|
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("claude-opus-4.6")).toBe(false)
|
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
|
||||||
expect(isAlreadyHighVariant("gpt-5.2")).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", () => {
|
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
|
// given various custom prefixes
|
||||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
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("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", () => {
|
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
|
// given prefixed model IDs with -high suffix
|
||||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("openai/gpt-5-2-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", () => {
|
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-sonnet-4-6": "claude-sonnet-4-6-high",
|
||||||
"claude-opus-4-6": "claude-opus-4-6-high",
|
"claude-opus-4-6": "claude-opus-4-6-high",
|
||||||
// Gemini
|
// Gemini
|
||||||
"gemini-3-pro": "gemini-3-pro-high",
|
"gemini-3-1-pro": "gemini-3-1-pro-high",
|
||||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
"gemini-3-1-pro-low": "gemini-3-1-pro-high",
|
||||||
"gemini-3-flash": "gemini-3-flash-high",
|
"gemini-3-flash": "gemini-3-flash-high",
|
||||||
// GPT-5
|
// GPT-5
|
||||||
"gpt-5": "gpt-5-high",
|
"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-chat-latest": "gpt-5-2-chat-latest-high",
|
||||||
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
||||||
// Antigravity (Google)
|
// 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",
|
"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")
|
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MAX_CONSECUTIVE_FAILURES,
|
MAX_CONSECUTIVE_FAILURES,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||||
|
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||||
import type { SessionStateStore } from "./session-state"
|
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 })
|
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (hasUnansweredQuestion(messages)) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(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)
|
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 () => {
|
test("should not inject when background tasks are running", async () => {
|
||||||
// given - session with running background tasks
|
// given - session with running background tasks
|
||||||
const sessionID = "main-789"
|
const sessionID = "main-789"
|
||||||
@@ -1663,7 +1688,6 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cancel all countdowns via cancelAllCountdowns", async () => {
|
test("should cancel all countdowns via cancelAllCountdowns", async () => {
|
||||||
// given - multiple sessions with running countdowns
|
// given - multiple sessions with running countdowns
|
||||||
const session1 = "main-cancel-all-1"
|
const session1 = "main-cancel-all-1"
|
||||||
const session2 = "main-cancel-all-2"
|
|
||||||
setMainSession(session1)
|
setMainSession(session1)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Todo } from "./types"
|
import type { Todo } from "./types"
|
||||||
|
|
||||||
export function getIncompleteCount(todos: Todo[]): number {
|
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,
|
temperature: 0.3,
|
||||||
},
|
},
|
||||||
visual: {
|
visual: {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OhMyOpenCodeConfig;
|
} as unknown as OhMyOpenCodeConfig;
|
||||||
@@ -41,7 +41,7 @@ describe("mergeConfigs", () => {
|
|||||||
// then quick should be preserved from base
|
// then quick should be preserved from base
|
||||||
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
|
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
|
||||||
// then visual should be added from override
|
// 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", () => {
|
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 isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
|
||||||
const builderEnabled =
|
const builderEnabled =
|
||||||
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||||
@@ -194,9 +203,9 @@ export async function applyAgentConfig(params: {
|
|||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
|
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
|
||||||
),
|
),
|
||||||
...userAgents,
|
...filterDisabledAgents(userAgents),
|
||||||
...projectAgents,
|
...filterDisabledAgents(projectAgents),
|
||||||
...pluginAgents,
|
...filterDisabledAgents(pluginAgents),
|
||||||
...filteredConfigAgents,
|
...filteredConfigAgents,
|
||||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||||
@@ -204,9 +213,9 @@ export async function applyAgentConfig(params: {
|
|||||||
} else {
|
} else {
|
||||||
params.config.agent = {
|
params.config.agent = {
|
||||||
...builtinAgents,
|
...builtinAgents,
|
||||||
...userAgents,
|
...filterDisabledAgents(userAgents),
|
||||||
...projectAgents,
|
...filterDisabledAgents(projectAgents),
|
||||||
...pluginAgents,
|
...filterDisabledAgents(pluginAgents),
|
||||||
...configAgent,
|
...configAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ describe("Prometheus category config resolution", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(config).toBeDefined()
|
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", () => {
|
test("user categories override default categories", () => {
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export function createContinuationHooks(args: {
|
|||||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||||
|
|
||||||
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
|
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
|
||||||
? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx))
|
? safeHook("stop-continuation-guard", () =>
|
||||||
|
createStopContinuationGuardHook(ctx, {
|
||||||
|
backgroundManager,
|
||||||
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
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", () => {
|
test("should NOT mutate output.message.model when message ID present", () => {
|
||||||
//#given
|
//#given
|
||||||
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
|
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
|
||||||
|
|||||||
@@ -114,10 +114,12 @@ export function applyUltraworkModelOverrideOnMessage(
|
|||||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||||
if (!override) return
|
if (!override) return
|
||||||
|
|
||||||
|
if (override.variant) {
|
||||||
|
output.message["variant"] = override.variant
|
||||||
|
output.message["thinking"] = override.variant
|
||||||
|
}
|
||||||
|
|
||||||
if (!override.providerID || !override.modelID) {
|
if (!override.providerID || !override.modelID) {
|
||||||
if (override.variant) {
|
|
||||||
output.message["variant"] = override.variant
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +133,8 @@ export function applyUltraworkModelOverrideOnMessage(
|
|||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||||
output.message.model = targetModel
|
output.message.model = targetModel
|
||||||
if (override.variant) {
|
|
||||||
output.message["variant"] = override.variant
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
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 "./shell-env"
|
||||||
export * from "./system-directive"
|
export * from "./system-directive"
|
||||||
export * from "./agent-tool-restrictions"
|
export * from "./agent-tool-restrictions"
|
||||||
|
export * from "./orchestrator-agents"
|
||||||
export * from "./model-requirements"
|
export * from "./model-requirements"
|
||||||
export * from "./model-resolver"
|
export * from "./model-resolver"
|
||||||
export { normalizeFallbackModels } from "./model-resolver"
|
export { normalizeFallbackModels } from "./model-resolver"
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ describe("migrateAgentConfigToCategory", () => {
|
|||||||
test("migrates model to category when mapping exists", () => {
|
test("migrates model to category when mapping exists", () => {
|
||||||
// given: Config with a model that has a category mapping
|
// given: Config with a model that has a category mapping
|
||||||
const config = {
|
const config = {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
}
|
}
|
||||||
@@ -823,7 +823,7 @@ describe("migrateAgentConfigToCategory", () => {
|
|||||||
test("handles all mapped models correctly", () => {
|
test("handles all mapped models correctly", () => {
|
||||||
// given: Configs for each mapped model
|
// given: Configs for each mapped model
|
||||||
const configs = [
|
const configs = [
|
||||||
{ model: "google/gemini-3-pro" },
|
{ model: "google/gemini-3.1-pro" },
|
||||||
{ model: "google/gemini-3-flash" },
|
{ model: "google/gemini-3-flash" },
|
||||||
{ model: "openai/gpt-5.2" },
|
{ model: "openai/gpt-5.2" },
|
||||||
{ model: "anthropic/claude-haiku-4-5" },
|
{ model: "anthropic/claude-haiku-4-5" },
|
||||||
@@ -893,7 +893,7 @@ describe("shouldDeleteAgentConfig", () => {
|
|||||||
// given: Config with fields matching category defaults
|
// given: Config with fields matching category defaults
|
||||||
const config = {
|
const config = {
|
||||||
category: "visual-engineering",
|
category: "visual-engineering",
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when: Check if config should be deleted
|
// when: Check if config should be deleted
|
||||||
@@ -1021,7 +1021,7 @@ describe("migrateConfigFile with backup", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
||||||
oracle: { model: "openai/gpt-5.2" },
|
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>>
|
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||||
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
||||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
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", () => {
|
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.
|
* This map will be removed in a future major version once migration period ends.
|
||||||
*/
|
*/
|
||||||
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
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",
|
"google/gemini-3-flash": "writing",
|
||||||
"openai/gpt-5.2": "ultrabrain",
|
"openai/gpt-5.2": "ultrabrain",
|
||||||
"anthropic/claude-haiku-4-5": "quick",
|
"anthropic/claude-haiku-4-5": "quick",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
writeModelsCache({
|
writeModelsCache({
|
||||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
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, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -74,7 +74,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
expect(result.size).toBe(3)
|
expect(result.size).toBe(3)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).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 () => {
|
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
||||||
@@ -97,7 +97,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
list: async () => ({
|
list: async () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
{ 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).toBeInstanceOf(Set)
|
||||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
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 () => {
|
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||||
@@ -126,7 +126,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
list: async () => ({
|
list: async () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
{ 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).toBeInstanceOf(Set)
|
||||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
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 () => {
|
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({
|
writeModelsCache({
|
||||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
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, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -525,7 +525,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
expect(result.size).toBe(1)
|
expect(result.size).toBe(1)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
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
|
// given cache with multiple providers
|
||||||
@@ -535,7 +535,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
writeModelsCache({
|
writeModelsCache({
|
||||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
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, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -544,7 +544,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
|
|
||||||
expect(result.size).toBe(2)
|
expect(result.size).toBe(2)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).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)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -759,7 +759,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
|||||||
models: {
|
models: {
|
||||||
opencode: ["big-pickle"],
|
opencode: ["big-pickle"],
|
||||||
anthropic: ["claude-opus-4-6"],
|
anthropic: ["claude-opus-4-6"],
|
||||||
google: ["gemini-3-pro"]
|
google: ["gemini-3.1-pro"]
|
||||||
},
|
},
|
||||||
connected: ["opencode", "anthropic", "google"]
|
connected: ["opencode", "anthropic", "google"]
|
||||||
})
|
})
|
||||||
@@ -771,7 +771,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
|||||||
expect(result.size).toBe(1)
|
expect(result.size).toBe(1)
|
||||||
expect(result.has("opencode/big-pickle")).toBe(true)
|
expect(result.has("opencode/big-pickle")).toBe(true)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
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 () => {
|
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: ["openai"], model: "gpt-5.2" },
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ 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
|
// when
|
||||||
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)
|
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)
|
||||||
|
|||||||
@@ -248,19 +248,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
|||||||
expect(primary.providers[0]).toBe("openai")
|
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
|
// given - visual-engineering category requirement
|
||||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||||
|
|
||||||
// when - accessing visual-engineering requirement
|
// 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).toBeDefined()
|
||||||
expect(visualEngineering.fallbackChain).toBeArray()
|
expect(visualEngineering.fallbackChain).toBeArray()
|
||||||
expect(visualEngineering.fallbackChain).toHaveLength(3)
|
expect(visualEngineering.fallbackChain).toHaveLength(3)
|
||||||
|
|
||||||
const primary = visualEngineering.fallbackChain[0]
|
const primary = visualEngineering.fallbackChain[0]
|
||||||
expect(primary.providers[0]).toBe("google")
|
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")
|
expect(primary.variant).toBe("high")
|
||||||
|
|
||||||
const second = visualEngineering.fallbackChain[1]
|
const second = visualEngineering.fallbackChain[1]
|
||||||
@@ -319,39 +319,43 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
|||||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
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
|
// given - artistry category requirement
|
||||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||||
|
|
||||||
// when - accessing artistry requirement
|
// 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).toBeDefined()
|
||||||
expect(artistry.fallbackChain).toBeArray()
|
expect(artistry.fallbackChain).toBeArray()
|
||||||
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
const primary = artistry.fallbackChain[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.variant).toBe("high")
|
||||||
expect(primary.providers[0]).toBe("google")
|
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
|
// given - writing category requirement
|
||||||
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
||||||
|
|
||||||
// when - accessing writing requirement
|
// 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).toBeDefined()
|
||||||
expect(writing.fallbackChain).toBeArray()
|
expect(writing.fallbackChain).toBeArray()
|
||||||
expect(writing.fallbackChain).toHaveLength(2)
|
expect(writing.fallbackChain).toHaveLength(3)
|
||||||
|
|
||||||
const primary = writing.fallbackChain[0]
|
const primary = writing.fallbackChain[0]
|
||||||
expect(primary.model).toBe("gemini-3-flash")
|
expect(primary.model).toBe("kimi-k2.5-free")
|
||||||
expect(primary.providers[0]).toBe("google")
|
expect(primary.providers[0]).toBe("opencode")
|
||||||
|
|
||||||
const second = writing.fallbackChain[1]
|
const second = writing.fallbackChain[1]
|
||||||
expect(second.model).toBe("claude-sonnet-4-6")
|
expect(second.model).toBe("gemini-3-flash")
|
||||||
expect(second.providers[0]).toBe("anthropic")
|
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", () => {
|
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")
|
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
|
// given
|
||||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||||
|
|
||||||
// when / #then
|
// 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: {
|
oracle: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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" },
|
{ 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: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ 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: {
|
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: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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: {
|
momus: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ 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: {
|
atlas: {
|
||||||
@@ -95,7 +95,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
fallbackChain: [
|
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: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ 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: {
|
ultrabrain: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
{ 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" },
|
{ 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: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ 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",
|
requiresModel: "gpt-5.3-codex",
|
||||||
},
|
},
|
||||||
artistry: {
|
artistry: {
|
||||||
fallbackChain: [
|
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: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
requiresModel: "gemini-3-pro",
|
requiresModel: "gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
quick: {
|
quick: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
@@ -141,11 +141,12 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ 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: {
|
writing: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -25,7 +25,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: undefined,
|
userModel: undefined,
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -40,14 +40,14 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: undefined,
|
userModel: undefined,
|
||||||
inheritedModel: undefined,
|
inheritedModel: undefined,
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModel(input)
|
const result = resolveModel(input)
|
||||||
|
|
||||||
// then
|
// 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 = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "",
|
userModel: "",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -72,14 +72,14 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: " ",
|
userModel: " ",
|
||||||
inheritedModel: "",
|
inheritedModel: "",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModel(input)
|
const result = resolveModel(input)
|
||||||
|
|
||||||
// then
|
// 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 = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -123,7 +123,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
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
|
// when
|
||||||
@@ -141,7 +141,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: "opencode/big-pickle",
|
uiSelectedModel: "opencode/big-pickle",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -158,7 +158,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: " ",
|
uiSelectedModel: " ",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -175,7 +175,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: "",
|
uiSelectedModel: "",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -195,7 +195,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
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
|
// when
|
||||||
@@ -215,7 +215,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -234,7 +234,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -252,7 +252,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -271,7 +271,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
{ 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"]),
|
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
|
// when
|
||||||
@@ -294,8 +294,8 @@ describe("resolveModelWithFallback", () => {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "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-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -313,7 +313,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/gpt-5-nano"]),
|
availableModels: new Set(["opencode/gpt-5-nano"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -331,7 +331,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
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
|
// when
|
||||||
@@ -346,7 +346,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given
|
// given
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -361,7 +361,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [],
|
fallbackChain: [],
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -378,7 +378,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["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
|
// when
|
||||||
@@ -397,7 +397,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/glm-5", "anthropic/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
|
// when
|
||||||
@@ -420,7 +420,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/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
|
// when
|
||||||
@@ -438,7 +438,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/glm-5"]),
|
availableModels: new Set(["opencode/glm-5"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -457,7 +457,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -477,14 +477,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then
|
// 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(result!.source).toBe("system-default")
|
||||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to 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" },
|
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -533,7 +533,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
@@ -544,7 +544,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
// then - should use github-copilot (second provider) since google not connected
|
// then - should use github-copilot (second provider) since google not connected
|
||||||
// model name is transformed to preview variant for github-copilot provider
|
// 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")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -577,14 +577,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should fall through to system default
|
// 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")
|
expect(result!.source).toBe("system-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -593,14 +593,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given
|
// given
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
availableModels: new Set(["openai/gpt-5.2"]),
|
availableModels: new Set(["openai/gpt-5.2"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then
|
// 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(result!.source).toBe("system-default")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -627,20 +627,20 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
test("tries all providers in first entry before moving to second entry", () => {
|
test("tries all providers in first entry before moving to second entry", () => {
|
||||||
// given
|
// given
|
||||||
const availableModels = new Set(["google/gemini-3-pro"])
|
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback({
|
const result = resolveModelWithFallback({
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels,
|
availableModels,
|
||||||
systemDefaultModel: "system/default",
|
systemDefaultModel: "system/default",
|
||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("provider-fallback")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -675,7 +675,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai"], model: "gpt-5.2" },
|
{ providers: ["openai"], model: "gpt-5.2" },
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels,
|
availableModels,
|
||||||
systemDefaultModel: "system/default",
|
systemDefaultModel: "system/default",
|
||||||
@@ -693,7 +693,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -708,32 +708,32 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
||||||
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
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 = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
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",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
|
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
|
||||||
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")
|
expect(result!.source).toBe("category-default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("categoryDefaultModel uses exact match when available", () => {
|
test("categoryDefaultModel uses exact match when available", () => {
|
||||||
// given - exact match exists
|
// given - exact match exists
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
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",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,14 +741,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should use exact match
|
// 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")
|
expect(result!.source).toBe("category-default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
||||||
// given - categoryDefaultModel has no match, but fallbackChain does
|
// given - categoryDefaultModel has no match, but fallbackChain does
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
@@ -768,11 +768,11 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - both userModel and categoryDefaultModel provided
|
// given - both userModel and categoryDefaultModel provided
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
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",
|
systemDefaultModel: "system/default",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +788,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - no availableModels but connected provider cache exists
|
// given - no availableModels but connected provider cache exists
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
@@ -797,7 +797,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should use transformed categoryDefaultModel since google is connected
|
// 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")
|
expect(result!.source).toBe("category-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -824,7 +824,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - category default already has -preview suffix
|
// given - category default already has -preview suffix
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro-preview",
|
categoryDefaultModel: "google/gemini-3.1-pro-preview",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||||
}
|
}
|
||||||
@@ -832,18 +832,18 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should NOT become gemini-3-pro-preview-preview
|
// then - should NOT become gemini-3.1-pro-preview-preview
|
||||||
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")
|
expect(result!.source).toBe("category-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro in fallback chain for google connected provider", () => {
|
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
|
||||||
// given - google connected, fallback chain has gemini-3-pro
|
// given - google connected, fallback chain has gemini-3.1-pro
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||||
@@ -853,7 +853,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should transform to preview variant for google provider
|
// 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")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
cacheSpy.mockRestore()
|
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-sonnet-4-5", "claude-sonnet-4.5")
|
||||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||||
.replace("claude-sonnet-4", "claude-sonnet-4")
|
.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")
|
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||||
}
|
}
|
||||||
if (provider === "google") {
|
if (provider === "google") {
|
||||||
return model
|
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")
|
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||||
}
|
}
|
||||||
return model
|
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> = {
|
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" },
|
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
|
||||||
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
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" },
|
quick: { model: "anthropic/claude-haiku-4-5" },
|
||||||
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
||||||
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
"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-opus-4-6",
|
||||||
"anthropic/claude-sonnet-4-6",
|
"anthropic/claude-sonnet-4-6",
|
||||||
"anthropic/claude-haiku-4-5",
|
"anthropic/claude-haiku-4-5",
|
||||||
"google/gemini-3-pro",
|
"google/gemini-3.1-pro",
|
||||||
"google/gemini-3-flash",
|
"google/gemini-3-flash",
|
||||||
"openai/gpt-5.2",
|
"openai/gpt-5.2",
|
||||||
"openai/gpt-5.3-codex",
|
"openai/gpt-5.3-codex",
|
||||||
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
|
|||||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
models: {
|
models: {
|
||||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
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"],
|
openai: ["gpt-5.2", "gpt-5.3-codex"],
|
||||||
},
|
},
|
||||||
connected: ["anthropic", "google", "openai"],
|
connected: ["anthropic", "google", "openai"],
|
||||||
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// when / #then
|
// when / #then
|
||||||
expect(category).toBeDefined()
|
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")
|
expect(category.variant).toBe("high")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).not.toBeNull()
|
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")
|
expect(result!.promptAppend).toContain("VISUAL/UI")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
|
|||||||
const categoryName = "visual-engineering"
|
const categoryName = "visual-engineering"
|
||||||
const userCategories = {
|
const userCategories = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
prompt_append: "Custom instructions here",
|
prompt_append: "Custom instructions here",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
|
|||||||
const categoryName = "visual-engineering"
|
const categoryName = "visual-engineering"
|
||||||
const userCategories = {
|
const userCategories = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then - category's built-in model wins over inheritedModel
|
// then - category's built-in model wins over inheritedModel
|
||||||
expect(result).not.toBeNull()
|
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", () => {
|
test("systemDefaultModel is used as fallback when custom category has no model", () => {
|
||||||
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).not.toBeNull()
|
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 = {
|
const mockClient = {
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
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: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/project" } }),
|
get: async () => ({ data: { directory: "/project" } }),
|
||||||
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
||||||
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
|
|||||||
const mockClient = {
|
const mockClient = {
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
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: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/project" } }),
|
get: async () => ({ data: { directory: "/project" } }),
|
||||||
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
||||||
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
|
|||||||
abort: new AbortController().signal,
|
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(
|
const result = await tool.execute(
|
||||||
{
|
{
|
||||||
description: "Test artistry forced background",
|
description: "Test artistry forced background",
|
||||||
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
|
|||||||
// when resolveCategoryConfig is called
|
// when resolveCategoryConfig is called
|
||||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
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).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", () => {
|
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
|
// 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
|
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
|
||||||
expect(promptBody.model).toBeDefined()
|
expect(promptBody.model).toBeDefined()
|
||||||
expect(promptBody.model.providerID).toBe("openai")
|
expect(promptBody.model.providerID).toBe("openai")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
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 { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||||
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
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.`
|
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",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user