Compare commits
39 Commits
fix/issue-
...
v3.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae54fd31f4 | ||
|
|
bdd86b1415 | ||
|
|
76cba9b222 | ||
|
|
2955dc868f | ||
|
|
3ab4b7f77b | ||
|
|
3540d1c550 | ||
|
|
9bc9dcaa18 | ||
|
|
f2a1412bf1 | ||
|
|
190c6991ac | ||
|
|
e17a00a906 | ||
|
|
c8aa1bbce4 | ||
|
|
911710e4d4 | ||
|
|
050b93bebb | ||
|
|
2ffa803b05 | ||
|
|
cf97494073 | ||
|
|
8fb5949ac6 | ||
|
|
04f50bac1f | ||
|
|
d1a0a66dde | ||
|
|
b1203b9501 | ||
|
|
58201220cc | ||
|
|
4efad491e7 | ||
|
|
4df69c58bf | ||
|
|
cc8ef7fe39 | ||
|
|
2ece7c3d0a | ||
|
|
decff3152a | ||
|
|
0526bac873 | ||
|
|
0c62656cc6 | ||
|
|
aff43bfc77 | ||
|
|
6865cee8ca | ||
|
|
8721ba471c | ||
|
|
d425f9bb80 | ||
|
|
cc5e9d1e9b | ||
|
|
269f37af1c | ||
|
|
ccb789e5df | ||
|
|
a6617d93c0 | ||
|
|
2295161022 | ||
|
|
0516f2febc | ||
|
|
07e8d965a8 | ||
|
|
718884210b |
18
.github/workflows/publish-platform.yml
vendored
18
.github/workflows/publish-platform.yml
vendored
@@ -35,15 +35,15 @@ jobs:
|
|||||||
# - Uploads compressed artifacts for the publish job
|
# - Uploads compressed artifacts for the publish job
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
|
runs-on: ${{ startsWith(matrix.platform, 'windows-') && 'windows-latest' || 'ubuntu-latest' }}
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 7
|
max-parallel: 11
|
||||||
matrix:
|
matrix:
|
||||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -95,14 +95,18 @@ jobs:
|
|||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
darwin-arm64) TARGET="bun-darwin-arm64" ;;
|
darwin-arm64) TARGET="bun-darwin-arm64" ;;
|
||||||
darwin-x64) TARGET="bun-darwin-x64" ;;
|
darwin-x64) TARGET="bun-darwin-x64" ;;
|
||||||
|
darwin-x64-baseline) TARGET="bun-darwin-x64-baseline" ;;
|
||||||
linux-x64) TARGET="bun-linux-x64" ;;
|
linux-x64) TARGET="bun-linux-x64" ;;
|
||||||
|
linux-x64-baseline) TARGET="bun-linux-x64-baseline" ;;
|
||||||
linux-arm64) TARGET="bun-linux-arm64" ;;
|
linux-arm64) TARGET="bun-linux-arm64" ;;
|
||||||
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
|
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
|
||||||
|
linux-x64-musl-baseline) TARGET="bun-linux-x64-musl-baseline" ;;
|
||||||
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
|
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
|
||||||
windows-x64) TARGET="bun-windows-x64" ;;
|
windows-x64) TARGET="bun-windows-x64" ;;
|
||||||
|
windows-x64-baseline) TARGET="bun-windows-x64-baseline" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
|
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
|
||||||
else
|
else
|
||||||
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
|
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
|
||||||
@@ -119,7 +123,7 @@ jobs:
|
|||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
cd packages/${PLATFORM}
|
cd packages/${PLATFORM}
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
# Windows: use 7z (pre-installed on windows-latest)
|
# Windows: use 7z (pre-installed on windows-latest)
|
||||||
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
||||||
else
|
else
|
||||||
@@ -155,7 +159,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 2
|
max-parallel: 2
|
||||||
matrix:
|
matrix:
|
||||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||||
steps:
|
steps:
|
||||||
- name: Check if already published
|
- name: Check if already published
|
||||||
id: check
|
id: check
|
||||||
@@ -184,7 +188,7 @@ jobs:
|
|||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
mkdir -p packages/${PLATFORM}
|
mkdir -p packages/${PLATFORM}
|
||||||
|
|
||||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
if [[ "$PLATFORM" == windows-* ]]; then
|
||||||
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
||||||
else
|
else
|
||||||
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
|
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
|
||||||
|
|||||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -189,7 +189,7 @@ jobs:
|
|||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||||
|
|
||||||
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64; do
|
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
|
||||||
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
|
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
|
||||||
mv tmp.json "packages/${platform}/package.json"
|
mv tmp.json "packages/${platform}/package.json"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -24,19 +24,7 @@
|
|||||||
"disabled_agents": {
|
"disabled_agents": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": [
|
|
||||||
"sisyphus",
|
|
||||||
"hephaestus",
|
|
||||||
"prometheus",
|
|
||||||
"oracle",
|
|
||||||
"librarian",
|
|
||||||
"explore",
|
|
||||||
"multimodal-looker",
|
|
||||||
"metis",
|
|
||||||
"momus",
|
|
||||||
"atlas"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"disabled_skills": {
|
"disabled_skills": {
|
||||||
|
|||||||
62
benchmarks/bun.lock
Normal file
62
benchmarks/bun.lock
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "hashline-edit-benchmark",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^1.3.0",
|
||||||
|
"@friendliai/ai-provider": "^1.0.9",
|
||||||
|
"ai": "^6.0.94",
|
||||||
|
"zod": "^4.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="],
|
||||||
|
|
||||||
|
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.24", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q=="],
|
||||||
|
|
||||||
|
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||||
|
|
||||||
|
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||||
|
|
||||||
|
"@friendliai/ai-provider": ["@friendliai/ai-provider@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.12" } }, "sha512-9TU4B1QFqPhbkONjI5afCF7Ox4jOqtGg1xw8mA9QHZdtlEbZxU+mBNvMPlI5pU5kPoN6s7wkXmFmxpID+own1A=="],
|
||||||
|
|
||||||
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||||
|
|
||||||
|
"ai": ["ai@6.0.101", "", { "dependencies": { "@ai-sdk/gateway": "3.0.55", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ur/NgbgOp1rdhyDiKDk6EOpSgd1g5ADlbcD1cjQJtQsnmhEngz3Rf8nK5JetDh0vnbLy2aEBpaQeL+zvLRWuaA=="],
|
||||||
|
|
||||||
|
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||||
|
|
||||||
|
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||||
|
|
||||||
|
"@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||||
|
|
||||||
|
"@friendliai/ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
|
"@friendliai/ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||||
|
|
||||||
|
"ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
|
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
193
benchmarks/headless.ts
Normal file
193
benchmarks/headless.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises"
|
||||||
|
import { join, dirname } from "node:path"
|
||||||
|
import { stepCountIs, streamText, type CoreMessage } from "ai"
|
||||||
|
import { tool } from "ai"
|
||||||
|
import { createFriendli } from "@friendliai/ai-provider"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { formatHashLines } from "../src/tools/hashline-edit/hash-computation"
|
||||||
|
import { normalizeHashlineEdits } from "../src/tools/hashline-edit/normalize-edits"
|
||||||
|
import { applyHashlineEditsWithReport } from "../src/tools/hashline-edit/edit-operations"
|
||||||
|
import { canonicalizeFileText, restoreFileText } from "../src/tools/hashline-edit/file-text-canonicalization"
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
|
||||||
|
const MAX_STEPS = 50
|
||||||
|
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
|
const emit = (event: Record<string, unknown>) =>
|
||||||
|
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
|
||||||
|
|
||||||
|
// ── CLI ──────────────────────────────────────────────────────
|
||||||
|
function parseArgs(): { prompt: string; modelId: string } {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
let prompt = ""
|
||||||
|
let modelId = DEFAULT_MODEL
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if ((args[i] === "-p" || args[i] === "--prompt") && args[i + 1]) {
|
||||||
|
prompt = args[++i]
|
||||||
|
} else if ((args[i] === "-m" || args[i] === "--model") && args[i + 1]) {
|
||||||
|
modelId = args[++i]
|
||||||
|
} else if (args[i] === "--reasoning-mode" && args[i + 1]) {
|
||||||
|
i++ // consume
|
||||||
|
}
|
||||||
|
// --no-translate, --think consumed silently
|
||||||
|
}
|
||||||
|
if (!prompt) {
|
||||||
|
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
return { prompt, modelId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tools ────────────────────────────────────────────────────
|
||||||
|
const readFileTool = tool({
|
||||||
|
description: "Read a file with hashline-tagged content (LINE#ID format)",
|
||||||
|
inputSchema: z.object({ path: z.string().describe("File path") }),
|
||||||
|
execute: async ({ path }) => {
|
||||||
|
const fullPath = join(process.cwd(), path)
|
||||||
|
try {
|
||||||
|
const content = await readFile(fullPath, "utf-8")
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const tagged = formatHashLines(content)
|
||||||
|
return `OK - read file\npath: ${path}\nlines: ${lines.length}\n\n${tagged}`
|
||||||
|
} catch {
|
||||||
|
return `Error: File not found: ${path}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const editFileTool = tool({
|
||||||
|
description: "Edit a file using hashline anchors (LINE#ID format)",
|
||||||
|
inputSchema: z.object({
|
||||||
|
path: z.string(),
|
||||||
|
edits: z.array(
|
||||||
|
z.object({
|
||||||
|
op: z.enum(["replace", "append", "prepend"]),
|
||||||
|
pos: z.string().optional(),
|
||||||
|
end: z.string().optional(),
|
||||||
|
lines: z.union([z.array(z.string()), z.string(), z.null()]),
|
||||||
|
})
|
||||||
|
).min(1),
|
||||||
|
}),
|
||||||
|
execute: async ({ path, edits }) => {
|
||||||
|
const fullPath = join(process.cwd(), path)
|
||||||
|
try {
|
||||||
|
let rawContent = ""
|
||||||
|
let exists = true
|
||||||
|
try {
|
||||||
|
rawContent = await readFile(fullPath, "utf-8")
|
||||||
|
} catch {
|
||||||
|
exists = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeHashlineEdits(edits)
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
const canCreate = normalized.every(
|
||||||
|
(e) => (e.op === "append" || e.op === "prepend") && !e.pos
|
||||||
|
)
|
||||||
|
if (!canCreate) return `Error: File not found: ${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = canonicalizeFileText(rawContent)
|
||||||
|
const result = applyHashlineEditsWithReport(envelope.content, normalized)
|
||||||
|
|
||||||
|
if (result.content === envelope.content) {
|
||||||
|
return `Error: No changes made to ${path}. The edits produced identical content.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeContent = restoreFileText(result.content, envelope)
|
||||||
|
await mkdir(dirname(fullPath), { recursive: true })
|
||||||
|
await writeFile(fullPath, writeContent, "utf-8")
|
||||||
|
|
||||||
|
const oldLineCount = rawContent.split("\n").length
|
||||||
|
const newLineCount = writeContent.split("\n").length
|
||||||
|
const delta = newLineCount - oldLineCount
|
||||||
|
const sign = delta > 0 ? "+" : ""
|
||||||
|
const action = exists ? "Updated" : "Created"
|
||||||
|
return `${action} ${path}\n${edits.length} edit(s) applied, ${sign}${delta} line(s)`
|
||||||
|
} catch (error) {
|
||||||
|
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Agent Loop ───────────────────────────────────────────────
|
||||||
|
async function run() {
|
||||||
|
const { prompt, modelId } = parseArgs()
|
||||||
|
|
||||||
|
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
|
||||||
|
const model = friendli(modelId)
|
||||||
|
const tools = { read_file: readFileTool, edit_file: editFileTool }
|
||||||
|
|
||||||
|
emit({ type: "user", content: prompt })
|
||||||
|
|
||||||
|
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
|
||||||
|
const system =
|
||||||
|
"You are a code editing assistant. Use read_file to read files and edit_file to edit them. " +
|
||||||
|
"Always read a file before editing it to get fresh LINE#ID anchors."
|
||||||
|
|
||||||
|
for (let step = 0; step < MAX_STEPS; step++) {
|
||||||
|
const stream = streamText({
|
||||||
|
model,
|
||||||
|
tools,
|
||||||
|
messages,
|
||||||
|
system,
|
||||||
|
stopWhen: stepCountIs(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
let currentText = ""
|
||||||
|
for await (const part of stream.fullStream) {
|
||||||
|
switch (part.type) {
|
||||||
|
case "text-delta":
|
||||||
|
currentText += part.text
|
||||||
|
break
|
||||||
|
case "tool-call":
|
||||||
|
emit({
|
||||||
|
type: "tool_call",
|
||||||
|
tool_call_id: part.toolCallId,
|
||||||
|
tool_name: part.toolName,
|
||||||
|
tool_input: part.args,
|
||||||
|
model: modelId,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "tool-result": {
|
||||||
|
const output = typeof part.result === "string" ? part.result : JSON.stringify(part.result)
|
||||||
|
const isError = typeof output === "string" && output.startsWith("Error:")
|
||||||
|
emit({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_call_id: part.toolCallId,
|
||||||
|
output,
|
||||||
|
...(isError ? { error: output } : {}),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await stream.response
|
||||||
|
messages.push(...response.messages)
|
||||||
|
|
||||||
|
const finishReason = await stream.finishReason
|
||||||
|
if (finishReason !== "tool-calls") {
|
||||||
|
if (currentText.trim()) {
|
||||||
|
emit({ type: "assistant", content: currentText, model: modelId })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signal + Startup ─────────────────────────────────────────
|
||||||
|
process.once("SIGINT", () => process.exit(0))
|
||||||
|
process.once("SIGTERM", () => process.exit(143))
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
run()
|
||||||
|
.catch((error) => {
|
||||||
|
emit({ type: "error", error: error instanceof Error ? error.message : String(error) })
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||||
|
console.error(`[headless] Completed in ${elapsed}s`)
|
||||||
|
})
|
||||||
19
benchmarks/package.json
Normal file
19
benchmarks/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "hashline-edit-benchmark",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Hashline edit tool benchmark using Vercel AI SDK with FriendliAI provider",
|
||||||
|
"scripts": {
|
||||||
|
"bench:basic": "bun run test-edit-ops.ts",
|
||||||
|
"bench:edge": "bun run test-edge-cases.ts",
|
||||||
|
"bench:multi": "bun run test-multi-model.ts",
|
||||||
|
"bench:all": "bun run bench:basic && bun run bench:edge"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai": "^6.0.94",
|
||||||
|
"@ai-sdk/openai": "^1.3.0",
|
||||||
|
"@friendliai/ai-provider": "^1.0.9",
|
||||||
|
"zod": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1121
benchmarks/test-edge-cases.ts
Normal file
1121
benchmarks/test-edge-cases.ts
Normal file
File diff suppressed because it is too large
Load Diff
808
benchmarks/test-edit-ops.ts
Normal file
808
benchmarks/test-edit-ops.ts
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Comprehensive headless edit_file stress test: 21 operation types
|
||||||
|
*
|
||||||
|
* Tests: 5 basic ops + 10 creative cases + 6 whitespace cases
|
||||||
|
* Each runs via headless mode with its own demo file + prompt.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run scripts/test-headless-edit-ops.ts [-m <model>] [--provider <provider>]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
// ── CLI arg passthrough ───────────────────────────────────────
|
||||||
|
const extraArgs: string[] = [];
|
||||||
|
const rawArgs = process.argv.slice(2);
|
||||||
|
for (let i = 0; i < rawArgs.length; i++) {
|
||||||
|
const arg = rawArgs[i];
|
||||||
|
if (
|
||||||
|
(arg === "-m" || arg === "--model" || arg === "--provider") &&
|
||||||
|
i + 1 < rawArgs.length
|
||||||
|
) {
|
||||||
|
extraArgs.push(arg, rawArgs[i + 1]);
|
||||||
|
i++;
|
||||||
|
} else if (arg === "--think" || arg === "--no-translate") {
|
||||||
|
extraArgs.push(arg);
|
||||||
|
} else if (arg === "--reasoning-mode" && i + 1 < rawArgs.length) {
|
||||||
|
extraArgs.push(arg, rawArgs[i + 1]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const RED = "\x1b[31m";
|
||||||
|
const YELLOW = "\x1b[33m";
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
const CYAN = "\x1b[36m";
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
const pass = (msg: string) => console.log(` ${GREEN}✓${RESET} ${msg}`);
|
||||||
|
const fail = (msg: string) => console.log(` ${RED}✗${RESET} ${msg}`);
|
||||||
|
const info = (msg: string) => console.log(` ${DIM}${msg}${RESET}`);
|
||||||
|
const warn = (msg: string) => console.log(` ${YELLOW}⚠${RESET} ${msg}`);
|
||||||
|
|
||||||
|
// ── Test case definition ─────────────────────────────────────
|
||||||
|
interface TestCase {
|
||||||
|
fileContent: string;
|
||||||
|
fileName: string;
|
||||||
|
name: string;
|
||||||
|
prompt: string;
|
||||||
|
validate: (content: string) => { passed: boolean; reason: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_CASES: TestCase[] = [
|
||||||
|
{
|
||||||
|
name: "1. Replace single line",
|
||||||
|
fileName: "config.txt",
|
||||||
|
fileContent: [
|
||||||
|
"host: localhost",
|
||||||
|
"port: 3000",
|
||||||
|
"debug: false",
|
||||||
|
"timeout: 30",
|
||||||
|
"retries: 3",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Follow these steps exactly:",
|
||||||
|
"Step 1: Call read_file on config.txt.",
|
||||||
|
"Step 2: Note the anchor for the port line (line 2).",
|
||||||
|
"Step 3: Call edit_file with path='config.txt' and edits containing ONE object:",
|
||||||
|
" { op: 'replace', pos: '<line2 anchor>', lines: ['port: 8080'] }",
|
||||||
|
"IMPORTANT: pos must be ONLY the anchor (like '2#KB'). lines must be a SEPARATE array field with the new content.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (content) => {
|
||||||
|
const has8080 = content.includes("port: 8080");
|
||||||
|
const has3000 = content.includes("port: 3000");
|
||||||
|
if (has8080 && !has3000) {
|
||||||
|
return { passed: true, reason: "port changed to 8080" };
|
||||||
|
}
|
||||||
|
if (has3000) {
|
||||||
|
return { passed: false, reason: "port still 3000 — edit not applied" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `unexpected content: ${content.slice(0, 100)}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2. Append after line",
|
||||||
|
fileName: "fruits.txt",
|
||||||
|
fileContent: ["apple", "banana", "cherry"].join("\n"),
|
||||||
|
prompt:
|
||||||
|
"Read fruits.txt with read_file. Then use edit_file with op='append' to insert a new line 'grape' after the 'banana' line. Use pos='LINE#HASH' of the banana line and lines=['grape'].",
|
||||||
|
validate: (content) => {
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
const bananaIdx = lines.findIndex((l) => l.trim() === "banana");
|
||||||
|
const grapeIdx = lines.findIndex((l) => l.trim() === "grape");
|
||||||
|
if (grapeIdx === -1) {
|
||||||
|
return { passed: false, reason: '"grape" not found in file' };
|
||||||
|
}
|
||||||
|
if (bananaIdx === -1) {
|
||||||
|
return { passed: false, reason: '"banana" was removed' };
|
||||||
|
}
|
||||||
|
if (grapeIdx !== bananaIdx + 1) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `"grape" at line ${grapeIdx + 1} but expected after "banana" at line ${bananaIdx + 1}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lines.length !== 4) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `expected 4 lines, got ${lines.length}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
reason: '"grape" correctly appended after "banana"',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "3. Prepend before line",
|
||||||
|
fileName: "code.txt",
|
||||||
|
fileContent: ["function greet() {", ' return "hello";', "}"].join("\n"),
|
||||||
|
prompt:
|
||||||
|
"Read code.txt with read_file. Then use edit_file with op='prepend' to add '// Greeting function' before the function line. Use pos='LINE#HASH' of the function line and lines=['// Greeting function'].",
|
||||||
|
validate: (content) => {
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
const commentIdx = lines.findIndex(
|
||||||
|
(l) => l.trim().startsWith("//") && l.toLowerCase().includes("greet")
|
||||||
|
);
|
||||||
|
const funcIdx = lines.findIndex((l) =>
|
||||||
|
l.trim().startsWith("function greet")
|
||||||
|
);
|
||||||
|
if (commentIdx === -1) {
|
||||||
|
return { passed: false, reason: "comment line not found" };
|
||||||
|
}
|
||||||
|
if (funcIdx === -1) {
|
||||||
|
return { passed: false, reason: '"function greet" line was removed' };
|
||||||
|
}
|
||||||
|
if (commentIdx !== funcIdx - 1) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `comment at line ${commentIdx + 1} but function at ${funcIdx + 1} — not directly before`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
reason: "comment correctly prepended before function",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "4. Range replace (multi-line → single line)",
|
||||||
|
fileName: "log.txt",
|
||||||
|
fileContent: [
|
||||||
|
"=== Log Start ===",
|
||||||
|
"INFO: started",
|
||||||
|
"WARN: slow query",
|
||||||
|
"ERROR: timeout",
|
||||||
|
"INFO: recovered",
|
||||||
|
"=== Log End ===",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Follow these steps exactly:",
|
||||||
|
"Step 1: Call read_file on log.txt to see line anchors.",
|
||||||
|
"Step 2: Note the anchor for 'WARN: slow query' (line 3) and 'ERROR: timeout' (line 4).",
|
||||||
|
"Step 3: Call edit_file with path='log.txt' and edits containing ONE object with THREE separate JSON fields:",
|
||||||
|
" { op: 'replace', pos: '<line3 anchor>', end: '<line4 anchor>', lines: ['RESOLVED: issues cleared'] }",
|
||||||
|
"CRITICAL: pos, end, and lines are THREE SEPARATE JSON fields. pos is ONLY '3#XX'. end is ONLY '4#YY'. lines is ['RESOLVED: issues cleared'].",
|
||||||
|
"If edit_file fails or errors, use write_file to write the complete correct file content instead.",
|
||||||
|
"The correct final content should be: === Log Start ===, INFO: started, RESOLVED: issues cleared, INFO: recovered, === Log End ===",
|
||||||
|
"Do not make any other changes.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (content) => {
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
const hasResolved = lines.some(
|
||||||
|
(l) => l.trim() === "RESOLVED: issues cleared"
|
||||||
|
);
|
||||||
|
const hasWarn = content.includes("WARN: slow query");
|
||||||
|
const hasError = content.includes("ERROR: timeout");
|
||||||
|
if (!hasResolved) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: '"RESOLVED: issues cleared" not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hasWarn || hasError) {
|
||||||
|
return { passed: false, reason: "old WARN/ERROR lines still present" };
|
||||||
|
}
|
||||||
|
// Core assertion: 2 old lines removed, 1 new line added = net -1 line
|
||||||
|
// Allow slight overshoot from model adding extra content
|
||||||
|
if (lines.length < 4 || lines.length > 6) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `expected ~5 lines, got ${lines.length}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
reason: "range replace succeeded — 2 lines → 1 line",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "5. Delete line",
|
||||||
|
fileName: "settings.txt",
|
||||||
|
fileContent: [
|
||||||
|
"mode: production",
|
||||||
|
"debug: true",
|
||||||
|
"cache: enabled",
|
||||||
|
"log_level: info",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Follow these steps exactly:",
|
||||||
|
"Step 1: Call read_file on settings.txt to see line anchors.",
|
||||||
|
"Step 2: Note the anchor for 'debug: true' (line 2).",
|
||||||
|
"Step 3: Call edit_file with path='settings.txt' and edits containing ONE object:",
|
||||||
|
" { op: 'replace', pos: '<line2 anchor>', lines: [] }",
|
||||||
|
"IMPORTANT: lines must be an empty array [] to delete the line. pos must be ONLY the anchor like '2#SR'.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (content) => {
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
const hasDebug = content.includes("debug: true");
|
||||||
|
if (hasDebug) {
|
||||||
|
return { passed: false, reason: '"debug: true" still present' };
|
||||||
|
}
|
||||||
|
if (lines.length !== 3) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: `expected 3 lines, got ${lines.length}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
content.includes("mode: production") &&
|
||||||
|
content.includes("cache: enabled")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { passed: false, reason: "other lines were removed" };
|
||||||
|
}
|
||||||
|
return { passed: true, reason: '"debug: true" successfully deleted' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Creative cases (6-15) ────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: "6. Batch edit — two replacements in one call",
|
||||||
|
fileName: "batch.txt",
|
||||||
|
fileContent: ["red", "green", "blue", "yellow"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read batch.txt with read_file.",
|
||||||
|
"Then call edit_file ONCE with path='batch.txt' and edits containing TWO objects:",
|
||||||
|
" 1) { op: 'replace', pos: '<line1 anchor>', lines: ['crimson'] }",
|
||||||
|
" 2) { op: 'replace', pos: '<line3 anchor>', lines: ['navy'] }",
|
||||||
|
"Both edits must be in the SAME edits array in a single edit_file call.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (!c.includes("crimson")) return { passed: false, reason: "'crimson' not found" };
|
||||||
|
if (!c.includes("navy")) return { passed: false, reason: "'navy' not found" };
|
||||||
|
if (c.includes("red")) return { passed: false, reason: "'red' still present" };
|
||||||
|
if (c.includes("blue")) return { passed: false, reason: "'blue' still present" };
|
||||||
|
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "both lines replaced in single call" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "7. Line expansion — 1 line → 3 lines",
|
||||||
|
fileName: "expand.txt",
|
||||||
|
fileContent: ["header", "TODO: implement", "footer"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read expand.txt with read_file.",
|
||||||
|
"Replace the 'TODO: implement' line (line 2) with THREE lines:",
|
||||||
|
" 'step 1: init', 'step 2: process', 'step 3: cleanup'",
|
||||||
|
"Use edit_file with op='replace', pos=<line2 anchor>, lines=['step 1: init', 'step 2: process', 'step 3: cleanup'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (c.includes("TODO")) return { passed: false, reason: "TODO line still present" };
|
||||||
|
if (!c.includes("step 1: init")) return { passed: false, reason: "'step 1: init' not found" };
|
||||||
|
if (!c.includes("step 3: cleanup")) return { passed: false, reason: "'step 3: cleanup' not found" };
|
||||||
|
if (lines.length !== 5) return { passed: false, reason: `expected 5 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "1 line expanded to 3 lines" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "8. Append at EOF",
|
||||||
|
fileName: "eof.txt",
|
||||||
|
fileContent: ["line one", "line two"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read eof.txt with read_file.",
|
||||||
|
"Use edit_file to append 'line three' after the LAST line of the file.",
|
||||||
|
"Use op='append', pos=<last line anchor>, lines=['line three'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (!c.includes("line three")) return { passed: false, reason: "'line three' not found" };
|
||||||
|
if (lines[lines.length - 1].trim() !== "line three")
|
||||||
|
return { passed: false, reason: "'line three' not at end" };
|
||||||
|
if (lines.length !== 3) return { passed: false, reason: `expected 3 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "appended at EOF" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "9. Special characters in content",
|
||||||
|
fileName: "special.json",
|
||||||
|
fileContent: [
|
||||||
|
'{',
|
||||||
|
' "name": "old-value",',
|
||||||
|
' "count": 42',
|
||||||
|
'}',
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read special.json with read_file.",
|
||||||
|
'Replace the line containing \"name\": \"old-value\" with \"name\": \"new-value\".',
|
||||||
|
"Use edit_file with op='replace', pos=<that line's anchor>, lines=[' \"name\": \"new-value\",'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
if (c.includes("old-value")) return { passed: false, reason: "'old-value' still present" };
|
||||||
|
if (!c.includes('"new-value"')) return { passed: false, reason: "'new-value' not found" };
|
||||||
|
if (!c.includes('"count": 42')) return { passed: false, reason: "other content was modified" };
|
||||||
|
return { passed: true, reason: "JSON value replaced with special chars intact" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10. Replace first line",
|
||||||
|
fileName: "first.txt",
|
||||||
|
fileContent: ["OLD HEADER", "body content", "footer"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read first.txt with read_file.",
|
||||||
|
"Replace the very first line 'OLD HEADER' with 'NEW HEADER'.",
|
||||||
|
"Use edit_file with op='replace', pos=<line1 anchor>, lines=['NEW HEADER'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (c.includes("OLD HEADER")) return { passed: false, reason: "'OLD HEADER' still present" };
|
||||||
|
if (lines[0].trim() !== "NEW HEADER") return { passed: false, reason: "first line is not 'NEW HEADER'" };
|
||||||
|
if (!c.includes("body content")) return { passed: false, reason: "body was modified" };
|
||||||
|
return { passed: true, reason: "first line replaced" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "11. Replace last line",
|
||||||
|
fileName: "last.txt",
|
||||||
|
fileContent: ["alpha", "bravo", "OLD_FOOTER"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read last.txt with read_file.",
|
||||||
|
"Replace the last line 'OLD_FOOTER' with 'NEW_FOOTER'.",
|
||||||
|
"Use edit_file with op='replace', pos=<last line anchor>, lines=['NEW_FOOTER'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (c.includes("OLD_FOOTER")) return { passed: false, reason: "'OLD_FOOTER' still present" };
|
||||||
|
if (lines[lines.length - 1].trim() !== "NEW_FOOTER")
|
||||||
|
return { passed: false, reason: "last line is not 'NEW_FOOTER'" };
|
||||||
|
return { passed: true, reason: "last line replaced" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "12. Adjacent line edits",
|
||||||
|
fileName: "adjacent.txt",
|
||||||
|
fileContent: ["aaa", "bbb", "ccc", "ddd"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read adjacent.txt with read_file.",
|
||||||
|
"Replace line 2 ('bbb') with 'BBB' and line 3 ('ccc') with 'CCC'.",
|
||||||
|
"Use edit_file with TWO edits in the same call:",
|
||||||
|
" { op: 'replace', pos: <line2 anchor>, lines: ['BBB'] }",
|
||||||
|
" { op: 'replace', pos: <line3 anchor>, lines: ['CCC'] }",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (c.includes("bbb")) return { passed: false, reason: "'bbb' still present" };
|
||||||
|
if (c.includes("ccc")) return { passed: false, reason: "'ccc' still present" };
|
||||||
|
if (!c.includes("BBB")) return { passed: false, reason: "'BBB' not found" };
|
||||||
|
if (!c.includes("CCC")) return { passed: false, reason: "'CCC' not found" };
|
||||||
|
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "two adjacent lines replaced" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "13. Prepend multi-line block",
|
||||||
|
fileName: "block.py",
|
||||||
|
fileContent: ["def main():", " print('hello')", "", "main()"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read block.py with read_file.",
|
||||||
|
"Prepend a 2-line comment block before 'def main():' (line 1).",
|
||||||
|
"The two lines are: '# Author: test' and '# Date: 2025-01-01'.",
|
||||||
|
"Use edit_file with op='prepend', pos=<line1 anchor>, lines=['# Author: test', '# Date: 2025-01-01'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (!c.includes("# Author: test")) return { passed: false, reason: "author comment not found" };
|
||||||
|
if (!c.includes("# Date: 2025-01-01")) return { passed: false, reason: "date comment not found" };
|
||||||
|
const defIdx = lines.findIndex((l) => l.startsWith("def main"));
|
||||||
|
const authorIdx = lines.findIndex((l) => l.includes("Author"));
|
||||||
|
if (authorIdx >= defIdx) return { passed: false, reason: "comments not before def" };
|
||||||
|
return { passed: true, reason: "2-line block prepended before function" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "14. Delete range — 3 consecutive lines",
|
||||||
|
fileName: "cleanup.txt",
|
||||||
|
fileContent: ["keep1", "remove-a", "remove-b", "remove-c", "keep2"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read cleanup.txt with read_file.",
|
||||||
|
"Delete lines 2-4 ('remove-a', 'remove-b', 'remove-c') using a single range replace.",
|
||||||
|
"Use edit_file with op='replace', pos=<line2 anchor>, end=<line4 anchor>, lines=[].",
|
||||||
|
"An empty lines array deletes the range.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (c.includes("remove")) return { passed: false, reason: "'remove' lines still present" };
|
||||||
|
if (!c.includes("keep1")) return { passed: false, reason: "'keep1' was deleted" };
|
||||||
|
if (!c.includes("keep2")) return { passed: false, reason: "'keep2' was deleted" };
|
||||||
|
if (lines.length !== 2) return { passed: false, reason: `expected 2 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "3 consecutive lines deleted via range" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "15. Replace with duplicate-content line",
|
||||||
|
fileName: "dupes.txt",
|
||||||
|
fileContent: ["item", "item", "item", "item"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read dupes.txt with read_file. All 4 lines have the same text 'item'.",
|
||||||
|
"Replace ONLY line 3 with 'CHANGED'. Do NOT modify any other line.",
|
||||||
|
"Use edit_file with op='replace', pos=<line3 anchor>, lines=['CHANGED'].",
|
||||||
|
"The anchor hash uniquely identifies line 3 even though the content is identical.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (!c.includes("CHANGED")) return { passed: false, reason: "'CHANGED' not found" };
|
||||||
|
const changedCount = lines.filter((l) => l.trim() === "CHANGED").length;
|
||||||
|
const itemCount = lines.filter((l) => l.trim() === "item").length;
|
||||||
|
if (changedCount !== 1) return { passed: false, reason: `expected 1 CHANGED, got ${changedCount}` };
|
||||||
|
if (itemCount !== 3) return { passed: false, reason: `expected 3 item lines, got ${itemCount}` };
|
||||||
|
if (lines.length !== 4) return { passed: false, reason: `expected 4 lines, got ${lines.length}` };
|
||||||
|
return { passed: true, reason: "only line 3 changed among duplicates" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Whitespace cases (16-21) ──────────────────────────────────
|
||||||
|
{
|
||||||
|
name: "16. Fix indentation — 2 spaces → 4 spaces",
|
||||||
|
fileName: "indent.js",
|
||||||
|
fileContent: ["function foo() {", " const x = 1;", " return x;", "}"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read indent.js with read_file.",
|
||||||
|
"Replace line 2 ' const x = 1;' (2-space indent) with ' const x = 1;' (4-space indent).",
|
||||||
|
"Use edit_file with op='replace', pos=<line2 anchor>, lines=[' const x = 1;'].",
|
||||||
|
"The ONLY change is the indentation: 2 spaces → 4 spaces. Content stays the same.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.split("\n");
|
||||||
|
const line2 = lines[1];
|
||||||
|
if (!line2) return { passed: false, reason: "line 2 missing" };
|
||||||
|
if (line2 === " const x = 1;") return { passed: true, reason: "indentation fixed to 4 spaces" };
|
||||||
|
if (line2 === " const x = 1;") return { passed: false, reason: "still 2-space indent" };
|
||||||
|
return { passed: false, reason: `unexpected line 2: '${line2}'` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "17. Replace preserving leading whitespace",
|
||||||
|
fileName: "preserve.py",
|
||||||
|
fileContent: [
|
||||||
|
"class Foo:",
|
||||||
|
" def old_method(self):",
|
||||||
|
" pass",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read preserve.py with read_file.",
|
||||||
|
"Replace line 2 ' def old_method(self):' with ' def new_method(self):'.",
|
||||||
|
"Keep the 4-space indentation. Only change the method name.",
|
||||||
|
"Use edit_file with op='replace', pos=<line2 anchor>, lines=[' def new_method(self):'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
if (c.includes("old_method")) return { passed: false, reason: "'old_method' still present" };
|
||||||
|
const lines = c.split("\n");
|
||||||
|
const methodLine = lines.find((l) => l.includes("new_method"));
|
||||||
|
if (!methodLine) return { passed: false, reason: "'new_method' not found" };
|
||||||
|
if (!methodLine.startsWith(" ")) return { passed: false, reason: "indentation lost" };
|
||||||
|
return { passed: true, reason: "method renamed with indentation preserved" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "18. Insert blank line between sections",
|
||||||
|
fileName: "sections.txt",
|
||||||
|
fileContent: ["[section-a]", "value-a=1", "[section-b]", "value-b=2"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read sections.txt with read_file.",
|
||||||
|
"Insert a blank empty line between 'value-a=1' (line 2) and '[section-b]' (line 3).",
|
||||||
|
"Use edit_file with op='append', pos=<line2 anchor>, lines=[''].",
|
||||||
|
"lines=[''] inserts one empty line.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.split("\n");
|
||||||
|
const valAIdx = lines.findIndex((l) => l.includes("value-a=1"));
|
||||||
|
const secBIdx = lines.findIndex((l) => l.includes("[section-b]"));
|
||||||
|
if (valAIdx === -1) return { passed: false, reason: "'value-a=1' missing" };
|
||||||
|
if (secBIdx === -1) return { passed: false, reason: "'[section-b]' missing" };
|
||||||
|
if (secBIdx - valAIdx < 2) return { passed: false, reason: "no blank line between sections" };
|
||||||
|
const between = lines[valAIdx + 1];
|
||||||
|
if (between.trim() !== "") return { passed: false, reason: `line between is '${between}', not blank` };
|
||||||
|
return { passed: true, reason: "blank line inserted between sections" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "19. Delete blank line",
|
||||||
|
fileName: "noblank.txt",
|
||||||
|
fileContent: ["first", "", "second", "third"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read noblank.txt with read_file.",
|
||||||
|
"Delete the empty blank line (line 2). Use edit_file with op='replace', pos=<line2 anchor>, lines=[].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
const lines = c.trim().split("\n");
|
||||||
|
if (lines.length !== 3) return { passed: false, reason: `expected 3 lines, got ${lines.length}` };
|
||||||
|
if (lines[0].trim() !== "first") return { passed: false, reason: "'first' not on line 1" };
|
||||||
|
if (lines[1].trim() !== "second") return { passed: false, reason: "'second' not on line 2" };
|
||||||
|
return { passed: true, reason: "blank line deleted" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "20. Tab → spaces conversion",
|
||||||
|
fileName: "tabs.txt",
|
||||||
|
fileContent: ["start", "\tindented-with-tab", "end"].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read tabs.txt with read_file.",
|
||||||
|
"Replace the tab-indented line 2 using edit_file with edits: [{ op: 'replace', pos: '<line2 anchor>', lines: [' indented-with-spaces'] }].",
|
||||||
|
"Expected final line 2 to be 4 spaces followed by indented-with-spaces.",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
if (c.includes("\t")) return { passed: false, reason: "tab still present" };
|
||||||
|
if (!c.includes(" indented-with-spaces"))
|
||||||
|
return { passed: false, reason: "' indented-with-spaces' not found" };
|
||||||
|
if (!c.includes("start")) return { passed: false, reason: "'start' was modified" };
|
||||||
|
return { passed: true, reason: "tab converted to 4 spaces" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "21. Deeply nested indent replacement",
|
||||||
|
fileName: "nested.ts",
|
||||||
|
fileContent: [
|
||||||
|
"if (a) {",
|
||||||
|
" if (b) {",
|
||||||
|
" if (c) {",
|
||||||
|
" old_call();",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
"Read nested.ts with read_file.",
|
||||||
|
"Replace line 4 ' old_call();' with ' new_call();'.",
|
||||||
|
"Preserve the exact 6-space indentation. Only change the function name.",
|
||||||
|
"Use edit_file with op='replace', pos=<line4 anchor>, lines=[' new_call();'].",
|
||||||
|
].join(" "),
|
||||||
|
validate: (c) => {
|
||||||
|
if (c.includes("old_call")) return { passed: false, reason: "'old_call' still present" };
|
||||||
|
const lines = c.split("\n");
|
||||||
|
const callLine = lines.find((l) => l.includes("new_call"));
|
||||||
|
if (!callLine) return { passed: false, reason: "'new_call' not found" };
|
||||||
|
const leadingSpaces = callLine.match(/^ */)?.[0].length ?? 0;
|
||||||
|
if (leadingSpaces !== 6) return { passed: false, reason: `expected 6-space indent, got ${leadingSpaces}` };
|
||||||
|
return { passed: true, reason: "deeply nested line replaced with indent preserved" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── JSONL event types ─────────────────────────────────────────
|
||||||
|
interface ToolCallEvent {
|
||||||
|
tool_call_id: string;
|
||||||
|
tool_input: Record<string, unknown>;
|
||||||
|
tool_name: string;
|
||||||
|
type: "tool_call";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolResultEvent {
|
||||||
|
error?: string;
|
||||||
|
output: string;
|
||||||
|
tool_call_id: string;
|
||||||
|
type: "tool_result";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnyEvent {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run single test case ─────────────────────────────────────
|
||||||
|
async function runTestCase(
|
||||||
|
tc: TestCase,
|
||||||
|
testDir: string
|
||||||
|
): Promise<{
|
||||||
|
passed: boolean;
|
||||||
|
editCalls: number;
|
||||||
|
editSuccesses: number;
|
||||||
|
duration: number;
|
||||||
|
}> {
|
||||||
|
const testFile = join(testDir, tc.fileName);
|
||||||
|
writeFileSync(testFile, tc.fileContent, "utf-8");
|
||||||
|
|
||||||
|
const headlessScript = resolve(import.meta.dir, "headless.ts");
|
||||||
|
const headlessArgs = [
|
||||||
|
"run",
|
||||||
|
headlessScript,
|
||||||
|
"-p",
|
||||||
|
tc.prompt,
|
||||||
|
"--no-translate",
|
||||||
|
...extraArgs,
|
||||||
|
];
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const output = await new Promise<string>((res, reject) => {
|
||||||
|
const proc = spawn("bun", headlessArgs, {
|
||||||
|
cwd: testDir,
|
||||||
|
env: { ...process.env, BUN_INSTALL: process.env.BUN_INSTALL },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
proc.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
reject(new Error("Timed out after 4 minutes"));
|
||||||
|
},
|
||||||
|
4 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Exit code ${code}\n${stderr.slice(-500)}`));
|
||||||
|
} else {
|
||||||
|
res(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Parse events
|
||||||
|
const events: AnyEvent[] = [];
|
||||||
|
for (const line of output.split("\n").filter((l) => l.trim())) {
|
||||||
|
try {
|
||||||
|
events.push(JSON.parse(line) as AnyEvent);
|
||||||
|
} catch {
|
||||||
|
// skip non-JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = events.filter(
|
||||||
|
(e) => e.type === "tool_call"
|
||||||
|
) as unknown as ToolCallEvent[];
|
||||||
|
const toolResults = events.filter(
|
||||||
|
(e) => e.type === "tool_result"
|
||||||
|
) as unknown as ToolResultEvent[];
|
||||||
|
|
||||||
|
const editCalls = toolCalls.filter((e) => e.tool_name === "edit_file");
|
||||||
|
const editCallIds = new Set(editCalls.map((e) => e.tool_call_id));
|
||||||
|
const editResults = toolResults.filter((e) =>
|
||||||
|
editCallIds.has(e.tool_call_id)
|
||||||
|
);
|
||||||
|
const editSuccesses = editResults.filter((e) => !e.error);
|
||||||
|
|
||||||
|
// Show blocked calls
|
||||||
|
const editErrors = editResults.filter((e) => e.error);
|
||||||
|
for (const err of editErrors) {
|
||||||
|
const matchingCall = editCalls.find(
|
||||||
|
(c) => c.tool_call_id === err.tool_call_id
|
||||||
|
);
|
||||||
|
info(` blocked: ${err.error?.slice(0, 120)}`);
|
||||||
|
if (matchingCall) {
|
||||||
|
info(` input: ${JSON.stringify(matchingCall.tool_input).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file content
|
||||||
|
let finalContent: string;
|
||||||
|
try {
|
||||||
|
finalContent = readFileSync(testFile, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
editCalls: editCalls.length,
|
||||||
|
editSuccesses: editSuccesses.length,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = tc.validate(finalContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: validation.passed,
|
||||||
|
editCalls: editCalls.length,
|
||||||
|
editSuccesses: editSuccesses.length,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────
|
||||||
|
const main = async () => {
|
||||||
|
console.log(`\n${BOLD}Headless Edit Operations Test — ${TEST_CASES.length} Types${RESET}\n`);
|
||||||
|
|
||||||
|
const testDir = join(tmpdir(), `edit-ops-${Date.now()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
info(`Test dir: ${testDir}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
let totalPassed = 0;
|
||||||
|
const results: { name: string; passed: boolean; detail: string }[] = [];
|
||||||
|
|
||||||
|
for (const tc of TEST_CASES) {
|
||||||
|
console.log(`${CYAN}${BOLD}${tc.name}${RESET}`);
|
||||||
|
info(`File: ${tc.fileName}`);
|
||||||
|
info(`Prompt: "${tc.prompt.slice(0, 80)}..."`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runTestCase(tc, testDir);
|
||||||
|
const status = result.passed
|
||||||
|
? `${GREEN}PASS${RESET}`
|
||||||
|
: `${RED}FAIL${RESET}`;
|
||||||
|
const detail = `edit_file: ${result.editSuccesses}/${result.editCalls} succeeded, ${(result.duration / 1000).toFixed(1)}s`;
|
||||||
|
|
||||||
|
console.log(` ${status} — ${detail}`);
|
||||||
|
|
||||||
|
if (result.passed) {
|
||||||
|
totalPassed++;
|
||||||
|
// Validate the file to show reason
|
||||||
|
const content = readFileSync(join(testDir, tc.fileName), "utf-8");
|
||||||
|
const v = tc.validate(content);
|
||||||
|
pass(v.reason);
|
||||||
|
} else {
|
||||||
|
const content = readFileSync(join(testDir, tc.fileName), "utf-8");
|
||||||
|
const v = tc.validate(content);
|
||||||
|
fail(v.reason);
|
||||||
|
info(
|
||||||
|
`Final content:\n${content
|
||||||
|
.split("\n")
|
||||||
|
.map((l, i) => ` ${i + 1}: ${l}`)
|
||||||
|
.join("\n")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ name: tc.name, passed: result.passed, detail });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.log(` ${RED}ERROR${RESET} — ${msg.slice(0, 200)}`);
|
||||||
|
fail(msg.slice(0, 200));
|
||||||
|
results.push({ name: tc.name, passed: false, detail: msg.slice(0, 100) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file for next test (in case of side effects)
|
||||||
|
try {
|
||||||
|
rmSync(join(testDir, tc.fileName), { force: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(`${BOLD}━━━ Summary ━━━${RESET}`);
|
||||||
|
for (const r of results) {
|
||||||
|
const icon = r.passed ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
||||||
|
console.log(` ${icon} ${r.name} — ${r.detail}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
console.log(
|
||||||
|
`${BOLD}Result: ${totalPassed}/${TEST_CASES.length} passed (${Math.round((totalPassed / TEST_CASES.length) * 100)}%)${RESET}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
try {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (totalPassed === TEST_CASES.length) {
|
||||||
|
console.log(
|
||||||
|
`\n${BOLD}${GREEN}🎉 ALL TESTS PASSED — 100% success rate!${RESET}\n`
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(`\n${BOLD}${RED}Some tests failed.${RESET}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
280
benchmarks/test-multi-model.ts
Normal file
280
benchmarks/test-multi-model.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Multi-model edit_file test runner
|
||||||
|
*
|
||||||
|
* Runs test-headless-edit-ops.ts against every available model
|
||||||
|
* and produces a summary table.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run scripts/test-multi-model-edit.ts [--timeout <seconds>]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
// ── Models ────────────────────────────────────────────────────
|
||||||
|
const MODELS = [
|
||||||
|
{ id: "MiniMaxAI/MiniMax-M2.5", short: "M2.5" },
|
||||||
|
// { id: "MiniMaxAI/MiniMax-M2.1", short: "M2.1" }, // masked: slow + timeout-prone
|
||||||
|
// { id: "zai-org/GLM-5", short: "GLM-5" }, // masked: API 503
|
||||||
|
{ id: "zai-org/GLM-4.7", short: "GLM-4.7" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── CLI args ──────────────────────────────────────────────────
|
||||||
|
let perModelTimeoutSec = 900; // 15 min default per model (5 tests)
|
||||||
|
const rawArgs = process.argv.slice(2);
|
||||||
|
for (let i = 0; i < rawArgs.length; i++) {
|
||||||
|
if (rawArgs[i] === "--timeout" && i + 1 < rawArgs.length) {
|
||||||
|
const parsed = Number.parseInt(rawArgs[i + 1], 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||||
|
console.error(`Invalid --timeout value: ${rawArgs[i + 1]}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
perModelTimeoutSec = parsed;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const RED = "\x1b[31m";
|
||||||
|
const YELLOW = "\x1b[33m";
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
const CYAN = "\x1b[36m";
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
interface TestResult {
|
||||||
|
detail: string;
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelResult {
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
modelId: string;
|
||||||
|
modelShort: string;
|
||||||
|
tests: TestResult[];
|
||||||
|
totalPassed: number;
|
||||||
|
totalTests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse test-headless-edit-ops stdout ───────────────────────
|
||||||
|
function parseOpsOutput(stdout: string): TestResult[] {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
|
||||||
|
// Match lines like: " PASS — edit_file: 1/1 succeeded, 32.5s"
|
||||||
|
// or " FAIL — edit_file: 0/3 succeeded, 15.2s"
|
||||||
|
// or " ERROR — Timed out after 10 minutes"
|
||||||
|
// Following a line like: "1. Replace single line"
|
||||||
|
const lines = stdout.split("\n");
|
||||||
|
|
||||||
|
let currentTestName = "";
|
||||||
|
for (const line of lines) {
|
||||||
|
// Detect test name: starts with ANSI-colored bold cyan + "N. Name"
|
||||||
|
// Strip ANSI codes for matching
|
||||||
|
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
|
|
||||||
|
// Test name pattern: "N. <name>"
|
||||||
|
const testNameMatch = stripped.match(/^\s*(\d+\.\s+.+)$/);
|
||||||
|
if (
|
||||||
|
testNameMatch &&
|
||||||
|
!stripped.includes("—") &&
|
||||||
|
!stripped.includes("✓") &&
|
||||||
|
!stripped.includes("✗")
|
||||||
|
) {
|
||||||
|
currentTestName = testNameMatch[1].trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result line: PASS/FAIL/ERROR
|
||||||
|
if (currentTestName && stripped.includes("PASS")) {
|
||||||
|
const detail = stripped.replace(/^\s*PASS\s*—?\s*/, "").trim();
|
||||||
|
results.push({
|
||||||
|
name: currentTestName,
|
||||||
|
passed: true,
|
||||||
|
detail: detail || "passed",
|
||||||
|
});
|
||||||
|
currentTestName = "";
|
||||||
|
} else if (currentTestName && stripped.includes("FAIL")) {
|
||||||
|
const detail = stripped.replace(/^\s*FAIL\s*—?\s*/, "").trim();
|
||||||
|
results.push({
|
||||||
|
name: currentTestName,
|
||||||
|
passed: false,
|
||||||
|
detail: detail || "failed",
|
||||||
|
});
|
||||||
|
currentTestName = "";
|
||||||
|
} else if (currentTestName && stripped.includes("ERROR")) {
|
||||||
|
const detail = stripped.replace(/^\s*ERROR\s*—?\s*/, "").trim();
|
||||||
|
results.push({
|
||||||
|
name: currentTestName,
|
||||||
|
passed: false,
|
||||||
|
detail: detail || "error",
|
||||||
|
});
|
||||||
|
currentTestName = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run one model ────────────────────────────────────────────
|
||||||
|
async function runModel(model: {
|
||||||
|
id: string;
|
||||||
|
short: string;
|
||||||
|
}): Promise<ModelResult> {
|
||||||
|
const opsScript = resolve(import.meta.dir, "test-edit-ops.ts");
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return new Promise<ModelResult>((resolvePromise) => {
|
||||||
|
const proc = spawn(
|
||||||
|
"bun",
|
||||||
|
["run", opsScript, "-m", model.id, "--no-translate"],
|
||||||
|
{
|
||||||
|
cwd: resolve(import.meta.dir),
|
||||||
|
env: { ...process.env, BUN_INSTALL: process.env.BUN_INSTALL },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
proc.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
resolvePromise({
|
||||||
|
modelId: model.id,
|
||||||
|
modelShort: model.short,
|
||||||
|
tests: [],
|
||||||
|
totalPassed: 0,
|
||||||
|
totalTests: 0,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: `Timed out after ${perModelTimeoutSec}s`,
|
||||||
|
});
|
||||||
|
}, perModelTimeoutSec * 1000);
|
||||||
|
|
||||||
|
proc.on("close", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const tests = parseOpsOutput(stdout);
|
||||||
|
const totalPassed = tests.filter((t) => t.passed).length;
|
||||||
|
|
||||||
|
resolvePromise({
|
||||||
|
modelId: model.id,
|
||||||
|
modelShort: model.short,
|
||||||
|
tests,
|
||||||
|
totalPassed,
|
||||||
|
totalTests: Math.max(tests.length, 5),
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolvePromise({
|
||||||
|
modelId: model.id,
|
||||||
|
modelShort: model.short,
|
||||||
|
tests: [],
|
||||||
|
totalPassed: 0,
|
||||||
|
totalTests: 0,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────
|
||||||
|
const main = async () => {
|
||||||
|
console.log(`\n${BOLD}═══ Multi-Model edit_file Test Runner ═══${RESET}\n`);
|
||||||
|
console.log(`${DIM}Models: ${MODELS.map((m) => m.short).join(", ")}${RESET}`);
|
||||||
|
console.log(`${DIM}Timeout: ${perModelTimeoutSec}s per model${RESET}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const allResults: ModelResult[] = [];
|
||||||
|
|
||||||
|
for (const model of MODELS) {
|
||||||
|
console.log(`${CYAN}${BOLD}▶ Testing ${model.short} (${model.id})${RESET}`);
|
||||||
|
const result = await runModel(model);
|
||||||
|
allResults.push(result);
|
||||||
|
|
||||||
|
const timeStr = `${(result.durationMs / 1000).toFixed(1)}s`;
|
||||||
|
if (result.error) {
|
||||||
|
console.log(` ${RED}ERROR${RESET}: ${result.error} (${timeStr})`);
|
||||||
|
} else {
|
||||||
|
const color =
|
||||||
|
result.totalPassed === result.totalTests
|
||||||
|
? GREEN
|
||||||
|
: result.totalPassed > 0
|
||||||
|
? YELLOW
|
||||||
|
: RED;
|
||||||
|
console.log(
|
||||||
|
` ${color}${result.totalPassed}/${result.totalTests} passed${RESET} (${timeStr})`
|
||||||
|
);
|
||||||
|
for (const t of result.tests) {
|
||||||
|
const icon = t.passed ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
||||||
|
console.log(` ${icon} ${t.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary Table ──────────────────────────────────────────
|
||||||
|
console.log(`${BOLD}═══ Summary ═══${RESET}\n`);
|
||||||
|
|
||||||
|
// Per-model results
|
||||||
|
for (const r of allResults) {
|
||||||
|
const timeStr = `${(r.durationMs / 1000).toFixed(0)}s`;
|
||||||
|
const color = r.error ? RED : r.totalPassed === r.totalTests ? GREEN : r.totalPassed > 0 ? YELLOW : RED;
|
||||||
|
const label = r.error ? `ERROR: ${r.error}` : `${r.totalPassed}/${r.totalTests}`;
|
||||||
|
console.log(` ${r.modelShort.padEnd(8)} ${color}${label}${RESET} (${timeStr})`);
|
||||||
|
for (const t of r.tests) {
|
||||||
|
const icon = t.passed ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
||||||
|
console.log(` ${icon} ${t.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Overall
|
||||||
|
const totalModels = allResults.length;
|
||||||
|
const erroredModels = allResults.filter((r) => r.error).length;
|
||||||
|
const perfectModels = allResults.filter(
|
||||||
|
(r) => !r.error && r.totalPassed === r.totalTests && r.totalTests > 0
|
||||||
|
).length;
|
||||||
|
console.log(
|
||||||
|
`${BOLD}Models with 100%: ${perfectModels}/${totalModels}${RESET}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const overallPassed = allResults.reduce((sum, r) => sum + r.totalPassed, 0);
|
||||||
|
const overallTotal = allResults.reduce((sum, r) => sum + r.totalTests, 0);
|
||||||
|
console.log(
|
||||||
|
`${BOLD}Overall: ${overallPassed}/${overallTotal} (${Math.round((overallPassed / overallTotal) * 100)}%)${RESET}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (erroredModels > 0) {
|
||||||
|
console.log(
|
||||||
|
`${BOLD}${RED}${erroredModels} model(s) errored. See details above.${RESET}\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else if (perfectModels === totalModels) {
|
||||||
|
console.log(`${BOLD}${GREEN}🎉 ALL MODELS PASSED ALL TESTS!${RESET}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`${BOLD}${YELLOW}Some models have failures. See details above.${RESET}\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
// Wrapper script that detects platform and spawns the correct binary
|
// Wrapper script that detects platform and spawns the correct binary
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -26,55 +27,116 @@ function getLibcFamily() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function supportsAvx2() {
|
||||||
|
if (process.arch !== "x64") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.OH_MY_OPENCODE_FORCE_BASELINE === "1") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
try {
|
||||||
|
const cpuInfo = readFileSync("/proc/cpuinfo", "utf8").toLowerCase();
|
||||||
|
return cpuInfo.includes("avx2");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const probe = spawnSync("sysctl", ["-n", "machdep.cpu.leaf7_features"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (probe.error || probe.status !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return probe.stdout.toUpperCase().includes("AVX2");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignalExitCode(signal) {
|
||||||
|
const signalCodeByName = {
|
||||||
|
SIGINT: 2,
|
||||||
|
SIGILL: 4,
|
||||||
|
SIGKILL: 9,
|
||||||
|
SIGTERM: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
return 128 + (signalCodeByName[signal] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const { platform, arch } = process;
|
const { platform, arch } = process;
|
||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
|
const avx2Supported = supportsAvx2();
|
||||||
|
|
||||||
// Get platform package name
|
let packageCandidates;
|
||||||
let pkg;
|
|
||||||
try {
|
try {
|
||||||
pkg = getPlatformPackage({ platform, arch, libcFamily });
|
packageCandidates = getPlatformPackageCandidates({
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
libcFamily,
|
||||||
|
preferBaseline: avx2Supported === false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\noh-my-opencode: ${error.message}\n`);
|
console.error(`\noh-my-opencode: ${error.message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve binary path
|
const resolvedBinaries = packageCandidates
|
||||||
const binRelPath = getBinaryPath(pkg, platform);
|
.map((pkg) => {
|
||||||
|
try {
|
||||||
let binPath;
|
return { pkg, binPath: require.resolve(getBinaryPath(pkg, platform)) };
|
||||||
try {
|
} catch {
|
||||||
binPath = require.resolve(binRelPath);
|
return null;
|
||||||
} catch {
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry !== null);
|
||||||
|
|
||||||
|
if (resolvedBinaries.length === 0) {
|
||||||
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
||||||
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
||||||
console.error(`Expected package: ${pkg}`);
|
console.error(`Expected packages (in order): ${packageCandidates.join(", ")}`);
|
||||||
console.error(`\nTo fix, run:`);
|
console.error(`\nTo fix, run:`);
|
||||||
console.error(` npm install ${pkg}\n`);
|
console.error(` npm install ${packageCandidates[0]}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the binary
|
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
||||||
const result = spawnSync(binPath, process.argv.slice(2), {
|
const currentBinary = resolvedBinaries[index];
|
||||||
stdio: "inherit",
|
const hasFallback = index < resolvedBinaries.length - 1;
|
||||||
});
|
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
||||||
|
stdio: "inherit",
|
||||||
// Handle spawn errors
|
});
|
||||||
if (result.error) {
|
|
||||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
if (result.error) {
|
||||||
console.error(`Error: ${result.error.message}\n`);
|
if (hasFallback) {
|
||||||
process.exit(2);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle signals
|
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||||
if (result.signal) {
|
console.error(`Error: ${result.error.message}\n`);
|
||||||
const signalNum = result.signal === "SIGTERM" ? 15 :
|
process.exit(2);
|
||||||
result.signal === "SIGKILL" ? 9 :
|
}
|
||||||
result.signal === "SIGINT" ? 2 : 1;
|
|
||||||
process.exit(128 + signalNum);
|
if (result.signal === "SIGILL" && hasFallback) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.signal) {
|
||||||
|
process.exit(getSignalExitCode(result.signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
14
bin/platform.d.ts
vendored
Normal file
14
bin/platform.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export declare function getPlatformPackage(options: {
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
libcFamily?: string | null;
|
||||||
|
}): string;
|
||||||
|
|
||||||
|
export declare function getPlatformPackageCandidates(options: {
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
libcFamily?: string | null;
|
||||||
|
preferBaseline?: boolean;
|
||||||
|
}): string[];
|
||||||
|
|
||||||
|
export declare function getBinaryPath(pkg: string, platform: string): string;
|
||||||
@@ -26,6 +26,50 @@ export function getPlatformPackage({ platform, arch, libcFamily }) {
|
|||||||
return `oh-my-opencode-${os}-${arch}${suffix}`;
|
return `oh-my-opencode-${os}-${arch}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {{ platform: string, arch: string, libcFamily?: string | null, preferBaseline?: boolean }} options */
|
||||||
|
export function getPlatformPackageCandidates({ platform, arch, libcFamily, preferBaseline = false }) {
|
||||||
|
const primaryPackage = getPlatformPackage({ platform, arch, libcFamily });
|
||||||
|
const baselinePackage = getBaselinePlatformPackage({ platform, arch, libcFamily });
|
||||||
|
|
||||||
|
if (!baselinePackage) {
|
||||||
|
return [primaryPackage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferBaseline ? [baselinePackage, primaryPackage] : [primaryPackage, baselinePackage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{ platform: string, arch: string, libcFamily?: string | null }} options */
|
||||||
|
function getBaselinePlatformPackage({ platform, arch, libcFamily }) {
|
||||||
|
if (arch !== "x64") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "darwin") {
|
||||||
|
return "oh-my-opencode-darwin-x64-baseline";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
return "oh-my-opencode-windows-x64-baseline";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "linux") {
|
||||||
|
if (libcFamily === null || libcFamily === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"Could not detect libc on Linux. " +
|
||||||
|
"Please ensure detect-libc is installed or report this issue."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libcFamily === "musl") {
|
||||||
|
return "oh-my-opencode-linux-x64-musl-baseline";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "oh-my-opencode-linux-x64-baseline";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the binary within a platform package
|
* Get the path to the binary within a platform package
|
||||||
* @param {string} pkg Package name
|
* @param {string} pkg Package name
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// bin/platform.test.ts
|
// bin/platform.test.ts
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
|
||||||
|
|
||||||
describe("getPlatformPackage", () => {
|
describe("getPlatformPackage", () => {
|
||||||
// #region Darwin platforms
|
// #region Darwin platforms
|
||||||
@@ -146,3 +146,58 @@ describe("getBinaryPath", () => {
|
|||||||
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getPlatformPackageCandidates", () => {
|
||||||
|
test("returns x64 and baseline candidates for Linux glibc", () => {
|
||||||
|
// #given Linux x64 with glibc
|
||||||
|
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
|
||||||
|
|
||||||
|
// #when getting package candidates
|
||||||
|
const result = getPlatformPackageCandidates(input);
|
||||||
|
|
||||||
|
// #then returns modern first then baseline fallback
|
||||||
|
expect(result).toEqual([
|
||||||
|
"oh-my-opencode-linux-x64",
|
||||||
|
"oh-my-opencode-linux-x64-baseline",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns x64 musl and baseline candidates for Linux musl", () => {
|
||||||
|
// #given Linux x64 with musl
|
||||||
|
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
|
||||||
|
|
||||||
|
// #when getting package candidates
|
||||||
|
const result = getPlatformPackageCandidates(input);
|
||||||
|
|
||||||
|
// #then returns musl modern first then musl baseline fallback
|
||||||
|
expect(result).toEqual([
|
||||||
|
"oh-my-opencode-linux-x64-musl",
|
||||||
|
"oh-my-opencode-linux-x64-musl-baseline",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns baseline first when preferBaseline is true", () => {
|
||||||
|
// #given Windows x64 and baseline preference
|
||||||
|
const input = { platform: "win32", arch: "x64", preferBaseline: true };
|
||||||
|
|
||||||
|
// #when getting package candidates
|
||||||
|
const result = getPlatformPackageCandidates(input);
|
||||||
|
|
||||||
|
// #then baseline package is preferred first
|
||||||
|
expect(result).toEqual([
|
||||||
|
"oh-my-opencode-windows-x64-baseline",
|
||||||
|
"oh-my-opencode-windows-x64",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns only one candidate for ARM64", () => {
|
||||||
|
// #given non-x64 platform
|
||||||
|
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
|
||||||
|
|
||||||
|
// #when getting package candidates
|
||||||
|
const result = getPlatformPackageCandidates(input);
|
||||||
|
|
||||||
|
// #then baseline fallback is not included
|
||||||
|
expect(result).toEqual(["oh-my-opencode-linux-arm64"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode",
|
"name": "oh-my-opencode",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -75,13 +75,17 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.8.5",
|
"oh-my-opencode-darwin-arm64": "3.9.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.8.5",
|
"oh-my-opencode-darwin-x64": "3.9.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.8.5",
|
"oh-my-opencode-darwin-x64-baseline": "3.9.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
"oh-my-opencode-linux-arm64": "3.9.0",
|
||||||
"oh-my-opencode-linux-x64": "3.8.5",
|
"oh-my-opencode-linux-arm64-musl": "3.9.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
"oh-my-opencode-linux-x64": "3.9.0",
|
||||||
"oh-my-opencode-windows-x64": "3.8.5"
|
"oh-my-opencode-linux-x64-baseline": "3.9.0",
|
||||||
|
"oh-my-opencode-linux-x64-musl": "3.9.0",
|
||||||
|
"oh-my-opencode-linux-x64-musl-baseline": "3.9.0",
|
||||||
|
"oh-my-opencode-windows-x64": "3.9.0",
|
||||||
|
"oh-my-opencode-windows-x64-baseline": "3.9.0"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@ast-grep/cli",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-arm64",
|
"name": "oh-my-opencode-darwin-arm64",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||||
"version": "3.1.1",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-darwin-x64",
|
"name": "oh-my-opencode-darwin-x64",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64-musl",
|
"name": "oh-my-opencode-linux-arm64-musl",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-arm64",
|
"name": "oh-my-opencode-linux-arm64",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-baseline",
|
"name": "oh-my-opencode-linux-x64-baseline",
|
||||||
"version": "3.1.1",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||||
"version": "3.1.1",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64-musl",
|
"name": "oh-my-opencode-linux-x64-musl",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-linux-x64",
|
"name": "oh-my-opencode-linux-x64",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64-baseline",
|
"name": "oh-my-opencode-windows-x64-baseline",
|
||||||
"version": "3.1.1",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oh-my-opencode-windows-x64",
|
"name": "oh-my-opencode-windows-x64",
|
||||||
"version": "3.8.5",
|
"version": "3.9.0",
|
||||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Runs after npm install to verify platform binary is available
|
// Runs after npm install to verify platform binary is available
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
|
import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -27,12 +27,28 @@ function main() {
|
|||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pkg = getPlatformPackage({ platform, arch, libcFamily });
|
const packageCandidates = getPlatformPackageCandidates({
|
||||||
const binPath = getBinaryPath(pkg, platform);
|
platform,
|
||||||
|
arch,
|
||||||
// Try to resolve the binary
|
libcFamily,
|
||||||
require.resolve(binPath);
|
});
|
||||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
|
|
||||||
|
const resolvedPackage = packageCandidates.find((pkg) => {
|
||||||
|
try {
|
||||||
|
require.resolve(getBinaryPath(pkg, platform));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolvedPackage) {
|
||||||
|
throw new Error(
|
||||||
|
`No platform binary package installed. Tried: ${packageCandidates.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch} (${resolvedPackage})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
||||||
console.warn(` The CLI may not work on this platform.`);
|
console.warn(` The CLI may not work on this platform.`);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
buildDecisionMatrix,
|
buildDecisionMatrix,
|
||||||
} from "./prompt-section-builder"
|
} from "./prompt-section-builder"
|
||||||
|
|
||||||
const MODE: AgentMode = "primary"
|
const MODE: AgentMode = "all"
|
||||||
|
|
||||||
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
categorizeTools,
|
categorizeTools,
|
||||||
} from "./dynamic-agent-prompt-builder";
|
} from "./dynamic-agent-prompt-builder";
|
||||||
|
|
||||||
const MODE: AgentMode = "primary";
|
const MODE: AgentMode = "all";
|
||||||
|
|
||||||
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
||||||
if (useTaskSystem) {
|
if (useTaskSystem) {
|
||||||
@@ -448,21 +448,6 @@ ${oracleSection}
|
|||||||
4. **Run build** if applicable — exit code 0 required
|
4. **Run build** if applicable — exit code 0 required
|
||||||
5. **Tell user** what you verified and the results — keep it clear and helpful
|
5. **Tell user** what you verified and the results — keep it clear and helpful
|
||||||
|
|
||||||
### Auto-Commit Policy (MANDATORY for implementation/fix work)
|
|
||||||
|
|
||||||
1. **Auto-commit after implementation is complete** when the task includes feature/fix code changes
|
|
||||||
2. **Commit ONLY after verification gates pass**:
|
|
||||||
- \`lsp_diagnostics\` clean on all modified files
|
|
||||||
- Related tests pass
|
|
||||||
- Typecheck/build pass when applicable
|
|
||||||
3. **If any gate fails, DO NOT commit** — fix issues first, re-run verification, then commit
|
|
||||||
4. **Use Conventional Commits format** with meaningful intent-focused messages:
|
|
||||||
- \`feat(scope): add ...\` for new functionality
|
|
||||||
- \`fix(scope): resolve ...\` for bug fixes
|
|
||||||
- \`refactor(scope): simplify ...\` for internal restructuring
|
|
||||||
5. **Do not make placeholder commits** (\`wip\`, \`temp\`, \`update\`) or commit unverified code
|
|
||||||
6. **If user explicitly says not to commit**, skip commit and report that changes are left uncommitted
|
|
||||||
|
|
||||||
- **File edit** — \`lsp_diagnostics\` clean
|
- **File edit** — \`lsp_diagnostics\` clean
|
||||||
- **Build** — Exit code 0
|
- **Build** — Exit code 0
|
||||||
- **Tests** — Pass (or pre-existing failures noted)
|
- **Tests** — Pass (or pre-existing failures noted)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
buildGeminiIntentGateEnforcement,
|
buildGeminiIntentGateEnforcement,
|
||||||
} from "./sisyphus-gemini-overlays";
|
} from "./sisyphus-gemini-overlays";
|
||||||
|
|
||||||
const MODE: AgentMode = "primary";
|
const MODE: AgentMode = "all";
|
||||||
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
||||||
category: "utility",
|
category: "utility",
|
||||||
cost: "EXPENSIVE",
|
cost: "EXPENSIVE",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ describe("isGptModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("gemini models are not gpt", () => {
|
test("gemini models are not gpt", () => {
|
||||||
expect(isGptModel("google/gemini-3-pro")).toBe(false);
|
expect(isGptModel("google/gemini-3.1-pro")).toBe(false);
|
||||||
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
|
expect(isGptModel("litellm/gemini-3.1-pro")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opencode provider is not gpt", () => {
|
test("opencode provider is not gpt", () => {
|
||||||
@@ -58,29 +58,29 @@ describe("isGptModel", () => {
|
|||||||
|
|
||||||
describe("isGeminiModel", () => {
|
describe("isGeminiModel", () => {
|
||||||
test("#given google provider models #then returns true", () => {
|
test("#given google provider models #then returns true", () => {
|
||||||
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
|
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true);
|
||||||
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
|
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
|
||||||
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given google-vertex provider models #then returns true", () => {
|
test("#given google-vertex provider models #then returns true", () => {
|
||||||
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
|
expect(isGeminiModel("google-vertex/gemini-3.1-pro")).toBe(true);
|
||||||
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given github copilot gemini models #then returns true", () => {
|
test("#given github copilot gemini models #then returns true", () => {
|
||||||
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
|
expect(isGeminiModel("github-copilot/gemini-3.1-pro")).toBe(true);
|
||||||
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given litellm proxied gemini models #then returns true", () => {
|
test("#given litellm proxied gemini models #then returns true", () => {
|
||||||
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
|
expect(isGeminiModel("litellm/gemini-3.1-pro")).toBe(true);
|
||||||
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
|
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
|
||||||
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given other proxied gemini models #then returns true", () => {
|
test("#given other proxied gemini models #then returns true", () => {
|
||||||
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
|
expect(isGeminiModel("custom-provider/gemini-3.1-pro")).toBe(true);
|
||||||
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
|
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -988,7 +988,7 @@ describe("buildAgent with category and skills", () => {
|
|||||||
const agent = buildAgent(source["test-agent"], TEST_MODEL)
|
const agent = buildAgent(source["test-agent"], TEST_MODEL)
|
||||||
|
|
||||||
// #then - category's built-in model is applied
|
// #then - category's built-in model is applied
|
||||||
expect(agent.model).toBe("google/gemini-3-pro")
|
expect(agent.model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("agent with category and existing model keeps existing model", () => {
|
test("agent with category and existing model keeps existing model", () => {
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
|||||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
@@ -334,34 +334,34 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
|||||||
"model": "opencode/glm-4.7-free",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -371,7 +371,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
|||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -386,7 +386,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
|||||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
@@ -395,44 +395,44 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
|||||||
"model": "opencode/glm-4.7-free",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -485,7 +485,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -506,7 +506,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -559,7 +559,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -581,7 +581,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -634,7 +634,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -655,7 +655,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
"model": "opencode/claude-sonnet-4-5",
|
"model": "opencode/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -708,7 +708,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -730,7 +730,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
"model": "opencode/claude-sonnet-4-5",
|
"model": "opencode/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -779,14 +779,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -845,14 +845,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "github-copilot/claude-haiku-4.5",
|
"model": "github-copilot/claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -863,7 +863,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1026,7 +1026,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1047,7 +1047,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gemini-3-pro",
|
"model": "opencode/gemini-3.1-pro",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1100,7 +1100,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1121,7 +1121,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1217,7 +1217,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"model": "google/gemini-3-flash-preview",
|
"model": "google/gemini-3-flash-preview",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
@@ -1231,14 +1231,14 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
@@ -1248,7 +1248,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1301,7 +1301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1322,7 +1322,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"model": "github-copilot/claude-sonnet-4.5",
|
"model": "github-copilot/claude-sonnet-4.5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "github-copilot/gemini-3-pro-preview",
|
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1375,7 +1375,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1396,7 +1396,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
@@ -1449,7 +1449,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
@@ -1471,7 +1471,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
"model": "anthropic/claude-sonnet-4-5",
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "google/gemini-3-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
|||||||
expect(models).toBeTruthy()
|
expect(models).toBeTruthy()
|
||||||
|
|
||||||
const required = [
|
const required = [
|
||||||
"antigravity-gemini-3-pro",
|
"antigravity-gemini-3.1-pro",
|
||||||
"antigravity-gemini-3-flash",
|
"antigravity-gemini-3-flash",
|
||||||
"antigravity-claude-sonnet-4-6",
|
"antigravity-claude-sonnet-4-6",
|
||||||
"antigravity-claude-sonnet-4-6-thinking",
|
"antigravity-claude-sonnet-4-6-thinking",
|
||||||
@@ -206,7 +206,7 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
|||||||
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
|
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
|
||||||
|
|
||||||
// #when checking Gemini Pro variants
|
// #when checking Gemini Pro variants
|
||||||
const pro = models["antigravity-gemini-3-pro"]
|
const pro = models["antigravity-gemini-3.1-pro"]
|
||||||
// #then should have low and high variants
|
// #then should have low and high variants
|
||||||
expect(pro.variants).toBeTruthy()
|
expect(pro.variants).toBeTruthy()
|
||||||
expect(pro.variants.low).toBeTruthy()
|
expect(pro.variants.low).toBeTruthy()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
||||||
*
|
*
|
||||||
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
|
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
|
||||||
* - `antigravity-gemini-3-pro` with variants: low, high
|
* - `antigravity-gemini-3.1-pro` with variants: low, high
|
||||||
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
|
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
|
||||||
*
|
*
|
||||||
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
|
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3.1-pro-high`) still work
|
||||||
* but variants are the recommended approach.
|
* but variants are the recommended approach.
|
||||||
*
|
*
|
||||||
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
|
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
|
||||||
@@ -16,7 +16,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
|||||||
google: {
|
google: {
|
||||||
name: "Google",
|
name: "Google",
|
||||||
models: {
|
models: {
|
||||||
"antigravity-gemini-3-pro": {
|
"antigravity-gemini-3.1-pro": {
|
||||||
name: "Gemini 3 Pro (Antigravity)",
|
name: "Gemini 3 Pro (Antigravity)",
|
||||||
limit: { context: 1048576, output: 65535 },
|
limit: { context: 1048576, output: 65535 },
|
||||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||||
@@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["bun", "install"], {
|
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||||
cwd: getConfigDir(),
|
cwd: getConfigDir(),
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
import { initConfigContext } from "./config-context"
|
import { initConfigContext } from "./config-context"
|
||||||
|
|
||||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||||
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
|
|||||||
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
||||||
for (const binary of OPENCODE_BINARIES) {
|
for (const binary of OPENCODE_BINARIES) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([binary, "--version"], {
|
const proc = spawnWithWindowsHide([binary, "--version"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
|
|||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
|
|
||||||
import type { DependencyInfo } from "../types"
|
import type { DependencyInfo } from "../types"
|
||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
|||||||
|
|
||||||
async function getBinaryVersion(binary: string): Promise<string | null> {
|
async function getBinaryVersion(binary: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||||
const output = await new Response(proc.stdout).text()
|
const output = await new Response(proc.stdout).text()
|
||||||
await proc.exited
|
await proc.exited
|
||||||
if (proc.exitCode === 0) {
|
if (proc.exitCode === 0) {
|
||||||
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
|
|||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("model-resolution check", () => {
|
|||||||
// then: Should have category entries
|
// then: Should have category entries
|
||||||
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
||||||
expect(visual).toBeDefined()
|
expect(visual).toBeDefined()
|
||||||
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro")
|
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3.1-pro")
|
||||||
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
|
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
import { OPENCODE_BINARIES } from "../constants"
|
import { OPENCODE_BINARIES } from "../constants"
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const command = buildVersionCommand(binaryPath, platform)
|
const command = buildVersionCommand(binaryPath, platform)
|
||||||
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
|
const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
|
||||||
const output = await new Response(processResult.stdout).text()
|
const output = await new Response(processResult.stdout).text()
|
||||||
await processResult.exited
|
await processResult.exited
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
export interface GhCliInfo {
|
export interface GhCliInfo {
|
||||||
installed: boolean
|
installed: boolean
|
||||||
version: string | null
|
version: string | null
|
||||||
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
|||||||
|
|
||||||
async function getGhVersion(): Promise<string | null> {
|
async function getGhVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||||
const output = await new Response(processResult.stdout).text()
|
const output = await new Response(processResult.stdout).text()
|
||||||
await processResult.exited
|
await processResult.exited
|
||||||
if (processResult.exitCode !== 0) return null
|
if (processResult.exitCode !== 0) return null
|
||||||
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
|
|||||||
error: string | null
|
error: string | null
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const processResult = Bun.spawn(["gh", "auth", "status"], {
|
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
oracle: {
|
oracle: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -59,7 +59,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
metis: {
|
metis: {
|
||||||
@@ -68,14 +68,14 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
momus: {
|
momus: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
atlas: {
|
atlas: {
|
||||||
@@ -84,7 +84,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||||
@@ -101,7 +101,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
ultrabrain: {
|
ultrabrain: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -109,17 +109,17 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
requiresModel: "gpt-5.3-codex",
|
requiresModel: "gpt-5.3-codex",
|
||||||
},
|
},
|
||||||
artistry: {
|
artistry: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
requiresModel: "gemini-3-pro",
|
requiresModel: "gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
quick: {
|
quick: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
@@ -139,7 +139,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
writing: {
|
writing: {
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("claude-haiku-4.5")
|
expect(result).toBe("claude-haiku-4.5")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||||
// #given github-copilot provider and gemini-3-pro model
|
// #given github-copilot provider and gemini-3.1-pro model
|
||||||
const provider = "github-copilot"
|
const provider = "github-copilot"
|
||||||
const model = "gemini-3-pro"
|
const model = "gemini-3.1-pro"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should transform to gemini-3-pro-preview
|
// #then should transform to gemini-3.1-pro-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
test("transforms gemini-3-flash to gemini-3-flash-preview", () => {
|
||||||
@@ -64,16 +64,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||||
// #given github-copilot provider and gemini-3-pro-preview model (already transformed)
|
// #given github-copilot provider and gemini-3.1-pro-preview model (already transformed)
|
||||||
const provider = "github-copilot"
|
const provider = "github-copilot"
|
||||||
const model = "gemini-3-pro-preview"
|
const model = "gemini-3.1-pro-preview"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should NOT become gemini-3-pro-preview-preview
|
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-flash-preview", () => {
|
test("prevents double transformation of gemini-3-flash-preview", () => {
|
||||||
@@ -102,16 +102,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro to gemini-3-pro-preview", () => {
|
test("transforms gemini-3.1-pro to gemini-3.1-pro-preview", () => {
|
||||||
// #given google provider and gemini-3-pro model
|
// #given google provider and gemini-3.1-pro model
|
||||||
const provider = "google"
|
const provider = "google"
|
||||||
const model = "gemini-3-pro"
|
const model = "gemini-3.1-pro"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should transform to gemini-3-pro-preview
|
// #then should transform to gemini-3.1-pro-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through other gemini models unchanged", () => {
|
test("passes through other gemini models unchanged", () => {
|
||||||
@@ -138,16 +138,16 @@ describe("transformModelForProvider", () => {
|
|||||||
expect(result).toBe("gemini-3-flash-preview")
|
expect(result).toBe("gemini-3-flash-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prevents double transformation of gemini-3-pro-preview", () => {
|
test("prevents double transformation of gemini-3.1-pro-preview", () => {
|
||||||
// #given google provider and gemini-3-pro-preview model (already transformed)
|
// #given google provider and gemini-3.1-pro-preview model (already transformed)
|
||||||
const provider = "google"
|
const provider = "google"
|
||||||
const model = "gemini-3-pro-preview"
|
const model = "gemini-3.1-pro-preview"
|
||||||
|
|
||||||
// #when transformModelForProvider is called
|
// #when transformModelForProvider is called
|
||||||
const result = transformModelForProvider(provider, model)
|
const result = transformModelForProvider(provider, model)
|
||||||
|
|
||||||
// #then should NOT become gemini-3-pro-preview-preview
|
// #then should NOT become gemini-3.1-pro-preview-preview
|
||||||
expect(result).toBe("gemini-3-pro-preview")
|
expect(result).toBe("gemini-3.1-pro-preview")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not transform claude models for google provider", () => {
|
test("does not transform claude models for google provider", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
|
|||||||
import { createJsonOutputManager } from "./json-output"
|
import { createJsonOutputManager } from "./json-output"
|
||||||
import { resolveSession } from "./session-resolver"
|
import { resolveSession } from "./session-resolver"
|
||||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
import * as originalSdk from "@opencode-ai/sdk"
|
import * as originalSdk from "@opencode-ai/sdk"
|
||||||
import * as originalPortUtils from "../../shared/port-utils"
|
import * as originalPortUtils from "../../shared/port-utils"
|
||||||
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
|
|||||||
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||||
expect(mockClient.session.get).toHaveBeenCalledWith({
|
expect(mockClient.session.get).toHaveBeenCalledWith({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
query: { directory: "/test" },
|
query: { directory: "/test" },
|
||||||
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(console, "error").mockImplementation(() => {})
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||||
exited: Promise.resolve(0),
|
exited: Promise.resolve(0),
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
stdout: undefined,
|
||||||
|
stderr: undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
|
|||||||
spyOn(console, "error").mockImplementation(() => {})
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
mockStdout = createMockWriteStream()
|
mockStdout = createMockWriteStream()
|
||||||
mockStderr = createMockWriteStream()
|
mockStderr = createMockWriteStream()
|
||||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||||
exited: Promise.resolve(0),
|
exited: Promise.resolve(0),
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
stdout: undefined,
|
||||||
|
stderr: undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
|
|||||||
const emitted = mockStdout.writes[0]!
|
const emitted = mockStdout.writes[0]!
|
||||||
expect(() => JSON.parse(emitted)).not.toThrow()
|
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
expect(args).toEqual(["sh", "-c", "echo done"])
|
expect(args).toEqual(["sh", "-c", "echo done"])
|
||||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||||
|
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
|
||||||
describe("executeOnCompleteHook", () => {
|
describe("executeOnCompleteHook", () => {
|
||||||
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
|
|||||||
return {
|
return {
|
||||||
exited: Promise.resolve(exitCode),
|
exited: Promise.resolve(exitCode),
|
||||||
exitCode,
|
exitCode,
|
||||||
} as unknown as ReturnType<typeof Bun.spawn>
|
stdout: undefined,
|
||||||
|
stderr: undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
}
|
}
|
||||||
|
|
||||||
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
||||||
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("executes command with correct env vars", async () => {
|
it("executes command with correct env vars", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
|
|
||||||
expect(args).toEqual(["sh", "-c", "echo test"])
|
expect(args).toEqual(["sh", "-c", "echo test"])
|
||||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("env var values are strings", async () => {
|
it("env var values are strings", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
|
|
||||||
expect(options?.env?.EXIT_CODE).toBe("1")
|
expect(options?.env?.EXIT_CODE).toBe("1")
|
||||||
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||||
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("empty command string is no-op", async () => {
|
it("empty command string is no-op", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("whitespace-only command is no-op", async () => {
|
it("whitespace-only command is no-op", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
|
|||||||
|
|
||||||
it("command failure logs warning but does not throw", async () => {
|
it("command failure logs warning but does not throw", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
await expect(
|
expect(
|
||||||
executeOnCompleteHook({
|
executeOnCompleteHook({
|
||||||
command: "false",
|
command: "false",
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
|
|||||||
it("spawn error logs warning but does not throw", async () => {
|
it("spawn error logs warning but does not throw", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnError = new Error("Command not found")
|
const spawnError = new Error("Command not found")
|
||||||
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
|
||||||
throw spawnError
|
throw spawnError
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
await expect(
|
expect(
|
||||||
executeOnCompleteHook({
|
executeOnCompleteHook({
|
||||||
command: "nonexistent-command",
|
command: "nonexistent-command",
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
export async function executeOnCompleteHook(options: {
|
export async function executeOnCompleteHook(options: {
|
||||||
command: string
|
command: string
|
||||||
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
|
|||||||
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
|
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
SESSION_ID: sessionId,
|
SESSION_ID: sessionId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delimiter, dirname, join } from "node:path"
|
import { delimiter, dirname, join } from "node:path"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
||||||
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
|
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
|
||||||
@@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
|
|||||||
|
|
||||||
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
|
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([binaryPath, "--version"], {
|
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||||
default_run_agent: z.string().optional(),
|
default_run_agent: z.string().optional(),
|
||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
disabled_agents: z.array(z.string()).optional(),
|
||||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||||
disabled_hooks: z.array(z.string()).optional(),
|
disabled_hooks: z.array(z.string()).optional(),
|
||||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
|||||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||||
// given
|
// given
|
||||||
const config: BackgroundTaskConfig = {
|
const config: BackgroundTaskConfig = {
|
||||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
modelConcurrency: { "google/gemini-3.1-pro": 5 },
|
||||||
providerConcurrency: { anthropic: 3 }
|
providerConcurrency: { anthropic: 3 }
|
||||||
}
|
}
|
||||||
const manager = new ConcurrencyManager(config)
|
const manager = new ConcurrencyManager(config)
|
||||||
@@ -95,7 +95,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
|||||||
// when
|
// when
|
||||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
|
||||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
|
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
|
||||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(modelLimit).toBe(10)
|
expect(modelLimit).toBe(10)
|
||||||
|
|||||||
@@ -2987,6 +2987,28 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
|||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
resetToastManager()
|
resetToastManager()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should clean pending notifications for deleted sessions", () => {
|
||||||
|
//#given
|
||||||
|
const manager = createBackgroundManager()
|
||||||
|
const sessionID = "session-pending-notifications"
|
||||||
|
|
||||||
|
manager.queuePendingNotification(sessionID, "<system-reminder>queued</system-reminder>")
|
||||||
|
expect(getPendingNotifications(manager).get(sessionID)).toEqual([
|
||||||
|
"<system-reminder>queued</system-reminder>",
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
manager.handleEvent({
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { info: { id: sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getPendingNotifications(manager).has(sessionID)).toBe(false)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("BackgroundManager.handleEvent - session.error", () => {
|
describe("BackgroundManager.handleEvent - session.error", () => {
|
||||||
|
|||||||
@@ -830,6 +830,8 @@ export class BackgroundManager {
|
|||||||
tasksToCancel.set(descendant.id, descendant)
|
tasksToCancel.set(descendant.id, descendant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pendingNotifications.delete(sessionID)
|
||||||
|
|
||||||
if (tasksToCancel.size === 0) return
|
if (tasksToCancel.size === 0) return
|
||||||
|
|
||||||
for (const task of tasksToCancel.values()) {
|
for (const task of tasksToCancel.values()) {
|
||||||
@@ -866,6 +868,13 @@ export class BackgroundManager {
|
|||||||
subagentSessions.delete(task.sessionID)
|
subagentSessions.delete(task.sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const task of tasksToCancel.values()) {
|
||||||
|
if (task.parentSessionID) {
|
||||||
|
this.pendingNotifications.delete(task.parentSessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SessionCategoryRegistry.remove(sessionID)
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe("TaskToastManager", () => {
|
|||||||
description: "Task with category default model",
|
description: "Task with category default model",
|
||||||
agent: "sisyphus-junior",
|
agent: "sisyphus-junior",
|
||||||
isBackground: false,
|
isBackground: false,
|
||||||
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
|
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
|
||||||
}
|
}
|
||||||
|
|
||||||
// when - addTask is called
|
// when - addTask is called
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
||||||
import type { InteractiveBashSessionState } from "./types";
|
import type { InteractiveBashSessionState } from "./types";
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||||
|
|
||||||
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sessionName of state.tmuxSessions) {
|
for (const sessionName of state.tmuxSessions) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { InteractiveBashSessionState } from "./types";
|
import type { InteractiveBashSessionState } from "./types";
|
||||||
import { loadInteractiveBashSessionState } from "./storage";
|
import { loadInteractiveBashSessionState } from "./storage";
|
||||||
import { OMO_SESSION_PREFIX } from "./constants";
|
import { OMO_SESSION_PREFIX } from "./constants";
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||||
|
|
||||||
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
||||||
if (!sessionStates.has(sessionID)) {
|
if (!sessionStates.has(sessionID)) {
|
||||||
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sessionName of state.tmuxSessions) {
|
for (const sessionName of state.tmuxSessions) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
});
|
});
|
||||||
|
|||||||
111
src/hooks/ralph-loop/completion-promise-detector.test.ts
Normal file
111
src/hooks/ralph-loop/completion-promise-detector.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { detectCompletionInSessionMessages } from "./completion-promise-detector"
|
||||||
|
|
||||||
|
type SessionMessage = {
|
||||||
|
info?: { role?: string }
|
||||||
|
parts?: Array<{ type: string; text?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginInput(messages: SessionMessage[]): PluginInput {
|
||||||
|
const pluginInput = {
|
||||||
|
client: { session: {} } as PluginInput["client"],
|
||||||
|
project: {} as PluginInput["project"],
|
||||||
|
directory: "/tmp",
|
||||||
|
worktree: "/tmp",
|
||||||
|
serverUrl: new URL("http://localhost"),
|
||||||
|
$: {} as PluginInput["$"],
|
||||||
|
} as PluginInput
|
||||||
|
|
||||||
|
pluginInput.client.session.messages =
|
||||||
|
(async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"]
|
||||||
|
|
||||||
|
return pluginInput
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detectCompletionInSessionMessages", () => {
|
||||||
|
describe("#given session with prior DONE and new messages", () => {
|
||||||
|
test("#when sinceMessageIndex excludes prior DONE #then should NOT detect completion", async () => {
|
||||||
|
// #given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Working on the new task" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const ctx = createPluginInput(messages)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID: "session-123",
|
||||||
|
promise: "DONE",
|
||||||
|
apiTimeoutMs: 1000,
|
||||||
|
directory: "/tmp",
|
||||||
|
sinceMessageIndex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(detected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#when sinceMessageIndex includes current DONE #then should detect completion", async () => {
|
||||||
|
// #given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Current completion <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const ctx = createPluginInput(messages)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID: "session-123",
|
||||||
|
promise: "DONE",
|
||||||
|
apiTimeoutMs: 1000,
|
||||||
|
directory: "/tmp",
|
||||||
|
sinceMessageIndex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(detected).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given no sinceMessageIndex (backward compat)", () => {
|
||||||
|
test("#then should scan all messages", async () => {
|
||||||
|
// #given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "No completion in latest message" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const ctx = createPluginInput(messages)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID: "session-123",
|
||||||
|
promise: "DONE",
|
||||||
|
apiTimeoutMs: 1000,
|
||||||
|
directory: "/tmp",
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(detected).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages(
|
|||||||
promise: string
|
promise: string
|
||||||
apiTimeoutMs: number
|
apiTimeoutMs: number
|
||||||
directory: string
|
directory: string
|
||||||
|
sinceMessageIndex?: number
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -75,12 +76,17 @@ export async function detectCompletionInSessionMessages(
|
|||||||
? responseData
|
? responseData
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
|
const scopedMessages =
|
||||||
|
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
|
||||||
|
? messageArray.slice(options.sinceMessageIndex)
|
||||||
|
: messageArray
|
||||||
|
|
||||||
|
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
|
||||||
if (assistantMessages.length === 0) return false
|
if (assistantMessages.length === 0) return false
|
||||||
|
|
||||||
const pattern = buildPromisePattern(options.promise)
|
const pattern = buildPromisePattern(options.promise)
|
||||||
const recentAssistants = assistantMessages.slice(-3)
|
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
|
||||||
for (const assistant of recentAssistants) {
|
const assistant = assistantMessages[index]
|
||||||
if (!assistant.parts) continue
|
if (!assistant.parts) continue
|
||||||
|
|
||||||
let responseText = ""
|
let responseText = ""
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ describe("ralph-loop", () => {
|
|||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
default_max_iterations: 200,
|
default_max_iterations: 200,
|
||||||
|
default_strategy: "continue",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -602,7 +603,7 @@ describe("ralph-loop", () => {
|
|||||||
expect(hook.getState()).toBeNull()
|
expect(hook.getState()).toBeNull()
|
||||||
|
|
||||||
// then - messages API was called with correct session ID
|
// then - messages API was called with correct session ID
|
||||||
expect(messagesCalls.length).toBe(1)
|
expect(messagesCalls.length).toBe(2)
|
||||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
expect(messagesCalls[0].sessionID).toBe("session-123")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,7 +633,7 @@ describe("ralph-loop", () => {
|
|||||||
expect(hook.getState()).toBeNull()
|
expect(hook.getState()).toBeNull()
|
||||||
|
|
||||||
// then - messages API was called with correct session ID
|
// then - messages API was called with correct session ID
|
||||||
expect(messagesCalls.length).toBe(1)
|
expect(messagesCalls.length).toBe(2)
|
||||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
expect(messagesCalls[0].sessionID).toBe("session-123")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -708,6 +709,57 @@ describe("ralph-loop", () => {
|
|||||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should skip concurrent idle events for same session when handler is in flight", async () => {
|
||||||
|
// given - active loop with delayed prompt injection
|
||||||
|
let releasePromptAsync: (() => void) | undefined
|
||||||
|
const promptAsyncBlocked = new Promise<void>((resolve) => {
|
||||||
|
releasePromptAsync = resolve
|
||||||
|
})
|
||||||
|
let firstPromptStartedResolve: (() => void) | undefined
|
||||||
|
const firstPromptStarted = new Promise<void>((resolve) => {
|
||||||
|
firstPromptStartedResolve = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockInput = createMockPluginInput() as {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
promptAsync: (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPromptAsync = mockInput.client.session.promptAsync
|
||||||
|
let promptAsyncCalls = 0
|
||||||
|
mockInput.client.session.promptAsync = async (opts) => {
|
||||||
|
promptAsyncCalls += 1
|
||||||
|
if (promptAsyncCalls === 1) {
|
||||||
|
firstPromptStartedResolve?.()
|
||||||
|
}
|
||||||
|
await promptAsyncBlocked
|
||||||
|
return originalPromptAsync(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hook = createRalphLoopHook(mockInput as Parameters<typeof createRalphLoopHook>[0])
|
||||||
|
hook.startLoop("session-123", "Build feature", { maxIterations: 10 })
|
||||||
|
|
||||||
|
// when - second idle arrives while first idle processing is still in flight
|
||||||
|
const firstIdle = hook.event({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||||
|
})
|
||||||
|
await firstPromptStarted
|
||||||
|
const secondIdle = hook.event({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
releasePromptAsync?.()
|
||||||
|
await Promise.all([firstIdle, secondIdle])
|
||||||
|
|
||||||
|
// then - only one continuation should be injected
|
||||||
|
expect(promptAsyncCalls).toBe(1)
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
expect(hook.getState()?.iteration).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||||
// given - active loop
|
// given - active loop
|
||||||
const hook = createRalphLoopHook(createMockPluginInput())
|
const hook = createRalphLoopHook(createMockPluginInput())
|
||||||
@@ -782,8 +834,8 @@ describe("ralph-loop", () => {
|
|||||||
expect(hook.getState()).toBeNull()
|
expect(hook.getState()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
|
test("should detect completion even when promise is older than previous narrow window", async () => {
|
||||||
// given - promise appears in an assistant message older than last 3
|
// given - promise appears in an older assistant message with additional assistant output after it
|
||||||
mockSessionMessages = [
|
mockSessionMessages = [
|
||||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early <promise>DONE</promise>" }] },
|
||||||
@@ -801,9 +853,40 @@ describe("ralph-loop", () => {
|
|||||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||||
})
|
})
|
||||||
|
|
||||||
// then - loop should continue (promise is older than last 3 assistant messages)
|
// then - loop should complete because all assistant messages are scanned
|
||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(0)
|
||||||
expect(hook.getState()?.iteration).toBe(2)
|
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||||
|
expect(hook.getState()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should detect completion when many assistant messages are emitted after promise", async () => {
|
||||||
|
// given - completion promise followed by long assistant output sequence
|
||||||
|
mockSessionMessages = [
|
||||||
|
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done now <promise>DONE</promise>" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let index = 1; index <= 25; index += 1) {
|
||||||
|
mockSessionMessages.push({
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: `Post-completion assistant output ${index}` }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||||
|
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||||
|
})
|
||||||
|
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||||
|
|
||||||
|
// when - session goes idle
|
||||||
|
await hook.event({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// then - loop should complete despite large trailing output
|
||||||
|
expect(promptCalls.length).toBe(0)
|
||||||
|
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||||
|
expect(hook.getState()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
||||||
@@ -992,7 +1075,7 @@ Original task: Build something`
|
|||||||
expect(promptCalls.length).toBe(0)
|
expect(promptCalls.length).toBe(0)
|
||||||
expect(hook.getState()).toBeNull()
|
expect(hook.getState()).toBeNull()
|
||||||
// API should NOT be called since transcript found completion
|
// API should NOT be called since transcript found completion
|
||||||
expect(messagesCalls.length).toBe(0)
|
expect(messagesCalls.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should show ultrawork completion toast", async () => {
|
test("should show ultrawork completion toast", async () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createLoopStateController(options: {
|
|||||||
loopOptions?: {
|
loopOptions?: {
|
||||||
maxIterations?: number
|
maxIterations?: number
|
||||||
completionPromise?: string
|
completionPromise?: string
|
||||||
|
messageCountAtStart?: number
|
||||||
ultrawork?: boolean
|
ultrawork?: boolean
|
||||||
strategy?: "reset" | "continue"
|
strategy?: "reset" | "continue"
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@ export function createLoopStateController(options: {
|
|||||||
loopOptions?.maxIterations ??
|
loopOptions?.maxIterations ??
|
||||||
config?.default_max_iterations ??
|
config?.default_max_iterations ??
|
||||||
DEFAULT_MAX_ITERATIONS,
|
DEFAULT_MAX_ITERATIONS,
|
||||||
|
message_count_at_start: loopOptions?.messageCountAtStart,
|
||||||
completion_promise:
|
completion_promise:
|
||||||
loopOptions?.completionPromise ??
|
loopOptions?.completionPromise ??
|
||||||
DEFAULT_COMPLETION_PROMISE,
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
@@ -93,5 +95,19 @@ export function createLoopStateController(options: {
|
|||||||
|
|
||||||
return state
|
return state
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null {
|
||||||
|
const state = readState(directory, stateDir)
|
||||||
|
if (!state || state.session_id !== sessionID) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
state.message_count_at_start = messageCountAtStart
|
||||||
|
if (!writeState(directory, state, stateDir)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function createRalphLoopEventHandler(
|
|||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: RalphLoopEventHandlerOptions,
|
options: RalphLoopEventHandlerOptions,
|
||||||
) {
|
) {
|
||||||
|
const inFlightSessions = new Set<string>()
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
@@ -32,115 +34,128 @@ export function createRalphLoopEventHandler(
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (options.sessionRecovery.isRecovering(sessionID)) {
|
if (inFlightSessions.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = options.loopState.getState()
|
inFlightSessions.add(sessionID)
|
||||||
if (!state || !state.active) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.session_id && state.session_id !== sessionID) {
|
|
||||||
if (options.checkSessionExists) {
|
|
||||||
try {
|
|
||||||
const exists = await options.checkSessionExists(state.session_id)
|
|
||||||
if (!exists) {
|
|
||||||
options.loopState.clear()
|
|
||||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
|
||||||
orphanedSessionId: state.session_id,
|
|
||||||
currentSessionId: sessionID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
|
||||||
sessionId: state.session_id,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcriptPath = options.getTranscriptPath(sessionID)
|
|
||||||
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
|
|
||||||
const completionViaApi = completionViaTranscript
|
|
||||||
? false
|
|
||||||
: await detectCompletionInSessionMessages(ctx, {
|
|
||||||
sessionID,
|
|
||||||
promise: state.completion_promise,
|
|
||||||
apiTimeoutMs: options.apiTimeoutMs,
|
|
||||||
directory: options.directory,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (completionViaTranscript || completionViaApi) {
|
|
||||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: state.iteration,
|
|
||||||
promise: state.completion_promise,
|
|
||||||
detectedVia: completionViaTranscript
|
|
||||||
? "transcript_file"
|
|
||||||
: "session_messages_api",
|
|
||||||
})
|
|
||||||
options.loopState.clear()
|
|
||||||
|
|
||||||
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
|
||||||
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
|
||||||
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.iteration >= state.max_iterations) {
|
|
||||||
log(`[${HOOK_NAME}] Max iterations reached`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: state.iteration,
|
|
||||||
max: state.max_iterations,
|
|
||||||
})
|
|
||||||
options.loopState.clear()
|
|
||||||
|
|
||||||
await ctx.client.tui?.showToast?.({
|
|
||||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
|
||||||
}).catch(() => {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = options.loopState.incrementIteration()
|
|
||||||
if (!newState) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: newState.iteration,
|
|
||||||
max: newState.max_iterations,
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.client.tui?.showToast?.({
|
|
||||||
body: {
|
|
||||||
title: "Ralph Loop",
|
|
||||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
|
||||||
variant: "info",
|
|
||||||
duration: 2000,
|
|
||||||
},
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await continueIteration(ctx, newState, {
|
|
||||||
previousSessionID: sessionID,
|
if (options.sessionRecovery.isRecovering(sessionID)) {
|
||||||
directory: options.directory,
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
apiTimeoutMs: options.apiTimeoutMs,
|
return
|
||||||
loopState: options.loopState,
|
}
|
||||||
})
|
|
||||||
} catch (err) {
|
const state = options.loopState.getState()
|
||||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
if (!state || !state.active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session_id && state.session_id !== sessionID) {
|
||||||
|
if (options.checkSessionExists) {
|
||||||
|
try {
|
||||||
|
const exists = await options.checkSessionExists(state.session_id)
|
||||||
|
if (!exists) {
|
||||||
|
options.loopState.clear()
|
||||||
|
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||||
|
orphanedSessionId: state.session_id,
|
||||||
|
currentSessionId: sessionID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||||
|
sessionId: state.session_id,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptPath = options.getTranscriptPath(sessionID)
|
||||||
|
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
|
||||||
|
const completionViaApi = completionViaTranscript
|
||||||
|
? false
|
||||||
|
: await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID,
|
||||||
|
promise: state.completion_promise,
|
||||||
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
directory: options.directory,
|
||||||
|
sinceMessageIndex: state.message_count_at_start,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completionViaTranscript || completionViaApi) {
|
||||||
|
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||||
|
sessionID,
|
||||||
|
iteration: state.iteration,
|
||||||
|
promise: state.completion_promise,
|
||||||
|
detectedVia: completionViaTranscript
|
||||||
|
? "transcript_file"
|
||||||
|
: "session_messages_api",
|
||||||
|
})
|
||||||
|
options.loopState.clear()
|
||||||
|
|
||||||
|
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
||||||
|
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
||||||
|
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.iteration >= state.max_iterations) {
|
||||||
|
log(`[${HOOK_NAME}] Max iterations reached`, {
|
||||||
|
sessionID,
|
||||||
|
iteration: state.iteration,
|
||||||
|
max: state.max_iterations,
|
||||||
|
})
|
||||||
|
options.loopState.clear()
|
||||||
|
|
||||||
|
await ctx.client.tui?.showToast?.({
|
||||||
|
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||||
|
}).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = options.loopState.incrementIteration()
|
||||||
|
if (!newState) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: String(err),
|
iteration: newState.iteration,
|
||||||
|
max: newState.max_iterations,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await ctx.client.tui?.showToast?.({
|
||||||
|
body: {
|
||||||
|
title: "Ralph Loop",
|
||||||
|
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||||
|
variant: "info",
|
||||||
|
duration: 2000,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await continueIteration(ctx, newState, {
|
||||||
|
previousSessionID: sessionID,
|
||||||
|
directory: options.directory,
|
||||||
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
loopState: options.loopState,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||||
|
sessionID,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
inFlightSessions.delete(sessionID)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface RalphLoopHook {
|
|||||||
options?: {
|
options?: {
|
||||||
maxIterations?: number
|
maxIterations?: number
|
||||||
completionPromise?: string
|
completionPromise?: string
|
||||||
|
messageCountAtStart?: number
|
||||||
ultrawork?: boolean
|
ultrawork?: boolean
|
||||||
strategy?: "reset" | "continue"
|
strategy?: "reset" | "continue"
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,19 @@ export interface RalphLoopHook {
|
|||||||
|
|
||||||
const DEFAULT_API_TIMEOUT = 5000 as const
|
const DEFAULT_API_TIMEOUT = 5000 as const
|
||||||
|
|
||||||
|
function getMessageCountFromResponse(messagesResponse: unknown): number {
|
||||||
|
if (Array.isArray(messagesResponse)) {
|
||||||
|
return messagesResponse.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse) {
|
||||||
|
const data = (messagesResponse as { data?: unknown }).data
|
||||||
|
return Array.isArray(data) ? data.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
export function createRalphLoopHook(
|
export function createRalphLoopHook(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options?: RalphLoopOptions
|
options?: RalphLoopOptions
|
||||||
@@ -51,7 +65,25 @@ export function createRalphLoopHook(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
startLoop: loopState.startLoop,
|
startLoop: (sessionID, prompt, loopOptions): boolean => {
|
||||||
|
const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions)
|
||||||
|
if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") {
|
||||||
|
return startSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.client.session
|
||||||
|
.messages({
|
||||||
|
path: { id: sessionID },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
.then((messagesResponse: unknown) => {
|
||||||
|
const messageCountAtStart = getMessageCountFromResponse(messagesResponse)
|
||||||
|
loopState.setMessageCountAtStart(sessionID, messageCountAtStart)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
return startSuccess
|
||||||
|
},
|
||||||
cancelLoop: loopState.cancelLoop,
|
cancelLoop: loopState.cancelLoop,
|
||||||
getState: loopState.getState as () => RalphLoopState | null,
|
getState: loopState.getState as () => RalphLoopState | null,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function waitUntil(condition: () => boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ralph-loop reset strategy race condition", () => {
|
describe("ralph-loop reset strategy race condition", () => {
|
||||||
test("should continue iteration when old session idle arrives before TUI switch completes", async () => {
|
test("should skip duplicate idle while reset iteration handling is in flight", async () => {
|
||||||
// given - reset strategy loop with blocked TUI session switch
|
// given - reset strategy loop with blocked TUI session switch
|
||||||
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
const promptCalls: Array<{ sessionID: string; text: string }> = []
|
||||||
const createSessionCalls: Array<{ parentID?: string }> = []
|
const createSessionCalls: Array<{ parentID?: string }> = []
|
||||||
@@ -85,7 +85,7 @@ describe("ralph-loop reset strategy race condition", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as Parameters<typeof createRalphLoopHook>[0])
|
} as unknown as Parameters<typeof createRalphLoopHook>[0])
|
||||||
|
|
||||||
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
||||||
|
|
||||||
@@ -100,14 +100,12 @@ describe("ralph-loop reset strategy race condition", () => {
|
|||||||
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
event: { type: "session.idle", properties: { sessionID: "session-old" } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitUntil(() => selectSessionCalls > 1)
|
|
||||||
|
|
||||||
selectSessionDeferred.resolve()
|
selectSessionDeferred.resolve()
|
||||||
await Promise.all([firstIdleEvent, secondIdleEvent])
|
await Promise.all([firstIdleEvent, secondIdleEvent])
|
||||||
|
|
||||||
// then - second idle should not be skipped during reset transition
|
// then - duplicate idle should be skipped to prevent concurrent continuation injection
|
||||||
expect(createSessionCalls.length).toBe(2)
|
expect(createSessionCalls.length).toBe(1)
|
||||||
expect(promptCalls.length).toBe(2)
|
expect(promptCalls.length).toBe(1)
|
||||||
expect(hook.getState()?.iteration).toBe(3)
|
expect(hook.getState()?.iteration).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
|
|||||||
active: isActive,
|
active: isActive,
|
||||||
iteration: iterationNum,
|
iteration: iterationNum,
|
||||||
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
|
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
|
||||||
|
message_count_at_start:
|
||||||
|
typeof data.message_count_at_start === "number"
|
||||||
|
? data.message_count_at_start
|
||||||
|
: typeof data.message_count_at_start === "string" && data.message_count_at_start.trim() !== ""
|
||||||
|
? Number(data.message_count_at_start)
|
||||||
|
: undefined,
|
||||||
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
|
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
|
||||||
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
|
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
|
||||||
prompt: body.trim(),
|
prompt: body.trim(),
|
||||||
@@ -72,13 +78,17 @@ export function writeState(
|
|||||||
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
||||||
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
||||||
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
|
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
|
||||||
|
const messageCountAtStartLine =
|
||||||
|
typeof state.message_count_at_start === "number"
|
||||||
|
? `message_count_at_start: ${state.message_count_at_start}\n`
|
||||||
|
: ""
|
||||||
const content = `---
|
const content = `---
|
||||||
active: ${state.active}
|
active: ${state.active}
|
||||||
iteration: ${state.iteration}
|
iteration: ${state.iteration}
|
||||||
max_iterations: ${state.max_iterations}
|
max_iterations: ${state.max_iterations}
|
||||||
completion_promise: "${state.completion_promise}"
|
completion_promise: "${state.completion_promise}"
|
||||||
started_at: "${state.started_at}"
|
started_at: "${state.started_at}"
|
||||||
${sessionIdLine}${ultraworkLine}${strategyLine}---
|
${sessionIdLine}${ultraworkLine}${strategyLine}${messageCountAtStartLine}---
|
||||||
${state.prompt}
|
${state.prompt}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface RalphLoopState {
|
|||||||
active: boolean
|
active: boolean
|
||||||
iteration: number
|
iteration: number
|
||||||
max_iterations: number
|
max_iterations: number
|
||||||
|
message_count_at_start?: number
|
||||||
completion_promise: string
|
completion_promise: string
|
||||||
started_at: string
|
started_at: string
|
||||||
prompt: string
|
prompt: string
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe("runtime-fallback", () => {
|
|||||||
await hook.event({
|
await hook.event({
|
||||||
event: {
|
event: {
|
||||||
type: "session.created",
|
type: "session.created",
|
||||||
properties: { info: { id: sessionID, model: "google/gemini-3-pro" } },
|
properties: { info: { id: sessionID, model: "google/gemini-3.1-pro" } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1841,7 +1841,7 @@ describe("runtime-fallback", () => {
|
|||||||
test("should apply fallback model on next chat.message after error", async () => {
|
test("should apply fallback model on next chat.message after error", async () => {
|
||||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3-pro"]),
|
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3.1-pro"]),
|
||||||
})
|
})
|
||||||
const sessionID = "test-session-switch"
|
const sessionID = "test-session-switch"
|
||||||
SessionCategoryRegistry.register(sessionID, "test")
|
SessionCategoryRegistry.register(sessionID, "test")
|
||||||
@@ -1916,7 +1916,7 @@ describe("runtime-fallback", () => {
|
|||||||
const input = createMockPluginInput()
|
const input = createMockPluginInput()
|
||||||
const hook = createRuntimeFallbackHook(input, {
|
const hook = createRuntimeFallbackHook(input, {
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3-pro"]),
|
pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.2", "google/gemini-3.1-pro"]),
|
||||||
})
|
})
|
||||||
const sessionID = "test-agent-fallback"
|
const sessionID = "test-agent-fallback"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearContinuationMarker,
|
clearContinuationMarker,
|
||||||
@@ -8,6 +9,11 @@ import { log } from "../../shared/logger"
|
|||||||
|
|
||||||
const HOOK_NAME = "stop-continuation-guard"
|
const HOOK_NAME = "stop-continuation-guard"
|
||||||
|
|
||||||
|
type StopContinuationBackgroundManager = Pick<
|
||||||
|
BackgroundManager,
|
||||||
|
"getAllDescendantTasks" | "cancelTask"
|
||||||
|
>
|
||||||
|
|
||||||
export interface StopContinuationGuard {
|
export interface StopContinuationGuard {
|
||||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||||
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
||||||
@@ -17,7 +23,10 @@ export interface StopContinuationGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStopContinuationGuardHook(
|
export function createStopContinuationGuardHook(
|
||||||
ctx: PluginInput
|
ctx: PluginInput,
|
||||||
|
options?: {
|
||||||
|
backgroundManager?: StopContinuationBackgroundManager
|
||||||
|
}
|
||||||
): StopContinuationGuard {
|
): StopContinuationGuard {
|
||||||
const stoppedSessions = new Set<string>()
|
const stoppedSessions = new Set<string>()
|
||||||
|
|
||||||
@@ -25,6 +34,38 @@ export function createStopContinuationGuardHook(
|
|||||||
stoppedSessions.add(sessionID)
|
stoppedSessions.add(sessionID)
|
||||||
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
|
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
|
||||||
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
||||||
|
|
||||||
|
const backgroundManager = options?.backgroundManager
|
||||||
|
if (!backgroundManager) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancellableTasks = backgroundManager
|
||||||
|
.getAllDescendantTasks(sessionID)
|
||||||
|
.filter((task) => task.status === "running" || task.status === "pending")
|
||||||
|
|
||||||
|
if (cancellableTasks.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void Promise.allSettled(
|
||||||
|
cancellableTasks.map(async (task) => {
|
||||||
|
await backgroundManager.cancelTask(task.id, {
|
||||||
|
source: "stop-continuation",
|
||||||
|
reason: "Continuation stopped via /stop-continuation",
|
||||||
|
abortSession: task.status === "running",
|
||||||
|
skipNotification: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
).then((results) => {
|
||||||
|
const cancelledCount = results.filter((result) => result.status === "fulfilled").length
|
||||||
|
const failedCount = results.length - cancelledCount
|
||||||
|
log(`[${HOOK_NAME}] Cancelled background tasks for stopped session`, {
|
||||||
|
sessionID,
|
||||||
|
cancelledCount,
|
||||||
|
failedCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStopped = (sessionID: string): boolean => {
|
const isStopped = (sessionID: string): boolean => {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import { mkdtempSync, rmSync } from "node:fs"
|
import { mkdtempSync, rmSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||||
import { readContinuationMarker } from "../../features/run-continuation-state"
|
import { readContinuationMarker } from "../../features/run-continuation-state"
|
||||||
import { createStopContinuationGuardHook } from "./index"
|
import { createStopContinuationGuardHook } from "./index"
|
||||||
|
|
||||||
|
type CancelCall = {
|
||||||
|
taskId: string
|
||||||
|
options?: Parameters<BackgroundManager["cancelTask"]>[1]
|
||||||
|
}
|
||||||
|
|
||||||
describe("stop-continuation-guard", () => {
|
describe("stop-continuation-guard", () => {
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
@@ -34,6 +40,33 @@ describe("stop-continuation-guard", () => {
|
|||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
description: `${id} description`,
|
||||||
|
parentSessionID: "parent-session",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
prompt: "prompt",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockBackgroundManager(tasks: BackgroundTask[], cancelCalls: CancelCall[]): Pick<BackgroundManager, "getAllDescendantTasks" | "cancelTask"> {
|
||||||
|
return {
|
||||||
|
getAllDescendantTasks: () => tasks,
|
||||||
|
cancelTask: async (taskId: string, options?: Parameters<BackgroundManager["cancelTask"]>[1]) => {
|
||||||
|
cancelCalls.push({ taskId, options })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
test("should mark session as stopped", () => {
|
test("should mark session as stopped", () => {
|
||||||
// given - a guard hook with no stopped sessions
|
// given - a guard hook with no stopped sessions
|
||||||
const input = createMockPluginInput()
|
const input = createMockPluginInput()
|
||||||
@@ -166,4 +199,31 @@ describe("stop-continuation-guard", () => {
|
|||||||
// then - should not throw and stopped session remains stopped
|
// then - should not throw and stopped session remains stopped
|
||||||
expect(guard.isStopped("some-session")).toBe(true)
|
expect(guard.isStopped("some-session")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should cancel only running and pending background tasks on stop", async () => {
|
||||||
|
// given - a background manager with mixed task statuses
|
||||||
|
const cancelCalls: CancelCall[] = []
|
||||||
|
const backgroundManager = createMockBackgroundManager(
|
||||||
|
[
|
||||||
|
createBackgroundTask("running", "task-running"),
|
||||||
|
createBackgroundTask("pending", "task-pending"),
|
||||||
|
createBackgroundTask("completed", "task-completed"),
|
||||||
|
],
|
||||||
|
cancelCalls,
|
||||||
|
)
|
||||||
|
const guard = createStopContinuationGuardHook(createMockPluginInput(), {
|
||||||
|
backgroundManager,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when - stop continuation is triggered
|
||||||
|
guard.stop("test-session-bg")
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
// then - only running and pending tasks are cancelled
|
||||||
|
expect(cancelCalls).toHaveLength(2)
|
||||||
|
expect(cancelCalls[0]?.taskId).toBe("task-running")
|
||||||
|
expect(cancelCalls[0]?.options?.abortSession).toBe(true)
|
||||||
|
expect(cancelCalls[1]?.taskId).toBe("task-pending")
|
||||||
|
expect(cancelCalls[1]?.options?.abortSession).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe("createThinkModeHook", () => {
|
|||||||
const input = createHookInput({
|
const input = createHookInput({
|
||||||
sessionID,
|
sessionID,
|
||||||
providerID: "google",
|
providerID: "google",
|
||||||
modelID: "gemini-3-pro",
|
modelID: "gemini-3.1-pro",
|
||||||
})
|
})
|
||||||
const output = createHookOutput("Please solve this directly")
|
const output = createHookOutput("Please solve this directly")
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe("think-mode switcher", () => {
|
|||||||
|
|
||||||
it("should handle Gemini preview variants", () => {
|
it("should handle Gemini preview variants", () => {
|
||||||
// given Gemini preview model IDs
|
// given Gemini preview model IDs
|
||||||
expect(getHighVariant("gemini-3-pro")).toBe(
|
expect(getHighVariant("gemini-3.1-pro")).toBe(
|
||||||
"gemini-3-pro-high"
|
"gemini-3-1-pro-high"
|
||||||
)
|
)
|
||||||
expect(getHighVariant("gemini-3-flash")).toBe(
|
expect(getHighVariant("gemini-3-flash")).toBe(
|
||||||
"gemini-3-flash-high"
|
"gemini-3-flash-high"
|
||||||
@@ -61,7 +61,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given model IDs that are already high variants
|
// given model IDs that are already high variants
|
||||||
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
||||||
expect(getHighVariant("gpt-5-2-high")).toBeNull()
|
expect(getHighVariant("gpt-5-2-high")).toBeNull()
|
||||||
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
|
expect(getHighVariant("gemini-3-1-pro-high")).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return null for unknown models", () => {
|
it("should return null for unknown models", () => {
|
||||||
@@ -77,7 +77,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given model IDs with -high suffix
|
// given model IDs with -high suffix
|
||||||
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
|
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
|
expect(isAlreadyHighVariant("gemini-3.1-pro-high")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should detect -high suffix after normalization", () => {
|
it("should detect -high suffix after normalization", () => {
|
||||||
@@ -90,7 +90,7 @@ describe("think-mode switcher", () => {
|
|||||||
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
|
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
|
||||||
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
|
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
|
||||||
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
|
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
|
||||||
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
|
expect(isAlreadyHighVariant("gemini-3.1-pro")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return false for models with 'high' in name but not suffix", () => {
|
it("should return false for models with 'high' in name but not suffix", () => {
|
||||||
@@ -129,7 +129,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given various custom prefixes
|
// given various custom prefixes
|
||||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
||||||
expect(getHighVariant("bedrock/claude-sonnet-4-6")).toBe("bedrock/claude-sonnet-4-6-high")
|
expect(getHighVariant("bedrock/claude-sonnet-4-6")).toBe("bedrock/claude-sonnet-4-6-high")
|
||||||
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
|
expect(getHighVariant("custom-llm/gemini-3.1-pro")).toBe("custom-llm/gemini-3-1-pro-high")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return null for prefixed models without high variant mapping", () => {
|
it("should return null for prefixed models without high variant mapping", () => {
|
||||||
@@ -150,7 +150,7 @@ describe("think-mode switcher", () => {
|
|||||||
// given prefixed model IDs with -high suffix
|
// given prefixed model IDs with -high suffix
|
||||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
||||||
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
|
expect(isAlreadyHighVariant("custom/gemini-3.1-pro-high")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return false for prefixed base models", () => {
|
it("should return false for prefixed base models", () => {
|
||||||
@@ -167,4 +167,3 @@ describe("think-mode switcher", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
|||||||
"claude-sonnet-4-6": "claude-sonnet-4-6-high",
|
"claude-sonnet-4-6": "claude-sonnet-4-6-high",
|
||||||
"claude-opus-4-6": "claude-opus-4-6-high",
|
"claude-opus-4-6": "claude-opus-4-6-high",
|
||||||
// Gemini
|
// Gemini
|
||||||
"gemini-3-pro": "gemini-3-pro-high",
|
"gemini-3-1-pro": "gemini-3-1-pro-high",
|
||||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
"gemini-3-1-pro-low": "gemini-3-1-pro-high",
|
||||||
"gemini-3-flash": "gemini-3-flash-high",
|
"gemini-3-flash": "gemini-3-flash-high",
|
||||||
// GPT-5
|
// GPT-5
|
||||||
"gpt-5": "gpt-5-high",
|
"gpt-5": "gpt-5-high",
|
||||||
@@ -82,7 +82,7 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
|||||||
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
||||||
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
||||||
// Antigravity (Google)
|
// Antigravity (Google)
|
||||||
"antigravity-gemini-3-pro": "antigravity-gemini-3-pro-high",
|
"antigravity-gemini-3-1-pro": "antigravity-gemini-3-1-pro-high",
|
||||||
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
|
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,4 +114,3 @@ export function isAlreadyHighVariant(modelID: string): boolean {
|
|||||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MAX_CONSECUTIVE_FAILURES,
|
MAX_CONSECUTIVE_FAILURES,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||||
|
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
@@ -74,6 +75,10 @@ export async function handleSessionIdle(args: {
|
|||||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (hasUnansweredQuestion(messages)) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
|
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
|
|
||||||
|
describe("hasUnansweredQuestion", () => {
|
||||||
|
test("given empty messages, returns false", () => {
|
||||||
|
expect(hasUnansweredQuestion([])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given null-ish input, returns false", () => {
|
||||||
|
expect(hasUnansweredQuestion(undefined as never)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given last assistant message with question tool_use, returns true", () => {
|
||||||
|
const messages = [
|
||||||
|
{ info: { role: "user" } },
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{ type: "tool_use", name: "question" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given last assistant message with question tool-invocation, returns true", () => {
|
||||||
|
const messages = [
|
||||||
|
{ info: { role: "user" } },
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{ type: "tool-invocation", toolName: "question" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given user message after question (answered), returns false", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{ type: "tool_use", name: "question" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ info: { role: "user" } },
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given assistant message with non-question tool, returns false", () => {
|
||||||
|
const messages = [
|
||||||
|
{ info: { role: "user" } },
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{ type: "tool_use", name: "bash" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given assistant message with no parts, returns false", () => {
|
||||||
|
const messages = [
|
||||||
|
{ info: { role: "user" } },
|
||||||
|
{ info: { role: "assistant" } },
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given role on message directly (not in info), returns true for question", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "user" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "tool_use", name: "question" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given mixed tools including question, returns true", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{ type: "tool_use", name: "bash" },
|
||||||
|
{ type: "tool_use", name: "question" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(hasUnansweredQuestion(messages)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { HOOK_NAME } from "./constants"
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: string
|
||||||
|
name?: string
|
||||||
|
toolName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
info?: { role?: string }
|
||||||
|
role?: string
|
||||||
|
parts?: MessagePart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUnansweredQuestion(messages: Message[]): boolean {
|
||||||
|
if (!messages || messages.length === 0) return false
|
||||||
|
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]
|
||||||
|
const role = msg.info?.role ?? msg.role
|
||||||
|
|
||||||
|
if (role === "user") return false
|
||||||
|
|
||||||
|
if (role === "assistant" && msg.parts) {
|
||||||
|
const hasQuestion = msg.parts.some(
|
||||||
|
(part) =>
|
||||||
|
(part.type === "tool_use" || part.type === "tool-invocation") &&
|
||||||
|
(part.name === "question" || part.toolName === "question"),
|
||||||
|
)
|
||||||
|
if (hasQuestion) {
|
||||||
|
log(`[${HOOK_NAME}] Detected pending question tool in last assistant message`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -297,6 +297,31 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should not inject when remaining todos are blocked or deleted", async () => {
|
||||||
|
// given - session where non-completed todos are only blocked/deleted
|
||||||
|
const sessionID = "main-blocked-deleted"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const mockInput = createMockPluginInput()
|
||||||
|
mockInput.client.session.todo = async () => ({ data: [
|
||||||
|
{ id: "1", content: "Blocked task", status: "blocked", priority: "high" },
|
||||||
|
{ id: "2", content: "Deleted task", status: "deleted", priority: "medium" },
|
||||||
|
{ id: "3", content: "Done task", status: "completed", priority: "low" },
|
||||||
|
]})
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||||
|
|
||||||
|
// when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fakeTimers.advanceBy(3000)
|
||||||
|
|
||||||
|
// then - no continuation injected
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("should not inject when background tasks are running", async () => {
|
test("should not inject when background tasks are running", async () => {
|
||||||
// given - session with running background tasks
|
// given - session with running background tasks
|
||||||
const sessionID = "main-789"
|
const sessionID = "main-789"
|
||||||
@@ -1663,7 +1688,6 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cancel all countdowns via cancelAllCountdowns", async () => {
|
test("should cancel all countdowns via cancelAllCountdowns", async () => {
|
||||||
// given - multiple sessions with running countdowns
|
// given - multiple sessions with running countdowns
|
||||||
const session1 = "main-cancel-all-1"
|
const session1 = "main-cancel-all-1"
|
||||||
const session2 = "main-cancel-all-2"
|
|
||||||
setMainSession(session1)
|
setMainSession(session1)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Todo } from "./types"
|
import type { Todo } from "./types"
|
||||||
|
|
||||||
export function getIncompleteCount(todos: Todo[]): number {
|
export function getIncompleteCount(todos: Todo[]): number {
|
||||||
return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length
|
return todos.filter(
|
||||||
|
(todo) =>
|
||||||
|
todo.status !== "completed"
|
||||||
|
&& todo.status !== "cancelled"
|
||||||
|
&& todo.status !== "blocked"
|
||||||
|
&& todo.status !== "deleted",
|
||||||
|
).length
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("mergeConfigs", () => {
|
|||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
},
|
},
|
||||||
visual: {
|
visual: {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OhMyOpenCodeConfig;
|
} as unknown as OhMyOpenCodeConfig;
|
||||||
@@ -41,7 +41,7 @@ describe("mergeConfigs", () => {
|
|||||||
// then quick should be preserved from base
|
// then quick should be preserved from base
|
||||||
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
|
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
|
||||||
// then visual should be added from override
|
// then visual should be added from override
|
||||||
expect(result.categories?.visual?.model).toBe("google/gemini-3-pro");
|
expect(result.categories?.visual?.model).toBe("google/gemini-3.1-pro");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should preserve base categories when override has no categories", () => {
|
it("should preserve base categories when override has no categories", () => {
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ export async function applyAgentConfig(params: {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const disabledAgentNames = new Set(
|
||||||
|
(migratedDisabledAgents ?? []).map(a => a.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterDisabledAgents = (agents: Record<string, unknown>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
|
const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true;
|
||||||
const builderEnabled =
|
const builderEnabled =
|
||||||
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||||
@@ -194,9 +203,9 @@ export async function applyAgentConfig(params: {
|
|||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
|
Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"),
|
||||||
),
|
),
|
||||||
...userAgents,
|
...filterDisabledAgents(userAgents),
|
||||||
...projectAgents,
|
...filterDisabledAgents(projectAgents),
|
||||||
...pluginAgents,
|
...filterDisabledAgents(pluginAgents),
|
||||||
...filteredConfigAgents,
|
...filteredConfigAgents,
|
||||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||||
@@ -204,9 +213,9 @@ export async function applyAgentConfig(params: {
|
|||||||
} else {
|
} else {
|
||||||
params.config.agent = {
|
params.config.agent = {
|
||||||
...builtinAgents,
|
...builtinAgents,
|
||||||
...userAgents,
|
...filterDisabledAgents(userAgents),
|
||||||
...projectAgents,
|
...filterDisabledAgents(projectAgents),
|
||||||
...pluginAgents,
|
...filterDisabledAgents(pluginAgents),
|
||||||
...configAgent,
|
...configAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ describe("Prometheus category config resolution", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(config).toBeDefined()
|
expect(config).toBeDefined()
|
||||||
expect(config?.model).toBe("google/gemini-3-pro")
|
expect(config?.model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("user categories override default categories", () => {
|
test("user categories override default categories", () => {
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export function createContinuationHooks(args: {
|
|||||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||||
|
|
||||||
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
|
const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
|
||||||
? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx))
|
? safeHook("stop-continuation-guard", () =>
|
||||||
|
createStopContinuationGuardHook(ctx, {
|
||||||
|
backgroundManager,
|
||||||
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ function tryUpdateMessageModel(
|
|||||||
if (result.changes === 0) return false
|
if (result.changes === 0) return false
|
||||||
if (variant) {
|
if (variant) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE message SET data = json_set(data, '$.variant', ?) WHERE id = ?`,
|
`UPDATE message SET data = json_set(data, '$.variant', ?, '$.thinking', ?) WHERE id = ?`,
|
||||||
).run(variant, messageId)
|
).run(variant, variant, messageId)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,30 @@ describe("applyUltraworkModelOverrideOnMessage", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should override keyword-detector variant with configured ultrawork variant on deferred path", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig("sisyphus", {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
variant: "extended",
|
||||||
|
})
|
||||||
|
const output = createOutput("ultrawork do something", { messageId: "msg_123" })
|
||||||
|
output.message["variant"] = "max"
|
||||||
|
output.message["thinking"] = "max"
|
||||||
|
const tui = createMockTui()
|
||||||
|
|
||||||
|
//#when
|
||||||
|
applyUltraworkModelOverrideOnMessage(config, "sisyphus", output, tui)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(dbOverrideSpy).toHaveBeenCalledWith(
|
||||||
|
"msg_123",
|
||||||
|
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
"extended",
|
||||||
|
)
|
||||||
|
expect(output.message["variant"]).toBe("extended")
|
||||||
|
expect(output.message["thinking"]).toBe("extended")
|
||||||
|
})
|
||||||
|
|
||||||
test("should NOT mutate output.message.model when message ID present", () => {
|
test("should NOT mutate output.message.model when message ID present", () => {
|
||||||
//#given
|
//#given
|
||||||
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
|
const sonnetModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
|
||||||
|
|||||||
@@ -114,10 +114,12 @@ export function applyUltraworkModelOverrideOnMessage(
|
|||||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||||
if (!override) return
|
if (!override) return
|
||||||
|
|
||||||
|
if (override.variant) {
|
||||||
|
output.message["variant"] = override.variant
|
||||||
|
output.message["thinking"] = override.variant
|
||||||
|
}
|
||||||
|
|
||||||
if (!override.providerID || !override.modelID) {
|
if (!override.providerID || !override.modelID) {
|
||||||
if (override.variant) {
|
|
||||||
output.message["variant"] = override.variant
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +133,8 @@ export function applyUltraworkModelOverrideOnMessage(
|
|||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||||
output.message.model = targetModel
|
output.message.model = targetModel
|
||||||
if (override.variant) {
|
|
||||||
output.message["variant"] = override.variant
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ describe("migrateAgentConfigToCategory", () => {
|
|||||||
test("migrates model to category when mapping exists", () => {
|
test("migrates model to category when mapping exists", () => {
|
||||||
// given: Config with a model that has a category mapping
|
// given: Config with a model that has a category mapping
|
||||||
const config = {
|
const config = {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
}
|
}
|
||||||
@@ -823,7 +823,7 @@ describe("migrateAgentConfigToCategory", () => {
|
|||||||
test("handles all mapped models correctly", () => {
|
test("handles all mapped models correctly", () => {
|
||||||
// given: Configs for each mapped model
|
// given: Configs for each mapped model
|
||||||
const configs = [
|
const configs = [
|
||||||
{ model: "google/gemini-3-pro" },
|
{ model: "google/gemini-3.1-pro" },
|
||||||
{ model: "google/gemini-3-flash" },
|
{ model: "google/gemini-3-flash" },
|
||||||
{ model: "openai/gpt-5.2" },
|
{ model: "openai/gpt-5.2" },
|
||||||
{ model: "anthropic/claude-haiku-4-5" },
|
{ model: "anthropic/claude-haiku-4-5" },
|
||||||
@@ -893,7 +893,7 @@ describe("shouldDeleteAgentConfig", () => {
|
|||||||
// given: Config with fields matching category defaults
|
// given: Config with fields matching category defaults
|
||||||
const config = {
|
const config = {
|
||||||
category: "visual-engineering",
|
category: "visual-engineering",
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when: Check if config should be deleted
|
// when: Check if config should be deleted
|
||||||
@@ -1021,7 +1021,7 @@ describe("migrateConfigFile with backup", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
||||||
oracle: { model: "openai/gpt-5.2" },
|
oracle: { model: "openai/gpt-5.2" },
|
||||||
"my-custom-agent": { model: "google/gemini-3-pro" },
|
"my-custom-agent": { model: "google/gemini-3.1-pro" },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,7 +1037,7 @@ describe("migrateConfigFile with backup", () => {
|
|||||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||||
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
||||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||||
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro")
|
expect(agents["my-custom-agent"].model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves category setting when explicitly set", () => {
|
test("preserves category setting when explicitly set", () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* This map will be removed in a future major version once migration period ends.
|
* This map will be removed in a future major version once migration period ends.
|
||||||
*/
|
*/
|
||||||
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
||||||
"google/gemini-3-pro": "visual-engineering",
|
"google/gemini-3.1-pro": "visual-engineering",
|
||||||
"google/gemini-3-flash": "writing",
|
"google/gemini-3-flash": "writing",
|
||||||
"openai/gpt-5.2": "ultrabrain",
|
"openai/gpt-5.2": "ultrabrain",
|
||||||
"anthropic/claude-haiku-4-5": "quick",
|
"anthropic/claude-haiku-4-5": "quick",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
writeModelsCache({
|
writeModelsCache({
|
||||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
google: { id: "google", models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await fetchAvailableModels(undefined, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -74,7 +74,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
expect(result.size).toBe(3)
|
expect(result.size).toBe(3)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
||||||
@@ -97,7 +97,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
list: async () => ({
|
list: async () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||||
{ id: "gemini-3-pro", provider: "google" },
|
{ id: "gemini-3.1-pro", provider: "google" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -107,7 +107,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
|
|
||||||
expect(result).toBeInstanceOf(Set)
|
expect(result).toBeInstanceOf(Set)
|
||||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||||
@@ -126,7 +126,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
list: async () => ({
|
list: async () => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||||
{ id: "gemini-3-pro", provider: "google" },
|
{ id: "gemini-3.1-pro", provider: "google" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -136,7 +136,7 @@ describe("fetchAvailableModels", () => {
|
|||||||
|
|
||||||
expect(result).toBeInstanceOf(Set)
|
expect(result).toBeInstanceOf(Set)
|
||||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||||
@@ -515,7 +515,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
writeModelsCache({
|
writeModelsCache({
|
||||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await fetchAvailableModels(undefined, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -525,7 +525,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
expect(result.size).toBe(1)
|
expect(result.size).toBe(1)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// given cache with multiple providers
|
// given cache with multiple providers
|
||||||
@@ -535,7 +535,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
writeModelsCache({
|
writeModelsCache({
|
||||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
google: { models: { "gemini-3.1-pro": { id: "gemini-3.1-pro" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await fetchAvailableModels(undefined, {
|
const result = await fetchAvailableModels(undefined, {
|
||||||
@@ -544,7 +544,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
|||||||
|
|
||||||
expect(result.size).toBe(2)
|
expect(result.size).toBe(2)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
expect(result.has("google/gemini-3.1-pro")).toBe(true)
|
||||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -759,7 +759,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
|||||||
models: {
|
models: {
|
||||||
opencode: ["big-pickle"],
|
opencode: ["big-pickle"],
|
||||||
anthropic: ["claude-opus-4-6"],
|
anthropic: ["claude-opus-4-6"],
|
||||||
google: ["gemini-3-pro"]
|
google: ["gemini-3.1-pro"]
|
||||||
},
|
},
|
||||||
connected: ["opencode", "anthropic", "google"]
|
connected: ["opencode", "anthropic", "google"]
|
||||||
})
|
})
|
||||||
@@ -771,7 +771,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
|||||||
expect(result.size).toBe(1)
|
expect(result.size).toBe(1)
|
||||||
expect(result.has("opencode/big-pickle")).toBe(true)
|
expect(result.has("opencode/big-pickle")).toBe(true)
|
||||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
||||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
expect(result.has("google/gemini-3.1-pro")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should handle object[] format with metadata (Ollama-style)", async () => {
|
it("should handle object[] format with metadata (Ollama-style)", async () => {
|
||||||
@@ -953,7 +953,7 @@ describe("fallback model availability", () => {
|
|||||||
{ providers: ["openai"], model: "gpt-5.2" },
|
{ providers: ["openai"], model: "gpt-5.2" },
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
]
|
]
|
||||||
const availableModels = new Set(["google/gemini-3-pro"])
|
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)
|
const result = resolveFirstAvailableFallback(fallbackChain, availableModels)
|
||||||
|
|||||||
@@ -248,19 +248,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
|||||||
expect(primary.providers[0]).toBe("openai")
|
expect(primary.providers[0]).toBe("openai")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
|
test("visual-engineering has valid fallbackChain with gemini-3.1-pro high as primary", () => {
|
||||||
// given - visual-engineering category requirement
|
// given - visual-engineering category requirement
|
||||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||||
|
|
||||||
// when - accessing visual-engineering requirement
|
// when - accessing visual-engineering requirement
|
||||||
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max)
|
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max)
|
||||||
expect(visualEngineering).toBeDefined()
|
expect(visualEngineering).toBeDefined()
|
||||||
expect(visualEngineering.fallbackChain).toBeArray()
|
expect(visualEngineering.fallbackChain).toBeArray()
|
||||||
expect(visualEngineering.fallbackChain).toHaveLength(3)
|
expect(visualEngineering.fallbackChain).toHaveLength(3)
|
||||||
|
|
||||||
const primary = visualEngineering.fallbackChain[0]
|
const primary = visualEngineering.fallbackChain[0]
|
||||||
expect(primary.providers[0]).toBe("google")
|
expect(primary.providers[0]).toBe("google")
|
||||||
expect(primary.model).toBe("gemini-3-pro")
|
expect(primary.model).toBe("gemini-3.1-pro")
|
||||||
expect(primary.variant).toBe("high")
|
expect(primary.variant).toBe("high")
|
||||||
|
|
||||||
const second = visualEngineering.fallbackChain[1]
|
const second = visualEngineering.fallbackChain[1]
|
||||||
@@ -319,39 +319,43 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
|||||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
|
test("artistry has valid fallbackChain with gemini-3.1-pro as primary", () => {
|
||||||
// given - artistry category requirement
|
// given - artistry category requirement
|
||||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||||
|
|
||||||
// when - accessing artistry requirement
|
// when - accessing artistry requirement
|
||||||
// then - fallbackChain exists with gemini-3-pro as first entry
|
// then - fallbackChain exists with gemini-3.1-pro as first entry
|
||||||
expect(artistry).toBeDefined()
|
expect(artistry).toBeDefined()
|
||||||
expect(artistry.fallbackChain).toBeArray()
|
expect(artistry.fallbackChain).toBeArray()
|
||||||
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
const primary = artistry.fallbackChain[0]
|
const primary = artistry.fallbackChain[0]
|
||||||
expect(primary.model).toBe("gemini-3-pro")
|
expect(primary.model).toBe("gemini-3.1-pro")
|
||||||
expect(primary.variant).toBe("high")
|
expect(primary.variant).toBe("high")
|
||||||
expect(primary.providers[0]).toBe("google")
|
expect(primary.providers[0]).toBe("google")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
|
test("writing has valid fallbackChain with kimi-k2.5-free as primary", () => {
|
||||||
// given - writing category requirement
|
// given - writing category requirement
|
||||||
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
||||||
|
|
||||||
// when - accessing writing requirement
|
// when - accessing writing requirement
|
||||||
// then - fallbackChain: gemini-3-flash → claude-sonnet-4-6
|
// then - fallbackChain: kimi-k2.5-free -> gemini-3-flash -> claude-sonnet-4-6
|
||||||
expect(writing).toBeDefined()
|
expect(writing).toBeDefined()
|
||||||
expect(writing.fallbackChain).toBeArray()
|
expect(writing.fallbackChain).toBeArray()
|
||||||
expect(writing.fallbackChain).toHaveLength(2)
|
expect(writing.fallbackChain).toHaveLength(3)
|
||||||
|
|
||||||
const primary = writing.fallbackChain[0]
|
const primary = writing.fallbackChain[0]
|
||||||
expect(primary.model).toBe("gemini-3-flash")
|
expect(primary.model).toBe("kimi-k2.5-free")
|
||||||
expect(primary.providers[0]).toBe("google")
|
expect(primary.providers[0]).toBe("opencode")
|
||||||
|
|
||||||
const second = writing.fallbackChain[1]
|
const second = writing.fallbackChain[1]
|
||||||
expect(second.model).toBe("claude-sonnet-4-6")
|
expect(second.model).toBe("gemini-3-flash")
|
||||||
expect(second.providers[0]).toBe("anthropic")
|
expect(second.providers[0]).toBe("google")
|
||||||
|
|
||||||
|
const third = writing.fallbackChain[2]
|
||||||
|
expect(third.model).toBe("claude-sonnet-4-6")
|
||||||
|
expect(third.providers[0]).toBe("anthropic")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("all 8 categories have valid fallbackChain arrays", () => {
|
test("all 8 categories have valid fallbackChain arrays", () => {
|
||||||
@@ -489,12 +493,12 @@ describe("requiresModel field in categories", () => {
|
|||||||
expect(deep.requiresModel).toBe("gpt-5.3-codex")
|
expect(deep.requiresModel).toBe("gpt-5.3-codex")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("artistry category has requiresModel set to gemini-3-pro", () => {
|
test("artistry category has requiresModel set to gemini-3.1-pro", () => {
|
||||||
// given
|
// given
|
||||||
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
|
||||||
|
|
||||||
// when / #then
|
// when / #then
|
||||||
expect(artistry.requiresModel).toBe("gemini-3-pro")
|
expect(artistry.requiresModel).toBe("gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
oracle: {
|
oracle: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -65,7 +65,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
metis: {
|
metis: {
|
||||||
@@ -73,14 +73,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
momus: {
|
momus: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
atlas: {
|
atlas: {
|
||||||
@@ -95,7 +95,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
@@ -103,7 +103,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
ultrabrain: {
|
ultrabrain: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -111,17 +111,17 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
],
|
],
|
||||||
requiresModel: "gpt-5.3-codex",
|
requiresModel: "gpt-5.3-codex",
|
||||||
},
|
},
|
||||||
artistry: {
|
artistry: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro", variant: "high" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
requiresModel: "gemini-3-pro",
|
requiresModel: "gemini-3.1-pro",
|
||||||
},
|
},
|
||||||
quick: {
|
quick: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
@@ -141,11 +141,12 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
writing: {
|
writing: {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -25,7 +25,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: undefined,
|
userModel: undefined,
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -40,14 +40,14 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: undefined,
|
userModel: undefined,
|
||||||
inheritedModel: undefined,
|
inheritedModel: undefined,
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModel(input)
|
const result = resolveModel(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe("google/gemini-3-pro")
|
expect(result).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "",
|
userModel: "",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -72,14 +72,14 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: " ",
|
userModel: " ",
|
||||||
inheritedModel: "",
|
inheritedModel: "",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModel(input)
|
const result = resolveModel(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe("google/gemini-3-pro")
|
expect(result).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ describe("resolveModel", () => {
|
|||||||
const input: ModelResolutionInput = {
|
const input: ModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
inheritedModel: "openai/gpt-5.2",
|
inheritedModel: "openai/gpt-5.2",
|
||||||
systemDefault: "google/gemini-3-pro",
|
systemDefault: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -123,7 +123,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -141,7 +141,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: "opencode/big-pickle",
|
uiSelectedModel: "opencode/big-pickle",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -158,7 +158,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: " ",
|
uiSelectedModel: " ",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -175,7 +175,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
uiSelectedModel: "",
|
uiSelectedModel: "",
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -195,7 +195,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -215,7 +215,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -234,7 +234,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -252,7 +252,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -271,7 +271,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -294,8 +294,8 @@ describe("resolveModelWithFallback", () => {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3-pro"]),
|
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3.1-pro"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -313,7 +313,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/gpt-5-nano"]),
|
availableModels: new Set(["opencode/gpt-5-nano"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -331,7 +331,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -346,7 +346,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given
|
// given
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -361,7 +361,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [],
|
fallbackChain: [],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -378,7 +378,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -397,7 +397,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
|
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -420,7 +420,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
|
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -438,7 +438,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["opencode/glm-5"]),
|
availableModels: new Set(["opencode/glm-5"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -457,7 +457,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -477,14 +477,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("system-default")
|
expect(result!.source).toBe("system-default")
|
||||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||||
})
|
})
|
||||||
@@ -516,7 +516,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -533,7 +533,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
@@ -544,7 +544,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
// then - should use github-copilot (second provider) since google not connected
|
// then - should use github-copilot (second provider) since google not connected
|
||||||
// model name is transformed to preview variant for github-copilot provider
|
// model name is transformed to preview variant for github-copilot provider
|
||||||
expect(result!.model).toBe("github-copilot/gemini-3-pro-preview")
|
expect(result!.model).toBe("github-copilot/gemini-3.1-pro-preview")
|
||||||
expect(result!.source).toBe("provider-fallback")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -577,14 +577,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should fall through to system default
|
// then - should fall through to system default
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("system-default")
|
expect(result!.source).toBe("system-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -593,14 +593,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given
|
// given
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
availableModels: new Set(["openai/gpt-5.2"]),
|
availableModels: new Set(["openai/gpt-5.2"]),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("system-default")
|
expect(result!.source).toBe("system-default")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -627,20 +627,20 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
test("tries all providers in first entry before moving to second entry", () => {
|
test("tries all providers in first entry before moving to second entry", () => {
|
||||||
// given
|
// given
|
||||||
const availableModels = new Set(["google/gemini-3-pro"])
|
const availableModels = new Set(["google/gemini-3.1-pro"])
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback({
|
const result = resolveModelWithFallback({
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels,
|
availableModels,
|
||||||
systemDefaultModel: "system/default",
|
systemDefaultModel: "system/default",
|
||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("provider-fallback")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -675,7 +675,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["openai"], model: "gpt-5.2" },
|
{ providers: ["openai"], model: "gpt-5.2" },
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels,
|
availableModels,
|
||||||
systemDefaultModel: "system/default",
|
systemDefaultModel: "system/default",
|
||||||
@@ -693,7 +693,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "google/gemini-3-pro",
|
systemDefaultModel: "google/gemini-3.1-pro",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -708,32 +708,32 @@ describe("resolveModelWithFallback", () => {
|
|||||||
|
|
||||||
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
||||||
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
||||||
// given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available
|
// given - gemini-3.1-pro is the category default, but only gemini-3.1-pro-preview is available
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should fuzzy match gemini-3-pro → gemini-3-pro-preview
|
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
|
||||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||||
expect(result!.source).toBe("category-default")
|
expect(result!.source).toBe("category-default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("categoryDefaultModel uses exact match when available", () => {
|
test("categoryDefaultModel uses exact match when available", () => {
|
||||||
// given - exact match exists
|
// given - exact match exists
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["google/gemini-3-pro", "google/gemini-3-pro-preview"]),
|
availableModels: new Set(["google/gemini-3.1-pro", "google/gemini-3.1-pro-preview"]),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,14 +741,14 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should use exact match
|
// then - should use exact match
|
||||||
expect(result!.model).toBe("google/gemini-3-pro")
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.source).toBe("category-default")
|
expect(result!.source).toBe("category-default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
||||||
// given - categoryDefaultModel has no match, but fallbackChain does
|
// given - categoryDefaultModel has no match, but fallbackChain does
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||||
],
|
],
|
||||||
@@ -768,11 +768,11 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - both userModel and categoryDefaultModel provided
|
// given - both userModel and categoryDefaultModel provided
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
userModel: "anthropic/claude-opus-4-6",
|
userModel: "anthropic/claude-opus-4-6",
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google"], model: "gemini-3-pro" },
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||||
systemDefaultModel: "system/default",
|
systemDefaultModel: "system/default",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +788,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - no availableModels but connected provider cache exists
|
// given - no availableModels but connected provider cache exists
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro",
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||||
}
|
}
|
||||||
@@ -797,7 +797,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should use transformed categoryDefaultModel since google is connected
|
// then - should use transformed categoryDefaultModel since google is connected
|
||||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||||
expect(result!.source).toBe("category-default")
|
expect(result!.source).toBe("category-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -824,7 +824,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// given - category default already has -preview suffix
|
// given - category default already has -preview suffix
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
categoryDefaultModel: "google/gemini-3-pro-preview",
|
categoryDefaultModel: "google/gemini-3.1-pro-preview",
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||||
}
|
}
|
||||||
@@ -832,18 +832,18 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// when
|
// when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should NOT become gemini-3-pro-preview-preview
|
// then - should NOT become gemini-3.1-pro-preview-preview
|
||||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||||
expect(result!.source).toBe("category-default")
|
expect(result!.source).toBe("category-default")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("transforms gemini-3-pro in fallback chain for google connected provider", () => {
|
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
|
||||||
// given - google connected, fallback chain has gemini-3-pro
|
// given - google connected, fallback chain has gemini-3.1-pro
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["google", "github-copilot"], model: "gemini-3-pro" },
|
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
|
||||||
],
|
],
|
||||||
availableModels: new Set(),
|
availableModels: new Set(),
|
||||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||||
@@ -853,7 +853,7 @@ describe("resolveModelWithFallback", () => {
|
|||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// then - should transform to preview variant for google provider
|
// then - should transform to preview variant for google provider
|
||||||
expect(result!.model).toBe("google/gemini-3-pro-preview")
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
||||||
expect(result!.source).toBe("provider-fallback")
|
expect(result!.source).toBe("provider-fallback")
|
||||||
cacheSpy.mockRestore()
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ export function transformModelForProvider(provider: string, model: string): stri
|
|||||||
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
|
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
|
||||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||||
.replace("claude-sonnet-4", "claude-sonnet-4")
|
.replace("claude-sonnet-4", "claude-sonnet-4")
|
||||||
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
|
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||||
}
|
}
|
||||||
if (provider === "google") {
|
if (provider === "google") {
|
||||||
return model
|
return model
|
||||||
.replace(/gemini-3-pro(?!-)/g, "gemini-3-pro-preview")
|
.replace(/gemini-3\.1-pro(?!-)/g, "gemini-3.1-pro-preview")
|
||||||
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
.replace(/gemini-3-flash(?!-)/g, "gemini-3-flash-preview")
|
||||||
}
|
}
|
||||||
return model
|
return model
|
||||||
|
|||||||
84
src/shared/spawn-with-windows-hide.ts
Normal file
84
src/shared/spawn-with-windows-hide.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { spawn as bunSpawn } from "bun"
|
||||||
|
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
|
||||||
|
export interface SpawnOptions {
|
||||||
|
cwd?: string
|
||||||
|
env?: Record<string, string | undefined>
|
||||||
|
stdin?: "pipe" | "inherit" | "ignore"
|
||||||
|
stdout?: "pipe" | "inherit" | "ignore"
|
||||||
|
stderr?: "pipe" | "inherit" | "ignore"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnedProcess {
|
||||||
|
readonly exitCode: number | null
|
||||||
|
readonly exited: Promise<number>
|
||||||
|
readonly stdout: ReadableStream<Uint8Array> | undefined
|
||||||
|
readonly stderr: ReadableStream<Uint8Array> | undefined
|
||||||
|
kill(signal?: NodeJS.Signals): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
|
||||||
|
if (!stream) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
|
||||||
|
let resolveExited: (exitCode: number) => void
|
||||||
|
let exitCode: number | null = null
|
||||||
|
|
||||||
|
const exited = new Promise<number>((resolve) => {
|
||||||
|
resolveExited = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
exitCode = code ?? 1
|
||||||
|
resolveExited(exitCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("error", () => {
|
||||||
|
if (exitCode === null) {
|
||||||
|
exitCode = 1
|
||||||
|
resolveExited(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
get exitCode() {
|
||||||
|
return exitCode
|
||||||
|
},
|
||||||
|
exited,
|
||||||
|
stdout: toReadableStream(proc.stdout),
|
||||||
|
stderr: toReadableStream(proc.stderr),
|
||||||
|
kill(signal?: NodeJS.Signals): void {
|
||||||
|
try {
|
||||||
|
if (!signal) {
|
||||||
|
proc.kill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.kill(signal)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return bunSpawn(command, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cmd, ...args] = command
|
||||||
|
const proc = nodeSpawn(cmd, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
shell: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return wrapNodeProcess(proc)
|
||||||
|
}
|
||||||
@@ -208,10 +208,10 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
|
|||||||
|
|
||||||
|
|
||||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||||
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
|
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
|
||||||
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
|
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
|
||||||
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
||||||
artistry: { model: "google/gemini-3-pro", variant: "high" },
|
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||||
quick: { model: "anthropic/claude-haiku-4-5" },
|
quick: { model: "anthropic/claude-haiku-4-5" },
|
||||||
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
||||||
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
truncateToTokenBudget,
|
truncateToTokenBudget,
|
||||||
} from "./token-limiter"
|
} from "./token-limiter"
|
||||||
|
|
||||||
|
const TRUNCATION_MARKER_TOKEN_OVERHEAD = estimateTokenCount("\n[TRUNCATED]")
|
||||||
|
|
||||||
describe("token-limiter", () => {
|
describe("token-limiter", () => {
|
||||||
test("estimateTokenCount uses 1 token per 4 chars approximation", () => {
|
test("estimateTokenCount uses 1 token per 4 chars approximation", () => {
|
||||||
// given
|
// given
|
||||||
@@ -40,7 +42,62 @@ describe("token-limiter", () => {
|
|||||||
const result = truncateToTokenBudget(content, maxTokens)
|
const result = truncateToTokenBudget(content, maxTokens)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(estimateTokenCount(result)).toBeLessThanOrEqual(maxTokens)
|
expect(estimateTokenCount(result)).toBeLessThanOrEqual(maxTokens + TRUNCATION_MARKER_TOKEN_OVERHEAD)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("truncateToTokenBudget", () => {
|
||||||
|
describe("#given content that exceeds budget", () => {
|
||||||
|
describe("#when content has newlines", () => {
|
||||||
|
test("#then should truncate at last newline boundary", () => {
|
||||||
|
// #given
|
||||||
|
const content = "line-1\nline-2\nline-3"
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = truncateToTokenBudget(content, 2)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe("line-1\n[TRUNCATED]")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#then should append [TRUNCATED] marker", () => {
|
||||||
|
// #given
|
||||||
|
const content = "line-1\nline-2\nline-3"
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = truncateToTokenBudget(content, 2)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toContain("[TRUNCATED]")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when content is single long line with no newlines", () => {
|
||||||
|
test("#then should slice and append [TRUNCATED] marker", () => {
|
||||||
|
// #given
|
||||||
|
const content = "A".repeat(30)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = truncateToTokenBudget(content, 2)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe("AAAAAAAA\n[TRUNCATED]")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given content within budget", () => {
|
||||||
|
test("#then should return content unchanged without marker", () => {
|
||||||
|
// #given
|
||||||
|
const content = "line-1\nline-2"
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = truncateToTokenBudget(content, 20)
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(result).toBe(content)
|
||||||
|
expect(result).not.toContain("[TRUNCATED]")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("buildSystemContentWithTokenLimit returns undefined when there is no content", () => {
|
test("buildSystemContentWithTokenLimit returns undefined when there is no content", () => {
|
||||||
@@ -76,10 +133,10 @@ describe("token-limiter", () => {
|
|||||||
const result = buildSystemContentWithTokenLimit(input, 80)
|
const result = buildSystemContentWithTokenLimit(input, 80)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toContain("AGENTS_CONTEXT:keep")
|
expect(result).toContain("AGENTS_C")
|
||||||
expect(result).toContain("CATEGORY_APPEND:keep")
|
expect(result).toContain("CATE")
|
||||||
expect(result).toContain("SKILL_ALPHA:")
|
expect(result).toContain("SKILL_ALPHA:")
|
||||||
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(80)
|
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(80 + TRUNCATION_MARKER_TOKEN_OVERHEAD)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("buildSystemContentWithTokenLimit truncates category after skills are exhausted", () => {
|
test("buildSystemContentWithTokenLimit truncates category after skills are exhausted", () => {
|
||||||
@@ -95,9 +152,9 @@ describe("token-limiter", () => {
|
|||||||
const result = buildSystemContentWithTokenLimit(input, 30)
|
const result = buildSystemContentWithTokenLimit(input, 30)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toContain("AGENTS_CONTEXT:keep")
|
expect(result).toContain("AGENTS_C")
|
||||||
expect(result).not.toContain("SKILL_ALPHA:" + "a".repeat(80))
|
expect(result).not.toContain("SKILL_ALPHA:" + "a".repeat(80))
|
||||||
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(30)
|
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(30 + TRUNCATION_MARKER_TOKEN_OVERHEAD)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("buildSystemContentWithTokenLimit truncates agents context last", () => {
|
test("buildSystemContentWithTokenLimit truncates agents context last", () => {
|
||||||
@@ -116,6 +173,6 @@ describe("token-limiter", () => {
|
|||||||
expect(result).toContain("AGENTS_CONTEXT:")
|
expect(result).toContain("AGENTS_CONTEXT:")
|
||||||
expect(result).not.toContain("SKILL_ALPHA:")
|
expect(result).not.toContain("SKILL_ALPHA:")
|
||||||
expect(result).not.toContain("CATEGORY_APPEND:")
|
expect(result).not.toContain("CATEGORY_APPEND:")
|
||||||
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10)
|
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10 + TRUNCATION_MARKER_TOKEN_OVERHEAD)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ export function truncateToTokenBudget(content: string, maxTokens: number): strin
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.slice(0, maxCharacters)
|
const sliced = content.slice(0, maxCharacters)
|
||||||
|
const lastNewline = sliced.lastIndexOf("\n")
|
||||||
|
if (lastNewline > 0) {
|
||||||
|
return `${sliced.slice(0, lastNewline)}\n[TRUNCATED]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sliced}\n[TRUNCATED]`
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinSystemParts(parts: string[]): string | undefined {
|
function joinSystemParts(parts: string[]): string | undefined {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const TEST_AVAILABLE_MODELS = new Set([
|
|||||||
"anthropic/claude-opus-4-6",
|
"anthropic/claude-opus-4-6",
|
||||||
"anthropic/claude-sonnet-4-6",
|
"anthropic/claude-sonnet-4-6",
|
||||||
"anthropic/claude-haiku-4-5",
|
"anthropic/claude-haiku-4-5",
|
||||||
"google/gemini-3-pro",
|
"google/gemini-3.1-pro",
|
||||||
"google/gemini-3-flash",
|
"google/gemini-3-flash",
|
||||||
"openai/gpt-5.2",
|
"openai/gpt-5.2",
|
||||||
"openai/gpt-5.3-codex",
|
"openai/gpt-5.3-codex",
|
||||||
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
|
|||||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
models: {
|
models: {
|
||||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
||||||
google: ["gemini-3-pro", "gemini-3-flash"],
|
google: ["gemini-3.1-pro", "gemini-3-flash"],
|
||||||
openai: ["gpt-5.2", "gpt-5.3-codex"],
|
openai: ["gpt-5.2", "gpt-5.3-codex"],
|
||||||
},
|
},
|
||||||
connected: ["anthropic", "google", "openai"],
|
connected: ["anthropic", "google", "openai"],
|
||||||
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// when / #then
|
// when / #then
|
||||||
expect(category).toBeDefined()
|
expect(category).toBeDefined()
|
||||||
expect(category.model).toBe("google/gemini-3-pro")
|
expect(category.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(category.variant).toBe("high")
|
expect(category.variant).toBe("high")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||||
expect(result!.promptAppend).toContain("VISUAL/UI")
|
expect(result!.promptAppend).toContain("VISUAL/UI")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
|
|||||||
const categoryName = "visual-engineering"
|
const categoryName = "visual-engineering"
|
||||||
const userCategories = {
|
const userCategories = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
prompt_append: "Custom instructions here",
|
prompt_append: "Custom instructions here",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
|
|||||||
const categoryName = "visual-engineering"
|
const categoryName = "visual-engineering"
|
||||||
const userCategories = {
|
const userCategories = {
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
model: "google/gemini-3-pro",
|
model: "google/gemini-3.1-pro",
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then - category's built-in model wins over inheritedModel
|
// then - category's built-in model wins over inheritedModel
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("systemDefaultModel is used as fallback when custom category has no model", () => {
|
test("systemDefaultModel is used as fallback when custom category has no model", () => {
|
||||||
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result!.config.model).toBe("google/gemini-3-pro")
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1738,7 +1738,7 @@ describe("sisyphus-task", () => {
|
|||||||
const mockClient = {
|
const mockClient = {
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
|
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/project" } }),
|
get: async () => ({ data: { directory: "/project" } }),
|
||||||
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
||||||
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
|
|||||||
const mockClient = {
|
const mockClient = {
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
|
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/project" } }),
|
get: async () => ({ data: { directory: "/project" } }),
|
||||||
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
||||||
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
|
|||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// when - artistry category (gemini-3-pro with high variant)
|
// when - artistry category (gemini-3.1-pro with high variant)
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
{
|
{
|
||||||
description: "Test artistry forced background",
|
description: "Test artistry forced background",
|
||||||
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
|
|||||||
// when resolveCategoryConfig is called
|
// when resolveCategoryConfig is called
|
||||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
||||||
|
|
||||||
// then should use category's built-in model (gemini-3-pro for visual-engineering)
|
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
|
||||||
expect(resolved).not.toBeNull()
|
expect(resolved).not.toBeNull()
|
||||||
expect(resolved!.model).toBe("google/gemini-3-pro")
|
expect(resolved!.model).toBe("google/gemini-3.1-pro")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("systemDefaultModel is used when no other model is available", () => {
|
test("systemDefaultModel is used when no other model is available", () => {
|
||||||
@@ -3522,7 +3522,7 @@ describe("sisyphus-task", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
|
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
|
||||||
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
|
// oracle fallback chain: gpt-5.2 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
|
||||||
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
|
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
|
||||||
expect(promptBody.model).toBeDefined()
|
expect(promptBody.model).toBeDefined()
|
||||||
expect(promptBody.model.providerID).toBe("openai")
|
expect(promptBody.model.providerID).toBe("openai")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
import { describe, expect, it } from "bun:test"
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { parsePatch } from "diff"
|
||||||
import { generateUnifiedDiff } from "./diff-utils"
|
import { generateUnifiedDiff } from "./diff-utils"
|
||||||
|
|
||||||
function createNumberedLines(totalLineCount: number): string {
|
function createNumberedLines(totalLineCount: number): string {
|
||||||
@@ -7,6 +8,66 @@ function createNumberedLines(totalLineCount: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("generateUnifiedDiff", () => {
|
describe("generateUnifiedDiff", () => {
|
||||||
|
describe("#given OpenCode compatibility format", () => {
|
||||||
|
it("#then includes the Index header emitted by diff library", () => {
|
||||||
|
//#given
|
||||||
|
const oldContent = "a\n"
|
||||||
|
const newContent = "b\n"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(diff).toContain("Index: test.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#then includes unified --- and +++ file headers", () => {
|
||||||
|
//#given
|
||||||
|
const oldContent = "a\n"
|
||||||
|
const newContent = "b\n"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(diff).toContain("--- test.ts")
|
||||||
|
expect(diff).toContain("+++ test.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#then remains parseable by OpenCode parsePatch flow", () => {
|
||||||
|
//#given
|
||||||
|
const oldContent = "line1\nline2\n"
|
||||||
|
const newContent = "line1\nline2-updated\n"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
|
||||||
|
const patches = parsePatch(diff)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(patches).toHaveLength(1)
|
||||||
|
expect(patches[0]?.oldFileName).toBe("test.ts")
|
||||||
|
expect(patches[0]?.newFileName).toBe("test.ts")
|
||||||
|
expect(patches[0]?.hunks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given content without trailing newline", () => {
|
||||||
|
it("#then keeps no-newline markers parseable", () => {
|
||||||
|
//#given
|
||||||
|
const oldContent = "a"
|
||||||
|
const newContent = "b"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const diff = generateUnifiedDiff(oldContent, newContent, "test.ts")
|
||||||
|
const patches = parsePatch(diff)
|
||||||
|
const hunkLines = patches[0]?.hunks[0]?.lines ?? []
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(diff).toContain("\\ No newline at end of file")
|
||||||
|
expect(hunkLines).toEqual(["-a", "\\ No newline at end of file", "+b", "\\ No newline at end of file"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("creates separate hunks for distant changes", () => {
|
it("creates separate hunks for distant changes", () => {
|
||||||
//#given
|
//#given
|
||||||
const oldContent = createNumberedLines(60)
|
const oldContent = createNumberedLines(60)
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
import { toNewLines } from "./edit-text-normalization"
|
import { toNewLines } from "./edit-text-normalization"
|
||||||
|
import { normalizeLineRef } from "./validation"
|
||||||
|
|
||||||
function normalizeEditPayload(payload: string | string[]): string {
|
function normalizeEditPayload(payload: string | string[]): string {
|
||||||
return toNewLines(payload).join("\n")
|
return toNewLines(payload).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalAnchor(anchor: string | undefined): string {
|
||||||
|
if (!anchor) return ""
|
||||||
|
return normalizeLineRef(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
function buildDedupeKey(edit: HashlineEdit): string {
|
function buildDedupeKey(edit: HashlineEdit): string {
|
||||||
switch (edit.op) {
|
switch (edit.op) {
|
||||||
case "replace":
|
case "replace":
|
||||||
return `replace|${edit.pos}|${edit.end ?? ""}|${normalizeEditPayload(edit.lines)}`
|
return `replace|${canonicalAnchor(edit.pos)}|${edit.end ? canonicalAnchor(edit.end) : ""}|${normalizeEditPayload(edit.lines)}`
|
||||||
case "append":
|
case "append":
|
||||||
return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
|
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
|
||||||
case "prepend":
|
case "prepend":
|
||||||
return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
|
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
|
||||||
default:
|
default:
|
||||||
return JSON.stringify(edit)
|
return JSON.stringify(edit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
import { describe, expect, it } from "bun:test"
|
||||||
import { applyHashlineEdits } from "./edit-operations"
|
import { applyHashlineEdits, applyHashlineEditsWithReport } from "./edit-operations"
|
||||||
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
|
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
|
||||||
import { computeLineHash } from "./hash-computation"
|
import { computeLineHash } from "./hash-computation"
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
@@ -389,3 +389,23 @@ describe("hashline edit operations", () => {
|
|||||||
expect(result).toEqual("replaced A\nline 3\nreplaced B")
|
expect(result).toEqual("replaced A\nline 3\nreplaced B")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("dedupe anchor canonicalization", () => {
|
||||||
|
it("deduplicates edits with whitespace-variant anchors", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2"
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const canonical = `1#${computeLineHash(1, lines[0])}`
|
||||||
|
const spaced = ` 1 # ${computeLineHash(1, lines[0])} `
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const report = applyHashlineEditsWithReport(content, [
|
||||||
|
{ op: "append", pos: canonical, lines: ["inserted"] },
|
||||||
|
{ op: "append", pos: spaced, lines: ["inserted"] },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(report.deduplicatedEdits).toBe(1)
|
||||||
|
expect(report.content).toBe("line 1\ninserted\nline 2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
|||||||
|
|
||||||
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
|
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
|
||||||
if (edits.length === 0) return false
|
if (edits.length === 0) return false
|
||||||
return edits.every((edit) => edit.op === "append" || edit.op === "prepend")
|
return edits.every((edit) => (edit.op === "append" || edit.op === "prepend") && !edit.pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSuccessMeta(
|
function buildSuccessMeta(
|
||||||
@@ -86,19 +86,19 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
const filePath = args.filePath
|
const filePath = args.filePath
|
||||||
const { delete: deleteMode, rename } = args
|
const { delete: deleteMode, rename } = args
|
||||||
|
|
||||||
|
if (deleteMode && rename) {
|
||||||
|
return "Error: delete and rename cannot be used together"
|
||||||
|
}
|
||||||
|
if (deleteMode && args.edits.length > 0) {
|
||||||
|
return "Error: delete mode requires edits to be an empty array"
|
||||||
|
}
|
||||||
|
|
||||||
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
|
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
|
||||||
return "Error: edits parameter must be a non-empty array"
|
return "Error: edits parameter must be a non-empty array"
|
||||||
}
|
}
|
||||||
|
|
||||||
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
|
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
|
||||||
|
|
||||||
if (deleteMode && rename) {
|
|
||||||
return "Error: delete and rename cannot be used together"
|
|
||||||
}
|
|
||||||
if (deleteMode && edits.length > 0) {
|
|
||||||
return "Error: delete mode requires edits to be an empty array"
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath)
|
||||||
const exists = await file.exists()
|
const exists = await file.exists()
|
||||||
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ WORKFLOW:
|
|||||||
VALIDATION:
|
VALIDATION:
|
||||||
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||||
Each edit must be one of: replace, append, prepend
|
Each edit must be one of: replace, append, prepend
|
||||||
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines"?: string|string[]|null }
|
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines": string|string[]|null }
|
||||||
lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||||
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
||||||
|
|
||||||
|
|||||||
@@ -341,4 +341,81 @@ describe("createHashlineEditTool", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(envelope.lineEnding).toBe("\r\n")
|
expect(envelope.lineEnding).toBe("\r\n")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("rejects delete=true with non-empty edits before normalization", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "delete-reject.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
delete: true,
|
||||||
|
edits: [{ op: "replace", pos: "1#ZZ", lines: "bad" }],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("delete mode requires edits to be an empty array")
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects delete=true combined with rename", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "delete-rename.txt")
|
||||||
|
fs.writeFileSync(filePath, "line1")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
delete: true,
|
||||||
|
rename: path.join(tempDir, "new-name.txt"),
|
||||||
|
edits: [],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("delete and rename cannot be used together")
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects missing file creation with anchored append", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "nonexistent.txt")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
edits: [{ op: "append", pos: "1#ZZ", lines: ["bad"] }],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toContain("File not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows missing file creation with unanchored append", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "newfile.txt")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
edits: [{ op: "append", lines: ["created"] }],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("created")
|
||||||
|
expect(result).toBe(`Updated ${filePath}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function createHashlineEditTool(): ToolDefinition {
|
|||||||
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
|
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
|
||||||
lines: tool.schema
|
lines: tool.schema
|
||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
|
.union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
|
||||||
.optional()
|
|
||||||
.describe("Replacement or inserted lines. null/[] deletes with replace"),
|
.describe("Replacement or inserted lines. null/[] deletes with replace"),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const MISMATCH_CONTEXT = 2
|
|||||||
|
|
||||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||||
|
|
||||||
function normalizeLineRef(ref: string): string {
|
export function normalizeLineRef(ref: string): string {
|
||||||
const originalTrimmed = ref.trim()
|
const originalTrimmed = ref.trim()
|
||||||
let trimmed = originalTrimmed
|
let trimmed = originalTrimmed
|
||||||
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user