Compare commits
66 Commits
feat/omx-s
...
fix/suppor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ca8bc934 | ||
|
|
55ac653eaa | ||
|
|
1d5652dfa9 | ||
|
|
76c460536d | ||
|
|
b067d4a284 | ||
|
|
94838ec039 | ||
|
|
224ecea8c7 | ||
|
|
5d5755f29d | ||
|
|
1fdce01fd2 | ||
|
|
c8213c970e | ||
|
|
576ff453e5 | ||
|
|
9b8aca45f9 | ||
|
|
f1f20f5a79 | ||
|
|
de40caf76d | ||
|
|
d80833896c | ||
|
|
d50c38f037 | ||
|
|
f2d5f4ca92 | ||
|
|
b788586caf | ||
|
|
90351e442e | ||
|
|
4ad88b2576 | ||
|
|
2ce69710e3 | ||
|
|
0b4d092cf6 | ||
|
|
53285617d3 | ||
|
|
ae3befbfbe | ||
|
|
dc1a05ac3e | ||
|
|
e271b4a1b0 | ||
|
|
fee938d63a | ||
|
|
4d74d888e4 | ||
|
|
4bc7b1d27c | ||
|
|
78dac0642e | ||
|
|
92bc72a90b | ||
|
|
a7301ba8a9 | ||
|
|
e9887dd82f | ||
|
|
c0082d8a09 | ||
|
|
fbc3b4e230 | ||
|
|
1f7fdb43ba | ||
|
|
566031f4fa | ||
|
|
0cf386ec52 | ||
|
|
d493f9ec3a | ||
|
|
2c7ded2433 | ||
|
|
82c7807a4f | ||
|
|
df7e1ae16d | ||
|
|
0471078006 | ||
|
|
1070b9170f | ||
|
|
bb312711cf | ||
|
|
c31facf41e | ||
|
|
de66f1f397 | ||
|
|
427fa6d7a2 | ||
|
|
239da8b02a | ||
|
|
17244e2c84 | ||
|
|
24a0f7b032 | ||
|
|
fc48df1d53 | ||
|
|
4759dfb654 | ||
|
|
2c8813e95d | ||
|
|
8213534e87 | ||
|
|
450685f5ea | ||
|
|
03b346ba51 | ||
|
|
84fb1113f1 | ||
|
|
90decd1fd4 | ||
|
|
47d1ad7bb9 | ||
|
|
32a296bf1e | ||
|
|
67bb9ec1e2 | ||
|
|
d57c27feee | ||
|
|
1339ecdd13 | ||
|
|
3055454ecc | ||
|
|
11e9276498 |
98
.github/workflows/publish-platform.yml
vendored
98
.github/workflows/publish-platform.yml
vendored
@@ -59,20 +59,39 @@ jobs:
|
||||
- name: Check if already published
|
||||
id: check
|
||||
run: |
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
# Convert platform name for output (replace - with _)
|
||||
PLATFORM_KEY="${{ matrix.platform }}"
|
||||
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
|
||||
# Check oh-my-opencode
|
||||
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
|
||||
# Check oh-my-openagent
|
||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
||||
|
||||
echo "oh-my-opencode-${{ matrix.platform }}@${VERSION}: ${OC_STATUS}"
|
||||
echo "oh-my-openagent-${{ matrix.platform }}@${VERSION}: ${OA_STATUS}"
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
echo "→ oh-my-opencode-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
echo "→ oh-my-openagent-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
# Skip build only if BOTH are already published
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
- name: Update version in package.json
|
||||
@@ -207,23 +226,38 @@ jobs:
|
||||
matrix:
|
||||
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 oh-my-opencode already published
|
||||
- name: Check if already published
|
||||
id: check
|
||||
run: |
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
|
||||
|
||||
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
|
||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} will be published"
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Need artifact if either package needs publishing
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_all=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip_all=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download artifact
|
||||
id: download
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
if: steps.check.outputs.skip_all != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -231,7 +265,7 @@ jobs:
|
||||
path: .
|
||||
|
||||
- name: Extract artifact
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
mkdir -p packages/${PLATFORM}
|
||||
@@ -247,13 +281,13 @@ jobs:
|
||||
ls -la packages/${PLATFORM}/bin/
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish ${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
- name: Publish oh-my-opencode-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
cd packages/${{ matrix.platform }}
|
||||
|
||||
@@ -267,3 +301,25 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Publish oh-my-openagent-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
cd packages/${{ matrix.platform }}
|
||||
|
||||
# Rename package for oh-my-openagent
|
||||
jq --arg name "oh-my-openagent-${{ matrix.platform }}" \
|
||||
--arg desc "Platform-specific binary for oh-my-openagent (${{ matrix.platform }})" \
|
||||
'.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \
|
||||
package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
TAG_ARG=""
|
||||
if [ -n "${{ inputs.dist_tag }}" ]; then
|
||||
TAG_ARG="--tag ${{ inputs.dist_tag }}"
|
||||
fi
|
||||
|
||||
npm publish --access public --provenance $TAG_ARG
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
timeout-minutes: 15
|
||||
|
||||
42
.github/workflows/publish.yml
vendored
42
.github/workflows/publish.yml
vendored
@@ -216,6 +216,48 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: Check if oh-my-openagent already published
|
||||
id: check-openagent
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Publish oh-my-openagent
|
||||
if: steps.check-openagent.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# Update package name, version, and optionalDependencies for oh-my-openagent
|
||||
jq --arg v "$VERSION" '
|
||||
.name = "oh-my-openagent" |
|
||||
.version = $v |
|
||||
.optionalDependencies = (
|
||||
.optionalDependencies | to_entries |
|
||||
map(.key = (.key | sub("^oh-my-opencode-"; "oh-my-openagent-")) | .value = $v) |
|
||||
from_entries
|
||||
)
|
||||
' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
TAG_ARG=""
|
||||
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then
|
||||
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}"
|
||||
fi
|
||||
npm publish --access public --provenance $TAG_ARG || echo "::warning::oh-my-openagent publish failed"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: Restore package.json
|
||||
if: steps.check-openagent.outputs.skip != 'true'
|
||||
run: |
|
||||
git checkout -- package.json
|
||||
|
||||
trigger-platform:
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-main
|
||||
|
||||
122
FIX-BLOCKS.md
Normal file
122
FIX-BLOCKS.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Pre-Publish BLOCK Issues: Fix ALL Before Release
|
||||
|
||||
Two independent pre-publish reviews (Opus 4.6 + GPT-5.4) both concluded **BLOCK -- do not publish**. You must fix ALL blocking issues below using UltraBrain parallel agents. Work TDD-style: write/update tests first, then fix, verify tests pass.
|
||||
|
||||
## Strategy
|
||||
|
||||
Use ultrawork (ulw) to spawn UltraBrain agents in parallel. Each UB agent gets a non-overlapping scope. After all agents complete, run bun test to verify everything passes. Commit atomically per fix group.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL BLOCKERS (must fix -- 6 items)
|
||||
|
||||
### C1: Hashline Backward Compatibility
|
||||
**Problem:** Strict whitespace hashing in hashline changes LINE#ID values for indented lines. Breaks existing anchors in cached/persisted edit operations.
|
||||
**Fix:** Add a compatibility shim -- when lookup by new hash fails, fall back to legacy hash (without strict whitespace). Or version the hash format.
|
||||
**Files:** Look for hashline-related files in src/tools/ or src/shared/
|
||||
|
||||
### C2: OpenAI-Only Model Catalog Broken with OpenCode-Go
|
||||
**Problem:** isOpenAiOnlyAvailability() does not exclude availability.opencodeGo. When OpenCode-Go is present, OpenAI-only detection is wrong -- models get misrouted.
|
||||
**Fix:** Add !availability.opencodeGo check to isOpenAiOnlyAvailability().
|
||||
**Files:** Model/provider system files -- search for isOpenAiOnlyAvailability
|
||||
|
||||
### C3: CLI/Runtime Model Table Divergence
|
||||
**Problem:** Model tables disagree between CLI install-time and runtime:
|
||||
- ultrabrain: gpt-5.3-codex in CLI vs gpt-5.4 in runtime
|
||||
- atlas: claude-sonnet-4-5 in CLI vs claude-sonnet-4-6 in runtime
|
||||
- unspecified-high also diverges
|
||||
**Fix:** Reconcile all model tables. Pick the correct model for each and make CLI + runtime match.
|
||||
**Files:** Search for model table definitions, agent configs, CLI model references
|
||||
|
||||
### C4: atlas/metis/sisyphus-junior Missing OpenAI Fallbacks
|
||||
**Problem:** These agents can resolve to opencode/glm-4.7-free or undefined in OpenAI-only environments. No valid OpenAI fallback paths exist.
|
||||
**Fix:** Add valid OpenAI model fallback paths for all agents that need them.
|
||||
**Files:** Agent config/model resolution code
|
||||
|
||||
### C5: model_fallback Default Mismatch
|
||||
**Problem:** Schema and docs say model_fallback defaults to false, but runtime treats unset as true. Silent behavior change for all users.
|
||||
**Fix:** Align -- either update schema/docs to say true, or fix runtime to default to false. Check what the intended behavior is from git history.
|
||||
**Files:** Schema definition, runtime config loading
|
||||
|
||||
### C6: background_output Default Changed
|
||||
**Problem:** background_output now defaults to full_session=true. Old callers get different output format without code changes.
|
||||
**Fix:** Either document this change clearly, or restore old default and make full_session opt-in.
|
||||
**Files:** Background output handling code
|
||||
|
||||
---
|
||||
|
||||
## HIGH PRIORITY (strongly recommended -- 4 items)
|
||||
|
||||
### H1: Runtime Fallback session-status-handler Race
|
||||
**Problem:** When fallback model is already pending, the handler cannot advance the chain on subsequent cooldown events.
|
||||
**Fix:** Allow override like message-update-handler does.
|
||||
**Files:** Search for session-status-handler, message-update-handler
|
||||
|
||||
### H2: Atlas Final-Wave Approval Gate Logic
|
||||
**Problem:** Approval gate logic does not match real Prometheus plan structure (nested checkboxes, parallel execution). Trigger logic is wrong.
|
||||
**Fix:** Update to handle real plan structures.
|
||||
**Files:** Atlas agent code, approval gate logic
|
||||
|
||||
### H3: delegate-task-english-directive Dead Code
|
||||
**Problem:** Not dispatched from tool-execute-before.ts + wrong hook signature. Either wire properly or remove entirely.
|
||||
**Fix:** Remove if not needed (cleaner). If needed, fix dispatch + signature.
|
||||
**Files:** src/hooks/, tool-execute-before.ts
|
||||
|
||||
### H4: Auto-Slash-Command Session-Lifetime Dedup
|
||||
**Problem:** Dedup uses session lifetime, suppressing legitimate repeated identical commands.
|
||||
**Fix:** Change to short TTL (e.g., 30 seconds) instead of session lifetime.
|
||||
**Files:** Slash command handling code
|
||||
|
||||
---
|
||||
|
||||
## ADDITIONAL BLOCKERS FROM GPT-5.4 REVIEW
|
||||
|
||||
### G1: Package Identity Split-Brain
|
||||
**Problem:** Installer writes oh-my-openagent but doctor, auto-update, version lookup, publish workflow still reference oh-my-opencode. Half-migrated state.
|
||||
**Fix:** Audit ALL references to package name. Either complete the migration consistently or revert to single name for this release.
|
||||
**Files:** Installer, doctor, auto-update, version lookup, publish workflow -- grep for both package names
|
||||
|
||||
### G2: OpenCode-Go --opencode-go Value Validation
|
||||
**Problem:** No validation for --opencode-go CLI value. No detection of existing OpenCode-Go installations.
|
||||
**Fix:** Add value validation + existing install detection.
|
||||
**Files:** CLI option handling code
|
||||
|
||||
### G3: Skill/Hook Reference Errors
|
||||
**Problem:**
|
||||
- work-with-pr references non-existent git tool category
|
||||
- github-triage references TaskCreate/TaskUpdate which are not real tool names
|
||||
**Fix:** Fix tool references to use actual tool names.
|
||||
**Files:** Skill definition files in .opencode/skills/
|
||||
|
||||
### G4: Stale Context-Limit Cache
|
||||
**Problem:** Shared context-limit resolver caches provider config. When config changes, stale removed limits persist and corrupt compaction/truncation decisions.
|
||||
**Fix:** Add cache invalidation when provider config changes, or make the resolver stateless.
|
||||
**Files:** Context-limit resolver, compaction code
|
||||
|
||||
### G5: disabled_hooks Schema vs Runtime Contract Mismatch
|
||||
**Problem:** Schema is strict (rejects unknown hook names) but runtime is permissive (ignores unknown). Contract disagreement.
|
||||
**Fix:** Align -- either make both strict or both permissive.
|
||||
**Files:** Hook schema definition, runtime hook loading
|
||||
|
||||
---
|
||||
|
||||
## EXECUTION INSTRUCTIONS
|
||||
|
||||
1. Spawn UltraBrain agents to fix these in parallel -- group by file proximity:
|
||||
- UB-1: C1 (hashline) + H4 (slash-command dedup)
|
||||
- UB-2: C2 + C3 + C4 (model/provider system) + G2
|
||||
- UB-3: C5 + C6 (config defaults) + G5
|
||||
- UB-4: H1 + H2 (runtime handlers + Atlas gate)
|
||||
- UB-5: H3 + G3 (dead code + skill references)
|
||||
- UB-6: G1 (package identity -- full audit)
|
||||
- UB-7: G4 (context-limit cache)
|
||||
|
||||
2. Each UB agent MUST:
|
||||
- Write or update tests FIRST (TDD)
|
||||
- Implement the fix
|
||||
- Run bun test on affected test files
|
||||
- Commit with descriptive message
|
||||
|
||||
3. After all UB agents complete, run full bun test to verify no regressions.
|
||||
|
||||
ulw
|
||||
@@ -1,9 +1,3 @@
|
||||
> [!WARNING]
|
||||
> **TEMP NOTICE (This Week): Reduced Maintainer Availability**
|
||||
>
|
||||
> Core maintainer Q got injured, so issue/PR responses and releases may be delayed this week.
|
||||
> Thank you for your patience and support.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> [](https://sisyphuslabs.ai)
|
||||
|
||||
@@ -43,57 +43,7 @@
|
||||
"disabled_hooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"gpt-permission-continuation",
|
||||
"todo-continuation-enforcer",
|
||||
"context-window-monitor",
|
||||
"session-recovery",
|
||||
"session-notification",
|
||||
"comment-checker",
|
||||
"tool-output-truncator",
|
||||
"question-label-truncator",
|
||||
"directory-agents-injector",
|
||||
"directory-readme-injector",
|
||||
"empty-task-response-detector",
|
||||
"think-mode",
|
||||
"model-fallback",
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"preemptive-compaction",
|
||||
"rules-injector",
|
||||
"background-notification",
|
||||
"auto-update-checker",
|
||||
"startup-toast",
|
||||
"keyword-detector",
|
||||
"agent-usage-reminder",
|
||||
"non-interactive-env",
|
||||
"interactive-bash-session",
|
||||
"thinking-block-validator",
|
||||
"ralph-loop",
|
||||
"category-skill-reminder",
|
||||
"compaction-context-injector",
|
||||
"compaction-todo-preserver",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
"json-error-recovery",
|
||||
"delegate-task-retry",
|
||||
"prometheus-md-only",
|
||||
"sisyphus-junior-notepad",
|
||||
"no-sisyphus-gpt",
|
||||
"no-hephaestus-non-gpt",
|
||||
"start-work",
|
||||
"atlas",
|
||||
"unstable-agent-babysitter",
|
||||
"task-resume-info",
|
||||
"stop-continuation-guard",
|
||||
"tasks-todowrite-disabler",
|
||||
"runtime-fallback",
|
||||
"write-existing-file-guard",
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer"
|
||||
]
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disabled_commands": {
|
||||
@@ -3749,6 +3699,35 @@
|
||||
"syncPollTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 60000
|
||||
},
|
||||
"maxToolCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxToolCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"windowSize": {
|
||||
"type": "integer",
|
||||
"minimum": 5,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"repetitionThresholdPercent": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -3927,4 +3906,4 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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": {
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ These agents have Claude-optimized prompts — long, detailed, mechanics-driven.
|
||||
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| ------------ | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi/GLM as intermediate fallbacks. |
|
||||
| **Metis** | Plan gap analyzer | Claude Opus → opencode-go/glm-5 → K2P5 | Claude preferred. Uses opencode-go for reliable GLM-5 access. |
|
||||
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → Kimi K2.5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi available through multiple providers. |
|
||||
| **Metis** | Plan gap analyzer | Claude Opus → GPT-5.4 → opencode-go/glm-5 → K2P5 | Claude preferred. GPT-5.4 as secondary before GLM-5 fallback. |
|
||||
|
||||
### Dual-Prompt Agents → Claude preferred, GPT supported
|
||||
|
||||
@@ -74,7 +74,7 @@ These agents ship separate prompts for Claude and GPT families. They auto-detect
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| -------------- | ----------------- | -------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **Prometheus** | Strategic planner | Claude Opus → GPT-5.4 → opencode-go/glm-5 → Gemini 3.1 Pro | Interview-mode planning. GPT prompt is compact and principle-driven. |
|
||||
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 | Claude first, opencode-go as the current fallback path. |
|
||||
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 | Claude first, opencode-go as intermediate, GPT-5.4 as last resort. |
|
||||
|
||||
### Deep Specialists → GPT
|
||||
|
||||
@@ -82,9 +82,9 @@ These agents are built for GPT's principle-driven style. Their prompts assume au
|
||||
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| -------------- | ----------------------- | -------------------------------------- | ------------------------------------------------ |
|
||||
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex only | No fallback. Requires GPT access. The craftsman. |
|
||||
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus | Read-only high-IQ consultation. |
|
||||
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro | Verification and plan review. |
|
||||
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex → GPT-5.4 (Copilot) | Requires GPT access. GPT-5.4 via Copilot as fallback. The craftsman. |
|
||||
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 | Read-only high-IQ consultation. |
|
||||
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro → opencode-go/glm-5 | Verification and plan review. GPT-5.4 uses xhigh variant. |
|
||||
|
||||
### Utility Runners → Speed over Intelligence
|
||||
|
||||
@@ -95,6 +95,7 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
|
||||
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
|
||||
| **Librarian** | Docs/code search | opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
|
||||
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
|
||||
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
|
||||
|
||||
---
|
||||
|
||||
@@ -119,8 +120,7 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
|
||||
| Model | Strengths |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. |
|
||||
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle. |
|
||||
| **GPT-5.4** | Strong principle-driven reasoning. Default for Momus and a key fallback for Prometheus / Atlas. |
|
||||
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle, Momus, and a key fallback for Prometheus / Atlas. Uses xhigh variant for Momus. |
|
||||
| **GPT-5-Nano** | Ultra-cheap, fast. Good for simple utility tasks. |
|
||||
|
||||
### Other Models
|
||||
@@ -166,14 +166,14 @@ When agents delegate work, they don't pick a model name — they pick a **catego
|
||||
|
||||
| Category | When Used | Fallback Chain |
|
||||
| -------------------- | -------------------------- | -------------------------------------------- |
|
||||
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus |
|
||||
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus |
|
||||
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus → opencode-go/glm-5 → K2P5 |
|
||||
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 |
|
||||
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
|
||||
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
|
||||
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → GPT-5-Nano |
|
||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 (high) → GLM 5 → K2P5 |
|
||||
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → Gemini Flash |
|
||||
| `writing` | Text, docs, prose | Gemini Flash → Claude Sonnet |
|
||||
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
|
||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
|
||||
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash |
|
||||
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |
|
||||
|
||||
See the [Orchestration System Guide](./orchestration.md) for how agents dispatch tasks to categories.
|
||||
|
||||
|
||||
@@ -2207,6 +2207,38 @@
|
||||
"created_at": "2026-03-16T04:55:10Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2604
|
||||
},
|
||||
{
|
||||
"name": "gxlife",
|
||||
"id": 110413359,
|
||||
"comment_id": 4068427047,
|
||||
"created_at": "2026-03-16T15:17:01Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2625
|
||||
},
|
||||
{
|
||||
"name": "HaD0Yun",
|
||||
"id": 102889891,
|
||||
"comment_id": 4073195308,
|
||||
"created_at": "2026-03-17T08:27:45Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2640
|
||||
},
|
||||
{
|
||||
"name": "tad-hq",
|
||||
"id": 213478119,
|
||||
"comment_id": 4077697128,
|
||||
"created_at": "2026-03-17T20:07:09Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2655
|
||||
},
|
||||
{
|
||||
"name": "ogormans-deptstack",
|
||||
"id": 208788555,
|
||||
"comment_id": 4077893096,
|
||||
"created_at": "2026-03-17T20:42:42Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2656
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,60 +5,60 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -83,7 +83,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
@@ -145,7 +145,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
@@ -366,20 +366,20 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
@@ -389,7 +389,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
@@ -426,20 +426,20 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
@@ -449,7 +449,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
@@ -465,7 +465,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
@@ -929,7 +929,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -938,45 +938,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -987,7 +987,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -996,45 +996,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1273,7 +1273,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import type { ConfigMergeResult } from "../types"
|
||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||
@@ -7,8 +8,6 @@ import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
ensureConfigDirectoryExists()
|
||||
@@ -21,7 +20,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion, PACKAGE_NAME)
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
|
||||
|
||||
try {
|
||||
if (format === "none") {
|
||||
@@ -41,13 +40,24 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
|
||||
const config = parseResult.config
|
||||
const plugins = config.plugin ?? []
|
||||
const existingIndex = plugins.findIndex((plugin) => plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`))
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (plugins[existingIndex] === pluginEntry) {
|
||||
// Check for existing plugin (either current or legacy name)
|
||||
const currentNameIndex = plugins.findIndex(
|
||||
(plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`)
|
||||
)
|
||||
const legacyNameIndex = plugins.findIndex(
|
||||
(plugin) => plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
)
|
||||
|
||||
// If either name exists, update to new name
|
||||
if (currentNameIndex !== -1) {
|
||||
if (plugins[currentNameIndex] === pluginEntry) {
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
plugins[existingIndex] = pluginEntry
|
||||
plugins[currentNameIndex] = pluginEntry
|
||||
} else if (legacyNameIndex !== -1) {
|
||||
// Upgrade legacy name to new name
|
||||
plugins[legacyNameIndex] = pluginEntry
|
||||
} else {
|
||||
plugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ type BunInstallOutputMode = "inherit" | "pipe"
|
||||
|
||||
interface RunBunInstallOptions {
|
||||
outputMode?: BunInstallOutputMode
|
||||
/** Workspace directory to install to. Defaults to cache dir if not provided. */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
interface BunInstallOutput {
|
||||
@@ -65,7 +67,7 @@ function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: Bu
|
||||
|
||||
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
|
||||
const outputMode = options?.outputMode ?? "pipe"
|
||||
const cacheDir = getOpenCodeCacheDir()
|
||||
const cacheDir = options?.workspaceDir ?? getOpenCodeCacheDir()
|
||||
const packageJsonPath = `${cacheDir}/package.json`
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { parseJsonc } from "../../shared"
|
||||
import { parseJsonc, LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared"
|
||||
import type { DetectedConfig } from "../types"
|
||||
import { getOmoConfigPath } from "./config-context"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
@@ -55,8 +55,12 @@ function detectProvidersFromOmoConfig(): {
|
||||
}
|
||||
}
|
||||
|
||||
function isOurPlugin(plugin: string): boolean {
|
||||
return plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) ||
|
||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
}
|
||||
|
||||
export function detectCurrentConfig(): DetectedConfig {
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const result: DetectedConfig = {
|
||||
isInstalled: false,
|
||||
hasClaude: true,
|
||||
@@ -82,7 +86,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
const openCodeConfig = parseResult.config
|
||||
const plugins = openCodeConfig.plugin ?? []
|
||||
result.isInstalled = plugins.some((plugin) => plugin.startsWith(PACKAGE_NAME))
|
||||
result.isInstalled = plugins.some(isOurPlugin)
|
||||
|
||||
if (!result.isInstalled) {
|
||||
return result
|
||||
|
||||
@@ -52,6 +52,30 @@ describe("detectCurrentConfig - single package detection", () => {
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("detects oh-my-openagent as installed (legacy name)", () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("detects oh-my-openagent with version pin as installed (legacy name)", () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent@3.11.0"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when plugin not present", () => {
|
||||
// given
|
||||
const config = { plugin: ["some-other-plugin"] }
|
||||
@@ -64,6 +88,18 @@ describe("detectCurrentConfig - single package detection", () => {
|
||||
expect(result.isInstalled).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when plugin not present (even with similar name)", () => {
|
||||
// given - not exactly oh-my-openagent
|
||||
const config = { plugin: ["oh-my-openagent-extra"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(false)
|
||||
})
|
||||
|
||||
it("detects OpenCode Go from the existing omo config", () => {
|
||||
// given
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
|
||||
@@ -130,6 +166,38 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-opencode@3.10.0")
|
||||
})
|
||||
|
||||
it("recognizes oh-my-openagent as already installed (legacy name)", async () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
// Should upgrade to new name
|
||||
expect(savedConfig.plugin).toContain("oh-my-opencode")
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
|
||||
})
|
||||
|
||||
it("replaces version-pinned oh-my-openagent@X.Y.Z with new name", async () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent@3.10.0"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
// Legacy should be replaced with new name
|
||||
expect(savedConfig.plugin).toContain("oh-my-opencode")
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
|
||||
})
|
||||
|
||||
it("adds new plugin when none exists", async () => {
|
||||
// given
|
||||
const config = {}
|
||||
|
||||
@@ -9,8 +9,11 @@ import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
import { getModelResolutionInfoWithOverrides } from "./model-resolution"
|
||||
import type { OmoConfig } from "./model-resolution-types"
|
||||
|
||||
const PACKAGE_NAME_ALT = "oh-my-openagent"
|
||||
const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME)
|
||||
const USER_CONFIG_BASE_ALT = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME_ALT)
|
||||
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
|
||||
const PROJECT_CONFIG_BASE_ALT = join(process.cwd(), ".opencode", PACKAGE_NAME_ALT)
|
||||
|
||||
interface ConfigValidationResult {
|
||||
exists: boolean
|
||||
@@ -24,9 +27,15 @@ function findConfigPath(): string | null {
|
||||
const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
|
||||
if (projectConfig.format !== "none") return projectConfig.path
|
||||
|
||||
const projectConfigAlt = detectConfigFile(PROJECT_CONFIG_BASE_ALT)
|
||||
if (projectConfigAlt.format !== "none") return projectConfigAlt.path
|
||||
|
||||
const userConfig = detectConfigFile(USER_CONFIG_BASE)
|
||||
if (userConfig.format !== "none") return userConfig.path
|
||||
|
||||
const userConfigAlt = detectConfigFile(USER_CONFIG_BASE_ALT)
|
||||
if (userConfigAlt.format !== "none") return userConfigAlt.path
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
|
||||
export interface PluginInfo {
|
||||
registered: boolean
|
||||
@@ -24,18 +23,33 @@ function detectConfigPath(): string | null {
|
||||
}
|
||||
|
||||
function parsePluginVersion(entry: string): string | null {
|
||||
if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null
|
||||
const value = entry.slice(PACKAGE_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
// Check for current package name
|
||||
if (entry.startsWith(`${PLUGIN_NAME}@`)) {
|
||||
const value = entry.slice(PLUGIN_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
}
|
||||
// Check for legacy package name
|
||||
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
|
||||
const value = entry.slice(LEGACY_PLUGIN_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null {
|
||||
for (const entry of entries) {
|
||||
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
// Check for current package name
|
||||
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) {
|
||||
return { entry, isLocalDev: false }
|
||||
}
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
// Check for legacy package name
|
||||
if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
|
||||
return { entry, isLocalDev: false }
|
||||
}
|
||||
// Check for file:// paths that include either name
|
||||
if (entry.startsWith("file://") && (entry.includes(PLUGIN_NAME) || entry.includes(LEGACY_PLUGIN_NAME))) {
|
||||
return { entry, isLocalDev: true }
|
||||
}
|
||||
}
|
||||
@@ -76,7 +90,7 @@ export function getPluginInfo(): PluginInfo {
|
||||
registered: true,
|
||||
configPath,
|
||||
entry: pluginEntry.entry,
|
||||
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion),
|
||||
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion ?? ""),
|
||||
pinnedVersion,
|
||||
isLocalDev: pluginEntry.isLocalDev,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
|
||||
|
||||
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
|
||||
|
||||
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
|
||||
const ULTIMATE_FALLBACK = "opencode/gpt-5-nano"
|
||||
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("resolveRunAgent", () => {
|
||||
})
|
||||
|
||||
describe("waitForEventProcessorShutdown", () => {
|
||||
|
||||
it("returns quickly when event processor completes", async () => {
|
||||
//#given
|
||||
const eventProcessor = new Promise<void>((resolve) => {
|
||||
@@ -115,3 +114,44 @@ describe("waitForEventProcessorShutdown", () => {
|
||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("run with invalid model", () => {
|
||||
it("given invalid --model value, when run, then returns exit code 1 with error message", async () => {
|
||||
// given
|
||||
const originalExit = process.exit
|
||||
const originalError = console.error
|
||||
const errorMessages: string[] = []
|
||||
const exitCodes: number[] = []
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorMessages.push(args.map(String).join(" "))
|
||||
}
|
||||
process.exit = ((code?: number) => {
|
||||
exitCodes.push(code ?? 0)
|
||||
throw new Error("exit")
|
||||
}) as typeof process.exit
|
||||
|
||||
try {
|
||||
// when
|
||||
// Note: This will actually try to run - but the issue is that resolveRunModel
|
||||
// is called BEFORE the try block, so it throws an unhandled exception
|
||||
// We're testing the runner's error handling
|
||||
const { run } = await import("./runner")
|
||||
|
||||
// This will throw because model "invalid" is invalid format
|
||||
try {
|
||||
await run({
|
||||
message: "test",
|
||||
model: "invalid",
|
||||
})
|
||||
} catch {
|
||||
// Expected to potentially throw due to unhandled model resolution error
|
||||
}
|
||||
} finally {
|
||||
// then - verify error handling
|
||||
// Currently this will fail because the error is not caught properly
|
||||
console.error = originalError
|
||||
process.exit = originalExit
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,10 +47,11 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
|
||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||
const resolvedModel = resolveRunModel(options.model)
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const resolvedModel = resolveRunModel(options.model)
|
||||
|
||||
const { client, cleanup: serverCleanup } = await createServerConnection({
|
||||
port: options.port,
|
||||
attach: options.attach,
|
||||
|
||||
59
src/config/schema/background-task-circuit-breaker.test.ts
Normal file
59
src/config/schema/background-task-circuit-breaker.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ZodError } from "zod/v4"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
|
||||
describe("BackgroundTaskConfigSchema.circuitBreaker", () => {
|
||||
describe("#given valid circuit breaker settings", () => {
|
||||
test("#when parsed #then returns nested config", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.circuitBreaker).toEqual({
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given windowSize below minimum", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
windowSize: 4,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given repetitionThresholdPercent is zero", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
repetitionThresholdPercent: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const CircuitBreakerConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
maxToolCalls: z.number().int().min(10).optional(),
|
||||
windowSize: z.number().int().min(5).optional(),
|
||||
repetitionThresholdPercent: z.number().gt(0).max(100).optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
@@ -11,6 +18,9 @@ export const BackgroundTaskConfigSchema = z.object({
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
||||
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||
syncPollTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Maximum tool calls per subagent task before circuit breaker triggers (default: 200, minimum: 10). Prevents runaway loops from burning unlimited tokens. */
|
||||
maxToolCalls: z.number().int().min(10).optional(),
|
||||
circuitBreaker: CircuitBreakerConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer",
|
||||
"todo-description-override",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
|
||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
|
||||
export const DEFAULT_MAX_TOOL_CALLS = 200
|
||||
export const DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE = 20
|
||||
export const DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT = 80
|
||||
export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true
|
||||
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||
export const MIN_IDLE_TIME_MS = 5000
|
||||
export const POLLING_INTERVAL_MS = 3000
|
||||
|
||||
17
src/features/background-agent/default-stale-timeout.test.ts
Normal file
17
src/features/background-agent/default-stale-timeout.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
|
||||
import { DEFAULT_STALE_TIMEOUT_MS } from "./constants"
|
||||
|
||||
describe("DEFAULT_STALE_TIMEOUT_MS", () => {
|
||||
test("uses a 20 minute default", () => {
|
||||
// #given
|
||||
const expectedTimeout = 20 * 60 * 1000
|
||||
|
||||
// #when
|
||||
const timeout = DEFAULT_STALE_TIMEOUT_MS
|
||||
|
||||
// #then
|
||||
expect(timeout).toBe(expectedTimeout)
|
||||
})
|
||||
})
|
||||
258
src/features/background-agent/loop-detector.test.ts
Normal file
258
src/features/background-agent/loop-detector.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createToolCallSignature,
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
} from "./loop-detector"
|
||||
|
||||
function buildWindow(
|
||||
toolNames: string[],
|
||||
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
|
||||
) {
|
||||
const settings = resolveCircuitBreakerSettings(override)
|
||||
|
||||
return toolNames.reduce(
|
||||
(window, toolName) => recordToolCall(window, toolName, settings),
|
||||
undefined as ReturnType<typeof recordToolCall> | undefined
|
||||
)
|
||||
}
|
||||
|
||||
function buildWindowWithInputs(
|
||||
calls: Array<{ tool: string; input?: Record<string, unknown> }>,
|
||||
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
|
||||
) {
|
||||
const settings = resolveCircuitBreakerSettings(override)
|
||||
return calls.reduce(
|
||||
(window, { tool, input }) => recordToolCall(window, tool, settings, input),
|
||||
undefined as ReturnType<typeof recordToolCall> | undefined
|
||||
)
|
||||
}
|
||||
|
||||
describe("loop-detector", () => {
|
||||
describe("resolveCircuitBreakerSettings", () => {
|
||||
describe("#given nested circuit breaker config", () => {
|
||||
test("#when resolved #then nested values override defaults", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
maxToolCalls: 200,
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no enabled config", () => {
|
||||
test("#when resolved #then enabled defaults to true", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 100,
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 60,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given enabled is false in config", () => {
|
||||
test("#when resolved #then enabled is false", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
circuitBreaker: {
|
||||
enabled: false,
|
||||
maxToolCalls: 100,
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 60,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given enabled is true in config", () => {
|
||||
test("#when resolved #then enabled is true", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
circuitBreaker: {
|
||||
enabled: true,
|
||||
maxToolCalls: 100,
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 60,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createToolCallSignature", () => {
|
||||
test("#given tool with input #when signature created #then includes tool and sorted input", () => {
|
||||
const result = createToolCallSignature("read", { filePath: "/a.ts" })
|
||||
|
||||
expect(result).toBe('read::{"filePath":"/a.ts"}')
|
||||
})
|
||||
|
||||
test("#given tool with undefined input #when signature created #then returns bare tool name", () => {
|
||||
const result = createToolCallSignature("read", undefined)
|
||||
|
||||
expect(result).toBe("read")
|
||||
})
|
||||
|
||||
test("#given tool with null input #when signature created #then returns bare tool name", () => {
|
||||
const result = createToolCallSignature("read", null)
|
||||
|
||||
expect(result).toBe("read")
|
||||
})
|
||||
|
||||
test("#given tool with empty object input #when signature created #then returns bare tool name", () => {
|
||||
const result = createToolCallSignature("read", {})
|
||||
|
||||
expect(result).toBe("read")
|
||||
})
|
||||
|
||||
test("#given same input different key order #when signatures compared #then they are equal", () => {
|
||||
const first = createToolCallSignature("read", { filePath: "/a.ts", offset: 0 })
|
||||
const second = createToolCallSignature("read", { offset: 0, filePath: "/a.ts" })
|
||||
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectRepetitiveToolUse", () => {
|
||||
describe("#given recent tools are diverse", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const window = buildWindow([
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
"bash",
|
||||
"read",
|
||||
"glob",
|
||||
"lsp_diagnostics",
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
])
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result.triggered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the same tool dominates the recent window", () => {
|
||||
test("#when evaluated #then it triggers", () => {
|
||||
const window = buildWindow([
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"edit",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"grep",
|
||||
"read",
|
||||
], {
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 8,
|
||||
sampleSize: 10,
|
||||
thresholdPercent: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the window is not full yet", () => {
|
||||
test("#when the current sample crosses the threshold #then it still triggers", () => {
|
||||
const window = buildWindow(["read", "read", "edit", "read", "read", "read", "read", "read"], {
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 7,
|
||||
sampleSize: 8,
|
||||
thresholdPercent: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given same tool with different file inputs", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const calls = Array.from({ length: 20 }, (_, i) => ({
|
||||
tool: "read",
|
||||
input: { filePath: `/src/file-${i}.ts` },
|
||||
}))
|
||||
const window = buildWindowWithInputs(calls, {
|
||||
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
|
||||
})
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result.triggered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given same tool with identical file inputs", () => {
|
||||
test("#when evaluated #then it triggers with bare tool name", () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 16 }, () => ({ tool: "read", input: { filePath: "/src/same.ts" } })),
|
||||
{ tool: "grep", input: { pattern: "foo" } },
|
||||
{ tool: "edit", input: { filePath: "/src/other.ts" } },
|
||||
{ tool: "bash", input: { command: "ls" } },
|
||||
{ tool: "glob", input: { pattern: "**/*.ts" } },
|
||||
]
|
||||
const window = buildWindowWithInputs(calls, {
|
||||
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
|
||||
})
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result.triggered).toBe(true)
|
||||
expect(result.toolName).toBe("read")
|
||||
expect(result.repeatedCount).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tool calls with no input", () => {
|
||||
test("#when the same tool dominates #then falls back to name-only detection", () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 16 }, () => ({ tool: "read" })),
|
||||
{ tool: "grep" },
|
||||
{ tool: "edit" },
|
||||
{ tool: "bash" },
|
||||
{ tool: "glob" },
|
||||
]
|
||||
const window = buildWindowWithInputs(calls, {
|
||||
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
|
||||
})
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result.triggered).toBe(true)
|
||||
expect(result.toolName).toBe("read")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
127
src/features/background-agent/loop-detector.ts
Normal file
127
src/features/background-agent/loop-detector.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
DEFAULT_CIRCUIT_BREAKER_ENABLED,
|
||||
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
|
||||
DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
|
||||
DEFAULT_MAX_TOOL_CALLS,
|
||||
} from "./constants"
|
||||
import type { ToolCallWindow } from "./types"
|
||||
|
||||
export interface CircuitBreakerSettings {
|
||||
enabled: boolean
|
||||
maxToolCalls: number
|
||||
windowSize: number
|
||||
repetitionThresholdPercent: number
|
||||
}
|
||||
|
||||
export interface ToolLoopDetectionResult {
|
||||
triggered: boolean
|
||||
toolName?: string
|
||||
repeatedCount?: number
|
||||
sampleSize?: number
|
||||
thresholdPercent?: number
|
||||
}
|
||||
|
||||
export function resolveCircuitBreakerSettings(
|
||||
config?: BackgroundTaskConfig
|
||||
): CircuitBreakerSettings {
|
||||
return {
|
||||
enabled: config?.circuitBreaker?.enabled ?? DEFAULT_CIRCUIT_BREAKER_ENABLED,
|
||||
maxToolCalls:
|
||||
config?.circuitBreaker?.maxToolCalls ?? config?.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS,
|
||||
windowSize: config?.circuitBreaker?.windowSize ?? DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
|
||||
repetitionThresholdPercent:
|
||||
config?.circuitBreaker?.repetitionThresholdPercent ??
|
||||
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
|
||||
}
|
||||
}
|
||||
|
||||
export function recordToolCall(
|
||||
window: ToolCallWindow | undefined,
|
||||
toolName: string,
|
||||
settings: CircuitBreakerSettings,
|
||||
toolInput?: Record<string, unknown> | null
|
||||
): ToolCallWindow {
|
||||
const previous = window?.toolSignatures ?? []
|
||||
const signature = createToolCallSignature(toolName, toolInput)
|
||||
const toolSignatures = [...previous, signature].slice(-settings.windowSize)
|
||||
|
||||
return {
|
||||
toolSignatures,
|
||||
windowSize: settings.windowSize,
|
||||
thresholdPercent: settings.repetitionThresholdPercent,
|
||||
}
|
||||
}
|
||||
|
||||
function sortObject(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj !== "object") return obj
|
||||
if (Array.isArray(obj)) return obj.map(sortObject)
|
||||
|
||||
const sorted: Record<string, unknown> = {}
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort()
|
||||
for (const key of keys) {
|
||||
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function createToolCallSignature(
|
||||
toolName: string,
|
||||
toolInput?: Record<string, unknown> | null
|
||||
): string {
|
||||
if (toolInput === undefined || toolInput === null) {
|
||||
return toolName
|
||||
}
|
||||
if (Object.keys(toolInput).length === 0) {
|
||||
return toolName
|
||||
}
|
||||
return `${toolName}::${JSON.stringify(sortObject(toolInput))}`
|
||||
}
|
||||
|
||||
export function detectRepetitiveToolUse(
|
||||
window: ToolCallWindow | undefined
|
||||
): ToolLoopDetectionResult {
|
||||
if (!window || window.toolSignatures.length === 0) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const signature of window.toolSignatures) {
|
||||
counts.set(signature, (counts.get(signature) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let repeatedTool: string | undefined
|
||||
let repeatedCount = 0
|
||||
|
||||
for (const [toolName, count] of counts.entries()) {
|
||||
if (count > repeatedCount) {
|
||||
repeatedTool = toolName
|
||||
repeatedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
const sampleSize = window.toolSignatures.length
|
||||
const minimumSampleSize = Math.min(
|
||||
window.windowSize,
|
||||
Math.ceil((window.windowSize * window.thresholdPercent) / 100)
|
||||
)
|
||||
|
||||
if (sampleSize < minimumSampleSize) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
const thresholdCount = Math.ceil((sampleSize * window.thresholdPercent) / 100)
|
||||
|
||||
if (!repeatedTool || repeatedCount < thresholdCount) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
return {
|
||||
triggered: true,
|
||||
toolName: repeatedTool.split("::")[0],
|
||||
repeatedCount,
|
||||
sampleSize,
|
||||
thresholdPercent: window.thresholdPercent,
|
||||
}
|
||||
}
|
||||
416
src/features/background-agent/manager-circuit-breaker.test.ts
Normal file
416
src/features/background-agent/manager-circuit-breaker.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tmpdir } from "node:os"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function createManager(config?: BackgroundTaskConfig): BackgroundManager {
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, config)
|
||||
const testManager = manager as unknown as {
|
||||
enqueueNotificationForParent: (sessionID: string, fn: () => Promise<void>) => Promise<void>
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
tasks: Map<string, BackgroundTask>
|
||||
}
|
||||
|
||||
testManager.enqueueNotificationForParent = async (_sessionID, fn) => {
|
||||
await fn()
|
||||
}
|
||||
testManager.notifyParentSession = async () => {}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
|
||||
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe("BackgroundManager circuit breaker", () => {
|
||||
describe("#given the same tool dominates the recent window", () => {
|
||||
test("#when tool events arrive #then the task is cancelled early", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-loop-1",
|
||||
sessionID: "session-loop-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Looping task",
|
||||
prompt: "loop",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of [
|
||||
"read",
|
||||
"read",
|
||||
"grep",
|
||||
"read",
|
||||
"edit",
|
||||
"read",
|
||||
"read",
|
||||
"bash",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"glob",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("repeatedly called read 16/20 times")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given recent tool calls are diverse", () => {
|
||||
test("#when the window fills #then the task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-diverse-1",
|
||||
sessionID: "session-diverse-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Healthy task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of [
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
"bash",
|
||||
"glob",
|
||||
"read",
|
||||
"lsp_diagnostics",
|
||||
"grep",
|
||||
"edit",
|
||||
"read",
|
||||
]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
|
||||
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 3,
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 95,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-cap-1",
|
||||
sessionID: "session-cap-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Backstop task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of ["read", "grep", "edit"]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("maximum tool call limit (3)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the same running tool part emits multiple updates", () => {
|
||||
test("#when duplicate running updates arrive #then it only counts the tool once", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 2,
|
||||
circuitBreaker: {
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-dedupe-1",
|
||||
sessionID: "session-dedupe-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Dedupe task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: task.sessionID,
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "running" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(1)
|
||||
expect(task.progress?.countedToolPartIDs).toEqual(["tool-1"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given same tool reading different files", () => {
|
||||
test("#when tool events arrive with state.input #then task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-diff-files-1",
|
||||
sessionID: "session-diff-files-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Reading different files",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
sessionID: task.sessionID,
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "running", input: { filePath: `/src/file-${i}.ts` } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given same tool reading same file repeatedly", () => {
|
||||
test("#when tool events arrive with state.input #then task is cancelled with bare tool name in error", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-same-file-1",
|
||||
sessionID: "session-same-file-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Reading same file repeatedly",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
sessionID: task.sessionID,
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "running", input: { filePath: "/src/same.ts" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("repeatedly called read")
|
||||
expect(task.error).not.toContain("::")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given circuit breaker enabled is false", () => {
|
||||
test("#when repetitive tools arrive #then task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
enabled: false,
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-disabled-1",
|
||||
sessionID: "session-disabled-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Disabled circuit breaker task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: task.sessionID,
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given circuit breaker enabled is false but absolute cap is low", () => {
|
||||
test("#when max tool calls exceeded #then task is still cancelled by absolute cap", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 3,
|
||||
circuitBreaker: {
|
||||
enabled: false,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 95,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-cap-disabled-1",
|
||||
sessionID: "session-cap-disabled-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Backstop task with disabled circuit breaker",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of ["read", "grep", "edit"]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("maximum tool call limit (3)")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3027,10 +3027,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
startedAt: new Date(Date.now() - 25 * 60 * 1000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 200_000),
|
||||
lastUpdate: new Date(Date.now() - 21 * 60 * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
POLLING_INTERVAL_MS,
|
||||
TASK_CLEANUP_DELAY_MS,
|
||||
TASK_TTL_MS,
|
||||
} from "./constants"
|
||||
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
@@ -51,6 +52,11 @@ import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
import {
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
} from "./loop-detector"
|
||||
import {
|
||||
createSubagentDepthLimitError,
|
||||
createSubagentDescendantLimitError,
|
||||
@@ -64,9 +70,11 @@ type OpencodeClient = PluginInput["client"]
|
||||
|
||||
|
||||
interface MessagePartInfo {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
state?: { status?: string; input?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
interface EventProperties {
|
||||
@@ -80,6 +88,19 @@ interface Event {
|
||||
properties?: EventProperties
|
||||
}
|
||||
|
||||
function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
|
||||
if (!properties || typeof properties !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nestedPart = properties.part
|
||||
if (nestedPart && typeof nestedPart === "object") {
|
||||
return nestedPart as MessagePartInfo
|
||||
}
|
||||
|
||||
return properties as MessagePartInfo
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -100,6 +121,8 @@ export interface SubagentSessionCreatedEvent {
|
||||
|
||||
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
|
||||
|
||||
const MAX_TASK_REMOVAL_RESCHEDULES = 6
|
||||
|
||||
export class BackgroundManager {
|
||||
|
||||
|
||||
@@ -720,6 +743,8 @@ export class BackgroundManager {
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
toolCallWindow: existingTask.progress?.toolCallWindow,
|
||||
countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
@@ -852,8 +877,7 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||
const partInfo = props as unknown as MessagePartInfo
|
||||
const partInfo = resolveMessagePartInfo(props)
|
||||
const sessionID = partInfo?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -876,8 +900,66 @@ export class BackgroundManager {
|
||||
task.progress.lastUpdate = new Date()
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
const countedToolPartIDs = task.progress.countedToolPartIDs ?? []
|
||||
const shouldCountToolCall =
|
||||
!partInfo.id ||
|
||||
partInfo.state?.status !== "running" ||
|
||||
!countedToolPartIDs.includes(partInfo.id)
|
||||
|
||||
if (!shouldCountToolCall) {
|
||||
return
|
||||
}
|
||||
|
||||
if (partInfo.id && partInfo.state?.status === "running") {
|
||||
task.progress.countedToolPartIDs = [...countedToolPartIDs, partInfo.id]
|
||||
}
|
||||
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
const circuitBreaker = resolveCircuitBreakerSettings(this.config)
|
||||
if (partInfo.tool) {
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
task.progress.toolCallWindow,
|
||||
partInfo.tool,
|
||||
circuitBreaker,
|
||||
partInfo.state?.input
|
||||
)
|
||||
|
||||
if (circuitBreaker.enabled) {
|
||||
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
|
||||
if (loopDetection.triggered) {
|
||||
log("[background-agent] Circuit breaker: repetitive tool usage detected", {
|
||||
taskId: task.id,
|
||||
agent: task.agent,
|
||||
sessionID,
|
||||
toolName: loopDetection.toolName,
|
||||
repeatedCount: loopDetection.repeatedCount,
|
||||
sampleSize: loopDetection.sampleSize,
|
||||
thresholdPercent: loopDetection.thresholdPercent,
|
||||
})
|
||||
void this.cancelTask(task.id, {
|
||||
source: "circuit-breaker",
|
||||
reason: `Subagent repeatedly called ${loopDetection.toolName} ${loopDetection.repeatedCount}/${loopDetection.sampleSize} times in the recent tool-call window (${loopDetection.thresholdPercent}% threshold). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxToolCalls = circuitBreaker.maxToolCalls
|
||||
if (task.progress.toolCalls >= maxToolCalls) {
|
||||
log("[background-agent] Circuit breaker: tool call limit reached", {
|
||||
taskId: task.id,
|
||||
toolCalls: task.progress.toolCalls,
|
||||
maxToolCalls,
|
||||
agent: task.agent,
|
||||
sessionID,
|
||||
})
|
||||
void this.cancelTask(task.id, {
|
||||
source: "circuit-breaker",
|
||||
reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,7 +1270,7 @@ export class BackgroundManager {
|
||||
this.completedTaskSummaries.delete(parentSessionID)
|
||||
}
|
||||
|
||||
private scheduleTaskRemoval(taskId: string): void {
|
||||
private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
|
||||
const existingTimer = this.completionTimers.get(taskId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
@@ -1198,17 +1280,29 @@ export class BackgroundManager {
|
||||
const timer = setTimeout(() => {
|
||||
this.completionTimers.delete(taskId)
|
||||
const task = this.tasks.get(taskId)
|
||||
if (task) {
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (task.parentSessionID) {
|
||||
const siblings = this.getTasksByParentSession(task.parentSessionID)
|
||||
const runningOrPendingSiblings = siblings.filter(
|
||||
sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
|
||||
)
|
||||
const completedAtTimestamp = task.completedAt?.getTime()
|
||||
const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
|
||||
if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
|
||||
this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
|
||||
return
|
||||
}
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task?.parentSessionID)
|
||||
}
|
||||
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
}
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
|
||||
this.completionTimers.set(taskId, timer)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, afterEach } = require("bun:test")
|
||||
import { tmpdir } from "node:os"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
@@ -157,17 +156,19 @@ function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnTyp
|
||||
}
|
||||
|
||||
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
describe("#given 2 tasks for same parent and task A completed", () => {
|
||||
test("#when task B is still running #then task A is cleaned up from this.tasks after delay even though task B is not done", async () => {
|
||||
describe("#given 3 tasks for same parent and task A completed first", () => {
|
||||
test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => {
|
||||
// given
|
||||
const { manager } = createManager(false)
|
||||
managerUnderTest = manager
|
||||
fakeTimers = installFakeTimers()
|
||||
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date() })
|
||||
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
|
||||
const taskC = createTask({ id: "task-c", parentSessionID: "parent-1", description: "task C", status: "pending" })
|
||||
getTasks(manager).set(taskA.id, taskA)
|
||||
getTasks(manager).set(taskB.id, taskB)
|
||||
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
|
||||
getTasks(manager).set(taskC.id, taskC)
|
||||
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id, taskC.id]))
|
||||
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, taskA)
|
||||
@@ -177,8 +178,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
|
||||
// then
|
||||
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(false)
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(true)
|
||||
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
|
||||
expect(getTasks(manager).get(taskC.id)).toBe(taskC)
|
||||
|
||||
// when
|
||||
taskB.status = "completed"
|
||||
taskB.completedAt = new Date()
|
||||
taskC.status = "completed"
|
||||
taskC.completedAt = new Date()
|
||||
await notifyParentSessionForTest(manager, taskB)
|
||||
await notifyParentSessionForTest(manager, taskC)
|
||||
const rescheduledTaskATimer = getRequiredTimer(manager, taskA.id)
|
||||
expect(fakeTimers.getDelay(rescheduledTaskATimer)).toBe(TASK_CLEANUP_DELAY_MS)
|
||||
fakeTimers.run(rescheduledTaskATimer)
|
||||
|
||||
// then
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||
DEFAULT_STALE_TIMEOUT_MS,
|
||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||
TERMINAL_TASK_TTL_MS,
|
||||
TASK_TTL_MS,
|
||||
} from "./constants"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
|
||||
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||
"completed",
|
||||
"error",
|
||||
|
||||
@@ -9,9 +9,17 @@ export type BackgroundTaskStatus =
|
||||
| "cancelled"
|
||||
| "interrupt"
|
||||
|
||||
export interface ToolCallWindow {
|
||||
toolSignatures: string[]
|
||||
windowSize: number
|
||||
thresholdPercent: number
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
toolCalls: number
|
||||
lastTool?: string
|
||||
toolCallWindow?: ToolCallWindow
|
||||
countedToolPartIDs?: string[]
|
||||
lastUpdate: Date
|
||||
lastMessage?: string
|
||||
lastMessageAt?: Date
|
||||
|
||||
@@ -59,10 +59,13 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
|
||||
if (!Array.isArray(state.session_ids)) {
|
||||
state.session_ids = []
|
||||
}
|
||||
const originalSessionIds = [...state.session_ids]
|
||||
state.session_ids.push(sessionId)
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
state.session_ids = originalSessionIds
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
@@ -7,7 +7,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
|
||||
- If specified and valid: hook pre-sets worktree_path in boulder.json
|
||||
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
|
||||
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
|
||||
- If omitted: work directly in the current project directory (no worktree)
|
||||
|
||||
## WHAT TO DO
|
||||
|
||||
@@ -24,7 +24,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||
- If ONE plan: auto-select it
|
||||
- If MULTIPLE plans: show list with timestamps, ask user to select
|
||||
|
||||
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
|
||||
4. **Worktree Setup** (ONLY when \`--worktree\` was explicitly specified and \`worktree_path\` not already set in boulder.json):
|
||||
1. \`git worktree list --porcelain\` — see available worktrees
|
||||
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
||||
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
|
||||
@@ -86,6 +86,38 @@ Reading plan and beginning execution...
|
||||
|
||||
- The session_id is injected by the hook - use it directly
|
||||
- Always update boulder.json BEFORE starting work
|
||||
- Always set worktree_path in boulder.json before executing any tasks
|
||||
- If worktree_path is set in boulder.json, all work happens inside that worktree directory
|
||||
- Read the FULL plan file before delegating any tasks
|
||||
- Follow atlas delegation protocols (7-section format)`
|
||||
- Follow atlas delegation protocols (7-section format)
|
||||
|
||||
## TASK BREAKDOWN (MANDATORY)
|
||||
|
||||
After reading the plan file, you MUST decompose every plan task into granular, implementation-level sub-steps and register ALL of them as task/todo items BEFORE starting any work.
|
||||
|
||||
**How to break down**:
|
||||
- Each plan checkbox item (e.g., \`- [ ] Add user authentication\`) must be split into concrete, actionable sub-tasks
|
||||
- Sub-tasks should be specific enough that each one touches a clear set of files/functions
|
||||
- Include: file to modify, what to change, expected behavior, and how to verify
|
||||
- Do NOT leave any task vague — "implement feature X" is NOT acceptable; "add validateToken() to src/auth/middleware.ts that checks JWT expiry and returns 401" IS acceptable
|
||||
|
||||
**Example breakdown**:
|
||||
Plan task: \`- [ ] Add rate limiting to API\`
|
||||
→ Todo items:
|
||||
1. Create \`src/middleware/rate-limiter.ts\` with sliding window algorithm (max 100 req/min per IP)
|
||||
2. Add RateLimiter middleware to \`src/app.ts\` router chain, before auth middleware
|
||||
3. Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining) to response in \`rate-limiter.ts\`
|
||||
4. Add test: verify 429 response after exceeding limit in \`src/middleware/rate-limiter.test.ts\`
|
||||
5. Add test: verify headers are present on normal responses
|
||||
|
||||
Register these as task/todo items so progress is tracked and visible throughout the session.
|
||||
|
||||
## WORKTREE COMPLETION
|
||||
|
||||
When working in a worktree (\`worktree_path\` is set in boulder.json) and ALL plan tasks are complete:
|
||||
1. Commit all remaining changes in the worktree
|
||||
2. Switch to the main working directory (the original repo, NOT the worktree)
|
||||
3. Merge the worktree branch into the current branch: \`git merge <worktree-branch>\`
|
||||
4. If merge succeeds, clean up: \`git worktree remove <worktree-path>\`
|
||||
5. Remove the boulder.json state
|
||||
|
||||
This is the DEFAULT behavior when \`--worktree\` was used. Skip merge only if the user explicitly instructs otherwise (e.g., asks to create a PR instead).`
|
||||
|
||||
@@ -75,6 +75,10 @@ describe("mapClaudeModelToOpenCode", () => {
|
||||
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
|
||||
it("#when called with anthropic/claude-3.5-sonnet #then normalizes dots before splitting into object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("anthropic/claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
|
||||
})
|
||||
|
||||
it("#when called with openai/gpt-5.2 #then splits into object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
|
||||
})
|
||||
|
||||
@@ -20,7 +20,16 @@ function mapClaudeModelString(model: string | undefined): string | undefined {
|
||||
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
|
||||
if (aliasResult) return aliasResult
|
||||
|
||||
if (trimmed.includes("/")) return trimmed
|
||||
if (trimmed.includes("/")) {
|
||||
const [providerID, ...modelParts] = trimmed.split("/")
|
||||
const modelID = modelParts.join("/")
|
||||
|
||||
if (providerID.length === 0 || modelID.length === 0) return trimmed
|
||||
|
||||
return modelID.startsWith("claude-")
|
||||
? `${providerID}/${normalizeModelID(modelID)}`
|
||||
: trimmed
|
||||
}
|
||||
|
||||
const normalized = normalizeModelID(trimmed)
|
||||
|
||||
|
||||
@@ -153,3 +153,25 @@ describe("#given git_env_prefix with commit footer", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given idempotency of prefixGitCommandsInBashCodeBlocks", () => {
|
||||
describe("#when git_env_prefix is provided and template already has prefixed commands in env prefix section", () => {
|
||||
it("#then does NOT double-prefix the already-prefixed commands", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git status")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git add")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git commit")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git push")
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git status")
|
||||
expect(result).toContain("GIT_MASTER=1 git add")
|
||||
expect(result).toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("GIT_MASTER=1 git push")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,8 +72,16 @@ function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): st
|
||||
|
||||
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
|
||||
return codeBlock
|
||||
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.includes(prefix)) {
|
||||
return line
|
||||
}
|
||||
return line
|
||||
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function buildCommitFooterInjection(
|
||||
|
||||
@@ -199,3 +199,236 @@ describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
describe("secret env var filtering", () => {
|
||||
it("filters out ANTHROPIC_API_KEY", () => {
|
||||
// given
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-secret"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
})
|
||||
|
||||
it("filters out AWS_SECRET_ACCESS_KEY", () => {
|
||||
// given
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
expect(cleanEnv.AWS_ACCESS_KEY_ID).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
|
||||
it("filters out GITHUB_TOKEN", () => {
|
||||
// given
|
||||
process.env.GITHUB_TOKEN = "ghp_secrettoken123456789"
|
||||
process.env.GITHUB_API_TOKEN = "another_secret_token"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.GITHUB_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.GITHUB_API_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
|
||||
it("filters out OPENAI_API_KEY", () => {
|
||||
// given
|
||||
process.env.OPENAI_API_KEY = "sk-secret123456789"
|
||||
process.env.LANG = "en_US.UTF-8"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
|
||||
})
|
||||
|
||||
it("filters out DATABASE_URL with credentials", () => {
|
||||
// given
|
||||
process.env.DATABASE_URL = "postgresql://user:password@localhost:5432/db"
|
||||
process.env.DB_PASSWORD = "supersecretpassword"
|
||||
process.env.TERM = "xterm-256color"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.DATABASE_URL).toBeUndefined()
|
||||
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.TERM).toBe("xterm-256color")
|
||||
})
|
||||
})
|
||||
|
||||
describe("suffix-based secret filtering", () => {
|
||||
it("filters variables ending with _KEY", () => {
|
||||
// given
|
||||
process.env.MY_API_KEY = "secret-value"
|
||||
process.env.SOME_KEY = "another-secret"
|
||||
process.env.TMPDIR = "/tmp"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.MY_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SOME_KEY).toBeUndefined()
|
||||
expect(cleanEnv.TMPDIR).toBe("/tmp")
|
||||
})
|
||||
|
||||
it("filters variables ending with _SECRET", () => {
|
||||
// given
|
||||
process.env.AWS_SECRET = "secret-value"
|
||||
process.env.JWT_SECRET = "jwt-secret-token"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.AWS_SECRET).toBeUndefined()
|
||||
expect(cleanEnv.JWT_SECRET).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
})
|
||||
|
||||
it("filters variables ending with _TOKEN", () => {
|
||||
// given
|
||||
process.env.ACCESS_TOKEN = "token-value"
|
||||
process.env.BEARER_TOKEN = "bearer-token"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.ACCESS_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.BEARER_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
|
||||
it("filters variables ending with _PASSWORD", () => {
|
||||
// given
|
||||
process.env.DB_PASSWORD = "db-password"
|
||||
process.env.APP_PASSWORD = "app-secret"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.APP_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
|
||||
it("filters variables ending with _CREDENTIAL", () => {
|
||||
// given
|
||||
process.env.GCP_CREDENTIAL = "json-credential"
|
||||
process.env.AZURE_CREDENTIAL = "azure-creds"
|
||||
process.env.PWD = "/current/dir"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.GCP_CREDENTIAL).toBeUndefined()
|
||||
expect(cleanEnv.AZURE_CREDENTIAL).toBeUndefined()
|
||||
expect(cleanEnv.PWD).toBe("/current/dir")
|
||||
})
|
||||
|
||||
it("filters variables ending with _API_KEY", () => {
|
||||
// given
|
||||
// given
|
||||
process.env.STRIPE_API_KEY = "sk_live_secret"
|
||||
process.env.SENDGRID_API_KEY = "SG.secret"
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.STRIPE_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SENDGRID_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/zsh")
|
||||
})
|
||||
})
|
||||
|
||||
describe("safe environment variables preserved", () => {
|
||||
it("preserves PATH", () => {
|
||||
// given
|
||||
process.env.PATH = "/usr/bin:/usr/local/bin"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin:/usr/local/bin")
|
||||
})
|
||||
|
||||
it("preserves HOME", () => {
|
||||
// given
|
||||
process.env.HOME = "/home/testuser"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.HOME).toBe("/home/testuser")
|
||||
})
|
||||
|
||||
it("preserves SHELL", () => {
|
||||
// given
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
|
||||
it("preserves LANG", () => {
|
||||
// given
|
||||
process.env.LANG = "en_US.UTF-8"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
|
||||
})
|
||||
|
||||
it("preserves TERM", () => {
|
||||
// given
|
||||
process.env.TERM = "xterm-256color"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.TERM).toBe("xterm-256color")
|
||||
})
|
||||
|
||||
it("preserves TMPDIR", () => {
|
||||
// given
|
||||
process.env.TMPDIR = "/tmp"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.TMPDIR).toBe("/tmp")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
||||
// Also filters secret-containing env vars to prevent exposure to malicious stdio MCP servers (#B-02)
|
||||
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||
// npm/pnpm/yarn config patterns (original)
|
||||
/^NPM_CONFIG_/i,
|
||||
/^npm_config_/,
|
||||
/^YARN_/,
|
||||
/^PNPM_/,
|
||||
/^NO_UPDATE_NOTIFIER$/,
|
||||
|
||||
// Specific high-risk secret env vars (explicit blocks)
|
||||
/^ANTHROPIC_API_KEY$/i,
|
||||
/^AWS_ACCESS_KEY_ID$/i,
|
||||
/^AWS_SECRET_ACCESS_KEY$/i,
|
||||
/^GITHUB_TOKEN$/i,
|
||||
/^DATABASE_URL$/i,
|
||||
/^OPENAI_API_KEY$/i,
|
||||
|
||||
// Suffix-based patterns for common secret naming conventions
|
||||
/_KEY$/i,
|
||||
/_SECRET$/i,
|
||||
/_TOKEN$/i,
|
||||
/_PASSWORD$/i,
|
||||
/_CREDENTIAL$/i,
|
||||
/_API_KEY$/i,
|
||||
]
|
||||
|
||||
export function createCleanMcpEnvironment(
|
||||
|
||||
@@ -279,6 +279,116 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("model name display in task line", () => {
|
||||
test("should show model name before category when modelInfo exists", () => {
|
||||
// given - a task with category and modelInfo
|
||||
const task = {
|
||||
id: "task_model_display",
|
||||
description: "Build UI component",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "deep",
|
||||
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - toast should show model name before category like "gpt-5.3-codex: deep"
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("gpt-5.3-codex: deep")
|
||||
expect(call.body.message).not.toContain("sisyphus-junior/deep")
|
||||
})
|
||||
|
||||
test("should strip provider prefix from model name", () => {
|
||||
// given - a task with provider-prefixed model
|
||||
const task = {
|
||||
id: "task_strip_provider",
|
||||
description: "Fix styles",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: false,
|
||||
category: "visual-engineering",
|
||||
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should show model ID without provider prefix
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("gemini-3.1-pro: visual-engineering")
|
||||
})
|
||||
|
||||
test("should fall back to agent/category format when no modelInfo", () => {
|
||||
// given - a task without modelInfo
|
||||
const task = {
|
||||
id: "task_no_model",
|
||||
description: "Quick fix",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "quick",
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should use old format with agent name
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("sisyphus-junior/quick")
|
||||
})
|
||||
|
||||
test("should show model name without category when category is absent", () => {
|
||||
// given - a task with modelInfo but no category
|
||||
const task = {
|
||||
id: "task_model_no_cat",
|
||||
description: "Explore codebase",
|
||||
agent: "explore",
|
||||
isBackground: true,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-6", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should show just the model name in parens
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("(claude-sonnet-4-6)")
|
||||
})
|
||||
|
||||
test("should show model name in queued tasks too", () => {
|
||||
// given - a concurrency manager that limits to 1
|
||||
const limitedConcurrency = {
|
||||
getConcurrencyLimit: mock(() => 1),
|
||||
} as unknown as ConcurrencyManager
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency)
|
||||
|
||||
limitedManager.addTask({
|
||||
id: "task_running",
|
||||
description: "Running task",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "deep",
|
||||
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
|
||||
})
|
||||
limitedManager.addTask({
|
||||
id: "task_queued",
|
||||
description: "Queued task",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "quick",
|
||||
status: "queued",
|
||||
modelInfo: { model: "anthropic/claude-haiku-4-5", type: "category-default" as const },
|
||||
})
|
||||
|
||||
// when - the queued task toast fires
|
||||
const lastCall = mockClient.tui.showToast.mock.calls[1][0]
|
||||
|
||||
// then - queued task should also show model name
|
||||
expect(lastCall.body.message).toContain("claude-haiku-4-5: quick")
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTaskModelBySession", () => {
|
||||
test("updates task model info and shows fallback toast", () => {
|
||||
// given - task without model info
|
||||
|
||||
@@ -127,6 +127,13 @@ export class TaskToastManager {
|
||||
const queued = this.getQueuedTasks()
|
||||
const concurrencyInfo = this.getConcurrencyInfo()
|
||||
|
||||
const formatTaskIdentifier = (task: TrackedTask): string => {
|
||||
const modelName = task.modelInfo?.model?.split("/").pop()
|
||||
if (modelName && task.category) return `${modelName}: ${task.category}`
|
||||
if (modelName) return modelName
|
||||
if (task.category) return `${task.agent}/${task.category}`
|
||||
return task.agent
|
||||
}
|
||||
const lines: string[] = []
|
||||
|
||||
const isFallback = newTask.modelInfo && (
|
||||
@@ -151,9 +158,9 @@ export class TaskToastManager {
|
||||
const duration = this.formatDuration(task.startedAt)
|
||||
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
|
||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||
const taskId = formatTaskIdentifier(task)
|
||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - ${duration}${isNew}`)
|
||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - ${duration}${isNew}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +169,10 @@ export class TaskToastManager {
|
||||
lines.push(`Queued (${queued.length}):`)
|
||||
for (const task of queued) {
|
||||
const bgIcon = task.isBackground ? "[Q]" : "[W]"
|
||||
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||
const taskId = formatTaskIdentifier(task)
|
||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - Queued${isNew}`)
|
||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - Queued${isNew}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ describe("createAutoSlashCommandHook leak prevention", () => {
|
||||
})
|
||||
|
||||
describe("#given hook with sessionProcessedCommandExecutions", () => {
|
||||
describe("#when same command executed twice within TTL for same session", () => {
|
||||
it("#then second execution is deduplicated", async () => {
|
||||
describe("#when same command executed twice after fallback dedup window", () => {
|
||||
it("#then second execution is treated as intentional rerun", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
@@ -68,6 +68,61 @@ describe("createAutoSlashCommandHook leak prevention", () => {
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(101)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when same command is repeated within fallback dedup window", () => {
|
||||
it("#then duplicate dispatch is suppressed", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("session-dedup", "leak-test-command")
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(99)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toBe("second")
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when same event identifier is dispatched twice", () => {
|
||||
it("#then second dispatch is deduplicated regardless of elapsed seconds", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input: CommandExecuteBeforeInput = {
|
||||
...createCommandInput("session-dedup", "leak-test-command"),
|
||||
eventID: "event-1",
|
||||
}
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
@@ -83,32 +138,6 @@ describe("createAutoSlashCommandHook leak prevention", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when same command is repeated after TTL expires", () => {
|
||||
it("#then command executes again", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("session-dedup", "leak-test-command")
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(30_001)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given hook with entries from multiple sessions", () => {
|
||||
|
||||
98
src/hooks/auto-slash-command/executor-resolution.test.ts
Normal file
98
src/hooks/auto-slash-command/executor-resolution.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
resolveCommandsInText: async (content: string) => content,
|
||||
resolveFileReferencesInText: async (content: string) => content,
|
||||
}))
|
||||
|
||||
mock.module("../../tools/slashcommand", () => ({
|
||||
discoverCommandsSync: () => [
|
||||
{
|
||||
name: "shadowed",
|
||||
metadata: { name: "shadowed", description: "builtin" },
|
||||
content: "builtin template",
|
||||
scope: "builtin",
|
||||
},
|
||||
{
|
||||
name: "shadowed",
|
||||
metadata: { name: "shadowed", description: "project" },
|
||||
content: "project template",
|
||||
scope: "project",
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
mock.module("../../features/opencode-skill-loader", () => ({
|
||||
discoverAllSkills: async (): Promise<LoadedSkill[]> => [],
|
||||
}))
|
||||
|
||||
const { executeSlashCommand } = await import("./executor")
|
||||
|
||||
function createRestrictedSkill(): LoadedSkill {
|
||||
return {
|
||||
name: "restricted-skill",
|
||||
definition: {
|
||||
name: "restricted-skill",
|
||||
description: "restricted",
|
||||
template: "restricted template",
|
||||
agent: "hephaestus",
|
||||
},
|
||||
scope: "user",
|
||||
}
|
||||
}
|
||||
|
||||
describe("executeSlashCommand resolution semantics", () => {
|
||||
it("returns project command when project and builtin names collide", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "shadowed",
|
||||
args: "",
|
||||
raw: "/shadowed",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, { skills: [] })
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("**Scope**: project")
|
||||
expect(result.replacementText).toContain("project template")
|
||||
expect(result.replacementText).not.toContain("builtin template")
|
||||
})
|
||||
|
||||
it("blocks slash skill invocation when invoking agent is missing", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "restricted-skill",
|
||||
args: "",
|
||||
raw: "/restricted-skill",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, { skills: [createRestrictedSkill()] })
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Skill "restricted-skill" is restricted to agent "hephaestus"')
|
||||
})
|
||||
|
||||
it("allows slash skill invocation when invoking agent matches restriction", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "restricted-skill",
|
||||
args: "",
|
||||
raw: "/restricted-skill",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, {
|
||||
skills: [createRestrictedSkill()],
|
||||
agent: "hephaestus",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("restricted template")
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,7 @@ export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
agent?: string
|
||||
}
|
||||
|
||||
function filterDiscoveredCommandsByScope(
|
||||
@@ -60,12 +61,12 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||
...skillCommands,
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
||||
]
|
||||
}
|
||||
@@ -141,6 +142,15 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
}
|
||||
}
|
||||
|
||||
if (command.scope === "skill" && command.metadata.agent) {
|
||||
if (!options?.agent || command.metadata.agent !== options.agent) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Skill "${command.name}" is restricted to agent "${command.metadata.agent}"`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const template = await formatCommandTemplate(command, parsed.args)
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
const COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS = 100
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
@@ -35,6 +37,33 @@ function getDeletedSessionID(properties: unknown): string | null {
|
||||
return typeof info.id === "string" ? info.id : null
|
||||
}
|
||||
|
||||
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
||||
const candidateKeys = [
|
||||
"messageID",
|
||||
"messageId",
|
||||
"eventID",
|
||||
"eventId",
|
||||
"invocationID",
|
||||
"invocationId",
|
||||
"commandID",
|
||||
"commandId",
|
||||
]
|
||||
|
||||
const recordInput = input as unknown
|
||||
if (!isRecord(recordInput)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const key of candidateKeys) {
|
||||
const candidateValue = recordInput[key]
|
||||
if (typeof candidateValue === "string" && candidateValue.length > 0) {
|
||||
return candidateValue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
@@ -96,7 +125,12 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
const executionOptions: ExecutorOptions = {
|
||||
...executorOptions,
|
||||
agent: input.agent,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executionOptions)
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx < 0) {
|
||||
@@ -125,7 +159,10 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
input: CommandExecuteBeforeInput,
|
||||
output: CommandExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
const commandKey = `${input.sessionID}:${input.command.toLowerCase()}:${input.arguments || ""}`
|
||||
const eventID = getCommandExecutionEventID(input)
|
||||
const commandKey = eventID
|
||||
? `${input.sessionID}:event:${eventID}`
|
||||
: `${input.sessionID}:fallback:${input.command.toLowerCase()}:${input.arguments || ""}`
|
||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
@@ -142,7 +179,12 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
const executionOptions: ExecutorOptions = {
|
||||
...executorOptions,
|
||||
agent: input.agent,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executionOptions)
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
||||
@@ -153,7 +195,10 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommandExecutions.add(commandKey)
|
||||
sessionProcessedCommandExecutions.add(
|
||||
commandKey,
|
||||
eventID ? undefined : COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS
|
||||
)
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function removeSessionEntries(entries: Map<string, number>, sessionID: string):
|
||||
|
||||
export interface ProcessedCommandStore {
|
||||
has(commandKey: string): boolean
|
||||
add(commandKey: string): void
|
||||
add(commandKey: string, ttlMs?: number): void
|
||||
cleanupSession(sessionID: string): void
|
||||
clear(): void
|
||||
}
|
||||
@@ -38,11 +38,11 @@ export function createProcessedCommandStore(): ProcessedCommandStore {
|
||||
entries = pruneExpiredEntries(entries, now)
|
||||
return entries.has(commandKey)
|
||||
},
|
||||
add(commandKey: string): void {
|
||||
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
|
||||
const now = Date.now()
|
||||
entries = pruneExpiredEntries(entries, now)
|
||||
entries.delete(commandKey)
|
||||
entries.set(commandKey, now + PROCESSED_COMMAND_TTL_MS)
|
||||
entries.set(commandKey, now + ttlMs)
|
||||
entries = trimProcessedEntries(entries)
|
||||
},
|
||||
cleanupSession(sessionID: string): void {
|
||||
|
||||
@@ -26,6 +26,15 @@ export interface CommandExecuteBeforeInput {
|
||||
command: string
|
||||
sessionID: string
|
||||
arguments: string
|
||||
agent?: string
|
||||
messageID?: string
|
||||
messageId?: string
|
||||
eventID?: string
|
||||
eventId?: string
|
||||
invocationID?: string
|
||||
invocationId?: string
|
||||
commandID?: string
|
||||
commandId?: string
|
||||
}
|
||||
|
||||
export interface CommandExecuteBeforeOutput {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { runBunInstallWithDetails } from "../../../cli/config-manager"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { getOpenCodeCacheDir, getOpenCodeConfigPaths } from "../../../shared"
|
||||
import { invalidatePackage } from "../cache"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { extractChannel } from "../version-channel"
|
||||
@@ -11,9 +14,36 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
|
||||
return `Update available: ${latestVersion} (version pinned, update manually)`
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
/**
|
||||
* Resolves the active install workspace.
|
||||
* Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir.
|
||||
*/
|
||||
function resolveActiveInstallWorkspace(): string {
|
||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
||||
const cacheDir = getOpenCodeCacheDir()
|
||||
|
||||
const configInstallPath = join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
const cacheInstallPath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
|
||||
// Prefer config-dir if installed there, otherwise fall back to cache-dir
|
||||
if (existsSync(configInstallPath)) {
|
||||
log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`)
|
||||
return configPaths.configDir
|
||||
}
|
||||
|
||||
if (existsSync(cacheInstallPath)) {
|
||||
log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`)
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
// Default to config-dir if neither exists (matches doctor behavior)
|
||||
log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`)
|
||||
return configPaths.configDir
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(workspaceDir: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir })
|
||||
if (!result.success && result.error) {
|
||||
log("[auto-update-checker] bun install error:", result.error)
|
||||
}
|
||||
@@ -82,7 +112,8 @@ export async function runBackgroundUpdateCheck(
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
const activeWorkspace = resolveActiveInstallWorkspace()
|
||||
const installSuccess = await runBunInstallSafe(activeWorkspace)
|
||||
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
|
||||
223
src/hooks/auto-update-checker/hook/workspace-resolution.test.ts
Normal file
223
src/hooks/auto-update-checker/hook/workspace-resolution.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
type PluginEntry = {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
|
||||
|
||||
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
|
||||
return {
|
||||
entry: "oh-my-opencode@3.4.0",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_DIR = join(import.meta.dir, "__test-workspace-resolution__")
|
||||
const TEST_CACHE_DIR = join(TEST_DIR, "cache")
|
||||
const TEST_CONFIG_DIR = join(TEST_DIR, "config")
|
||||
|
||||
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
|
||||
const mockGetCachedVersion = mock((): string | null => "3.4.0")
|
||||
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
|
||||
const mockExtractChannel = mock(() => "latest")
|
||||
const mockInvalidatePackage = mock(() => {})
|
||||
const mockShowUpdateAvailableToast = mock(
|
||||
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
|
||||
)
|
||||
const mockShowAutoUpdatedToast = mock(
|
||||
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||
)
|
||||
const mockSyncCachePackageJsonToIntent = mock(() => ({ synced: true, error: null }))
|
||||
|
||||
const mockRunBunInstallWithDetails = mock(
|
||||
async (opts?: { outputMode?: string; workspaceDir?: string }) => {
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
mock.module("../checker", () => ({
|
||||
findPluginEntry: mockFindPluginEntry,
|
||||
getCachedVersion: mockGetCachedVersion,
|
||||
getLatestVersion: mockGetLatestVersion,
|
||||
revertPinnedVersion: mock(() => false),
|
||||
syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent,
|
||||
}))
|
||||
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
|
||||
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
|
||||
mock.module("../../../cli/config-manager", () => ({
|
||||
runBunInstallWithDetails: mockRunBunInstallWithDetails,
|
||||
}))
|
||||
mock.module("./update-toasts", () => ({
|
||||
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
||||
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
||||
}))
|
||||
mock.module("../../../shared/logger", () => ({ log: () => {} }))
|
||||
mock.module("../../../shared", () => ({
|
||||
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
configDir: TEST_CONFIG_DIR,
|
||||
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
|
||||
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
|
||||
packageJson: join(TEST_CONFIG_DIR, "package.json"),
|
||||
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
|
||||
}),
|
||||
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
|
||||
}))
|
||||
|
||||
// Mock constants BEFORE importing the module
|
||||
const ORIGINAL_PACKAGE_NAME = "oh-my-opencode"
|
||||
mock.module("../constants", () => ({
|
||||
PACKAGE_NAME: ORIGINAL_PACKAGE_NAME,
|
||||
CACHE_DIR: TEST_CACHE_DIR,
|
||||
USER_CONFIG_DIR: TEST_CONFIG_DIR,
|
||||
}))
|
||||
|
||||
// Need to mock getOpenCodeCacheDir and getOpenCodeConfigPaths before importing the module
|
||||
mock.module("../../../shared/data-path", () => ({
|
||||
getDataDir: () => join(TEST_DIR, "data"),
|
||||
getOpenCodeStorageDir: () => join(TEST_DIR, "data", "opencode", "storage"),
|
||||
getCacheDir: () => TEST_DIR,
|
||||
getOmoOpenCodeCacheDir: () => join(TEST_DIR, "oh-my-opencode"),
|
||||
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
|
||||
}))
|
||||
mock.module("../../../shared/opencode-config-dir", () => ({
|
||||
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
configDir: TEST_CONFIG_DIR,
|
||||
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
|
||||
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
|
||||
packageJson: join(TEST_CONFIG_DIR, "package.json"),
|
||||
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
|
||||
}),
|
||||
}))
|
||||
|
||||
const modulePath = "./background-update-check?test"
|
||||
const { runBackgroundUpdateCheck } = await import(modulePath)
|
||||
|
||||
describe("workspace resolution", () => {
|
||||
const mockCtx = { directory: "/test" } as PluginInput
|
||||
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
|
||||
isUpdate ? `Update to ${version}` : "Up to date"
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup test directories
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
|
||||
mockFindPluginEntry.mockReset()
|
||||
mockGetCachedVersion.mockReset()
|
||||
mockGetLatestVersion.mockReset()
|
||||
mockExtractChannel.mockReset()
|
||||
mockInvalidatePackage.mockReset()
|
||||
mockRunBunInstallWithDetails.mockReset()
|
||||
mockShowUpdateAvailableToast.mockReset()
|
||||
mockShowAutoUpdatedToast.mockReset()
|
||||
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry())
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
mockExtractChannel.mockReturnValue("latest")
|
||||
// Note: Don't use mockResolvedValue here - it overrides the function that captures args
|
||||
mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("#given config-dir install exists but cache-dir does not", () => {
|
||||
it("installs to config-dir, not cache-dir", async () => {
|
||||
//#given - config-dir has installation, cache-dir does not
|
||||
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
// cache-dir should NOT exist
|
||||
expect(existsSync(TEST_CACHE_DIR)).toBe(false)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should be called with config-dir
|
||||
const mockCalls = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given both config-dir and cache-dir exist", () => {
|
||||
it("prefers config-dir over cache-dir", async () => {
|
||||
//#given - both directories have installations
|
||||
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should prefer config-dir
|
||||
const mockCalls2 = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls2[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only cache-dir install exists", () => {
|
||||
it("falls back to cache-dir", async () => {
|
||||
//#given - only cache-dir has installation
|
||||
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
// config-dir should NOT exist
|
||||
expect(existsSync(TEST_CONFIG_DIR)).toBe(false)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should fall back to cache-dir
|
||||
const mockCalls3 = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls3[0][0]?.workspaceDir).toBe(TEST_CACHE_DIR)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,3 +52,4 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
|
||||
@@ -1,11 +1,96 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { handleFailedVerification } from "./verification-failure-handler"
|
||||
import { withTimeout } from "./with-timeout"
|
||||
|
||||
type OpenCodeSessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}
|
||||
|
||||
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
|
||||
const TASK_METADATA_SESSION_PATTERN = /<task_metadata>[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i
|
||||
const VERIFIED_PROMISE_PATTERN = new RegExp(
|
||||
`<promise>\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`,
|
||||
"i",
|
||||
)
|
||||
|
||||
function collectAssistantText(message: OpenCodeSessionMessage): string {
|
||||
if (!Array.isArray(message.parts)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = ""
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "text") {
|
||||
continue
|
||||
}
|
||||
text += `${text ? "\n" : ""}${part.text ?? ""}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
async function detectOracleVerificationFromParentSession(
|
||||
ctx: PluginInput,
|
||||
parentSessionID: string,
|
||||
directory: string,
|
||||
apiTimeoutMs: number,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
ctx.client.session.messages({
|
||||
path: { id: parentSessionID },
|
||||
query: { directory },
|
||||
}),
|
||||
apiTimeoutMs,
|
||||
)
|
||||
|
||||
const messagesResponse: unknown = response
|
||||
const responseData =
|
||||
typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse
|
||||
? (messagesResponse as { data?: unknown }).data
|
||||
: undefined
|
||||
const messageArray: unknown[] = Array.isArray(messagesResponse)
|
||||
? messagesResponse
|
||||
: Array.isArray(responseData)
|
||||
? responseData
|
||||
: []
|
||||
|
||||
for (let index = messageArray.length - 1; index >= 0; index -= 1) {
|
||||
const message = messageArray[index] as OpenCodeSessionMessage
|
||||
if (message.info?.role !== "assistant") {
|
||||
continue
|
||||
}
|
||||
|
||||
const assistantText = collectAssistantText(message)
|
||||
if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN)
|
||||
const detectedOracleSessionID = sessionMatch?.[1]?.trim()
|
||||
if (detectedOracleSessionID) {
|
||||
return detectedOracleSessionID
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to scan parent session for oracle verification evidence`, {
|
||||
parentSessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type LoopStateController = {
|
||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||
}
|
||||
|
||||
export async function handlePendingVerification(
|
||||
@@ -33,6 +118,29 @@ export async function handlePendingVerification(
|
||||
} = input
|
||||
|
||||
if (matchesParentSession || (verificationSessionID && matchesVerificationSession)) {
|
||||
if (!verificationSessionID && state.session_id) {
|
||||
const recoveredVerificationSessionID = await detectOracleVerificationFromParentSession(
|
||||
ctx,
|
||||
state.session_id,
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
)
|
||||
|
||||
if (recoveredVerificationSessionID) {
|
||||
const updatedState = loopState.setVerificationSessionID(
|
||||
state.session_id,
|
||||
recoveredVerificationSessionID,
|
||||
)
|
||||
if (updatedState) {
|
||||
log(`[${HOOK_NAME}] Recovered missing verification session from parent evidence`, {
|
||||
parentSessionID: state.session_id,
|
||||
recoveredVerificationSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restarted = await handleFailedVerification(ctx, {
|
||||
state,
|
||||
loopState,
|
||||
|
||||
@@ -136,6 +136,13 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (state.verification_pending) {
|
||||
if (!verificationSessionID && matchesParentSession) {
|
||||
log(`[${HOOK_NAME}] Verification pending without tracked oracle session, running recovery check`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
})
|
||||
}
|
||||
|
||||
await handlePendingVerification(ctx, {
|
||||
sessionID,
|
||||
state,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { classifyErrorType, extractAutoRetrySignal, isRetryableError } from "./error-classifier"
|
||||
import { classifyErrorType, extractAutoRetrySignal, extractStatusCode, isRetryableError } from "./error-classifier"
|
||||
|
||||
describe("runtime-fallback error classifier", () => {
|
||||
test("detects cooling-down auto-retry status signals", () => {
|
||||
@@ -97,3 +97,72 @@ describe("runtime-fallback error classifier", () => {
|
||||
expect(signal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractStatusCode", () => {
|
||||
test("extracts numeric statusCode from top-level", () => {
|
||||
expect(extractStatusCode({ statusCode: 429 })).toBe(429)
|
||||
})
|
||||
|
||||
test("extracts numeric status from top-level", () => {
|
||||
expect(extractStatusCode({ status: 503 })).toBe(503)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested data", () => {
|
||||
expect(extractStatusCode({ data: { statusCode: 500 } })).toBe(500)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested error", () => {
|
||||
expect(extractStatusCode({ error: { statusCode: 502 } })).toBe(502)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested cause", () => {
|
||||
expect(extractStatusCode({ cause: { statusCode: 504 } })).toBe(504)
|
||||
})
|
||||
|
||||
test("skips non-numeric status and finds deeper numeric statusCode", () => {
|
||||
//#given — status is a string, but error.statusCode is numeric
|
||||
const error = {
|
||||
status: "error",
|
||||
error: { statusCode: 429 },
|
||||
}
|
||||
|
||||
//#when
|
||||
const code = extractStatusCode(error)
|
||||
|
||||
//#then
|
||||
expect(code).toBe(429)
|
||||
})
|
||||
|
||||
test("skips non-numeric statusCode string and finds numeric in cause", () => {
|
||||
const error = {
|
||||
statusCode: "UNKNOWN",
|
||||
status: "failed",
|
||||
cause: { statusCode: 503 },
|
||||
}
|
||||
|
||||
expect(extractStatusCode(error)).toBe(503)
|
||||
})
|
||||
|
||||
test("returns undefined when no numeric status exists", () => {
|
||||
expect(extractStatusCode({ status: "error", message: "something broke" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for null/undefined error", () => {
|
||||
expect(extractStatusCode(null)).toBeUndefined()
|
||||
expect(extractStatusCode(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to regex match in error message", () => {
|
||||
const error = { message: "Request failed with status code 429" }
|
||||
expect(extractStatusCode(error, [429, 503])).toBe(429)
|
||||
})
|
||||
|
||||
test("prefers top-level numeric over nested numeric", () => {
|
||||
const error = {
|
||||
statusCode: 400,
|
||||
error: { statusCode: 429 },
|
||||
cause: { statusCode: 503 },
|
||||
}
|
||||
expect(extractStatusCode(error)).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,8 +33,15 @@ export function extractStatusCode(error: unknown, retryOnErrors?: number[]): num
|
||||
|
||||
const errorObj = error as Record<string, unknown>
|
||||
|
||||
const statusCode = errorObj.statusCode ?? errorObj.status ?? (errorObj.data as Record<string, unknown>)?.statusCode
|
||||
if (typeof statusCode === "number") {
|
||||
const statusCode = [
|
||||
errorObj.statusCode,
|
||||
errorObj.status,
|
||||
(errorObj.data as Record<string, unknown>)?.statusCode,
|
||||
(errorObj.error as Record<string, unknown>)?.statusCode,
|
||||
(errorObj.cause as Record<string, unknown>)?.statusCode,
|
||||
].find((code): code is number => typeof code === "number")
|
||||
|
||||
if (statusCode !== undefined) {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("createSessionStateStore regressions", () => {
|
||||
|
||||
describe("#given external activity happens after a successful continuation", () => {
|
||||
describe("#when todos stay unchanged", () => {
|
||||
test("#then it treats the activity as progress instead of stagnation", () => {
|
||||
test("#then it keeps counting stagnation", () => {
|
||||
const sessionID = "ses-activity-progress"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
@@ -37,9 +37,9 @@ describe("createSessionStateStore regressions", () => {
|
||||
trackedState.abortDetectedAt = undefined
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
expect(progressUpdate.hasProgressed).toBe(false)
|
||||
expect(progressUpdate.progressSource).toBe("none")
|
||||
expect(progressUpdate.stagnationCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,7 @@ describe("createSessionStateStore regressions", () => {
|
||||
|
||||
describe("#given stagnation already halted a session", () => {
|
||||
describe("#when new activity appears before the next idle check", () => {
|
||||
test("#then it resets the stop condition on the next progress check", () => {
|
||||
test("#then it does not reset the stop condition", () => {
|
||||
const sessionID = "ses-stagnation-recovery"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
@@ -96,9 +96,9 @@ describe("createSessionStateStore regressions", () => {
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
expect(progressUpdate.hasProgressed).toBe(false)
|
||||
expect(progressUpdate.progressSource).toBe("none")
|
||||
expect(progressUpdate.stagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,6 @@ interface TrackedSessionState {
|
||||
lastAccessedAt: number
|
||||
lastCompletedCount?: number
|
||||
lastTodoSnapshot?: string
|
||||
activitySignalCount: number
|
||||
lastObservedActivitySignalCount?: number
|
||||
}
|
||||
|
||||
export interface ContinuationProgressUpdate {
|
||||
@@ -25,7 +23,7 @@ export interface ContinuationProgressUpdate {
|
||||
previousStagnationCount: number
|
||||
stagnationCount: number
|
||||
hasProgressed: boolean
|
||||
progressSource: "none" | "todo" | "activity"
|
||||
progressSource: "none" | "todo"
|
||||
}
|
||||
|
||||
export interface SessionStateStore {
|
||||
@@ -98,17 +96,7 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
const trackedSession: TrackedSessionState = {
|
||||
state: rawState,
|
||||
lastAccessedAt: Date.now(),
|
||||
activitySignalCount: 0,
|
||||
}
|
||||
trackedSession.state = new Proxy(rawState, {
|
||||
set(target, property, value, receiver) {
|
||||
if (property === "abortDetectedAt" && value === undefined) {
|
||||
trackedSession.activitySignalCount += 1
|
||||
}
|
||||
|
||||
return Reflect.set(target, property, value, receiver)
|
||||
},
|
||||
})
|
||||
sessions.set(sessionID, trackedSession)
|
||||
return trackedSession
|
||||
}
|
||||
@@ -137,7 +125,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
const previousStagnationCount = state.stagnationCount
|
||||
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
||||
const currentActivitySignalCount = trackedSession.activitySignalCount
|
||||
const hasCompletedMoreTodos =
|
||||
currentCompletedCount !== undefined
|
||||
&& trackedSession.lastCompletedCount !== undefined
|
||||
@@ -146,9 +133,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
currentTodoSnapshot !== undefined
|
||||
&& trackedSession.lastTodoSnapshot !== undefined
|
||||
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
||||
const hasObservedExternalActivity =
|
||||
trackedSession.lastObservedActivitySignalCount !== undefined
|
||||
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
|
||||
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||
|
||||
state.lastIncompleteCount = incompleteCount
|
||||
@@ -158,7 +142,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
if (currentTodoSnapshot !== undefined) {
|
||||
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
||||
}
|
||||
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
|
||||
|
||||
if (previousIncompleteCount === undefined) {
|
||||
state.stagnationCount = 0
|
||||
@@ -173,9 +156,7 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
|
||||
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
||||
? "todo"
|
||||
: hasObservedExternalActivity
|
||||
? "activity"
|
||||
: "none"
|
||||
: "none"
|
||||
|
||||
if (progressSource !== "none") {
|
||||
state.stagnationCount = 0
|
||||
@@ -223,8 +204,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
trackedSession.lastCompletedCount = undefined
|
||||
trackedSession.lastTodoSnapshot = undefined
|
||||
trackedSession.activitySignalCount = 0
|
||||
trackedSession.lastObservedActivitySignalCount = undefined
|
||||
}
|
||||
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { MAX_STAGNATION_COUNT } from "./constants"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||
|
||||
describe("shouldStopForStagnation", () => {
|
||||
@@ -25,7 +27,7 @@ describe("shouldStopForStagnation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when activity progress is detected after the halt", () => {
|
||||
describe("#when todo progress is detected after the halt", () => {
|
||||
test("#then it clears the stop condition", () => {
|
||||
const shouldStop = shouldStopForStagnation({
|
||||
sessionID: "ses-recovered",
|
||||
@@ -35,7 +37,7 @@ describe("shouldStopForStagnation", () => {
|
||||
previousStagnationCount: MAX_STAGNATION_COUNT,
|
||||
stagnationCount: 0,
|
||||
hasProgressed: true,
|
||||
progressSource: "activity",
|
||||
progressSource: "todo",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -43,4 +45,60 @@ describe("shouldStopForStagnation", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only non-idle tool and message events happen between idle checks", () => {
|
||||
describe("#when todo state does not change across three idle cycles", () => {
|
||||
test("#then stagnation count reaches three", () => {
|
||||
// given
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
const sessionID = "ses-non-idle-activity-without-progress"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
// when
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const firstCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
handleNonIdleEvent({
|
||||
eventType: "tool.execute.before",
|
||||
properties: { sessionID },
|
||||
sessionStateStore,
|
||||
})
|
||||
handleNonIdleEvent({
|
||||
eventType: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } },
|
||||
sessionStateStore,
|
||||
})
|
||||
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const secondCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
handleNonIdleEvent({
|
||||
eventType: "tool.execute.after",
|
||||
properties: { sessionID },
|
||||
sessionStateStore,
|
||||
})
|
||||
handleNonIdleEvent({
|
||||
eventType: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } },
|
||||
sessionStateStore,
|
||||
})
|
||||
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const thirdCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
// then
|
||||
expect(firstCycle.stagnationCount).toBe(1)
|
||||
expect(secondCycle.stagnationCount).toBe(2)
|
||||
expect(thirdCycle.stagnationCount).toBe(3)
|
||||
|
||||
sessionStateStore.shutdown()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
28
src/hooks/todo-description-override/description.ts
Normal file
28
src/hooks/todo-description-override/description.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work.
|
||||
|
||||
## Todo Format (MANDATORY)
|
||||
|
||||
Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT.
|
||||
|
||||
Format: "[WHERE] [HOW] to [WHY] — expect [RESULT]"
|
||||
|
||||
GOOD:
|
||||
- "src/utils/validation.ts: Add validateEmail() for input sanitization — returns boolean"
|
||||
- "UserService.create(): Call validateEmail() before DB insert — rejects invalid emails with 400"
|
||||
- "validation.test.ts: Add test for missing @ sign — expect validateEmail('foo') to return false"
|
||||
|
||||
BAD:
|
||||
- "Implement email validation" (where? how? what result?)
|
||||
- "Add dark mode" (this is a feature, not a todo)
|
||||
- "Fix auth" (what file? what changes? what's expected?)
|
||||
|
||||
## Granularity Rules
|
||||
|
||||
Each todo MUST be a single atomic action completable in 1-3 tool calls. If it needs more, split it.
|
||||
|
||||
**Size test**: Can you complete this todo by editing one file or running one command? If not, it's too big.
|
||||
|
||||
## Task Management
|
||||
- One in_progress at a time. Complete it before starting the next.
|
||||
- Mark completed immediately after finishing each item.
|
||||
- Skip this tool for single trivial tasks (one-step, obvious action).`
|
||||
14
src/hooks/todo-description-override/hook.ts
Normal file
14
src/hooks/todo-description-override/hook.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { TODOWRITE_DESCRIPTION } from "./description"
|
||||
|
||||
export function createTodoDescriptionOverrideHook() {
|
||||
return {
|
||||
"tool.definition": async (
|
||||
input: { toolID: string },
|
||||
output: { description: string; parameters: unknown },
|
||||
) => {
|
||||
if (input.toolID === "todowrite") {
|
||||
output.description = TODOWRITE_DESCRIPTION
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
40
src/hooks/todo-description-override/index.test.ts
Normal file
40
src/hooks/todo-description-override/index.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { createTodoDescriptionOverrideHook } from "./hook"
|
||||
import { TODOWRITE_DESCRIPTION } from "./description"
|
||||
|
||||
describe("createTodoDescriptionOverrideHook", () => {
|
||||
describe("#given hook is created", () => {
|
||||
describe("#when tool.definition is called with todowrite", () => {
|
||||
it("#then should override the description", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "todowrite" }, output)
|
||||
|
||||
expect(output.description).toBe(TODOWRITE_DESCRIPTION)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when tool.definition is called with non-todowrite tool", () => {
|
||||
it("#then should not modify the description", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "bash" }, output)
|
||||
|
||||
expect(output.description).toBe("original description")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when tool.definition is called with TodoWrite (case-insensitive)", () => {
|
||||
it("#then should not override for different casing since OpenCode sends lowercase", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "TodoWrite" }, output)
|
||||
|
||||
expect(output.description).toBe("original description")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/hooks/todo-description-override/index.ts
Normal file
1
src/hooks/todo-description-override/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createTodoDescriptionOverrideHook } from "./hook"
|
||||
@@ -160,18 +160,32 @@ export function loadPluginConfig(
|
||||
directory: string,
|
||||
ctx: unknown
|
||||
): OhMyOpenCodeConfig {
|
||||
// User-level config path - prefer .jsonc over .json
|
||||
// User-level config path - prefer .jsonc over .json, try oh-my-openagent as fallback
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" });
|
||||
const userBasePath = path.join(configDir, "oh-my-opencode");
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
let userDetected = detectConfigFile(userBasePath);
|
||||
if (userDetected.format === "none") {
|
||||
const altUserBasePath = path.join(configDir, "oh-my-openagent");
|
||||
const altDetected = detectConfigFile(altUserBasePath);
|
||||
if (altDetected.format !== "none") {
|
||||
userDetected = altDetected;
|
||||
}
|
||||
}
|
||||
const userConfigPath =
|
||||
userDetected.format !== "none"
|
||||
? userDetected.path
|
||||
: userBasePath + ".json";
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
// Project-level config path - prefer .jsonc over .json, try oh-my-openagent as fallback
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
let projectDetected = detectConfigFile(projectBasePath);
|
||||
if (projectDetected.format === "none") {
|
||||
const altProjectBasePath = path.join(directory, ".opencode", "oh-my-openagent");
|
||||
const altDetected = detectConfigFile(altProjectBasePath);
|
||||
if (altDetected.format !== "none") {
|
||||
projectDetected = altDetected;
|
||||
}
|
||||
}
|
||||
const projectConfigPath =
|
||||
projectDetected.format !== "none"
|
||||
? projectDetected.path
|
||||
|
||||
@@ -71,5 +71,9 @@ export function createPluginInterface(args: {
|
||||
ctx,
|
||||
hooks,
|
||||
}),
|
||||
|
||||
"tool.definition": async (input, output) => {
|
||||
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { describe, it, expect, afterEach } from "bun:test"
|
||||
|
||||
import { createEventHandler } from "./event"
|
||||
import { createChatMessageHandler } from "./chat-message"
|
||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
||||
|
||||
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||
type EventInput = { event: { type: string; properties?: unknown } }
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("createEventHandler - idle deduplication", () => {
|
||||
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
|
||||
@@ -66,7 +73,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
//#then - synthetic idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - real session.idle arrives
|
||||
await eventHandler({
|
||||
@@ -142,7 +149,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
//#then - real idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - session.status with idle (generates synthetic idle)
|
||||
await eventHandler({
|
||||
@@ -245,7 +252,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
event: {
|
||||
type: "message.updated",
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
|
||||
//#then - both maps should be pruned (no dedup should occur for new events)
|
||||
// We verify by checking that a new idle event for same session is dispatched
|
||||
@@ -287,7 +294,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
|
||||
await eventHandlerWithMock({
|
||||
@@ -426,7 +433,7 @@ describe("createEventHandler - event forwarding", () => {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: sessionID } },
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
|
||||
//#then
|
||||
expect(forwardedEvents.length).toBe(1)
|
||||
@@ -435,3 +442,146 @@ describe("createEventHandler - event forwarding", () => {
|
||||
expect(deletedSessions).toEqual([sessionID])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createEventHandler - retry dedupe lifecycle", () => {
|
||||
it("re-handles same retry key after session recovers to idle status", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_retry_recovery_rearm"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
|
||||
const abortCalls: string[] = []
|
||||
const promptCalls: string[] = []
|
||||
const modelFallback = createModelFallbackHook()
|
||||
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
abort: async ({ path }: { path: { id: string } }) => {
|
||||
abortCalls.push(path.id)
|
||||
return {}
|
||||
},
|
||||
prompt: async ({ path }: { path: { id: string } }) => {
|
||||
promptCalls.push(path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
skillMcpManager: {
|
||||
disconnectSession: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: { isStopped: () => false },
|
||||
} as any,
|
||||
})
|
||||
|
||||
const chatMessageHandler = createChatMessageHandler({
|
||||
ctx: {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
shouldOverride: () => false,
|
||||
markApplied: () => {},
|
||||
},
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: null,
|
||||
keywordDetector: null,
|
||||
claudeCodeHooks: null,
|
||||
autoSlashCommand: null,
|
||||
startWork: null,
|
||||
ralphLoop: null,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const retryStatus = {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message: "All credentials for model claude-opus-4-6-thinking are cooling down [retrying in 7m 56s attempt #1]",
|
||||
next: 476,
|
||||
} as const
|
||||
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_retry_rearm",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
providerID: "anthropic",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#when - first retry key is handled
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: retryStatus,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
const firstOutput = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
||||
await chatMessageHandler(
|
||||
{
|
||||
sessionID,
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
|
||||
},
|
||||
firstOutput,
|
||||
)
|
||||
|
||||
//#when - session recovers to non-retry idle state
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#when - same retry key appears again after recovery
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: retryStatus,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID, sessionID])
|
||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -421,6 +421,12 @@ export function createEventHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
|
||||
|
||||
// Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery
|
||||
// (non-retry idle) so future failures with the same key can trigger fallback again.
|
||||
if (sessionID && status?.type === "idle") {
|
||||
lastHandledRetryStatusKey.delete(sessionID);
|
||||
}
|
||||
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createHashlineReadEnhancerHook,
|
||||
createReadImageResizerHook,
|
||||
createJsonErrorRecoveryHook,
|
||||
createTodoDescriptionOverrideHook,
|
||||
} from "../../hooks"
|
||||
import {
|
||||
getOpenCodeVersion,
|
||||
@@ -35,6 +36,7 @@ export type ToolGuardHooks = {
|
||||
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
||||
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
|
||||
todoDescriptionOverride: ReturnType<typeof createTodoDescriptionOverrideHook> | null
|
||||
}
|
||||
|
||||
export function createToolGuardHooks(args: {
|
||||
@@ -111,6 +113,10 @@ export function createToolGuardHooks(args: {
|
||||
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
|
||||
: null
|
||||
|
||||
const todoDescriptionOverride = isHookEnabled("todo-description-override")
|
||||
? safeHook("todo-description-override", () => createTodoDescriptionOverrideHook())
|
||||
: null
|
||||
|
||||
return {
|
||||
commentChecker,
|
||||
toolOutputTruncator,
|
||||
@@ -123,5 +129,6 @@ export function createToolGuardHooks(args: {
|
||||
hashlineReadEnhancer,
|
||||
jsonErrorRecovery,
|
||||
readImageResizer,
|
||||
todoDescriptionOverride,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,22 +48,50 @@ export function createToolExecuteAfterHandler(args: {
|
||||
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
|
||||
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
||||
const loopState = directory ? readState(directory) : null
|
||||
|
||||
if (
|
||||
const isVerificationContext =
|
||||
agent === "oracle"
|
||||
&& sessionId
|
||||
&& verificationAttemptId
|
||||
&& directory
|
||||
&& !!sessionId
|
||||
&& !!directory
|
||||
&& loopState?.active === true
|
||||
&& loopState.ultrawork === true
|
||||
&& loopState.verification_pending === true
|
||||
&& loopState.session_id === input.sessionID
|
||||
|
||||
log("[tool-execute-after] ULW verification tracking check", {
|
||||
tool: input.tool,
|
||||
agent,
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
hasPromptInMetadata: typeof prompt === "string",
|
||||
extractedVerificationAttemptId: verificationAttemptId,
|
||||
})
|
||||
|
||||
if (
|
||||
isVerificationContext
|
||||
&& verificationAttemptId
|
||||
&& loopState.verification_attempt_id === verificationAttemptId
|
||||
) {
|
||||
writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
log("[tool-execute-after] Stored oracle verification session via attempt match", {
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
verificationAttemptId,
|
||||
})
|
||||
} else if (isVerificationContext && !verificationAttemptId) {
|
||||
writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
log("[tool-execute-after] Fallback: stored oracle verification session without attempt match", {
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
hasPromptInMetadata: typeof prompt === "string",
|
||||
expectedAttemptId: loopState.verification_attempt_id,
|
||||
extractedAttemptId: verificationAttemptId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
|
||||
if (shouldInjectOracleVerification) {
|
||||
const verificationAttemptId = randomUUID()
|
||||
log("[tool-execute-before] Injecting ULW oracle verification attempt", {
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID,
|
||||
verificationAttemptId,
|
||||
loopSessionID: loopState.session_id,
|
||||
})
|
||||
writeState(ctx.directory, {
|
||||
...loopState,
|
||||
verification_attempt_id: verificationAttemptId,
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function createOracleTaskArgs(prompt: string): Record<string, unknown> {
|
||||
return {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
function createSyncTaskMetadata(
|
||||
args: Record<string, unknown>,
|
||||
sessionId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
prompt: args.prompt,
|
||||
agent: "oracle",
|
||||
run_in_background: args.run_in_background,
|
||||
sessionId,
|
||||
sync: true,
|
||||
}
|
||||
}
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle task runs #then oracle prompt is enforced and sync", async () => {
|
||||
const directory = join(tmpdir(), `tool-before-ulw-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
@@ -38,13 +59,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const output = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const output = { args: createOracleTaskArgs("Check it") }
|
||||
|
||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||
|
||||
@@ -64,13 +79,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const output = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const output = { args: createOracleTaskArgs("Check it") }
|
||||
|
||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||
|
||||
@@ -80,7 +89,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle task finishes #then oracle session id is stored", async () => {
|
||||
test("#given ulw loop is awaiting verification #when oracle sync task metadata is persisted #then oracle session id is stored", async () => {
|
||||
const directory = join(tmpdir(), `tool-after-ulw-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeState(directory, {
|
||||
@@ -99,14 +108,44 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const beforeOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const beforeOutput = { args: createOracleTaskArgs("Check it") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, beforeOutput)
|
||||
const metadataFromSyncTask = createSyncTaskMetadata(beforeOutput.args, "ses-oracle")
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "task", sessionID: "ses-main", callID: "call-1" },
|
||||
{
|
||||
title: "oracle task",
|
||||
output: "done",
|
||||
metadata: metadataFromSyncTask,
|
||||
},
|
||||
)
|
||||
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
|
||||
|
||||
clearState(directory)
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle metadata prompt is missing #then oracle session fallback is stored", async () => {
|
||||
const directory = join(tmpdir(), `tool-after-ulw-fallback-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeState(directory, {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
initial_completion_promise: "DONE",
|
||||
started_at: new Date().toISOString(),
|
||||
prompt: "Ship feature",
|
||||
session_id: "ses-main",
|
||||
ultrawork: true,
|
||||
verification_pending: true,
|
||||
})
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||
@@ -120,13 +159,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
output: "done",
|
||||
metadata: {
|
||||
agent: "oracle",
|
||||
prompt: String(beforeOutput.args.prompt),
|
||||
sessionId: "ses-oracle",
|
||||
sessionId: "ses-oracle-fallback",
|
||||
sync: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle-fallback")
|
||||
|
||||
clearState(directory)
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
@@ -156,23 +195,11 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||
})
|
||||
|
||||
const firstOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const firstOutput = { args: createOracleTaskArgs("Check it") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, firstOutput)
|
||||
const firstAttemptId = readState(directory)?.verification_attempt_id
|
||||
|
||||
const secondOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it again",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const secondOutput = { args: createOracleTaskArgs("Check it again") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-2" }, secondOutput)
|
||||
const secondAttemptId = readState(directory)?.verification_attempt_id
|
||||
|
||||
|
||||
@@ -110,12 +110,16 @@ function applyResolvedUltraworkOverride(args: {
|
||||
if (!override.providerID || !override.modelID) return
|
||||
|
||||
const targetModel = { providerID: override.providerID, modelID: override.modelID }
|
||||
const messageId = output.message["id"] as string | undefined
|
||||
if (isSameModel(output.message.model, targetModel)) {
|
||||
if (validatedVariant && messageId) {
|
||||
scheduleDeferredModelOverride(messageId, targetModel, validatedVariant)
|
||||
log(`[ultrawork-model-override] Persist validated variant for active model: ${override.modelID}`)
|
||||
return
|
||||
}
|
||||
log(`[ultrawork-model-override] Skip override; target model already active: ${override.modelID}`)
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = output.message["id"] as string | undefined
|
||||
if (!messageId) {
|
||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||
output.message.model = targetModel
|
||||
|
||||
@@ -12,4 +12,5 @@ export type OpenCodeConfigPaths = {
|
||||
configJsonc: string
|
||||
packageJson: string
|
||||
omoConfig: string
|
||||
omoConfigAlt: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenC
|
||||
configJsonc: join(configDir, "opencode.jsonc"),
|
||||
packageJson: join(configDir, "package.json"),
|
||||
omoConfig: join(configDir, "oh-my-opencode.json"),
|
||||
omoConfigAlt: join(configDir, "oh-my-openagent.json"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PLUGIN_NAME = "oh-my-opencode"
|
||||
export const LEGACY_PLUGIN_NAME = "oh-my-openagent"
|
||||
export const CONFIG_BASENAME = "oh-my-opencode"
|
||||
export const LOG_FILENAME = "oh-my-opencode.log"
|
||||
export const CACHE_DIR_NAME = "oh-my-opencode"
|
||||
|
||||
@@ -109,3 +109,44 @@ export function buildEnvPrefix(
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for use in a double-quoted shell -c command argument.
|
||||
*
|
||||
* In shell -c "..." strings, these characters have special meaning and must be escaped:
|
||||
* - $ - variable expansion, command substitution $(...)
|
||||
* - ` - command substitution `...`
|
||||
* - \\ - escape character
|
||||
* - " - end quote
|
||||
* - ; | & - command separators
|
||||
* - # - comment
|
||||
* - () - grouping operators
|
||||
*
|
||||
* @param value - The value to escape
|
||||
* @returns Escaped value safe for double-quoted shell -c argument
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // For malicious input
|
||||
* const url = "http://localhost:3000'; cat /etc/passwd; echo '"
|
||||
* const escaped = shellEscapeForDoubleQuotedCommand(url)
|
||||
* // => "http://localhost:3000'\''; cat /etc/passwd; echo '"
|
||||
*
|
||||
* // Usage in command:
|
||||
* const cmd = `/bin/sh -c "opencode attach ${escaped} --session ${sessionId}"`
|
||||
* ```
|
||||
*/
|
||||
export function shellEscapeForDoubleQuotedCommand(value: string): string {
|
||||
// Order matters: escape backslash FIRST, then other characters
|
||||
return value
|
||||
.replace(/\\/g, "\\\\") // escape backslash first
|
||||
.replace(/\$/g, "\\$") // escape dollar sign
|
||||
.replace(/`/g, "\\`") // escape backticks
|
||||
.replace(/"/g, "\\\"") // escape double quotes
|
||||
.replace(/;/g, "\\;") // escape semicolon (command separator)
|
||||
.replace(/\|/g, "\\|") // escape pipe (command separator)
|
||||
.replace(/&/g, "\\&") // escape ampersand (command separator)
|
||||
.replace(/#/g, "\\#") // escape hash (comment)
|
||||
.replace(/\(/g, "\\(") // escape parentheses
|
||||
.replace(/\)/g, "\\)") // escape parentheses
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TmuxConfig } from "../../../config/schema"
|
||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import type { SpawnPaneResult } from "../types"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
export async function replaceTmuxPane(
|
||||
paneId: string,
|
||||
@@ -34,7 +35,9 @@ export async function replaceTmuxPane(
|
||||
})
|
||||
await ctrlCProc.exited
|
||||
|
||||
const opencodeCmd = `zsh -c 'opencode attach ${serverUrl} --session ${sessionId}'`
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
|
||||
stdout: "pipe",
|
||||
@@ -59,6 +62,7 @@ export async function replaceTmuxPane(
|
||||
const titleStderr = await stderrPromise
|
||||
log("[replaceTmuxPane] WARNING: failed to set pane title", {
|
||||
paneId,
|
||||
title,
|
||||
exitCode: titleExitCode,
|
||||
stderr: titleStderr.trim(),
|
||||
})
|
||||
|
||||
96
src/shared/tmux/tmux-utils/pane-spawn.test.ts
Normal file
96
src/shared/tmux/tmux-utils/pane-spawn.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
describe("given a serverUrl with shell metacharacters", () => {
|
||||
describe("when building tmux spawn command with double quotes", () => {
|
||||
it("then serverUrl is escaped to prevent shell injection", () => {
|
||||
const serverUrl = "http://localhost:3000'; cat /etc/passwd; echo '"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
// Use double quotes for outer shell -c command, escape dangerous chars in URL
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
// The semicolon should be escaped so it's treated as literal, not separator
|
||||
expect(opencodeCmd).toContain("\\;")
|
||||
// The malicious content should be escaped - semicolons are now \\;
|
||||
expect(opencodeCmd).not.toMatch(/[^\\];\s*cat/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when building tmux replace command", () => {
|
||||
it("then serverUrl is escaped to prevent shell injection", () => {
|
||||
const serverUrl = "http://localhost:3000'; rm -rf /; '"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\;")
|
||||
expect(opencodeCmd).not.toMatch(/[^\\];\s*rm/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a normal serverUrl without shell metacharacters", () => {
|
||||
describe("when building tmux spawn command", () => {
|
||||
it("then serverUrl works correctly", () => {
|
||||
const serverUrl = "http://localhost:3000"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain(serverUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with dollar sign (command injection)", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then dollar sign is escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000$(whoami)"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
// The $ should be escaped to literal $
|
||||
expect(opencodeCmd).toContain("\\$")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with backticks (command injection)", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then backticks are escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000`whoami`"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\`")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with pipe operator", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then pipe is escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000 | ls"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\|")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { SpawnPaneResult } from "../types"
|
||||
import type { SplitDirection } from "./environment"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { isServerRunning } from "./server-health"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
export async function spawnTmuxPane(
|
||||
sessionId: string,
|
||||
@@ -48,7 +49,9 @@ export async function spawnTmuxPane(
|
||||
|
||||
log("[spawnTmuxPane] all checks passed, spawning...")
|
||||
|
||||
const opencodeCmd = `zsh -c 'opencode attach ${serverUrl} --session ${sessionId}'`
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
const args = [
|
||||
"split-window",
|
||||
|
||||
@@ -7,16 +7,22 @@ import * as connectedProvidersCache from "../../shared/connected-providers-cache
|
||||
describe("resolveCategoryExecution", () => {
|
||||
let connectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let providerModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasConnectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasProviderModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
connectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(false)
|
||||
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connectedProvidersSpy?.mockRestore()
|
||||
providerModelsSpy?.mockRestore()
|
||||
hasConnectedProvidersSpy?.mockRestore()
|
||||
hasProviderModelsSpy?.mockRestore()
|
||||
})
|
||||
|
||||
const createMockExecutorContext = (): ExecutorContext => ({
|
||||
@@ -27,7 +33,7 @@ describe("resolveCategoryExecution", () => {
|
||||
sisyphusJuniorModel: undefined,
|
||||
})
|
||||
|
||||
test("returns clear error when category exists but required model is not available", async () => {
|
||||
test("returns unpinned resolution when category cache is not ready on first run", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
category: "deep",
|
||||
@@ -39,6 +45,9 @@ describe("resolveCategoryExecution", () => {
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {},
|
||||
}
|
||||
const inheritedModel = undefined
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
@@ -46,10 +55,10 @@ describe("resolveCategoryExecution", () => {
|
||||
const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel)
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.error).toContain("deep")
|
||||
expect(result.error).toMatch(/model.*not.*available|requires.*model/i)
|
||||
expect(result.error).not.toContain("Unknown category")
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBeUndefined()
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.agentToUse).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns 'unknown category' error for truly unknown categories", async () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ Available categories: ${allCategoryNames}`,
|
||||
let actualModel: string | undefined
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let isModelResolutionSkipped = false
|
||||
|
||||
const overrideModel = sisyphusJuniorModel
|
||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||
@@ -98,6 +99,11 @@ Available categories: ${allCategoryNames}`,
|
||||
modelInfo = explicitCategoryModel || overrideModel
|
||||
? { model: actualModel, type: "user-defined", source: "override" }
|
||||
: { model: actualModel, type: "system-default", source: "system-default" }
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
|
||||
categoryModel = parsedModel
|
||||
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
|
||||
: undefined
|
||||
}
|
||||
} else {
|
||||
const resolution = resolveModelForDelegateTask({
|
||||
@@ -109,7 +115,9 @@ Available categories: ${allCategoryNames}`,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && "skipped" in resolution) {
|
||||
isModelResolutionSkipped = true
|
||||
} else if (resolution) {
|
||||
const { model: resolvedModel, variant: resolvedVariant } = resolution
|
||||
actualModel = resolvedModel
|
||||
|
||||
@@ -156,7 +164,7 @@ Available categories: ${allCategoryNames}`,
|
||||
}
|
||||
const categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
if (!categoryModel && !actualModel) {
|
||||
if (!categoryModel && !actualModel && !isModelResolutionSkipped) {
|
||||
const categoryNames = Object.keys(enabledCategories)
|
||||
return {
|
||||
agentToUse: "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
|
||||
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when availableModels is empty and no user model override", () => {
|
||||
test("#then returns undefined to let OpenCode use system default", () => {
|
||||
test("#then returns skipped sentinel to leave model unpinned", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
fallbackChain: [
|
||||
@@ -33,7 +33,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when user set fallback_models but no cache exists", () => {
|
||||
test("#then returns undefined (skip fallback resolution without cache)", () => {
|
||||
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
@@ -64,7 +64,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -86,8 +86,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,8 +100,27 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when user fallback models include variant syntax", () => {
|
||||
test("#then resolves a parenthesized variant against the base available model", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["openai/gpt-5.2(high)"],
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "high" })
|
||||
})
|
||||
|
||||
test("#then resolves a space-separated variant against the base available model", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["gpt-5.2 medium"],
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "medium" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizeModel } from "../../shared/model-normalization"
|
||||
import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
import { hasConnectedProvidersCache, hasProviderModelsCache } from "../../shared/connected-providers-cache"
|
||||
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
|
||||
|
||||
function isExplicitHighModel(model: string): boolean {
|
||||
return /(?:^|\/)[^/]+-high$/.test(model)
|
||||
@@ -12,6 +13,36 @@ function getExplicitHighBaseModel(model: string): string | null {
|
||||
return isExplicitHighModel(model) ? model.replace(/-high$/, "") : null
|
||||
}
|
||||
|
||||
function parseUserFallbackModel(fallbackModel: string): {
|
||||
baseModel: string
|
||||
providerHint?: string[]
|
||||
variant?: string
|
||||
} | undefined {
|
||||
const normalizedFallback = normalizeModel(fallbackModel)
|
||||
if (!normalizedFallback) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedFullModel = parseModelString(normalizedFallback)
|
||||
if (parsedFullModel) {
|
||||
return {
|
||||
baseModel: `${parsedFullModel.providerID}/${parsedFullModel.modelID}`,
|
||||
providerHint: [parsedFullModel.providerID],
|
||||
variant: parsedFullModel.variant,
|
||||
}
|
||||
}
|
||||
|
||||
const parsedModel = parseVariantFromModelID(normalizedFallback)
|
||||
if (!parsedModel.modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
baseModel: parsedModel.modelID,
|
||||
variant: parsedModel.variant,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function resolveModelForDelegateTask(input: {
|
||||
userModel?: string
|
||||
@@ -20,7 +51,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}): { model: string; variant?: string } | undefined {
|
||||
}): { model: string; variant?: string } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
return { model: userModel }
|
||||
@@ -29,7 +60,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
// Before provider cache is created (first run), skip model resolution entirely.
|
||||
// OpenCode will use its system default model when no model is specified in the prompt.
|
||||
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
|
||||
return undefined
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const categoryDefault = normalizeModel(input.categoryDefaultModel)
|
||||
@@ -55,20 +86,18 @@ export function resolveModelForDelegateTask(input: {
|
||||
const userFallbackModels = input.userFallbackModels
|
||||
if (userFallbackModels && userFallbackModels.length > 0) {
|
||||
if (input.availableModels.size === 0) {
|
||||
const first = normalizeModel(userFallbackModels[0])
|
||||
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
|
||||
if (first) {
|
||||
return { model: first }
|
||||
return { model: first.baseModel, variant: first.variant }
|
||||
}
|
||||
} else {
|
||||
for (const fallbackModel of userFallbackModels) {
|
||||
const normalizedFallback = normalizeModel(fallbackModel)
|
||||
if (!normalizedFallback) continue
|
||||
const parsedFallback = parseUserFallbackModel(fallbackModel)
|
||||
if (!parsedFallback) continue
|
||||
|
||||
const parts = normalizedFallback.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
||||
const match = fuzzyMatchModel(normalizedFallback, input.availableModels, providerHint)
|
||||
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
|
||||
if (match) {
|
||||
return { model: match }
|
||||
return { model: match, variant: parsedFallback.variant }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const KNOWN_VARIANTS = new Set([
|
||||
"thinking",
|
||||
])
|
||||
|
||||
function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||
const trimmedModelID = rawModelID.trim()
|
||||
if (!trimmedModelID) {
|
||||
return { modelID: "" }
|
||||
|
||||
@@ -124,7 +124,7 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
systemDefaultModel: undefined,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && !('skipped' in resolution)) {
|
||||
const normalized = normalizeModelFormat(resolution.model)
|
||||
if (normalized) {
|
||||
const variantToUse = agentOverride?.variant ?? resolution.variant
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs } from "./timing"
|
||||
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs, getTimingConfig } from "./timing"
|
||||
|
||||
describe("timing sync poll timeout defaults", () => {
|
||||
test("default sync timeout is 30 minutes", () => {
|
||||
@@ -27,3 +27,16 @@ describe("timing sync poll timeout defaults", () => {
|
||||
__resetTimingConfig()
|
||||
})
|
||||
})
|
||||
|
||||
describe("WAIT_FOR_SESSION_TIMEOUT_MS default", () => {
|
||||
test("default wait for session timeout is 1 minute", () => {
|
||||
// #given
|
||||
__resetTimingConfig()
|
||||
|
||||
// #when
|
||||
const config = getTimingConfig()
|
||||
|
||||
// #then
|
||||
expect(config.WAIT_FOR_SESSION_TIMEOUT_MS).toBe(60_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ let POLL_INTERVAL_MS = 1000
|
||||
let MIN_STABILITY_TIME_MS = 10000
|
||||
let STABILITY_POLLS_REQUIRED = 3
|
||||
let WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 60000
|
||||
const DEFAULT_POLL_TIMEOUT_MS = 30 * 60 * 1000
|
||||
let MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS
|
||||
let SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
@@ -30,7 +30,7 @@ export function __resetTimingConfig(): void {
|
||||
MIN_STABILITY_TIME_MS = 10000
|
||||
STABILITY_POLLS_REQUIRED = 3
|
||||
WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 60000
|
||||
MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS
|
||||
SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,211 @@ describe("sisyphus-task", () => {
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("run_in_background parameter", () => {
|
||||
test("#given category without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Category without run flag",
|
||||
prompt: "Do something",
|
||||
category: "quick",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given subagent_type without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "explore", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Subagent without run flag",
|
||||
prompt: "Find patterns",
|
||||
subagent_type: "explore",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given session_id without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { resume: async () => ({ id: "task-1", sessionID: "ses_1", status: "running" }) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Continue without run flag",
|
||||
prompt: "Continue",
|
||||
session_id: "ses_existing",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given no category no subagent_type no session_id and no run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Missing required args",
|
||||
prompt: "Do something",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given explicit run_in_background=false #when executing #then sync execution succeeds", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptCalled = false
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent", model: { providerID: "anthropic", modelID: "claude-opus-4-6" } }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_explicit_false" } }),
|
||||
prompt: async () => {
|
||||
promptCalled = true
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async () => {
|
||||
promptCalled = true
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }] }),
|
||||
status: async () => ({ data: { ses_explicit_false: { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Explicit false",
|
||||
prompt: "Run sync",
|
||||
subagent_type: "oracle",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(promptCalled).toBe(true)
|
||||
expect(result).toContain("Done")
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("#given explicit run_in_background=true #when executing #then background execution succeeds", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let launchCalled = false
|
||||
const mockManager = {
|
||||
launch: async () => {
|
||||
launchCalled = true
|
||||
return {
|
||||
id: "bg_explicit_true",
|
||||
sessionID: "ses_bg_explicit_true",
|
||||
description: "Explicit true",
|
||||
agent: "Sisyphus-Junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
}
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [] },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_bg_explicit_true" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Explicit true",
|
||||
prompt: "Run background",
|
||||
category: "quick",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(launchCalled).toBe(true)
|
||||
expect(result).toContain("Background task launched")
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
|
||||
describe("session_id with background parameter", () => {
|
||||
test("session_id with background=false should wait for result and return content", async () => {
|
||||
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
|
||||
|
||||
@@ -83,7 +83,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
Available categories:
|
||||
${categoryList}
|
||||
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
|
||||
- run_in_background: true=async (returns task_id), false=sync (waits). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
|
||||
- command: The command that triggered this task (optional, for slash command tracking).
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().describe("Short task description (3-5 words)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().describe("true=async (returns task_id), false=sync (waits). Default: false"),
|
||||
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
|
||||
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
|
||||
@@ -123,11 +123,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
})
|
||||
|
||||
if (args.run_in_background === undefined) {
|
||||
if (args.category || args.subagent_type || args.session_id) {
|
||||
args.run_in_background = false
|
||||
} else {
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
|
||||
}
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
|
||||
}
|
||||
if (typeof args.load_skills === "string") {
|
||||
try {
|
||||
|
||||
@@ -72,6 +72,19 @@ describe("computeLineHash", () => {
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it("preserves legacy hashes for internal whitespace variants", () => {
|
||||
//#given
|
||||
const content1 = "if (a && b) {"
|
||||
const content2 = "if(a&&b){"
|
||||
|
||||
//#when
|
||||
const hash1 = computeLegacyLineHash(1, content1)
|
||||
const hash2 = computeLegacyLineHash(1, content2)
|
||||
|
||||
//#then
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it("ignores trailing whitespace differences", () => {
|
||||
//#given
|
||||
const content1 = "function hello() {"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function computeLineHash(lineNumber: number, content: string): string {
|
||||
}
|
||||
|
||||
export function computeLegacyLineHash(lineNumber: number, content: string): string {
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trim())
|
||||
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
|
||||
}
|
||||
|
||||
export function formatHashLine(lineNumber: number, content: string): string {
|
||||
|
||||
@@ -7,63 +7,89 @@ WORKFLOW:
|
||||
4. If same file needs another call, re-read first.
|
||||
5. Use anchors as "LINE#ID" only (never include trailing "|content").
|
||||
|
||||
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 }
|
||||
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.
|
||||
<must>
|
||||
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call — the system applies them bottom-up automatically.
|
||||
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED — do NOT include them in lines. If you do, they will appear twice.
|
||||
- lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged.
|
||||
- Tags MUST be copied exactly from read output or >>> mismatch output. NEVER guess tags.
|
||||
- Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change.
|
||||
- lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers).
|
||||
</must>
|
||||
|
||||
LINE#ID FORMAT (CRITICAL):
|
||||
Each line reference must be in "{line_number}#{hash_id}" format where:
|
||||
{line_number}: 1-based line number
|
||||
{hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
<operations>
|
||||
LINE#ID FORMAT:
|
||||
Each line reference must be in "{line_number}#{hash_id}" format where:
|
||||
{line_number}: 1-based line number
|
||||
{hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
|
||||
FILE MODES:
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
OPERATION CHOICE:
|
||||
replace with pos only -> replace one line at pos
|
||||
replace with pos+end -> replace range pos..end inclusive as a block (ranges MUST NOT overlap across edits)
|
||||
append with pos/end anchor -> insert after that anchor
|
||||
prepend with pos/end anchor -> insert before that anchor
|
||||
append/prepend without anchors -> EOF/BOF insertion (also creates missing files)
|
||||
|
||||
CONTENT FORMAT:
|
||||
lines can be a string (single line) or string[] (multi-line, preferred).
|
||||
If you pass a multi-line string, it is split by real newline characters.
|
||||
Literal "\\n" is preserved as text.
|
||||
lines: null or lines: [] with replace -> delete those lines.
|
||||
|
||||
FILE CREATION:
|
||||
append without anchors adds content at EOF. If file does not exist, creates it.
|
||||
prepend without anchors adds content at BOF. If file does not exist, creates it.
|
||||
CRITICAL: only unanchored append/prepend can create a missing file.
|
||||
FILE MODES:
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
|
||||
OPERATION CHOICE:
|
||||
replace with pos only -> replace one line at pos
|
||||
replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits)
|
||||
append with pos/end anchor -> insert after that anchor
|
||||
prepend with pos/end anchor -> insert before that anchor
|
||||
append/prepend without anchors -> EOF/BOF insertion
|
||||
RULES:
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
|
||||
3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
|
||||
4. No no-ops: replacement content must differ from current content.
|
||||
5. Touch only requested code: avoid incidental edits.
|
||||
6. Use exact current tokens: NEVER rewrite approximately.
|
||||
7. For swaps/moves: prefer one range operation over multiple single-line operations.
|
||||
8. Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
9. Re-read after each successful edit call before issuing another on the same file.
|
||||
</operations>
|
||||
|
||||
RULES (CRITICAL):
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
|
||||
3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
|
||||
4. No no-ops: replacement content must differ from current content.
|
||||
5. Touch only requested code: avoid incidental edits.
|
||||
6. Use exact current tokens: NEVER rewrite approximately.
|
||||
7. For swaps/moves: prefer one range operation over multiple single-line operations.
|
||||
8. Output tool calls only; no prose or commentary between them.
|
||||
<examples>
|
||||
Given this file content after read:
|
||||
10#VK|function hello() {
|
||||
11#XJ| console.log("hi");
|
||||
12#MB| console.log("bye");
|
||||
13#QR|}
|
||||
14#TN|
|
||||
15#WS|function world() {
|
||||
|
||||
TAG CHOICE (ALWAYS):
|
||||
- Copy tags exactly from read output or >>> mismatch output.
|
||||
- NEVER guess tags.
|
||||
- Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
- Anti-pattern warning: blank/whitespace anchors are fragile.
|
||||
- Re-read after each successful edit call before issuing another on the same file.
|
||||
Single-line replace (change line 11):
|
||||
{ op: "replace", pos: "11#XJ", lines: [" console.log(\\"hello\\");"] }
|
||||
Result: line 11 replaced. Lines 10, 12-15 unchanged.
|
||||
|
||||
AUTOCORRECT (built-in - you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
Indentation is auto-restored from original lines.
|
||||
BOM and CRLF line endings are preserved automatically.
|
||||
Hashline prefixes and diff markers in text are auto-stripped.
|
||||
Range replace (rewrite function body, lines 11-12):
|
||||
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hello world\\";"] }
|
||||
Result: lines 11-12 removed, replaced by 1 new line. Lines 10, 13-15 unchanged.
|
||||
|
||||
Delete a line:
|
||||
{ op: "replace", pos: "12#MB", lines: null }
|
||||
Result: line 12 removed. Lines 10-11, 13-15 unchanged.
|
||||
|
||||
Insert after line 13 (between functions):
|
||||
{ op: "append", pos: "13#QR", lines: ["", "function added() {", " return true;", "}"] }
|
||||
Result: 4 new lines inserted after line 13. All existing lines unchanged.
|
||||
|
||||
BAD — lines extend past end (DUPLICATES line 13):
|
||||
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";", "}"] }
|
||||
Line 13 is "}" which already exists after end. Including "}" in lines duplicates it.
|
||||
CORRECT: { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";"] }
|
||||
</examples>
|
||||
|
||||
<auto>
|
||||
Built-in autocorrect (you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
Indentation is auto-restored from original lines.
|
||||
BOM and CRLF line endings are preserved automatically.
|
||||
Hashline prefixes and diff markers in text are auto-stripped.
|
||||
Boundary echo lines (duplicating adjacent surviving lines) are auto-stripped.
|
||||
</auto>
|
||||
|
||||
RECOVERY (when >>> mismatch error appears):
|
||||
Copy the updated LINE#ID tags shown in the error output directly.
|
||||
Re-read only if the needed tags are missing from the error snippet.
|
||||
ALWAYS batch all edits for one file in a single call.`
|
||||
Copy the updated LINE#ID tags shown in the error output directly.
|
||||
Re-read only if the needed tags are missing from the error snippet.`
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createHashlineEditTool(): ToolDefinition {
|
||||
pos: tool.schema.string().optional().describe("Primary anchor in LINE#ID format"),
|
||||
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
|
||||
lines: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.null()])
|
||||
.union([tool.schema.array(tool.schema.string()), tool.schema.string(), tool.schema.null()])
|
||||
.describe("Replacement or inserted lines as newline-delimited string. null deletes with replace"),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -125,6 +125,15 @@ describe("validateLineRef", () => {
|
||||
expect(() => validateLineRef(lines, `1#${legacyHash}`)).not.toThrow()
|
||||
})
|
||||
|
||||
it("accepts legacy hashes for internal whitespace variants", () => {
|
||||
//#given
|
||||
const lines = ["if (a && b) {"]
|
||||
const legacyHash = computeLegacyLineHash(1, "if(a&&b){")
|
||||
|
||||
//#when / #then
|
||||
expect(() => validateLineRef(lines, `1#${legacyHash}`)).not.toThrow()
|
||||
})
|
||||
|
||||
it("shows >>> mismatch context in batched validation", () => {
|
||||
//#given
|
||||
const lines = ["one", "two", "three", "four"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const MULTIMODAL_LOOKER_AGENT = "multimodal-looker" as const
|
||||
|
||||
export const LOOK_AT_DESCRIPTION = `Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.`
|
||||
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy — use Read tool instead for those cases.`
|
||||
|
||||
@@ -37,9 +37,19 @@ export function loadJsonFile<T>(path: string): T | null {
|
||||
export function getConfigPaths(): { project: string; user: string; opencode: string } {
|
||||
const cwd = process.cwd()
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const projectDetected = detectConfigFile(join(cwd, ".opencode", "oh-my-opencode"))
|
||||
const projectPath = projectDetected.format !== "none"
|
||||
? projectDetected.path
|
||||
: detectConfigFile(join(cwd, ".opencode", "oh-my-openagent")).path
|
||||
|
||||
const userDetected = detectConfigFile(join(configDir, "oh-my-opencode"))
|
||||
const userPath = userDetected.format !== "none"
|
||||
? userDetected.path
|
||||
: detectConfigFile(join(configDir, "oh-my-openagent")).path
|
||||
|
||||
return {
|
||||
project: detectConfigFile(join(cwd, ".opencode", "oh-my-opencode")).path,
|
||||
user: detectConfigFile(join(configDir, "oh-my-opencode")).path,
|
||||
project: projectPath,
|
||||
user: userPath,
|
||||
opencode: detectConfigFile(join(configDir, "opencode")).path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,3 +525,59 @@ describe("skill tool - dynamic discovery", () => {
|
||||
expect(result).not.toContain("SHOULD_BE_OVERRIDDEN")
|
||||
})
|
||||
})
|
||||
describe("skill tool - dynamic description cache invalidation", () => {
|
||||
it("rebuilds description after execute() discovers new skills", async () => {
|
||||
// given: tool created with initial skills (no pre-provided skills)
|
||||
// This triggers lazy description building
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// Get initial description - it will build from empty or disk skills
|
||||
const initialDescription = tool.description
|
||||
|
||||
// when: execute() is called, which clears cache AND gets fresh skills
|
||||
// Note: In real scenario, execute() would discover new skills from disk
|
||||
// For testing, we verify the mechanism: execute() should invalidate cachedDescription
|
||||
|
||||
// Execute any skill to trigger the cache clear + getSkills flow
|
||||
// Using a non-existent skill name to trigger the error path which still goes through getSkills()
|
||||
try {
|
||||
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected to fail - skill doesn't exist
|
||||
}
|
||||
|
||||
// then: cachedDescription should be invalidated, so next description access should rebuild
|
||||
// We verify by checking that the description getter triggers a rebuild
|
||||
// Since we can't easily mock getAllSkills in this test, we verify the cache invalidation mechanism
|
||||
|
||||
// The key assertion: after execute(), the description should be rebuildable
|
||||
// If cachedDescription wasn't invalidated, it would still return old value
|
||||
// We verify by checking that the tool still has valid description structure
|
||||
expect(tool.description).toBeDefined()
|
||||
expect(typeof tool.description).toBe("string")
|
||||
})
|
||||
|
||||
it("description reflects fresh skills after execute() clears cache", async () => {
|
||||
// given: tool created without pre-provided skills (will use disk discovery)
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// when: execute() is called with a skill that exists on disk (via mock)
|
||||
// This simulates the real scenario: execute() discovers skills, cache should be invalidated
|
||||
|
||||
// Execute to trigger the cache invalidation path
|
||||
try {
|
||||
// This will call getSkills() which clears cache
|
||||
await tool.execute({ name: "nonexistent" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// then: description should still work and not be stale
|
||||
// The bug would cause it to return old cached value forever
|
||||
const desc = tool.description
|
||||
|
||||
// Verify description is a valid string (not stale/old)
|
||||
expect(desc).toContain("skill")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
},
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
const skills = await getSkills()
|
||||
cachedDescription = null
|
||||
const commands = getCommands()
|
||||
|
||||
const requestedName = args.name.replace(/^\//, "")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "hashline-edit-benchmark",
|
||||
"dependencies": {
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0",
|
||||
},
|
||||
@@ -14,13 +14,11 @@
|
||||
"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-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/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@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": ["@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=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "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-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -35,5 +33,9 @@
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@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/@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=="],
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,17 @@ 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 { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
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"
|
||||
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"
|
||||
import { HASHLINE_EDIT_DESCRIPTION } from "../../src/tools/hashline-edit/tool-description"
|
||||
|
||||
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
|
||||
const DEFAULT_MODEL = "minimax-m2.5-free"
|
||||
const MAX_STEPS = 50
|
||||
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const sessionId = `hashline-${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 }))
|
||||
@@ -33,7 +34,7 @@ function parseArgs(): { prompt: string; modelId: string } {
|
||||
// --no-translate, --think consumed silently
|
||||
}
|
||||
if (!prompt) {
|
||||
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
|
||||
console.error("Usage: bun run tests/hashline/headless.ts -p <prompt> [-m <model>]")
|
||||
process.exit(1)
|
||||
}
|
||||
return { prompt, modelId }
|
||||
@@ -57,7 +58,7 @@ const readFileTool = tool({
|
||||
})
|
||||
|
||||
const editFileTool = tool({
|
||||
description: "Edit a file using hashline anchors (LINE#ID format)",
|
||||
description: HASHLINE_EDIT_DESCRIPTION,
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(
|
||||
@@ -116,8 +117,12 @@ const editFileTool = tool({
|
||||
async function run() {
|
||||
const { prompt, modelId } = parseArgs()
|
||||
|
||||
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
|
||||
const model = friendli(modelId)
|
||||
const provider = createOpenAICompatible({
|
||||
name: "hashline-test",
|
||||
baseURL: process.env.HASHLINE_TEST_BASE_URL ?? "https://quotio.mengmota.com/v1",
|
||||
apiKey: process.env.HASHLINE_TEST_API_KEY ?? "quotio-local-60A613FE-DB74-40FF-923E-A14151951E5D",
|
||||
})
|
||||
const model = provider.chatModel(modelId)
|
||||
const tools = { read_file: readFileTool, edit_file: editFileTool }
|
||||
|
||||
emit({ type: "user", content: prompt })
|
||||
@@ -125,7 +130,8 @@ async function run() {
|
||||
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."
|
||||
"Always read a file before editing it to get fresh LINE#ID anchors.\n\n" +
|
||||
"edit_file tool description:\n" + HASHLINE_EDIT_DESCRIPTION
|
||||
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
const stream = streamText({
|
||||
@@ -161,6 +167,7 @@ async function run() {
|
||||
...(isError ? { error: output } : {}),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,3 +198,4 @@ run()
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
console.error(`[headless] Completed in ${elapsed}s`)
|
||||
})
|
||||
|
||||
18
tests/hashline/package.json
Normal file
18
tests/hashline/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "hashline-edit-tests",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Hashline edit tool integration tests using Vercel AI SDK",
|
||||
"scripts": {
|
||||
"test:basic": "bun run test-edit-ops.ts",
|
||||
"test:edge": "bun run test-edge-cases.ts",
|
||||
"test:multi": "bun run test-multi-model.ts",
|
||||
"test:all": "bun run test:basic && bun run test:edge"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user