Compare commits

...

39 Commits

Author SHA1 Message Date
github-actions[bot]
ae54fd31f4 release: v3.9.0 2026-02-26 19:30:38 +00:00
YeonGyu-Kim
bdd86b1415 fix(hephaestus): remove auto-commit policy to prevent surprise commits
The auto-commit section instructed Hephaestus to automatically commit after
implementation work. Users who didn't know about this behavior would get
surprise commits — a trust-breaking behavioral change flagged by 5 Oracle
reviews as the sole publish blocker for 3.9.0.
2026-02-27 04:27:07 +09:00
YeonGyu-Kim
76cba9b222 Merge pull request #2159 from code-yeongyu/fix/ralph-loop-completion-scoping
fix(ralph-loop): scope completion detection to messages since loop start
2026-02-27 03:23:46 +09:00
YeonGyu-Kim
2955dc868f Merge pull request #2158 from code-yeongyu/fix/hashline-diff-format-compat
test(hashline-edit): verify diff format compatibility with OpenCode UI
2026-02-27 03:23:43 +09:00
YeonGyu-Kim
3ab4b7f77b Merge pull request #2157 from code-yeongyu/fix/token-limiter-safe-truncation
fix(token-limiter): truncate at newline boundaries instead of raw slice
2026-02-27 03:23:40 +09:00
YeonGyu-Kim
3540d1c550 Merge pull request #2156 from code-yeongyu/fix/background-pending-notif-leak
fix(background-agent): clean pendingNotifications on session.deleted
2026-02-27 03:23:38 +09:00
YeonGyu-Kim
9bc9dcaa18 Merge pull request #2155 from code-yeongyu/fix/ultrawork-thinking-db-write
fix(ultrawork-db): write $.thinking alongside $.variant in deferred model override
2026-02-27 03:23:30 +09:00
YeonGyu-Kim
f2a1412bf1 test(ralph-loop): harden completion detector PluginInput mock 2026-02-27 03:12:22 +09:00
YeonGyu-Kim
190c6991ac fix(ralph-loop): persist session message count at loop start 2026-02-27 03:08:30 +09:00
YeonGyu-Kim
e17a00a906 fix(ralph-loop): scope completion detection to messages since loop start 2026-02-27 03:05:14 +09:00
YeonGyu-Kim
c8aa1bbce4 test(hashline-edit): add diff format compatibility tests 2026-02-27 03:02:49 +09:00
YeonGyu-Kim
911710e4d4 fix(token-limiter): truncate at newline boundaries instead of raw slice 2026-02-27 03:02:04 +09:00
YeonGyu-Kim
050b93bebb fix(background-agent): clean pendingNotifications on session.deleted 2026-02-27 03:00:39 +09:00
YeonGyu-Kim
2ffa803b05 fix(ultrawork-db): write $.thinking alongside $.variant in deferred model override 2026-02-27 02:59:22 +09:00
YeonGyu-Kim
cf97494073 Merge pull request #2154 from minpeter/feat/hashline-benchmark
fix(hashline-edit): harden deduplication, validation, and add benchmark suite
2026-02-27 02:14:12 +09:00
minpeter
8fb5949ac6 fix(benchmarks): address review feedback on error handling and validation
- headless.ts: emit error field on tool_result when output starts with Error:
- test-multi-model.ts: errored/timed-out models now shown as RED and exit(1)
- test-multi-model.ts: validate --timeout arg (reject NaN/negative)
- test-edge-cases.ts: use exact match instead of trim() for whitespace test
- test-edge-cases.ts: skip file pre-creation for create-via-append test

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:44:51 +09:00
minpeter
04f50bac1f feat(benchmarks): add hashline-edit test suites (46 tests)
Ported from code-editing-agent benchmark:
- test-edit-ops.ts: 21 basic edit operations (replace, append, prepend, delete, batch, range)
- test-edge-cases.ts: 25 edge cases (unicode, long lines, whitespace, special chars, file creation)
- test-multi-model.ts: multi-model comparison runner

Verified 21/21 + 25/25 (100%) with Minimax M2.5 via FriendliAI.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:37:49 +09:00
minpeter
d1a0a66dde feat(benchmarks): add hashline-edit benchmark agent and deps
Standalone headless agent using Vercel AI SDK v6 with FriendliAI provider.
Imports hashline-edit pure functions directly from src/ for benchmarking
the edit tool against LLMs (Minimax M2.5 via FriendliAI).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-27 01:37:40 +09:00
minpeter
b1203b9501 Fix hashline-edit deduplication and validation
- Canonicalize anchors in dedupe keys to handle whitespace variants
- Make lines field required in edit operations
- Only allow unanchored append/prepend to create missing files
- Reorder delete/rename validation to prevent edge cases
- Add allow_non_gpt_model and max_prompt_tokens to config schema
  ```
2026-02-27 01:37:19 +09:00
YeonGyu-Kim
58201220cc Merge pull request #2093 from code-yeongyu/fix/issue-1966-ultrawork-variant
fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
2026-02-26 23:23:14 +09:00
YeonGyu-Kim
4efad491e7 Merge pull request #2149 from code-yeongyu/fix/issue-1815-1733-prompt-token-count
fix(delegate-task): add token counting and truncation to prevent context overflow
2026-02-26 23:19:35 +09:00
YeonGyu-Kim
4df69c58bf fix(keyword-detector): respect ultrawork config variant instead of hardcoding "max"
Closes #1966
2026-02-26 23:15:32 +09:00
YeonGyu-Kim
cc8ef7fe39 ci: trigger CI 2026-02-26 23:14:33 +09:00
YeonGyu-Kim
2ece7c3d0a Merge pull request #1963 from MoerAI/fix/multi-issue-1888-1693-1891
fix: resolve issues #1888, #1693, #1891
2026-02-26 23:13:00 +09:00
YeonGyu-Kim
decff3152a Merge pull request #2145 from code-yeongyu/fix/issue-1915-windows-spawn-hide
fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
2026-02-26 23:12:57 +09:00
YeonGyu-Kim
0526bac873 Merge pull request #2148 from code-yeongyu/fix/issue-2121-legacy-hardware-baseline
fix(ci): add baseline CPU variant binaries for legacy hardware support
2026-02-26 21:09:19 +09:00
YeonGyu-Kim
0c62656cc6 Merge pull request #2146 from code-yeongyu/fix/issue-2065-1968-model-updates
fix(models): update Gemini 3→3.1 Pro and add Kimi K2.5 to writing category
2026-02-26 21:07:23 +09:00
YeonGyu-Kim
aff43bfc77 Merge pull request #2143 from code-yeongyu/fix/issue-2017-stop-continuation-cancel
fix(stop-continuation): wire backgroundManager to cancel running tasks on stop
2026-02-26 21:07:20 +09:00
YeonGyu-Kim
6865cee8ca Merge pull request #2141 from code-yeongyu/fix/issue-2084-ralph-loop-inflight
fix(ralph-loop): add inFlight guard and improve completion detection to prevent infinite loops
2026-02-26 21:07:17 +09:00
YeonGyu-Kim
8721ba471c Merge pull request #2140 from code-yeongyu/fix/issue-2025-blocked-todo-continuation
fix(todo-continuation): exclude blocked todos from incomplete count to prevent infinite loops
2026-02-26 21:06:55 +09:00
YeonGyu-Kim
d425f9bb80 fix(models): update Gemini 3 to 3.1 Pro and add Kimi to writing category fallback
Closes #2065

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

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

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

View File

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

View File

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

View File

@@ -24,19 +24,7 @@
"disabled_agents": {
"type": "array",
"items": {
"type": "string",
"enum": [
"sisyphus",
"hephaestus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"metis",
"momus",
"atlas"
]
"type": "string"
}
},
"disabled_skills": {

62
benchmarks/bun.lock Normal file
View 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
View 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
View 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"
}
}

File diff suppressed because it is too large Load Diff

808
benchmarks/test-edit-ops.ts Normal file
View 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();

View 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();

View File

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

14
bin/platform.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
export declare function getPlatformPackage(options: {
platform: string;
arch: string;
libcFamily?: string | null;
}): string;
export declare function getPlatformPackageCandidates(options: {
platform: string;
arch: string;
libcFamily?: string | null;
preferBaseline?: boolean;
}): string[];
export declare function getBinaryPath(pkg: string, platform: string): string;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"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",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -75,13 +75,17 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5"
"oh-my-opencode-darwin-arm64": "3.9.0",
"oh-my-opencode-darwin-x64": "3.9.0",
"oh-my-opencode-darwin-x64-baseline": "3.9.0",
"oh-my-opencode-linux-arm64": "3.9.0",
"oh-my-opencode-linux-arm64-musl": "3.9.0",
"oh-my-opencode-linux-x64": "3.9.0",
"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": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.5",
"version": "3.9.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.5",
"version": "3.9.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.5",
"version": "3.9.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.5",
"version": "3.9.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"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)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.5",
"version": "3.9.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ import {
categorizeTools,
} from "./dynamic-agent-prompt-builder";
const MODE: AgentMode = "primary";
const MODE: AgentMode = "all";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
@@ -448,21 +448,6 @@ ${oracleSection}
4. **Run build** if applicable — exit code 0 required
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
- **Build** — Exit code 0
- **Tests** — Pass (or pre-existing failures noted)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2987,6 +2987,28 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown()
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", () => {

View File

@@ -830,6 +830,8 @@ export class BackgroundManager {
tasksToCancel.set(descendant.id, descendant)
}
this.pendingNotifications.delete(sessionID)
if (tasksToCancel.size === 0) return
for (const task of tasksToCancel.values()) {
@@ -866,6 +868,13 @@ export class BackgroundManager {
subagentSessions.delete(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.parentSessionID) {
this.pendingNotifications.delete(task.parentSessionID)
}
}
SessionCategoryRegistry.remove(sessionID)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages(
promise: string
apiTimeoutMs: number
directory: string
sinceMessageIndex?: number
},
): Promise<boolean> {
try {
@@ -75,12 +76,17 @@ export async function detectCompletionInSessionMessages(
? 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
const pattern = buildPromisePattern(options.promise)
const recentAssistants = assistantMessages.slice(-3)
for (const assistant of recentAssistants) {
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
const assistant = assistantMessages[index]
if (!assistant.parts) continue
let responseText = ""

View File

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

View File

@@ -23,6 +23,7 @@ export function createLoopStateController(options: {
loopOptions?: {
maxIterations?: number
completionPromise?: string
messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
},
@@ -34,6 +35,7 @@ export function createLoopStateController(options: {
loopOptions?.maxIterations ??
config?.default_max_iterations ??
DEFAULT_MAX_ITERATIONS,
message_count_at_start: loopOptions?.messageCountAtStart,
completion_promise:
loopOptions?.completionPromise ??
DEFAULT_COMPLETION_PROMISE,
@@ -93,5 +95,19 @@ export function createLoopStateController(options: {
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
},
}
}

View File

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

View File

@@ -13,6 +13,7 @@ export interface RalphLoopHook {
options?: {
maxIterations?: number
completionPromise?: string
messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
}
@@ -23,6 +24,19 @@ export interface RalphLoopHook {
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(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -51,7 +65,25 @@ export function createRalphLoopHook(
return {
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,
getState: loopState.getState as () => RalphLoopState | null,
}

View File

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

View File

@@ -44,6 +44,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
active: isActive,
iteration: iterationNum,
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,
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
prompt: body.trim(),
@@ -72,13 +78,17 @@ export function writeState(
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\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 = `---
active: ${state.active}
iteration: ${state.iteration}
max_iterations: ${state.max_iterations}
completion_promise: "${state.completion_promise}"
started_at: "${state.started_at}"
${sessionIdLine}${ultraworkLine}${strategyLine}---
${sessionIdLine}${ultraworkLine}${strategyLine}${messageCountAtStartLine}---
${state.prompt}
`

View File

@@ -4,6 +4,7 @@ export interface RalphLoopState {
active: boolean
iteration: number
max_iterations: number
message_count_at_start?: number
completion_promise: string
started_at: string
prompt: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { hasUnansweredQuestion } from "./pending-question-detection"
describe("hasUnansweredQuestion", () => {
test("given empty messages, returns false", () => {
expect(hasUnansweredQuestion([])).toBe(false)
})
test("given null-ish input, returns false", () => {
expect(hasUnansweredQuestion(undefined as never)).toBe(false)
})
test("given last assistant message with question tool_use, returns true", () => {
const messages = [
{ info: { role: "user" } },
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "question" },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
test("given last assistant message with question tool-invocation, returns true", () => {
const messages = [
{ info: { role: "user" } },
{
info: { role: "assistant" },
parts: [
{ type: "tool-invocation", toolName: "question" },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
test("given user message after question (answered), returns false", () => {
const messages = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "question" },
],
},
{ info: { role: "user" } },
]
expect(hasUnansweredQuestion(messages)).toBe(false)
})
test("given assistant message with non-question tool, returns false", () => {
const messages = [
{ info: { role: "user" } },
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "bash" },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(false)
})
test("given assistant message with no parts, returns false", () => {
const messages = [
{ info: { role: "user" } },
{ info: { role: "assistant" } },
]
expect(hasUnansweredQuestion(messages)).toBe(false)
})
test("given role on message directly (not in info), returns true for question", () => {
const messages = [
{ role: "user" },
{
role: "assistant",
parts: [
{ type: "tool_use", name: "question" },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
test("given mixed tools including question, returns true", () => {
const messages = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "bash" },
{ type: "tool_use", name: "question" },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
interface MessagePart {
type: string
name?: string
toolName?: string
}
interface Message {
info?: { role?: string }
role?: string
parts?: MessagePart[]
}
export function hasUnansweredQuestion(messages: Message[]): boolean {
if (!messages || messages.length === 0) return false
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const role = msg.info?.role ?? msg.role
if (role === "user") return false
if (role === "assistant" && msg.parts) {
const hasQuestion = msg.parts.some(
(part) =>
(part.type === "tool_use" || part.type === "tool-invocation") &&
(part.name === "question" || part.toolName === "question"),
)
if (hasQuestion) {
log(`[${HOOK_NAME}] Detected pending question tool in last assistant message`)
return true
}
return false
}
}
return false
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,8 +23,8 @@ function tryUpdateMessageModel(
if (result.changes === 0) return false
if (variant) {
db.prepare(
`UPDATE message SET data = json_set(data, '$.variant', ?) WHERE id = ?`,
).run(variant, messageId)
`UPDATE message SET data = json_set(data, '$.variant', ?, '$.thinking', ?) WHERE id = ?`,
).run(variant, variant, messageId)
}
return true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { Readable } from "node:stream"
export interface SpawnOptions {
cwd?: string
env?: Record<string, string | undefined>
stdin?: "pipe" | "inherit" | "ignore"
stdout?: "pipe" | "inherit" | "ignore"
stderr?: "pipe" | "inherit" | "ignore"
}
export interface SpawnedProcess {
readonly exitCode: number | null
readonly exited: Promise<number>
readonly stdout: ReadableStream<Uint8Array> | undefined
readonly stderr: ReadableStream<Uint8Array> | undefined
kill(signal?: NodeJS.Signals): void
}
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
if (!stream) {
return undefined
}
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
}
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
let resolveExited: (exitCode: number) => void
let exitCode: number | null = null
const exited = new Promise<number>((resolve) => {
resolveExited = resolve
})
proc.on("exit", (code) => {
exitCode = code ?? 1
resolveExited(exitCode)
})
proc.on("error", () => {
if (exitCode === null) {
exitCode = 1
resolveExited(1)
}
})
return {
get exitCode() {
return exitCode
},
exited,
stdout: toReadableStream(proc.stdout),
stderr: toReadableStream(proc.stderr),
kill(signal?: NodeJS.Signals): void {
try {
if (!signal) {
proc.kill()
return
}
proc.kill(signal)
} catch {}
},
}
}
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
if (process.platform !== "win32") {
return bunSpawn(command, options)
}
const [cmd, ...args] = command
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
windowsHide: true,
shell: true,
})
return wrapNodeProcess(proc)
}

View File

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

View File

@@ -19,6 +19,8 @@ import {
truncateToTokenBudget,
} from "./token-limiter"
const TRUNCATION_MARKER_TOKEN_OVERHEAD = estimateTokenCount("\n[TRUNCATED]")
describe("token-limiter", () => {
test("estimateTokenCount uses 1 token per 4 chars approximation", () => {
// given
@@ -40,7 +42,62 @@ describe("token-limiter", () => {
const result = truncateToTokenBudget(content, maxTokens)
// 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", () => {
@@ -76,10 +133,10 @@ describe("token-limiter", () => {
const result = buildSystemContentWithTokenLimit(input, 80)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).toContain("CATEGORY_APPEND:keep")
expect(result).toContain("AGENTS_C")
expect(result).toContain("CATE")
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", () => {
@@ -95,9 +152,9 @@ describe("token-limiter", () => {
const result = buildSystemContentWithTokenLimit(input, 30)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).toContain("AGENTS_C")
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", () => {
@@ -116,6 +173,6 @@ describe("token-limiter", () => {
expect(result).toContain("AGENTS_CONTEXT:")
expect(result).not.toContain("SKILL_ALPHA:")
expect(result).not.toContain("CATEGORY_APPEND:")
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10)
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10 + TRUNCATION_MARKER_TOKEN_OVERHEAD)
})
})

View File

@@ -20,7 +20,13 @@ export function truncateToTokenBudget(content: string, maxTokens: number): strin
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 {

View File

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

View File

@@ -1,5 +1,6 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { parsePatch } from "diff"
import { generateUnifiedDiff } from "./diff-utils"
function createNumberedLines(totalLineCount: number): string {
@@ -7,6 +8,66 @@ function createNumberedLines(totalLineCount: number): string {
}
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", () => {
//#given
const oldContent = createNumberedLines(60)

View File

@@ -1,18 +1,24 @@
import type { HashlineEdit } from "./types"
import { toNewLines } from "./edit-text-normalization"
import { normalizeLineRef } from "./validation"
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function canonicalAnchor(anchor: string | undefined): string {
if (!anchor) return ""
return normalizeLineRef(anchor)
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.op) {
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":
return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
return `append|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
case "prepend":
return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
return `prepend|${canonicalAnchor(edit.pos)}|${normalizeEditPayload(edit.lines)}`
default:
return JSON.stringify(edit)
}

View File

@@ -1,5 +1,5 @@
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 { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
@@ -389,3 +389,23 @@ describe("hashline edit operations", () => {
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")
})
})

View File

@@ -33,7 +33,7 @@ function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
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(
@@ -86,19 +86,19 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
const filePath = args.filePath
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)) {
return "Error: edits parameter must be a non-empty array"
}
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 exists = await file.exists()
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {

View File

@@ -10,7 +10,7 @@ WORKFLOW:
VALIDATION:
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
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)
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.

View File

@@ -341,4 +341,81 @@ describe("createHashlineEditTool", () => {
//#then
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}`)
})
})

View File

@@ -31,7 +31,6 @@ export function createHashlineEditTool(): ToolDefinition {
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
lines: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
.optional()
.describe("Replacement or inserted lines. null/[] deletes with replace"),
})
)

View File

@@ -15,7 +15,7 @@ const MISMATCH_CONTEXT = 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()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")

Some files were not shown because too many files have changed in this diff Show More