Compare commits

...

22 Commits

Author SHA1 Message Date
YeonGyu-Kim
4f8286c561 refactor(hooks): extract shared isOrchestratorAgent utility to avoid duplication 2026-02-28 13:32:09 +09:00
ismeth
35edcecd8f fix(agent-usage-reminder): skip reminders for non-orchestrator subagents 2026-02-26 17:05:33 +01:00
YeonGyu-Kim
58201220cc Merge pull request #2093 from code-yeongyu/fix/issue-1966-ultrawork-variant
fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
2026-02-26 23:23:14 +09:00
YeonGyu-Kim
4efad491e7 Merge pull request #2149 from code-yeongyu/fix/issue-1815-1733-prompt-token-count
fix(delegate-task): add token counting and truncation to prevent context overflow
2026-02-26 23:19:35 +09:00
YeonGyu-Kim
4df69c58bf fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
Closes #1966
2026-02-26 23:15:32 +09:00
YeonGyu-Kim
cc8ef7fe39 ci: trigger CI 2026-02-26 23:14:33 +09:00
YeonGyu-Kim
2ece7c3d0a Merge pull request #1963 from MoerAI/fix/multi-issue-1888-1693-1891
fix: resolve issues #1888, #1693, #1891
2026-02-26 23:13:00 +09:00
YeonGyu-Kim
decff3152a Merge pull request #2145 from code-yeongyu/fix/issue-1915-windows-spawn-hide
fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
2026-02-26 23:12:57 +09:00
YeonGyu-Kim
0526bac873 Merge pull request #2148 from code-yeongyu/fix/issue-2121-legacy-hardware-baseline
fix(ci): add baseline CPU variant binaries for legacy hardware support
2026-02-26 21:09:19 +09:00
YeonGyu-Kim
0c62656cc6 Merge pull request #2146 from code-yeongyu/fix/issue-2065-1968-model-updates
fix(models): update Gemini 3→3.1 Pro and add Kimi K2.5 to writing category
2026-02-26 21:07:23 +09:00
YeonGyu-Kim
aff43bfc77 Merge pull request #2143 from code-yeongyu/fix/issue-2017-stop-continuation-cancel
fix(stop-continuation): wire backgroundManager to cancel running tasks on stop
2026-02-26 21:07:20 +09:00
YeonGyu-Kim
6865cee8ca Merge pull request #2141 from code-yeongyu/fix/issue-2084-ralph-loop-inflight
fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
2026-02-26 21:07:17 +09:00
YeonGyu-Kim
8721ba471c Merge pull request #2140 from code-yeongyu/fix/issue-2025-blocked-todo-continuation
fix(todo-continuation): exclude blocked todos from incomplete count to prevent infinite loops
2026-02-26 21:06:55 +09:00
YeonGyu-Kim
d425f9bb80 fix(models): update Gemini 3 to 3.1 Pro and add Kimi to writing category fallback
Closes #2065

Closes #1968
2026-02-26 21:01:26 +09:00
YeonGyu-Kim
cc5e9d1e9b fix(ci): add baseline CPU variant binaries for legacy hardware support
Closes #2121
2026-02-26 21:00:45 +09:00
YeonGyu-Kim
269f37af1c fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
Closes #1915
2026-02-26 21:00:40 +09:00
YeonGyu-Kim
ccb789e5df fix(stop-continuation): wire backgroundManager to cancel running tasks on stop
Closes #2017
2026-02-26 20:59:35 +09:00
YeonGyu-Kim
a6617d93c0 fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
Closes #2084
2026-02-26 20:59:18 +09:00
YeonGyu-Kim
2295161022 fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
Closes #2084
2026-02-26 20:58:55 +09:00
YeonGyu-Kim
0516f2febc fix(todo-continuation): exclude blocked todos from incomplete count to prevent infinite loops
Closes #2025
2026-02-26 20:58:48 +09:00
YeonGyu-Kim
07e8d965a8 fix(atlas): allow task and call_omo_agent tools for subagent dispatch
Closes #2044
2026-02-26 20:54:42 +09:00
MoerAI
718884210b fix: resolve issues #1888, #1693, #1891
- fix(hooks): skip todo continuation when agent has pending question (#1888)
  Add pending-question-detection module that walks messages backwards
  to detect unanswered question tool_use, preventing CONTINUATION_PROMPT
  injection while awaiting user response.

- fix(config): allow custom agent names in disabled_agents (#1693)
  Change disabled_agents schema from BuiltinAgentNameSchema to z.string()
  and add filterDisabledAgents helper in agent-config-handler to filter
  user, project, and plugin agents with case-insensitive matching.

- fix(agents): change primary agents mode to 'all' (#1891)
  Update Sisyphus, Hephaestus, and Atlas agent modes from 'primary'
  to 'all' so they are available for @mention routing and task()
  delegation in addition to direct chat.
2026-02-24 16:57:02 +09:00
69 changed files with 1201 additions and 481 deletions

View File

@@ -35,15 +35,15 @@ jobs:
# - Uploads compressed artifacts for the publish job
# =============================================================================
build:
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
runs-on: ${{ startsWith(matrix.platform, 'windows-') && 'windows-latest' || 'ubuntu-latest' }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 7
max-parallel: 11
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- uses: actions/checkout@v4
@@ -95,14 +95,18 @@ jobs:
case "$PLATFORM" in
darwin-arm64) TARGET="bun-darwin-arm64" ;;
darwin-x64) TARGET="bun-darwin-x64" ;;
darwin-x64-baseline) TARGET="bun-darwin-x64-baseline" ;;
linux-x64) TARGET="bun-linux-x64" ;;
linux-x64-baseline) TARGET="bun-linux-x64-baseline" ;;
linux-arm64) TARGET="bun-linux-arm64" ;;
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
linux-x64-musl-baseline) TARGET="bun-linux-x64-musl-baseline" ;;
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
windows-x64) TARGET="bun-windows-x64" ;;
windows-x64-baseline) TARGET="bun-windows-x64-baseline" ;;
esac
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
else
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
@@ -119,7 +123,7 @@ jobs:
PLATFORM="${{ matrix.platform }}"
cd packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
# Windows: use 7z (pre-installed on windows-latest)
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
else
@@ -155,7 +159,7 @@ jobs:
fail-fast: false
max-parallel: 2
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- name: Check if already published
id: check
@@ -184,7 +188,7 @@ jobs:
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
if [[ "$PLATFORM" == windows-* ]]; then
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
else
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/

View File

@@ -189,7 +189,7 @@ jobs:
VERSION="${{ steps.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64; do
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
mv tmp.json "packages/${platform}/package.json"
done

View File

@@ -3,8 +3,9 @@
// Wrapper script that detects platform and spawns the correct binary
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
const require = createRequire(import.meta.url);
@@ -26,55 +27,116 @@ function getLibcFamily() {
}
}
function supportsAvx2() {
if (process.arch !== "x64") {
return null;
}
if (process.env.OH_MY_OPENCODE_FORCE_BASELINE === "1") {
return false;
}
if (process.platform === "linux") {
try {
const cpuInfo = readFileSync("/proc/cpuinfo", "utf8").toLowerCase();
return cpuInfo.includes("avx2");
} catch {
return null;
}
}
if (process.platform === "darwin") {
const probe = spawnSync("sysctl", ["-n", "machdep.cpu.leaf7_features"], {
encoding: "utf8",
});
if (probe.error || probe.status !== 0) {
return null;
}
return probe.stdout.toUpperCase().includes("AVX2");
}
return null;
}
function getSignalExitCode(signal) {
const signalCodeByName = {
SIGINT: 2,
SIGILL: 4,
SIGKILL: 9,
SIGTERM: 15,
};
return 128 + (signalCodeByName[signal] ?? 1);
}
function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
const avx2Supported = supportsAvx2();
// Get platform package name
let pkg;
let packageCandidates;
try {
pkg = getPlatformPackage({ platform, arch, libcFamily });
packageCandidates = getPlatformPackageCandidates({
platform,
arch,
libcFamily,
preferBaseline: avx2Supported === false,
});
} catch (error) {
console.error(`\noh-my-opencode: ${error.message}\n`);
process.exit(1);
}
// Resolve binary path
const binRelPath = getBinaryPath(pkg, platform);
let binPath;
try {
binPath = require.resolve(binRelPath);
} catch {
const resolvedBinaries = packageCandidates
.map((pkg) => {
try {
return { pkg, binPath: require.resolve(getBinaryPath(pkg, platform)) };
} catch {
return null;
}
})
.filter((entry) => entry !== null);
if (resolvedBinaries.length === 0) {
console.error(`\noh-my-opencode: Platform binary not installed.`);
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
console.error(`Expected package: ${pkg}`);
console.error(`Expected packages (in order): ${packageCandidates.join(", ")}`);
console.error(`\nTo fix, run:`);
console.error(` npm install ${pkg}\n`);
console.error(` npm install ${packageCandidates[0]}\n`);
process.exit(1);
}
// Spawn the binary
const result = spawnSync(binPath, process.argv.slice(2), {
stdio: "inherit",
});
// Handle spawn errors
if (result.error) {
console.error(`\noh-my-opencode: Failed to execute binary.`);
console.error(`Error: ${result.error.message}\n`);
process.exit(2);
}
// Handle signals
if (result.signal) {
const signalNum = result.signal === "SIGTERM" ? 15 :
result.signal === "SIGKILL" ? 9 :
result.signal === "SIGINT" ? 2 : 1;
process.exit(128 + signalNum);
for (let index = 0; index < resolvedBinaries.length; index += 1) {
const currentBinary = resolvedBinaries[index];
const hasFallback = index < resolvedBinaries.length - 1;
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
stdio: "inherit",
});
if (result.error) {
if (hasFallback) {
continue;
}
console.error(`\noh-my-opencode: Failed to execute binary.`);
console.error(`Error: ${result.error.message}\n`);
process.exit(2);
}
if (result.signal === "SIGILL" && hasFallback) {
continue;
}
if (result.signal) {
process.exit(getSignalExitCode(result.signal));
}
process.exit(result.status ?? 1);
}
process.exit(result.status ?? 1);
process.exit(1);
}
main();

14
bin/platform.d.ts vendored Normal file
View 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;

View File

@@ -26,6 +26,50 @@ export function getPlatformPackage({ platform, arch, libcFamily }) {
return `oh-my-opencode-${os}-${arch}${suffix}`;
}
/** @param {{ platform: string, arch: string, libcFamily?: string | null, preferBaseline?: boolean }} options */
export function getPlatformPackageCandidates({ platform, arch, libcFamily, preferBaseline = false }) {
const primaryPackage = getPlatformPackage({ platform, arch, libcFamily });
const baselinePackage = getBaselinePlatformPackage({ platform, arch, libcFamily });
if (!baselinePackage) {
return [primaryPackage];
}
return preferBaseline ? [baselinePackage, primaryPackage] : [primaryPackage, baselinePackage];
}
/** @param {{ platform: string, arch: string, libcFamily?: string | null }} options */
function getBaselinePlatformPackage({ platform, arch, libcFamily }) {
if (arch !== "x64") {
return null;
}
if (platform === "darwin") {
return "oh-my-opencode-darwin-x64-baseline";
}
if (platform === "win32") {
return "oh-my-opencode-windows-x64-baseline";
}
if (platform === "linux") {
if (libcFamily === null || libcFamily === undefined) {
throw new Error(
"Could not detect libc on Linux. " +
"Please ensure detect-libc is installed or report this issue."
);
}
if (libcFamily === "musl") {
return "oh-my-opencode-linux-x64-musl-baseline";
}
return "oh-my-opencode-linux-x64-baseline";
}
return null;
}
/**
* Get the path to the binary within a platform package
* @param {string} pkg Package name

View File

@@ -1,6 +1,6 @@
// bin/platform.test.ts
import { describe, expect, test } from "bun:test";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
describe("getPlatformPackage", () => {
// #region Darwin platforms
@@ -146,3 +146,58 @@ describe("getBinaryPath", () => {
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
});
});
describe("getPlatformPackageCandidates", () => {
test("returns x64 and baseline candidates for Linux glibc", () => {
// #given Linux x64 with glibc
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then returns modern first then baseline fallback
expect(result).toEqual([
"oh-my-opencode-linux-x64",
"oh-my-opencode-linux-x64-baseline",
]);
});
test("returns x64 musl and baseline candidates for Linux musl", () => {
// #given Linux x64 with musl
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then returns musl modern first then musl baseline fallback
expect(result).toEqual([
"oh-my-opencode-linux-x64-musl",
"oh-my-opencode-linux-x64-musl-baseline",
]);
});
test("returns baseline first when preferBaseline is true", () => {
// #given Windows x64 and baseline preference
const input = { platform: "win32", arch: "x64", preferBaseline: true };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then baseline package is preferred first
expect(result).toEqual([
"oh-my-opencode-windows-x64-baseline",
"oh-my-opencode-windows-x64",
]);
});
test("returns only one candidate for ARM64", () => {
// #given non-x64 platform
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
// #when getting package candidates
const result = getPlatformPackageCandidates(input);
// #then baseline fallback is not included
expect(result).toEqual(["oh-my-opencode-linux-arm64"]);
});
});

View File

@@ -77,11 +77,15 @@
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-darwin-x64-baseline": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-baseline": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5"
"oh-my-opencode-linux-x64-musl-baseline": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5",
"oh-my-opencode-windows-x64-baseline": "3.8.5"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -2,7 +2,7 @@
// Runs after npm install to verify platform binary is available
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
const require = createRequire(import.meta.url);
@@ -27,12 +27,28 @@ function main() {
const libcFamily = getLibcFamily();
try {
const pkg = getPlatformPackage({ platform, arch, libcFamily });
const binPath = getBinaryPath(pkg, platform);
// Try to resolve the binary
require.resolve(binPath);
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
const packageCandidates = getPlatformPackageCandidates({
platform,
arch,
libcFamily,
});
const resolvedPackage = packageCandidates.find((pkg) => {
try {
require.resolve(getBinaryPath(pkg, platform));
return true;
} catch {
return false;
}
});
if (!resolvedPackage) {
throw new Error(
`No platform binary package installed. Tried: ${packageCandidates.join(", ")}`
);
}
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch} (${resolvedPackage})`);
} catch (error) {
console.warn(`⚠ oh-my-opencode: ${error.message}`);
console.warn(` The CLI may not work on this platform.`);

View File

@@ -29,7 +29,7 @@ import {
buildDecisionMatrix,
} from "./prompt-section-builder"
const MODE: AgentMode = "primary"
const MODE: AgentMode = "all"
export type AtlasPromptSource = "default" | "gpt" | "gemini"

View File

@@ -19,7 +19,7 @@ import {
categorizeTools,
} from "./dynamic-agent-prompt-builder";
const MODE: AgentMode = "primary";
const MODE: AgentMode = "all";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {

View File

@@ -8,7 +8,7 @@ import {
buildGeminiIntentGateEnforcement,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary";
const MODE: AgentMode = "all";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "EXPENSIVE",

View File

@@ -47,8 +47,8 @@ describe("isGptModel", () => {
});
test("gemini models are not gpt", () => {
expect(isGptModel("google/gemini-3-pro")).toBe(false);
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
expect(isGptModel("google/gemini-3.1-pro")).toBe(false);
expect(isGptModel("litellm/gemini-3.1-pro")).toBe(false);
});
test("opencode provider is not gpt", () => {
@@ -58,29 +58,29 @@ describe("isGptModel", () => {
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
});
test("#given google-vertex provider models #then returns true", () => {
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
});
test("#given github copilot gemini models #then returns true", () => {
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
});
test("#given litellm proxied gemini models #then returns true", () => {
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
});
test("#given other proxied gemini models #then returns true", () => {
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
expect(isGeminiModel("custom-provider/gemini-3.1-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});

View File

@@ -988,7 +988,7 @@ describe("buildAgent with category and skills", () => {
const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then - category's built-in model is applied
expect(agent.model).toBe("google/gemini-3-pro")
expect(agent.model).toBe("google/gemini-3.1-pro")
})
test("agent with category and existing model keeps existing model", () => {

View File

@@ -325,7 +325,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -334,34 +334,34 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -371,7 +371,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -386,7 +386,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -395,44 +395,44 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"momus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "google/gemini-3-flash-preview",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
},
"unspecified-low": {
"model": "google/gemini-3-flash-preview",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -506,7 +506,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -581,7 +581,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -634,7 +634,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -655,7 +655,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"model": "opencode/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -708,7 +708,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -730,7 +730,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"model": "opencode/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -779,14 +779,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -845,14 +845,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -863,7 +863,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1026,7 +1026,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
},
"categories": {
"artistry": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"deep": {
@@ -1047,7 +1047,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"model": "opencode/gemini-3.1-pro",
"variant": "high",
},
"writing": {
@@ -1100,7 +1100,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1121,7 +1121,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1217,7 +1217,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "google/gemini-3-flash-preview",
},
"oracle": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"prometheus": {
@@ -1231,14 +1231,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
"ultrabrain": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"unspecified-high": {
@@ -1248,7 +1248,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1301,7 +1301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
},
"categories": {
"artistry": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1322,7 +1322,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "github-copilot/claude-sonnet-4.5",
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"model": "github-copilot/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1375,7 +1375,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1396,7 +1396,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {
@@ -1449,7 +1449,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
},
"categories": {
"artistry": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"deep": {
@@ -1471,7 +1471,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"writing": {

View File

@@ -178,7 +178,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(models).toBeTruthy()
const required = [
"antigravity-gemini-3-pro",
"antigravity-gemini-3.1-pro",
"antigravity-gemini-3-flash",
"antigravity-claude-sonnet-4-6",
"antigravity-claude-sonnet-4-6-thinking",
@@ -206,7 +206,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
// #when checking Gemini Pro variants
const pro = models["antigravity-gemini-3-pro"]
const pro = models["antigravity-gemini-3.1-pro"]
// #then should have low and high variants
expect(pro.variants).toBeTruthy()
expect(pro.variants.low).toBeTruthy()

View File

@@ -4,10 +4,10 @@
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3.1-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3.1-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
@@ -16,7 +16,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
"antigravity-gemini-3.1-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },

View File

@@ -1,4 +1,5 @@
import { getConfigDir } from "./config-context"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
@@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",

View File

@@ -1,4 +1,5 @@
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
const proc = spawnWithWindowsHide([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
import { dirname, join } from "node:path"
import type { DependencyInfo } from "../types"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getBinaryVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
path: resolvedPath,
}
}

View File

@@ -26,7 +26,7 @@ describe("model-resolution check", () => {
// then: Should have category entries
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro")
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3.1-pro")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
})
})

View File

@@ -1,6 +1,7 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
import { OPENCODE_BINARIES } from "../constants"
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
): Promise<string | null> {
try {
const command = buildVersionCommand(binaryPath, platform)
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited

View File

@@ -1,3 +1,5 @@
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
export interface GhCliInfo {
installed: boolean
version: string | null
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getGhVersion(): Promise<string | null> {
try {
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited
if (processResult.exitCode !== 0) return null
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
error: string | null
}> {
try {
const processResult = Bun.spawn(["gh", "auth", "status"], {
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },

View File

@@ -24,7 +24,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
oracle: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -59,7 +59,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
metis: {
@@ -68,14 +68,14 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
momus: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
atlas: {
@@ -84,7 +84,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
}
@@ -92,7 +92,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
@@ -101,7 +101,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
ultrabrain: {
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -109,17 +109,17 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
requiresModel: "gpt-5.3-codex",
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
requiresModel: "gemini-3-pro",
requiresModel: "gemini-3.1-pro",
},
quick: {
fallbackChain: [
@@ -139,7 +139,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
writing: {

View File

@@ -40,16 +40,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("claude-haiku-4.5")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro model
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
// #given github-copilot provider and gemini-3.1-pro model
const provider = "github-copilot"
const model = "gemini-3-pro"
const model = "gemini-3.1-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should transform to gemini-3.1-pro-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
@@ -64,16 +64,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
test("prevents double transformation of gemini-3.1-pro-preview", () => {
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
const provider = "github-copilot"
const model = "gemini-3-pro-preview"
const model = "gemini-3.1-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should NOT become gemini-3.1-pro-preview-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("prevents double transformation of gemini-3-flash-preview", () => {
@@ -102,16 +102,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro model
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
// #given google provider and gemini-3.1-pro model
const provider = "google"
const model = "gemini-3-pro"
const model = "gemini-3.1-pro"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to gemini-3-pro-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should transform to gemini-3.1-pro-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("passes through other gemini models unchanged", () => {
@@ -138,16 +138,16 @@ describe("transformModelForProvider", () => {
expect(result).toBe("gemini-3-flash-preview")
})
test("prevents double transformation of gemini-3-pro-preview", () => {
// #given google provider and gemini-3-pro-preview model (already transformed)
test("prevents double transformation of gemini-3.1-pro-preview", () => {
// #given google provider and gemini-3.1-pro-preview model (already transformed)
const provider = "google"
const model = "gemini-3-pro-preview"
const model = "gemini-3.1-pro-preview"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should NOT become gemini-3-pro-preview-preview
expect(result).toBe("gemini-3-pro-preview")
// #then should NOT become gemini-3.1-pro-preview-preview
expect(result).toBe("gemini-3.1-pro-preview")
})
test("does not transform claude models for google provider", () => {

View File

@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output"
import { resolveSession } from "./session-resolver"
import { executeOnCompleteHook } from "./on-complete-hook"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import type { OpencodeClient } from "./types"
import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils"
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
// then
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(mockClient.session.get).toHaveBeenCalledWith({
path: { id: sessionId },
query: { directory: "/test" },
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
beforeEach(() => {
spyOn(console, "error").mockImplementation(() => {})
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>)
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
})
afterEach(() => {
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
// then
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000")
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
spyOn(console, "error").mockImplementation(() => {})
mockStdout = createMockWriteStream()
mockStderr = createMockWriteStream()
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>)
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
})
afterEach(() => {
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
const emitted = mockStdout.writes[0]!
expect(() => JSON.parse(emitted)).not.toThrow()
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo done"])
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000")

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import { executeOnCompleteHook } from "./on-complete-hook"
describe("executeOnCompleteHook", () => {
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
return {
exited: Promise.resolve(exitCode),
exitCode,
} as unknown as ReturnType<typeof Bun.spawn>
stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
}
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
it("executes command with correct env vars", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
// then
expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo test"])
expect(options?.env?.SESSION_ID).toBe("session-123")
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
it("env var values are strings", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
})
// then
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.EXIT_CODE).toBe("1")
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
it("empty command string is no-op", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
it("whitespace-only command is no-op", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try {
// when
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
it("command failure logs warning but does not throw", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
try {
// when
await expect(
expect(
executeOnCompleteHook({
command: "false",
sessionId: "session-123",
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
it("spawn error logs warning but does not throw", async () => {
// given
const spawnError = new Error("Command not found")
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
throw spawnError
})
try {
// when
await expect(
expect(
executeOnCompleteHook({
command: "nonexistent-command",
sessionId: "session-123",

View File

@@ -1,4 +1,5 @@
import pc from "picocolors"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
export async function executeOnCompleteHook(options: {
command: string
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
try {
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
env: {
...process.env,
SESSION_ID: sessionId,

View File

@@ -1,4 +1,5 @@
import { delimiter, dirname, join } from "node:path"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
@@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
try {
const proc = Bun.spawn([binaryPath, "--version"], {
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
stdout: "pipe",
stderr: "pipe",
})

View File

@@ -27,7 +27,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
default_run_agent: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_agents: z.array(z.string()).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(z.string()).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),

View File

@@ -34,7 +34,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
// given
const config: BackgroundTaskConfig = {
modelConcurrency: { "google/gemini-3-pro": 5 },
modelConcurrency: { "google/gemini-3.1-pro": 5 },
providerConcurrency: { anthropic: 3 }
}
const manager = new ConcurrencyManager(config)
@@ -95,7 +95,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
// when
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
// then
expect(modelLimit).toBe(10)

View File

@@ -162,7 +162,7 @@ describe("TaskToastManager", () => {
description: "Task with category default model",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
}
// when - addTask is called

View File

@@ -6,6 +6,7 @@ import {
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
import { isOrchestratorAgent } from "../../shared/orchestrator-agents";
interface ToolExecuteInput {
tool: string;
@@ -60,6 +61,11 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
output: ToolExecuteOutput,
) => {
const { tool, sessionID } = input;
if (!isOrchestratorAgent(sessionID)) {
return;
}
const toolLower = tool.toLowerCase();
if (AGENT_TOOLS.has(toolLower)) {

View File

@@ -1,20 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { isOrchestratorAgent } from "../../shared/orchestrator-agents"
import { buildReminderMessage } from "./formatter"
/**
* Target agents that should receive category+skill reminders.
* These are orchestrator agents that delegate work to specialized agents.
*/
const TARGET_AGENTS = new Set([
"sisyphus",
"sisyphus-junior",
"atlas",
])
/**
* Tools that indicate the agent is doing work that could potentially be delegated.
* When these tools are used, we remind the agent about the category+skill system.
@@ -73,22 +62,11 @@ export function createCategorySkillReminderHook(
return sessionStates.get(sessionID)!
}
function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
const agent = getSessionAgent(sessionID) ?? inputAgent
if (!agent) return false
const agentKey = getAgentConfigKey(agent)
return (
TARGET_AGENTS.has(agentKey) ||
agentKey.includes("sisyphus") ||
agentKey.includes("atlas")
)
}
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
const { tool, sessionID } = input
const toolLower = tool.toLowerCase()
if (!isTargetAgent(sessionID, input.agent)) {
if (!isOrchestratorAgent(sessionID, input.agent)) {
return
}

View File

@@ -6,6 +6,7 @@ import {
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types";
import { subagentSessions } from "../../features/claude-code-session-state";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
})

View File

@@ -1,6 +1,7 @@
import type { InteractiveBashSessionState } from "./types";
import { loadInteractiveBashSessionState } from "./storage";
import { OMO_SESSION_PREFIX } from "./constants";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
): Promise<void> {
for (const sessionName of state.tmuxSessions) {
try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
});

View File

@@ -79,8 +79,8 @@ export async function detectCompletionInSessionMessages(
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
const recentAssistants = assistantMessages.slice(-3)
for (const assistant of recentAssistants) {
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
const assistant = assistantMessages[index]
if (!assistant.parts) continue
let responseText = ""

View File

@@ -494,6 +494,7 @@ describe("ralph-loop", () => {
config: {
enabled: true,
default_max_iterations: 200,
default_strategy: "continue",
},
})
@@ -708,6 +709,57 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should skip concurrent idle events for same session when handler is in flight", async () => {
// given - active loop with delayed prompt injection
let releasePromptAsync: (() => void) | undefined
const promptAsyncBlocked = new Promise<void>((resolve) => {
releasePromptAsync = resolve
})
let firstPromptStartedResolve: (() => void) | undefined
const firstPromptStarted = new Promise<void>((resolve) => {
firstPromptStartedResolve = resolve
})
const mockInput = createMockPluginInput() as {
client: {
session: {
promptAsync: (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => Promise<unknown>
}
}
}
const originalPromptAsync = mockInput.client.session.promptAsync
let promptAsyncCalls = 0
mockInput.client.session.promptAsync = async (opts) => {
promptAsyncCalls += 1
if (promptAsyncCalls === 1) {
firstPromptStartedResolve?.()
}
await promptAsyncBlocked
return originalPromptAsync(opts)
}
const hook = createRalphLoopHook(mockInput as Parameters<typeof createRalphLoopHook>[0])
hook.startLoop("session-123", "Build feature", { maxIterations: 10 })
// when - second idle arrives while first idle processing is still in flight
const firstIdle = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await firstPromptStarted
const secondIdle = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
releasePromptAsync?.()
await Promise.all([firstIdle, secondIdle])
// then - only one continuation should be injected
expect(promptAsyncCalls).toBe(1)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
@@ -782,8 +834,8 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
})
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
// given - promise appears in an assistant message older than last 3
test("should detect completion even when promise is older than previous narrow window", async () => {
// given - promise appears in an older assistant message with additional assistant output after it
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
@@ -801,9 +853,40 @@ describe("ralph-loop", () => {
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should continue (promise is older than last 3 assistant messages)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
// then - loop should complete because all assistant messages are scanned
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should detect completion when many assistant messages are emitted after promise", async () => {
// given - completion promise followed by long assistant output sequence
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done now <promise>DONE</promise>" }] },
]
for (let index = 1; index <= 25; index += 1) {
mockSessionMessages.push({
info: { role: "assistant" },
parts: [{ type: "text", text: `Post-completion assistant output ${index}` }],
})
}
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - loop should complete despite large trailing output
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should allow starting new loop while previous loop is active (different session)", async () => {

View File

@@ -25,6 +25,8 @@ export function createRalphLoopEventHandler(
ctx: PluginInput,
options: RalphLoopEventHandlerOptions,
) {
const inFlightSessions = new Set<string>()
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
@@ -32,115 +34,127 @@ export function createRalphLoopEventHandler(
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (options.sessionRecovery.isRecovering(sessionID)) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
if (inFlightSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
return
}
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
if (options.checkSessionExists) {
try {
const exists = await options.checkSessionExists(state.session_id)
if (!exists) {
options.loopState.clear()
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = options.getTranscriptPath(sessionID)
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
const completionViaApi = completionViaTranscript
? false
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
})
if (completionViaTranscript || completionViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript
? "transcript_file"
: "session_messages_api",
})
options.loopState.clear()
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
options.loopState.clear()
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
inFlightSessions.add(sessionID)
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
if (options.sessionRecovery.isRecovering(sessionID)) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
if (options.checkSessionExists) {
try {
const exists = await options.checkSessionExists(state.session_id)
if (!exists) {
options.loopState.clear()
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = options.getTranscriptPath(sessionID)
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
const completionViaApi = completionViaTranscript
? false
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
})
if (completionViaTranscript || completionViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript
? "transcript_file"
: "session_messages_api",
})
options.loopState.clear()
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
options.loopState.clear()
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
error: String(err),
iteration: newState.iteration,
max: newState.max_iterations,
})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
return
} finally {
inFlightSessions.delete(sessionID)
}
return
}
if (event.type === "session.deleted") {

View File

@@ -36,7 +36,7 @@ async function waitUntil(condition: () => boolean): Promise<void> {
}
describe("ralph-loop reset strategy race condition", () => {
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
test("should skip duplicate idle while reset iteration handling is in flight", async () => {
// given - reset strategy loop with blocked TUI session switch
const promptCalls: Array<{ sessionID: string; text: string }> = []
const createSessionCalls: Array<{ parentID?: string }> = []
@@ -85,7 +85,7 @@ describe("ralph-loop reset strategy race condition", () => {
},
},
},
} as Parameters<typeof createRalphLoopHook>[0])
} as unknown as Parameters<typeof createRalphLoopHook>[0])
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
@@ -100,14 +100,12 @@ describe("ralph-loop reset strategy race condition", () => {
event: { type: "session.idle", properties: { sessionID: "session-old" } },
})
await waitUntil(() => selectSessionCalls > 1)
selectSessionDeferred.resolve()
await Promise.all([firstIdleEvent, secondIdleEvent])
// then - second idle should not be skipped during reset transition
expect(createSessionCalls.length).toBe(2)
expect(promptCalls.length).toBe(2)
expect(hook.getState()?.iteration).toBe(3)
// then - duplicate idle should be skipped to prevent concurrent continuation injection
expect(createSessionCalls.length).toBe(1)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
})

View File

@@ -125,7 +125,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-3-pro" } },
properties: { info: { id: sessionID, model: "google/gemini-3.1-pro" } },
},
})
@@ -1841,7 +1841,7 @@ describe("runtime-fallback", () => {
test("should apply fallback model on next chat.message after error", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3-pro"]),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3.1-pro"]),
})
const sessionID = "test-session-switch"
SessionCategoryRegistry.register(sessionID, "test")
@@ -1916,7 +1916,7 @@ describe("runtime-fallback", () => {
const input = createMockPluginInput()
const hook = createRuntimeFallbackHook(input, {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3-pro"]),
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3.1-pro"]),
})
const sessionID = "test-agent-fallback"

View File

@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
clearContinuationMarker,
@@ -8,6 +9,11 @@ import { log } from "../../shared/logger"
const HOOK_NAME = "stop-continuation-guard"
type StopContinuationBackgroundManager = Pick<
BackgroundManager,
"getAllDescendantTasks" | "cancelTask"
>
export interface StopContinuationGuard {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
"chat.message": (input: { sessionID?: string }) => Promise<void>
@@ -17,7 +23,10 @@ export interface StopContinuationGuard {
}
export function createStopContinuationGuardHook(
ctx: PluginInput
ctx: PluginInput,
options?: {
backgroundManager?: StopContinuationBackgroundManager
}
): StopContinuationGuard {
const stoppedSessions = new Set<string>()
@@ -25,6 +34,38 @@ export function createStopContinuationGuardHook(
stoppedSessions.add(sessionID)
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
const backgroundManager = options?.backgroundManager
if (!backgroundManager) {
return
}
const cancellableTasks = backgroundManager
.getAllDescendantTasks(sessionID)
.filter((task) => task.status === "running" || task.status === "pending")
if (cancellableTasks.length === 0) {
return
}
void Promise.allSettled(
cancellableTasks.map(async (task) => {
await backgroundManager.cancelTask(task.id, {
source: "stop-continuation",
reason: "Continuation stopped via /stop-continuation",
abortSession: task.status === "running",
skipNotification: true,
})
})
).then((results) => {
const cancelledCount = results.filter((result) => result.status === "fulfilled").length
const failedCount = results.length - cancelledCount
log(`[${HOOK_NAME}] Cancelled background tasks for stopped session`, {
sessionID,
cancelledCount,
failedCount,
})
})
}
const isStopped = (sessionID: string): boolean => {

View File

@@ -2,9 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import { readContinuationMarker } from "../../features/run-continuation-state"
import { createStopContinuationGuardHook } from "./index"
type CancelCall = {
taskId: string
options?: Parameters<BackgroundManager["cancelTask"]>[1]
}
describe("stop-continuation-guard", () => {
const tempDirs: string[] = []
@@ -34,6 +40,33 @@ describe("stop-continuation-guard", () => {
} as any
}
function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask {
return {
id,
status,
description: `${id} description`,
parentSessionID: "parent-session",
parentMessageID: "parent-message",
prompt: "prompt",
agent: "sisyphus-junior",
}
}
function createMockBackgroundManager(tasks: BackgroundTask[], cancelCalls: CancelCall[]): Pick<BackgroundManager, "getAllDescendantTasks" | "cancelTask"> {
return {
getAllDescendantTasks: () => tasks,
cancelTask: async (taskId: string, options?: Parameters<BackgroundManager["cancelTask"]>[1]) => {
cancelCalls.push({ taskId, options })
return true
},
}
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
test("should mark session as stopped", () => {
// given - a guard hook with no stopped sessions
const input = createMockPluginInput()
@@ -166,4 +199,31 @@ describe("stop-continuation-guard", () => {
// then - should not throw and stopped session remains stopped
expect(guard.isStopped("some-session")).toBe(true)
})
test("should cancel only running and pending background tasks on stop", async () => {
// given - a background manager with mixed task statuses
const cancelCalls: CancelCall[] = []
const backgroundManager = createMockBackgroundManager(
[
createBackgroundTask("running", "task-running"),
createBackgroundTask("pending", "task-pending"),
createBackgroundTask("completed", "task-completed"),
],
cancelCalls,
)
const guard = createStopContinuationGuardHook(createMockPluginInput(), {
backgroundManager,
})
// when - stop continuation is triggered
guard.stop("test-session-bg")
await flushMicrotasks()
// then - only running and pending tasks are cancelled
expect(cancelCalls).toHaveLength(2)
expect(cancelCalls[0]?.taskId).toBe("task-running")
expect(cancelCalls[0]?.options?.abortSession).toBe(true)
expect(cancelCalls[1]?.taskId).toBe("task-pending")
expect(cancelCalls[1]?.options?.abortSession).toBe(false)
})
})

View File

@@ -109,7 +109,7 @@ describe("createThinkModeHook", () => {
const input = createHookInput({
sessionID,
providerID: "google",
modelID: "gemini-3-pro",
modelID: "gemini-3.1-pro",
})
const output = createHookOutput("Please solve this directly")

View File

@@ -49,8 +49,8 @@ describe("think-mode switcher", () => {
it("should handle Gemini preview variants", () => {
// given Gemini preview model IDs
expect(getHighVariant("gemini-3-pro")).toBe(
"gemini-3-pro-high"
expect(getHighVariant("gemini-3.1-pro")).toBe(
"gemini-3-1-pro-high"
)
expect(getHighVariant("gemini-3-flash")).toBe(
"gemini-3-flash-high"
@@ -61,7 +61,7 @@ describe("think-mode switcher", () => {
// given model IDs that are already high variants
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
expect(getHighVariant("gpt-5-2-high")).toBeNull()
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
expect(getHighVariant("gemini-3-1-pro-high")).toBeNull()
})
it("should return null for unknown models", () => {
@@ -77,7 +77,7 @@ describe("think-mode switcher", () => {
// given model IDs with -high suffix
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
expect(isAlreadyHighVariant("gemini-3.1-pro-high")).toBe(true)
})
it("should detect -high suffix after normalization", () => {
@@ -90,7 +90,7 @@ describe("think-mode switcher", () => {
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
expect(isAlreadyHighVariant("gemini-3.1-pro")).toBe(false)
})
it("should return false for models with 'high' in name but not suffix", () => {
@@ -129,7 +129,7 @@ describe("think-mode switcher", () => {
// given various custom prefixes
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
expect(getHighVariant("bedrock/claude-sonnet-4-6")).toBe("bedrock/claude-sonnet-4-6-high")
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
expect(getHighVariant("custom-llm/gemini-3.1-pro")).toBe("custom-llm/gemini-3-1-pro-high")
})
it("should return null for prefixed models without high variant mapping", () => {
@@ -150,7 +150,7 @@ describe("think-mode switcher", () => {
// given prefixed model IDs with -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3.1-pro-high")).toBe(true)
})
it("should return false for prefixed base models", () => {
@@ -167,4 +167,3 @@ describe("think-mode switcher", () => {
})
})
})

View File

@@ -62,8 +62,8 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
"claude-sonnet-4-6": "claude-sonnet-4-6-high",
"claude-opus-4-6": "claude-opus-4-6-high",
// Gemini
"gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
"gemini-3-1-pro": "gemini-3-1-pro-high",
"gemini-3-1-pro-low": "gemini-3-1-pro-high",
"gemini-3-flash": "gemini-3-flash-high",
// GPT-5
"gpt-5": "gpt-5-high",
@@ -82,7 +82,7 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
"gpt-5-2-pro": "gpt-5-2-pro-high",
// Antigravity (Google)
"antigravity-gemini-3-pro": "antigravity-gemini-3-pro-high",
"antigravity-gemini-3-1-pro": "antigravity-gemini-3-1-pro-high",
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
}
@@ -114,4 +114,3 @@ export function isAlreadyHighVariant(modelID: string): boolean {
return ALREADY_HIGH.has(base) || base.endsWith("-high")
}

View File

@@ -15,6 +15,7 @@ import {
MAX_CONSECUTIVE_FAILURES,
} from "./constants"
import { isLastAssistantMessageAborted } from "./abort-detection"
import { hasUnansweredQuestion } from "./pending-question-detection"
import { getIncompleteCount } from "./todo"
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
import type { SessionStateStore } from "./session-state"
@@ -74,6 +75,10 @@ export async function handleSessionIdle(args: {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return
}
if (hasUnansweredQuestion(messages)) {
log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID })
return
}
} catch (error) {
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
}

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -297,6 +297,31 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})
test("should not inject when remaining todos are blocked or deleted", async () => {
// given - session where non-completed todos are only blocked/deleted
const sessionID = "main-blocked-deleted"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Blocked task", status: "blocked", priority: "high" },
{ id: "2", content: "Deleted task", status: "deleted", priority: "medium" },
{ id: "3", content: "Done task", status: "completed", priority: "low" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when background tasks are running", async () => {
// given - session with running background tasks
const sessionID = "main-789"
@@ -1663,7 +1688,6 @@ describe("todo-continuation-enforcer", () => {
test("should cancel all countdowns via cancelAllCountdowns", async () => {
// given - multiple sessions with running countdowns
const session1 = "main-cancel-all-1"
const session2 = "main-cancel-all-2"
setMainSession(session1)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})

View File

@@ -1,5 +1,11 @@
import type { Todo } from "./types"
export function getIncompleteCount(todos: Todo[]): number {
return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length
return todos.filter(
(todo) =>
todo.status !== "completed"
&& todo.status !== "cancelled"
&& todo.status !== "blocked"
&& todo.status !== "deleted",
).length
}

View File

@@ -27,7 +27,7 @@ describe("mergeConfigs", () => {
temperature: 0.3,
},
visual: {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
},
},
} as unknown as OhMyOpenCodeConfig;
@@ -41,7 +41,7 @@ describe("mergeConfigs", () => {
// then quick should be preserved from base
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
// then visual should be added from override
expect(result.categories?.visual?.model).toBe("google/gemini-3-pro");
expect(result.categories?.visual?.model).toBe("google/gemini-3.1-pro");
});
it("should preserve base categories when override has no categories", () => {

View File

@@ -106,6 +106,15 @@ export async function applyAgentConfig(params: {
]),
);
const disabledAgentNames = new Set(
(migratedDisabledAgents ?? []).map(a => a.toLowerCase())
);
const filterDisabledAgents = (agents: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase()))
);
const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled =
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
@@ -194,9 +203,9 @@ export async function applyAgentConfig(params: {
...Object.fromEntries(
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
),
...userAgents,
...projectAgents,
...pluginAgents,
...filterDisabledAgents(userAgents),
...filterDisabledAgents(projectAgents),
...filterDisabledAgents(pluginAgents),
...filteredConfigAgents,
build: { ...migratedBuild, mode: "subagent", hidden: true },
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
@@ -204,9 +213,9 @@ export async function applyAgentConfig(params: {
} else {
params.config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...pluginAgents,
...filterDisabledAgents(userAgents),
...filterDisabledAgents(projectAgents),
...filterDisabledAgents(pluginAgents),
...configAgent,
};
}

View File

@@ -570,7 +570,7 @@ describe("Prometheus category config resolution", () => {
// then
expect(config).toBeDefined()
expect(config?.model).toBe("google/gemini-3-pro")
expect(config?.model).toBe("google/gemini-3.1-pro")
})
test("user categories override default categories", () => {

View File

@@ -49,7 +49,10 @@ export function createContinuationHooks(args: {
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx))
? safeHook("stop-continuation-guard", () =>
createStopContinuationGuardHook(ctx, {
backgroundManager,
}))
: null
const compactionContextInjector = isHookEnabled("compaction-context-injector")

View File

@@ -279,6 +279,30 @@ describe("applyUltraworkModelOverrideOnMessage", () => {
)
})
test("should override keyword-detector variant with configured ultrawork variant on deferred path", () => {
//#given
const config = createConfig("sisyphus", {
model: "anthropic/claude-opus-4-6",
variant: "extended",
})
const output = createOutput("ultrawork do something", { messageId: "msg_123" })
output.message["variant"] = "max"
output.message["thinking"] = "max"
const tui = createMockTui()
//#when
applyUltraworkModelOverrideOnMessage(config, "sisyphus", output, tui)
//#then
expect(dbOverrideSpy).toHaveBeenCalledWith(
"msg_123",
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
"extended",
)
expect(output.message["variant"]).toBe("extended")
expect(output.message["thinking"]).toBe("extended")
})
test("should NOT mutate output.message.model when message ID present", () => {
//#given
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }

View File

@@ -114,10 +114,12 @@ export function applyUltraworkModelOverrideOnMessage(
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
if (!override) return
if (override.variant) {
output.message["variant"] = override.variant
output.message["thinking"] = override.variant
}
if (!override.providerID || !override.modelID) {
if (override.variant) {
output.message["variant"] = override.variant
}
return
}
@@ -131,10 +133,8 @@ export function applyUltraworkModelOverrideOnMessage(
if (!messageId) {
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
output.message.model = targetModel
if (override.variant) {
output.message["variant"] = override.variant
}
return
}
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"

View File

@@ -32,6 +32,7 @@ export * from "./session-cursor"
export * from "./shell-env"
export * from "./system-directive"
export * from "./agent-tool-restrictions"
export * from "./orchestrator-agents"
export * from "./model-requirements"
export * from "./model-resolver"
export { normalizeFallbackModels } from "./model-resolver"

View File

@@ -774,7 +774,7 @@ describe("migrateAgentConfigToCategory", () => {
test("migrates model to category when mapping exists", () => {
// given: Config with a model that has a category mapping
const config = {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
temperature: 0.5,
top_p: 0.9,
}
@@ -823,7 +823,7 @@ describe("migrateAgentConfigToCategory", () => {
test("handles all mapped models correctly", () => {
// given: Configs for each mapped model
const configs = [
{ model: "google/gemini-3-pro" },
{ model: "google/gemini-3.1-pro" },
{ model: "google/gemini-3-flash" },
{ model: "openai/gpt-5.2" },
{ model: "anthropic/claude-haiku-4-5" },
@@ -893,7 +893,7 @@ describe("shouldDeleteAgentConfig", () => {
// given: Config with fields matching category defaults
const config = {
category: "visual-engineering",
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
}
// when: Check if config should be deleted
@@ -1021,7 +1021,7 @@ describe("migrateConfigFile with backup", () => {
agents: {
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
oracle: { model: "openai/gpt-5.2" },
"my-custom-agent": { model: "google/gemini-3-pro" },
"my-custom-agent": { model: "google/gemini-3.1-pro" },
},
}
@@ -1037,7 +1037,7 @@ describe("migrateConfigFile with backup", () => {
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro")
expect(agents["my-custom-agent"].model).toBe("google/gemini-3.1-pro")
})
test("preserves category setting when explicitly set", () => {

View File

@@ -12,7 +12,7 @@
* This map will be removed in a future major version once migration period ends.
*/
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
"google/gemini-3-pro": "visual-engineering",
"google/gemini-3.1-pro": "visual-engineering",
"google/gemini-3-flash": "writing",
"openai/gpt-5.2": "ultrabrain",
"anthropic/claude-haiku-4-5": "quick",

View File

@@ -63,7 +63,7 @@ describe("fetchAvailableModels", () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
google: { id: "google", models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
})
const result = await fetchAvailableModels(undefined, {
@@ -74,7 +74,7 @@ describe("fetchAvailableModels", () => {
expect(result.size).toBe(3)
expect(result.has("openai/gpt-5.2")).toBe(true)
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
expect(result.has("google/gemini-3-pro")).toBe(true)
expect(result.has("google/gemini-3.1-pro")).toBe(true)
})
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
@@ -97,7 +97,7 @@ describe("fetchAvailableModels", () => {
list: async () => ({
data: [
{ id: "gpt-5.3-codex", provider: "openai" },
{ id: "gemini-3-pro", provider: "google" },
{ id: "gemini-3.1-pro", provider: "google" },
],
}),
},
@@ -107,7 +107,7 @@ describe("fetchAvailableModels", () => {
expect(result).toBeInstanceOf(Set)
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
expect(result.has("google/gemini-3-pro")).toBe(false)
expect(result.has("google/gemini-3.1-pro")).toBe(false)
})
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
@@ -126,7 +126,7 @@ describe("fetchAvailableModels", () => {
list: async () => ({
data: [
{ id: "gpt-5.3-codex", provider: "openai" },
{ id: "gemini-3-pro", provider: "google" },
{ id: "gemini-3.1-pro", provider: "google" },
],
}),
},
@@ -136,7 +136,7 @@ describe("fetchAvailableModels", () => {
expect(result).toBeInstanceOf(Set)
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
expect(result.has("google/gemini-3-pro")).toBe(true)
expect(result.has("google/gemini-3.1-pro")).toBe(true)
})
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
@@ -515,7 +515,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
writeModelsCache({
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
})
const result = await fetchAvailableModels(undefined, {
@@ -525,7 +525,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
expect(result.size).toBe(1)
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
expect(result.has("openai/gpt-5.2")).toBe(false)
expect(result.has("google/gemini-3-pro")).toBe(false)
expect(result.has("google/gemini-3.1-pro")).toBe(false)
})
// given cache with multiple providers
@@ -535,7 +535,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
writeModelsCache({
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
})
const result = await fetchAvailableModels(undefined, {
@@ -544,7 +544,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
expect(result.size).toBe(2)
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
expect(result.has("google/gemini-3-pro")).toBe(true)
expect(result.has("google/gemini-3.1-pro")).toBe(true)
expect(result.has("openai/gpt-5.2")).toBe(false)
})
@@ -759,7 +759,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
models: {
opencode: ["big-pickle"],
anthropic: ["claude-opus-4-6"],
google: ["gemini-3-pro"]
google: ["gemini-3.1-pro"]
},
connected: ["opencode", "anthropic", "google"]
})
@@ -771,7 +771,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
expect(result.size).toBe(1)
expect(result.has("opencode/big-pickle")).toBe(true)
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
expect(result.has("google/gemini-3-pro")).toBe(false)
expect(result.has("google/gemini-3.1-pro")).toBe(false)
})
it("should handle object[] format with metadata (Ollama-style)", async () => {
@@ -953,7 +953,7 @@ describe("fallback model availability", () => {
{ providers: ["openai"], model: "gpt-5.2" },
{ providers: ["anthropic"], model: "claude-opus-4-6" },
]
const availableModels = new Set(["google/gemini-3-pro"])
const availableModels = new Set(["google/gemini-3.1-pro"])
// when
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)

View File

@@ -248,19 +248,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("openai")
})
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
test("visual-engineering has valid fallbackChain with gemini-3.1-pro high as primary", () => {
// given - visual-engineering category requirement
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max)
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max)
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(3)
const primary = visualEngineering.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3-pro")
expect(primary.model).toBe("gemini-3.1-pro")
expect(primary.variant).toBe("high")
const second = visualEngineering.fallbackChain[1]
@@ -319,39 +319,43 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
})
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
test("artistry has valid fallbackChain with gemini-3.1-pro as primary", () => {
// given - artistry category requirement
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when - accessing artistry requirement
// then - fallbackChain exists with gemini-3-pro as first entry
// then - fallbackChain exists with gemini-3.1-pro as first entry
expect(artistry).toBeDefined()
expect(artistry.fallbackChain).toBeArray()
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
const primary = artistry.fallbackChain[0]
expect(primary.model).toBe("gemini-3-pro")
expect(primary.model).toBe("gemini-3.1-pro")
expect(primary.variant).toBe("high")
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
test("writing has valid fallbackChain with kimi-k2.5-free as primary", () => {
// given - writing category requirement
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
// when - accessing writing requirement
// then - fallbackChain: gemini-3-flash claude-sonnet-4-6
// then - fallbackChain: kimi-k2.5-free -> gemini-3-flash -> claude-sonnet-4-6
expect(writing).toBeDefined()
expect(writing.fallbackChain).toBeArray()
expect(writing.fallbackChain).toHaveLength(2)
expect(writing.fallbackChain).toHaveLength(3)
const primary = writing.fallbackChain[0]
expect(primary.model).toBe("gemini-3-flash")
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("kimi-k2.5-free")
expect(primary.providers[0]).toBe("opencode")
const second = writing.fallbackChain[1]
expect(second.model).toBe("claude-sonnet-4-6")
expect(second.providers[0]).toBe("anthropic")
expect(second.model).toBe("gemini-3-flash")
expect(second.providers[0]).toBe("google")
const third = writing.fallbackChain[2]
expect(third.model).toBe("claude-sonnet-4-6")
expect(third.providers[0]).toBe("anthropic")
})
test("all 8 categories have valid fallbackChain arrays", () => {
@@ -489,12 +493,12 @@ describe("requiresModel field in categories", () => {
expect(deep.requiresModel).toBe("gpt-5.3-codex")
})
test("artistry category has requiresModel set to gemini-3-pro", () => {
test("artistry category has requiresModel set to gemini-3.1-pro", () => {
// given
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when / #then
expect(artistry.requiresModel).toBe("gemini-3-pro")
expect(artistry.requiresModel).toBe("gemini-3.1-pro")
})
})

View File

@@ -32,7 +32,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
oracle: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -65,7 +65,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
metis: {
@@ -73,14 +73,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
momus: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
},
atlas: {
@@ -95,7 +95,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
@@ -103,7 +103,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
ultrabrain: {
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
@@ -111,17 +111,17 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
],
requiresModel: "gpt-5.3-codex",
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
requiresModel: "gemini-3-pro",
requiresModel: "gemini-3.1-pro",
},
quick: {
fallbackChain: [
@@ -141,11 +141,12 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
},
writing: {
fallbackChain: [
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
],

View File

@@ -10,7 +10,7 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: "anthropic/claude-opus-4-6",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
@@ -25,7 +25,7 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: undefined,
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
@@ -40,14 +40,14 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: undefined,
inheritedModel: undefined,
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("google/gemini-3-pro")
expect(result).toBe("google/gemini-3.1-pro")
})
})
@@ -57,7 +57,7 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: "",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
@@ -72,14 +72,14 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: " ",
inheritedModel: "",
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
const result = resolveModel(input)
// then
expect(result).toBe("google/gemini-3-pro")
expect(result).toBe("google/gemini-3.1-pro")
})
})
@@ -89,7 +89,7 @@ describe("resolveModel", () => {
const input: ModelResolutionInput = {
userModel: "anthropic/claude-opus-4-6",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
systemDefault: "google/gemini-3.1-pro",
}
// when
@@ -123,7 +123,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
],
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -141,7 +141,7 @@ describe("resolveModelWithFallback", () => {
uiSelectedModel: "opencode/big-pickle",
userModel: "anthropic/claude-opus-4-6",
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -158,7 +158,7 @@ describe("resolveModelWithFallback", () => {
uiSelectedModel: " ",
userModel: "anthropic/claude-opus-4-6",
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -175,7 +175,7 @@ describe("resolveModelWithFallback", () => {
uiSelectedModel: "",
userModel: "anthropic/claude-opus-4-6",
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -195,7 +195,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
],
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -215,7 +215,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-opus-4-6" },
],
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -234,7 +234,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-opus-4-6" },
],
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -252,7 +252,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-opus-4-6" },
],
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -271,7 +271,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
],
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -294,8 +294,8 @@ describe("resolveModelWithFallback", () => {
fallbackChain: [
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
],
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3-pro"]),
systemDefaultModel: "google/gemini-3-pro",
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3.1-pro"]),
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -313,7 +313,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
],
availableModels: new Set(["opencode/gpt-5-nano"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -331,7 +331,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
],
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -346,7 +346,7 @@ describe("resolveModelWithFallback", () => {
// given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -361,7 +361,7 @@ describe("resolveModelWithFallback", () => {
const input: ExtendedModelResolutionInput = {
fallbackChain: [],
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -378,7 +378,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
],
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -397,7 +397,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -420,7 +420,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["zai-coding-plan"], model: "glm-5" },
],
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -438,7 +438,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
],
availableModels: new Set(["opencode/glm-5"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -457,7 +457,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -477,14 +477,14 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
@@ -516,7 +516,7 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -533,7 +533,7 @@ describe("resolveModelWithFallback", () => {
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
@@ -544,7 +544,7 @@ describe("resolveModelWithFallback", () => {
// then - should use github-copilot (second provider) since google not connected
// model name is transformed to preview variant for github-copilot provider
expect(result!.model).toBe("github-copilot/gemini-3-pro-preview")
expect(result!.model).toBe("github-copilot/gemini-3.1-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
@@ -577,14 +577,14 @@ describe("resolveModelWithFallback", () => {
{ providers: ["anthropic"], model: "claude-opus-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then - should fall through to system default
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
cacheSpy.mockRestore()
})
@@ -593,14 +593,14 @@ describe("resolveModelWithFallback", () => {
// given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
const result = resolveModelWithFallback(input)
// then
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("system-default")
})
})
@@ -627,20 +627,20 @@ describe("resolveModelWithFallback", () => {
test("tries all providers in first entry before moving to second entry", () => {
// given
const availableModels = new Set(["google/gemini-3-pro"])
const availableModels = new Set(["google/gemini-3.1-pro"])
// when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
{ providers: ["google"], model: "gemini-3-pro" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels,
systemDefaultModel: "system/default",
})
// then
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("provider-fallback")
})
@@ -675,7 +675,7 @@ describe("resolveModelWithFallback", () => {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.2" },
{ providers: ["anthropic"], model: "claude-opus-4-6" },
{ providers: ["google"], model: "gemini-3-pro" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels,
systemDefaultModel: "system/default",
@@ -693,7 +693,7 @@ describe("resolveModelWithFallback", () => {
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-6",
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
systemDefaultModel: "google/gemini-3.1-pro",
}
// when
@@ -708,32 +708,32 @@ describe("resolveModelWithFallback", () => {
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
// given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available
// given - gemini-3.1-pro is the category default, but only gemini-3.1-pro-preview is available
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro",
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
// when
const result = resolveModelWithFallback(input)
// then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
expect(result!.model).toBe("google/gemini-3-pro-preview")
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
})
test("categoryDefaultModel uses exact match when available", () => {
// given - exact match exists
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro",
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google"], model: "gemini-3-pro" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3-pro", "google/gemini-3-pro-preview"]),
availableModels: new Set(["google/gemini-3.1-pro", "google/gemini-3.1-pro-preview"]),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
@@ -741,14 +741,14 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// then - should use exact match
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.model).toBe("google/gemini-3.1-pro")
expect(result!.source).toBe("category-default")
})
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
// given - categoryDefaultModel has no match, but fallbackChain does
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro",
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-6" },
],
@@ -768,11 +768,11 @@ describe("resolveModelWithFallback", () => {
// given - both userModel and categoryDefaultModel provided
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-6",
categoryDefaultModel: "google/gemini-3-pro",
categoryDefaultModel: "google/gemini-3.1-pro",
fallbackChain: [
{ providers: ["google"], model: "gemini-3-pro" },
{ providers: ["google"], model: "gemini-3.1-pro" },
],
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
systemDefaultModel: "system/default",
}
@@ -788,7 +788,7 @@ describe("resolveModelWithFallback", () => {
// given - no availableModels but connected provider cache exists
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro",
categoryDefaultModel: "google/gemini-3.1-pro",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
}
@@ -797,7 +797,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// then - should use transformed categoryDefaultModel since google is connected
expect(result!.model).toBe("google/gemini-3-pro-preview")
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
@@ -824,7 +824,7 @@ describe("resolveModelWithFallback", () => {
// given - category default already has -preview suffix
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
categoryDefaultModel: "google/gemini-3-pro-preview",
categoryDefaultModel: "google/gemini-3.1-pro-preview",
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
}
@@ -832,18 +832,18 @@ describe("resolveModelWithFallback", () => {
// when
const result = resolveModelWithFallback(input)
// then - should NOT become gemini-3-pro-preview-preview
expect(result!.model).toBe("google/gemini-3-pro-preview")
// then - should NOT become gemini-3.1-pro-preview-preview
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("category-default")
cacheSpy.mockRestore()
})
test("transforms gemini-3-pro in fallback chain for google connected provider", () => {
// given - google connected, fallback chain has gemini-3-pro
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
// given - google connected, fallback chain has gemini-3.1-pro
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["google", "github-copilot"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-5",
@@ -853,7 +853,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// then - should transform to preview variant for google provider
expect(result!.model).toBe("google/gemini-3-pro-preview")
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})

View 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")
)
}

View File

@@ -6,12 +6,12 @@ export function transformModelForProvider(provider: string, model: string): stri
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
}
return model

View 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)
}

View File

@@ -208,10 +208,10 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3-pro", variant: "high" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "anthropic/claude-haiku-4-5" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },

View File

@@ -17,7 +17,7 @@ const TEST_AVAILABLE_MODELS = new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"anthropic/claude-haiku-4-5",
"google/gemini-3-pro",
"google/gemini-3.1-pro",
"google/gemini-3-flash",
"openai/gpt-5.2",
"openai/gpt-5.3-codex",
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: {
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
google: ["gemini-3-pro", "gemini-3-flash"],
google: ["gemini-3.1-pro", "gemini-3-flash"],
openai: ["gpt-5.2", "gpt-5.3-codex"],
},
connected: ["anthropic", "google", "openai"],
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro")
expect(category.model).toBe("google/gemini-3.1-pro")
expect(category.variant).toBe("high")
})
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
expect(result!.promptAppend).toContain("VISUAL/UI")
})
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
prompt_append: "Custom instructions here",
},
}
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
temperature: 0.3,
},
}
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
// then - category's built-in model wins over inheritedModel
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used as fallback when custom category has no model", () => {
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
})
@@ -1738,7 +1738,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// when - artistry category (gemini-3-pro with high variant)
// when - artistry category (gemini-3.1-pro with high variant)
const result = await tool.execute(
{
description: "Test artistry forced background",
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
// when resolveCategoryConfig is called
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// then should use category's built-in model (gemini-3-pro for visual-engineering)
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
expect(resolved).not.toBeNull()
expect(resolved!.model).toBe("google/gemini-3-pro")
expect(resolved!.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used when no other model is available", () => {
@@ -3522,7 +3522,7 @@ describe("sisyphus-task", () => {
)
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
// oracle fallback chain: gpt-5.2 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
expect(promptBody.model).toBeDefined()
expect(promptBody.model.providerID).toBe("openai")

View File

@@ -1,4 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver"
@@ -89,7 +90,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
}
const proc = Bun.spawn([tmuxPath, ...parts], {
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
stdout: "pipe",
stderr: "pipe",
})