Compare commits
4 Commits
fix/plan-p
...
feat/omx-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed92a05e59 | ||
|
|
40f25fb07d | ||
|
|
c073169949 | ||
|
|
96f4b3b56c |
98
.github/workflows/publish-platform.yml
vendored
98
.github/workflows/publish-platform.yml
vendored
@@ -59,39 +59,20 @@ jobs:
|
|||||||
- name: Check if already published
|
- name: Check if already published
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
|
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||||
VERSION="${{ inputs.version }}"
|
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="${{ matrix.platform }}"
|
||||||
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
|
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=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "✓ ${PKG_NAME}@${VERSION} already published"
|
||||||
else
|
else
|
||||||
echo "skip=false" >> $GITHUB_OUTPUT
|
echo "skip=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Update version in package.json
|
- name: Update version in package.json
|
||||||
@@ -226,38 +207,23 @@ jobs:
|
|||||||
matrix:
|
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]
|
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||||
steps:
|
steps:
|
||||||
- name: Check if already published
|
- name: Check if oh-my-opencode already published
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
|
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||||
VERSION="${{ inputs.version }}"
|
VERSION="${{ inputs.version }}"
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||||
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
|
if [ "$STATUS" = "200" ]; then
|
||||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
echo "skip=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
|
||||||
if [ "$OC_STATUS" = "200" ]; then
|
|
||||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
|
||||||
else
|
else
|
||||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
echo "skip=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
echo "→ ${PKG_NAME}@${VERSION} will be published"
|
||||||
|
|
||||||
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
|
fi
|
||||||
|
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
id: download
|
id: download
|
||||||
if: steps.check.outputs.skip_all != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -265,7 +231,7 @@ jobs:
|
|||||||
path: .
|
path: .
|
||||||
|
|
||||||
- name: Extract artifact
|
- name: Extract artifact
|
||||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
mkdir -p packages/${PLATFORM}
|
mkdir -p packages/${PLATFORM}
|
||||||
@@ -281,13 +247,13 @@ jobs:
|
|||||||
ls -la packages/${PLATFORM}/bin/
|
ls -la packages/${PLATFORM}/bin/
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- name: Publish oh-my-opencode-${{ matrix.platform }}
|
- name: Publish ${{ matrix.platform }}
|
||||||
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
|
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
cd packages/${{ matrix.platform }}
|
cd packages/${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -301,25 +267,3 @@ jobs:
|
|||||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
timeout-minutes: 15
|
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,48 +216,6 @@ jobs:
|
|||||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: true
|
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:
|
trigger-platform:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: publish-main
|
needs: publish-main
|
||||||
|
|||||||
122
FIX-BLOCKS.md
122
FIX-BLOCKS.md
@@ -1,122 +0,0 @@
|
|||||||
# 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,3 +1,9 @@
|
|||||||
|
> [!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]
|
> [!NOTE]
|
||||||
>
|
>
|
||||||
> [](https://sisyphuslabs.ai)
|
> [](https://sisyphuslabs.ai)
|
||||||
|
|||||||
@@ -43,7 +43,57 @@
|
|||||||
"disabled_hooks": {
|
"disabled_hooks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"disabled_commands": {
|
"disabled_commands": {
|
||||||
@@ -3699,35 +3749,6 @@
|
|||||||
"syncPollTimeoutMs": {
|
"syncPollTimeoutMs": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 60000
|
"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
|
"additionalProperties": false
|
||||||
@@ -3906,4 +3927,4 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "hashline-edit-benchmark",
|
"name": "hashline-edit-benchmark",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
"@friendliai/ai-provider": "^1.0.9",
|
||||||
"ai": "^6.0.94",
|
"ai": "^6.0.94",
|
||||||
"zod": "^4.1.0",
|
"zod": "^4.1.0",
|
||||||
},
|
},
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
"packages": {
|
"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/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.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/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
|
||||||
|
|
||||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
"@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.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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -33,9 +35,5 @@
|
|||||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"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,17 +3,16 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"
|
|||||||
import { join, dirname } from "node:path"
|
import { join, dirname } from "node:path"
|
||||||
import { stepCountIs, streamText, type CoreMessage } from "ai"
|
import { stepCountIs, streamText, type CoreMessage } from "ai"
|
||||||
import { tool } from "ai"
|
import { tool } from "ai"
|
||||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
import { createFriendli } from "@friendliai/ai-provider"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { formatHashLines } from "../../src/tools/hashline-edit/hash-computation"
|
import { formatHashLines } from "../src/tools/hashline-edit/hash-computation"
|
||||||
import { normalizeHashlineEdits } from "../../src/tools/hashline-edit/normalize-edits"
|
import { normalizeHashlineEdits } from "../src/tools/hashline-edit/normalize-edits"
|
||||||
import { applyHashlineEditsWithReport } from "../../src/tools/hashline-edit/edit-operations"
|
import { applyHashlineEditsWithReport } from "../src/tools/hashline-edit/edit-operations"
|
||||||
import { canonicalizeFileText, restoreFileText } from "../../src/tools/hashline-edit/file-text-canonicalization"
|
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 = "minimax-m2.5-free"
|
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
|
||||||
const MAX_STEPS = 50
|
const MAX_STEPS = 50
|
||||||
const sessionId = `hashline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
const emit = (event: Record<string, unknown>) =>
|
const emit = (event: Record<string, unknown>) =>
|
||||||
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
|
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
|
||||||
@@ -34,7 +33,7 @@ function parseArgs(): { prompt: string; modelId: string } {
|
|||||||
// --no-translate, --think consumed silently
|
// --no-translate, --think consumed silently
|
||||||
}
|
}
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
console.error("Usage: bun run tests/hashline/headless.ts -p <prompt> [-m <model>]")
|
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
return { prompt, modelId }
|
return { prompt, modelId }
|
||||||
@@ -58,7 +57,7 @@ const readFileTool = tool({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const editFileTool = tool({
|
const editFileTool = tool({
|
||||||
description: HASHLINE_EDIT_DESCRIPTION,
|
description: "Edit a file using hashline anchors (LINE#ID format)",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
edits: z.array(
|
edits: z.array(
|
||||||
@@ -117,12 +116,8 @@ const editFileTool = tool({
|
|||||||
async function run() {
|
async function run() {
|
||||||
const { prompt, modelId } = parseArgs()
|
const { prompt, modelId } = parseArgs()
|
||||||
|
|
||||||
const provider = createOpenAICompatible({
|
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
|
||||||
name: "hashline-test",
|
const model = friendli(modelId)
|
||||||
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 }
|
const tools = { read_file: readFileTool, edit_file: editFileTool }
|
||||||
|
|
||||||
emit({ type: "user", content: prompt })
|
emit({ type: "user", content: prompt })
|
||||||
@@ -130,8 +125,7 @@ async function run() {
|
|||||||
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
|
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
|
||||||
const system =
|
const system =
|
||||||
"You are a code editing assistant. Use read_file to read files and edit_file to edit them. " +
|
"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.\n\n" +
|
"Always read a file before editing it to get fresh LINE#ID anchors."
|
||||||
"edit_file tool description:\n" + HASHLINE_EDIT_DESCRIPTION
|
|
||||||
|
|
||||||
for (let step = 0; step < MAX_STEPS; step++) {
|
for (let step = 0; step < MAX_STEPS; step++) {
|
||||||
const stream = streamText({
|
const stream = streamText({
|
||||||
@@ -167,7 +161,6 @@ async function run() {
|
|||||||
...(isError ? { error: output } : {}),
|
...(isError ? { error: output } : {}),
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,4 +191,3 @@ run()
|
|||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||||
console.error(`[headless] Completed in ${elapsed}s`)
|
console.error(`[headless] Completed in ${elapsed}s`)
|
||||||
})
|
})
|
||||||
|
|
||||||
18
benchmarks/package.json
Normal file
18
benchmarks/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ import { resolve } from "node:path";
|
|||||||
|
|
||||||
// ── Models ────────────────────────────────────────────────────
|
// ── Models ────────────────────────────────────────────────────
|
||||||
const MODELS = [
|
const MODELS = [
|
||||||
{ id: "minimax-m2.5-free", short: "M2.5-Free" },
|
{ id: "MiniMaxAI/MiniMax-M2.5", short: "M2.5" },
|
||||||
|
// { id: "MiniMaxAI/MiniMax-M2.1", short: "M2.1" }, // masked: slow + timeout-prone
|
||||||
|
// { id: "zai-org/GLM-5", short: "GLM-5" }, // masked: API 503
|
||||||
|
{ id: "zai-org/GLM-4.7", short: "GLM-4.7" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── CLI args ──────────────────────────────────────────────────
|
// ── CLI args ──────────────────────────────────────────────────
|
||||||
@@ -64,8 +64,8 @@ These agents have Claude-optimized prompts — long, detailed, mechanics-driven.
|
|||||||
|
|
||||||
| Agent | Role | Fallback Chain | Notes |
|
| Agent | Role | Fallback Chain | Notes |
|
||||||
| ------------ | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
| ------------ | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| **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. |
|
| **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 → GPT-5.4 → opencode-go/glm-5 → K2P5 | Claude preferred. GPT-5.4 as secondary before GLM-5 fallback. |
|
| **Metis** | Plan gap analyzer | Claude Opus → opencode-go/glm-5 → K2P5 | Claude preferred. Uses opencode-go for reliable GLM-5 access. |
|
||||||
|
|
||||||
### Dual-Prompt Agents → Claude preferred, GPT supported
|
### 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 |
|
| 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. |
|
| **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 → GPT-5.4 | Claude first, opencode-go as intermediate, GPT-5.4 as last resort. |
|
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 | Claude first, opencode-go as the current fallback path. |
|
||||||
|
|
||||||
### Deep Specialists → GPT
|
### 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 |
|
| Agent | Role | Fallback Chain | Notes |
|
||||||
| -------------- | ----------------------- | -------------------------------------- | ------------------------------------------------ |
|
| -------------- | ----------------------- | -------------------------------------- | ------------------------------------------------ |
|
||||||
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex → GPT-5.4 (Copilot) | Requires GPT access. GPT-5.4 via Copilot as fallback. The craftsman. |
|
| **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 → opencode-go/glm-5 | Read-only high-IQ consultation. |
|
| **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 → opencode-go/glm-5 | Verification and plan review. GPT-5.4 uses xhigh variant. |
|
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro | Verification and plan review. |
|
||||||
|
|
||||||
### Utility Runners → Speed over Intelligence
|
### Utility Runners → Speed over Intelligence
|
||||||
|
|
||||||
@@ -95,7 +95,6 @@ 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,7 +119,8 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
|
|||||||
| Model | Strengths |
|
| Model | Strengths |
|
||||||
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. |
|
| **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. |
|
||||||
| **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.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-Nano** | Ultra-cheap, fast. Good for simple utility tasks. |
|
| **GPT-5-Nano** | Ultra-cheap, fast. Good for simple utility tasks. |
|
||||||
|
|
||||||
### Other Models
|
### 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 |
|
| Category | When Used | Fallback Chain |
|
||||||
| -------------------- | -------------------------- | -------------------------------------------- |
|
| -------------------- | -------------------------- | -------------------------------------------- |
|
||||||
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus → opencode-go/glm-5 → K2P5 |
|
| `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 → opencode-go/glm-5 |
|
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus |
|
||||||
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
|
| `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 |
|
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
|
||||||
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
|
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → GPT-5-Nano |
|
||||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
|
| `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 → opencode-go/kimi-k2.5 → Gemini Flash |
|
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → Gemini Flash |
|
||||||
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |
|
| `writing` | Text, docs, prose | Gemini Flash → Claude Sonnet |
|
||||||
|
|
||||||
See the [Orchestration System Guide](./orchestration.md) for how agents dispatch tasks to categories.
|
See the [Orchestration System Guide](./orchestration.md) for how agents dispatch tasks to categories.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
||||||
|
"build:sdk": "cd packages/sdk && bun run build",
|
||||||
"build:all": "bun run build && bun run build:binaries",
|
"build:all": "bun run build && bun run build:binaries",
|
||||||
"build:binaries": "bun run script/build-binaries.ts",
|
"build:binaries": "bun run script/build-binaries.ts",
|
||||||
"build:schema": "bun run script/build-schema.ts",
|
"build:schema": "bun run script/build-schema.ts",
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
"postinstall": "node postinstall.mjs",
|
"postinstall": "node postinstall.mjs",
|
||||||
"prepublishOnly": "bun run clean && bun run build",
|
"prepublishOnly": "bun run clean && bun run build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "bun test"
|
"typecheck:sdk": "cd packages/sdk && bun run typecheck",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:sdk": "cd packages/sdk && bun run test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
|
|||||||
27
packages/sdk/README.md
Normal file
27
packages/sdk/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# @oh-my-openagent/sdk
|
||||||
|
|
||||||
|
Programmatic runner for starting or attaching to an OpenCode server, running oh-my-openagent sessions, and consuming normalized lifecycle events.
|
||||||
|
|
||||||
|
## `run()`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOmoRunner } from "@oh-my-openagent/sdk"
|
||||||
|
|
||||||
|
const runner = createOmoRunner({ directory: process.cwd(), agent: "prometheus" })
|
||||||
|
const result = await runner.run("Plan the next release")
|
||||||
|
await runner.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## `stream()`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOmoRunner } from "@oh-my-openagent/sdk"
|
||||||
|
|
||||||
|
const runner = createOmoRunner({ directory: process.cwd() })
|
||||||
|
|
||||||
|
for await (const event of runner.stream("Investigate the build failure")) {
|
||||||
|
console.log(event.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
await runner.close()
|
||||||
|
```
|
||||||
23
packages/sdk/package.json
Normal file
23
packages/sdk/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@oh-my-openagent/sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf dist && bun build ./src/index.ts --outdir dist --target bun --format esm && cp ./src/*.d.ts ./dist/",
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/sdk/src/create-omo-runner.d.ts
vendored
Normal file
3
packages/sdk/src/create-omo-runner.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { CreateOmoRunnerOptions, OmoRunner } from "./types"
|
||||||
|
|
||||||
|
export declare function createOmoRunner(options: CreateOmoRunnerOptions): OmoRunner
|
||||||
106
packages/sdk/src/create-omo-runner.test.ts
Normal file
106
packages/sdk/src/create-omo-runner.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
import type { ServerConnection } from "../../../src/cli/run/types"
|
||||||
|
|
||||||
|
const mockCreateServerConnection = mock(
|
||||||
|
async (): Promise<ServerConnection> => ({
|
||||||
|
client: {} as never,
|
||||||
|
cleanup: mock(() => {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockExecuteRunSession = mock(async (_options: unknown) => ({
|
||||||
|
exitCode: 0,
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
result: {
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
success: true,
|
||||||
|
durationMs: 10,
|
||||||
|
messageCount: 1,
|
||||||
|
summary: "done",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../../src/cli/run/server-connection", () => ({
|
||||||
|
createServerConnection: mockCreateServerConnection,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../../src/cli/run/run-engine", () => ({
|
||||||
|
executeRunSession: mockExecuteRunSession,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createOmoRunner } = await import("./create-omo-runner")
|
||||||
|
|
||||||
|
describe("createOmoRunner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreateServerConnection.mockClear()
|
||||||
|
mockExecuteRunSession.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reuses the same connection and enables question-aware execution", async () => {
|
||||||
|
const runner = createOmoRunner({
|
||||||
|
directory: "/repo",
|
||||||
|
agent: "atlas",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await runner.run("first")
|
||||||
|
const second = await runner.run("second", { agent: "prometheus" })
|
||||||
|
|
||||||
|
expect(first.summary).toBe("done")
|
||||||
|
expect(second.summary).toBe("done")
|
||||||
|
expect(mockCreateServerConnection).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockExecuteRunSession).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
directory: "/repo",
|
||||||
|
agent: "atlas",
|
||||||
|
questionPermission: "allow",
|
||||||
|
questionToolEnabled: true,
|
||||||
|
renderOutput: false,
|
||||||
|
}))
|
||||||
|
expect(mockExecuteRunSession).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
agent: "prometheus",
|
||||||
|
}))
|
||||||
|
await runner.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("streams normalized events", async () => {
|
||||||
|
mockExecuteRunSession.mockImplementationOnce(async (options: { eventObserver?: { onEvent?: (event: unknown) => Promise<void> } }) => {
|
||||||
|
await options.eventObserver?.onEvent?.({
|
||||||
|
type: "session.started",
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
agent: "Atlas (Plan Executor)",
|
||||||
|
resumed: false,
|
||||||
|
})
|
||||||
|
await options.eventObserver?.onEvent?.({
|
||||||
|
type: "session.completed",
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
result: {
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
success: true,
|
||||||
|
durationMs: 10,
|
||||||
|
messageCount: 1,
|
||||||
|
summary: "done",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
result: {
|
||||||
|
sessionId: "ses_runner",
|
||||||
|
success: true,
|
||||||
|
durationMs: 10,
|
||||||
|
messageCount: 1,
|
||||||
|
summary: "done",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const runner = createOmoRunner({ directory: "/repo" })
|
||||||
|
const seenTypes: string[] = []
|
||||||
|
|
||||||
|
for await (const event of runner.stream("stream")) {
|
||||||
|
seenTypes.push(event.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seenTypes).toEqual(["session.started", "session.completed"])
|
||||||
|
await runner.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
189
packages/sdk/src/create-omo-runner.ts
Normal file
189
packages/sdk/src/create-omo-runner.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { createServerConnection } from "../../../src/cli/run/server-connection"
|
||||||
|
import { executeRunSession } from "../../../src/cli/run/run-engine"
|
||||||
|
import type { RunEventObserver, ServerConnection } from "../../../src/cli/run/types"
|
||||||
|
import type {
|
||||||
|
CreateOmoRunnerOptions,
|
||||||
|
OmoRunInvocationOptions,
|
||||||
|
OmoRunner,
|
||||||
|
RunResult,
|
||||||
|
StreamEvent,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
class AsyncEventQueue<T> implements AsyncIterable<T> {
|
||||||
|
private readonly values: T[] = []
|
||||||
|
private readonly waiters: Array<(value: IteratorResult<T>) => void> = []
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
push(value: T): void {
|
||||||
|
if (this.closed) return
|
||||||
|
const waiter = this.waiters.shift()
|
||||||
|
if (waiter) {
|
||||||
|
waiter({ value, done: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.values.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
while (this.waiters.length > 0) {
|
||||||
|
const waiter = this.waiters.shift()
|
||||||
|
waiter?.({ value: undefined, done: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
const value = this.values.shift()
|
||||||
|
if (value !== undefined) {
|
||||||
|
return Promise.resolve({ value, done: false })
|
||||||
|
}
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.resolve({ value: undefined, done: true })
|
||||||
|
}
|
||||||
|
return new Promise<IteratorResult<T>>((resolve) => {
|
||||||
|
this.waiters.push(resolve)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOmoRunner(options: CreateOmoRunnerOptions): OmoRunner {
|
||||||
|
const {
|
||||||
|
directory,
|
||||||
|
agent,
|
||||||
|
port,
|
||||||
|
model,
|
||||||
|
attach,
|
||||||
|
includeRawEvents = false,
|
||||||
|
onIdle,
|
||||||
|
onQuestion,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
} = options
|
||||||
|
let connectionPromise: Promise<ServerConnection> | null = null
|
||||||
|
let connectionController: AbortController | null = null
|
||||||
|
let closed = false
|
||||||
|
let activeRun: Promise<unknown> | null = null
|
||||||
|
|
||||||
|
const silentLogger = {
|
||||||
|
log: () => {},
|
||||||
|
error: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureConnection = async (): Promise<ServerConnection> => {
|
||||||
|
if (closed) {
|
||||||
|
throw new Error("Runner is closed")
|
||||||
|
}
|
||||||
|
if (connectionPromise === null) {
|
||||||
|
connectionController = new AbortController()
|
||||||
|
connectionPromise = createServerConnection({
|
||||||
|
port,
|
||||||
|
attach,
|
||||||
|
signal: connectionController.signal,
|
||||||
|
logger: silentLogger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await connectionPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const createObserver = (
|
||||||
|
queue?: AsyncEventQueue<StreamEvent>,
|
||||||
|
): RunEventObserver => ({
|
||||||
|
includeRawEvents,
|
||||||
|
onEvent: async (event) => {
|
||||||
|
queue?.push(event as StreamEvent)
|
||||||
|
},
|
||||||
|
onIdle,
|
||||||
|
onQuestion,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const runOnce = async (
|
||||||
|
prompt: string,
|
||||||
|
invocationOptions: OmoRunInvocationOptions | undefined,
|
||||||
|
observer: RunEventObserver,
|
||||||
|
): Promise<RunResult> => {
|
||||||
|
if (activeRun !== null) {
|
||||||
|
throw new Error("Runner already has an active operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await ensureConnection()
|
||||||
|
const execution = executeRunSession({
|
||||||
|
client: connection.client,
|
||||||
|
message: prompt,
|
||||||
|
directory,
|
||||||
|
agent: invocationOptions?.agent ?? agent,
|
||||||
|
model: invocationOptions?.model ?? model,
|
||||||
|
sessionId: invocationOptions?.sessionId,
|
||||||
|
questionPermission: "allow",
|
||||||
|
questionToolEnabled: true,
|
||||||
|
renderOutput: false,
|
||||||
|
logger: silentLogger,
|
||||||
|
eventObserver: observer,
|
||||||
|
signal: invocationOptions?.signal,
|
||||||
|
})
|
||||||
|
activeRun = execution
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
void observer.onError?.({
|
||||||
|
type: "session.error",
|
||||||
|
sessionId: invocationOptions?.sessionId ?? "",
|
||||||
|
error: "Aborted by caller",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
invocationOptions?.signal?.addEventListener("abort", abortHandler, { once: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = await execution
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
invocationOptions?.signal?.removeEventListener("abort", abortHandler)
|
||||||
|
activeRun = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run(prompt, invocationOptions) {
|
||||||
|
return await runOnce(prompt, invocationOptions, createObserver())
|
||||||
|
},
|
||||||
|
stream(prompt, invocationOptions) {
|
||||||
|
const queue = new AsyncEventQueue<StreamEvent>()
|
||||||
|
const execution = runOnce(prompt, invocationOptions, createObserver(queue))
|
||||||
|
.catch((error) => {
|
||||||
|
queue.push({
|
||||||
|
type: "session.error",
|
||||||
|
sessionId: invocationOptions?.sessionId ?? "",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
queue.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
try {
|
||||||
|
for await (const event of queue) {
|
||||||
|
yield event
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await execution
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
closed = true
|
||||||
|
connectionController?.abort()
|
||||||
|
const connection = await connectionPromise
|
||||||
|
connection?.cleanup()
|
||||||
|
connectionPromise = null
|
||||||
|
connectionController = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/sdk/src/index.d.ts
vendored
Normal file
8
packages/sdk/src/index.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createOmoRunner } from "./create-omo-runner"
|
||||||
|
export type {
|
||||||
|
CreateOmoRunnerOptions,
|
||||||
|
OmoRunInvocationOptions,
|
||||||
|
OmoRunner,
|
||||||
|
RunResult,
|
||||||
|
StreamEvent,
|
||||||
|
} from "./types"
|
||||||
8
packages/sdk/src/index.ts
Normal file
8
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createOmoRunner } from "./create-omo-runner"
|
||||||
|
export type {
|
||||||
|
CreateOmoRunnerOptions,
|
||||||
|
OmoRunInvocationOptions,
|
||||||
|
OmoRunner,
|
||||||
|
RunResult,
|
||||||
|
StreamEvent,
|
||||||
|
} from "./types"
|
||||||
95
packages/sdk/src/types.d.ts
vendored
Normal file
95
packages/sdk/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export interface RunResult {
|
||||||
|
sessionId: string
|
||||||
|
success: boolean
|
||||||
|
durationMs: number
|
||||||
|
messageCount: number
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| {
|
||||||
|
type: "session.started"
|
||||||
|
sessionId: string
|
||||||
|
agent: string
|
||||||
|
resumed: boolean
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "message.delta"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "message.completed"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.started"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.completed"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
output?: string
|
||||||
|
status: "completed" | "error"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.idle"
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.question"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
question?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.completed"
|
||||||
|
sessionId: string
|
||||||
|
result: RunResult
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.error"
|
||||||
|
sessionId: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "raw"
|
||||||
|
sessionId: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoRunInvocationOptions {
|
||||||
|
sessionId?: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
agent?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOmoRunnerOptions {
|
||||||
|
directory: string
|
||||||
|
agent?: string
|
||||||
|
port?: number
|
||||||
|
model?: string
|
||||||
|
attach?: string
|
||||||
|
includeRawEvents?: boolean
|
||||||
|
onIdle?: (event: Extract<StreamEvent, { type: "session.idle" }>) => void | Promise<void>
|
||||||
|
onQuestion?: (event: Extract<StreamEvent, { type: "session.question" }>) => void | Promise<void>
|
||||||
|
onComplete?: (event: Extract<StreamEvent, { type: "session.completed" }>) => void | Promise<void>
|
||||||
|
onError?: (event: Extract<StreamEvent, { type: "session.error" }>) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoRunner {
|
||||||
|
run(prompt: string, options?: OmoRunInvocationOptions): Promise<RunResult>
|
||||||
|
stream(prompt: string, options?: OmoRunInvocationOptions): AsyncIterable<StreamEvent>
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
95
packages/sdk/src/types.ts
Normal file
95
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export interface RunResult {
|
||||||
|
sessionId: string
|
||||||
|
success: boolean
|
||||||
|
durationMs: number
|
||||||
|
messageCount: number
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| {
|
||||||
|
type: "session.started"
|
||||||
|
sessionId: string
|
||||||
|
agent: string
|
||||||
|
resumed: boolean
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "message.delta"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "message.completed"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.started"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.completed"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
output?: string
|
||||||
|
status: "completed" | "error"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.idle"
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.question"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
question?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.completed"
|
||||||
|
sessionId: string
|
||||||
|
result: RunResult
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "session.error"
|
||||||
|
sessionId: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "raw"
|
||||||
|
sessionId: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoRunInvocationOptions {
|
||||||
|
sessionId?: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
agent?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOmoRunnerOptions {
|
||||||
|
directory: string
|
||||||
|
agent?: string
|
||||||
|
port?: number
|
||||||
|
model?: string
|
||||||
|
attach?: string
|
||||||
|
includeRawEvents?: boolean
|
||||||
|
onIdle?: (event: Extract<StreamEvent, { type: "session.idle" }>) => void | Promise<void>
|
||||||
|
onQuestion?: (event: Extract<StreamEvent, { type: "session.question" }>) => void | Promise<void>
|
||||||
|
onComplete?: (event: Extract<StreamEvent, { type: "session.completed" }>) => void | Promise<void>
|
||||||
|
onError?: (event: Extract<StreamEvent, { type: "session.error" }>) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoRunner {
|
||||||
|
run(prompt: string, options?: OmoRunInvocationOptions): Promise<RunResult>
|
||||||
|
stream(prompt: string, options?: OmoRunInvocationOptions): AsyncIterable<StreamEvent>
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
24
packages/sdk/tsconfig.json
Normal file
24
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"rootDir": "../.."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"../../src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"../../src/**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2207,38 +2207,6 @@
|
|||||||
"created_at": "2026-03-16T04:55:10Z",
|
"created_at": "2026-03-16T04:55:10Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 2604
|
"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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"hephaestus": {
|
"hephaestus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"librarian": {
|
"librarian": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": {
|
"sisyphus-junior": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
|||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
@@ -145,7 +145,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
|||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"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",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": {
|
"sisyphus-junior": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"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",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": {
|
"sisyphus-junior": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -465,7 +465,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
|||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"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",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "zai-coding-plan/glm-4.6v",
|
"model": "zai-coding-plan/glm-4.6v",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"sisyphus": {
|
"sisyphus": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": {
|
"sisyphus-junior": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"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",
|
"model": "zai-coding-plan/glm-4.7",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "zai-coding-plan/glm-4.6v",
|
"model": "zai-coding-plan/glm-4.6v",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"sisyphus": {
|
"sisyphus": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": {
|
"sisyphus-junior": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"ultrabrain": {
|
"ultrabrain": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"unspecified-high": {
|
"unspecified-high": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
"unspecified-low": {
|
"unspecified-low": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"visual-engineering": {
|
"visual-engineering": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
"writing": {
|
"writing": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1273,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ Examples:
|
|||||||
|
|
||||||
Agent resolution order:
|
Agent resolution order:
|
||||||
1) --agent flag
|
1) --agent flag
|
||||||
2) OPENCODE_DEFAULT_AGENT
|
2) OPENCODE_AGENT
|
||||||
3) oh-my-opencode.json "default_run_agent"
|
3) OPENCODE_DEFAULT_AGENT
|
||||||
4) Sisyphus (fallback)
|
4) oh-my-opencode.json "default_run_agent"
|
||||||
|
5) Sisyphus (fallback)
|
||||||
|
|
||||||
Available core agents:
|
Available core agents:
|
||||||
Sisyphus, Hephaestus, Prometheus, Atlas
|
Sisyphus, Hephaestus, Prometheus, Atlas
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs"
|
import { readFileSync, writeFileSync } from "node:fs"
|
||||||
import type { ConfigMergeResult } from "../types"
|
import type { ConfigMergeResult } from "../types"
|
||||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
|
||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||||
@@ -8,6 +7,8 @@ import { detectConfigFormat } from "./opencode-config-format"
|
|||||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||||
|
|
||||||
|
const PACKAGE_NAME = "oh-my-opencode"
|
||||||
|
|
||||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||||
try {
|
try {
|
||||||
ensureConfigDirectoryExists()
|
ensureConfigDirectoryExists()
|
||||||
@@ -20,7 +21,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { format, path } = detectConfigFormat()
|
const { format, path } = detectConfigFormat()
|
||||||
const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
|
const pluginEntry = await getPluginNameWithVersion(currentVersion, PACKAGE_NAME)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (format === "none") {
|
if (format === "none") {
|
||||||
@@ -40,24 +41,13 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
|||||||
|
|
||||||
const config = parseResult.config
|
const config = parseResult.config
|
||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
const existingIndex = plugins.findIndex((plugin) => plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`))
|
||||||
|
|
||||||
// Check for existing plugin (either current or legacy name)
|
if (existingIndex !== -1) {
|
||||||
const currentNameIndex = plugins.findIndex(
|
if (plugins[existingIndex] === pluginEntry) {
|
||||||
(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 }
|
return { success: true, configPath: path }
|
||||||
}
|
}
|
||||||
plugins[currentNameIndex] = pluginEntry
|
plugins[existingIndex] = pluginEntry
|
||||||
} else if (legacyNameIndex !== -1) {
|
|
||||||
// Upgrade legacy name to new name
|
|
||||||
plugins[legacyNameIndex] = pluginEntry
|
|
||||||
} else {
|
} else {
|
||||||
plugins.push(pluginEntry)
|
plugins.push(pluginEntry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ type BunInstallOutputMode = "inherit" | "pipe"
|
|||||||
|
|
||||||
interface RunBunInstallOptions {
|
interface RunBunInstallOptions {
|
||||||
outputMode?: BunInstallOutputMode
|
outputMode?: BunInstallOutputMode
|
||||||
/** Workspace directory to install to. Defaults to cache dir if not provided. */
|
|
||||||
workspaceDir?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BunInstallOutput {
|
interface BunInstallOutput {
|
||||||
@@ -67,7 +65,7 @@ function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: Bu
|
|||||||
|
|
||||||
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
|
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
|
||||||
const outputMode = options?.outputMode ?? "pipe"
|
const outputMode = options?.outputMode ?? "pipe"
|
||||||
const cacheDir = options?.workspaceDir ?? getOpenCodeCacheDir()
|
const cacheDir = getOpenCodeCacheDir()
|
||||||
const packageJsonPath = `${cacheDir}/package.json`
|
const packageJsonPath = `${cacheDir}/package.json`
|
||||||
|
|
||||||
if (!existsSync(packageJsonPath)) {
|
if (!existsSync(packageJsonPath)) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs"
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
import { parseJsonc, LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared"
|
import { parseJsonc } from "../../shared"
|
||||||
import type { DetectedConfig } from "../types"
|
import type { DetectedConfig } from "../types"
|
||||||
import { getOmoConfigPath } from "./config-context"
|
import { getOmoConfigPath } from "./config-context"
|
||||||
import { detectConfigFormat } from "./opencode-config-format"
|
import { detectConfigFormat } from "./opencode-config-format"
|
||||||
@@ -55,12 +55,8 @@ 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 {
|
export function detectCurrentConfig(): DetectedConfig {
|
||||||
|
const PACKAGE_NAME = "oh-my-opencode"
|
||||||
const result: DetectedConfig = {
|
const result: DetectedConfig = {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
hasClaude: true,
|
hasClaude: true,
|
||||||
@@ -86,7 +82,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
|||||||
|
|
||||||
const openCodeConfig = parseResult.config
|
const openCodeConfig = parseResult.config
|
||||||
const plugins = openCodeConfig.plugin ?? []
|
const plugins = openCodeConfig.plugin ?? []
|
||||||
result.isInstalled = plugins.some(isOurPlugin)
|
result.isInstalled = plugins.some((plugin) => plugin.startsWith(PACKAGE_NAME))
|
||||||
|
|
||||||
if (!result.isInstalled) {
|
if (!result.isInstalled) {
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -52,30 +52,6 @@ describe("detectCurrentConfig - single package detection", () => {
|
|||||||
expect(result.isInstalled).toBe(true)
|
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", () => {
|
it("returns false when plugin not present", () => {
|
||||||
// given
|
// given
|
||||||
const config = { plugin: ["some-other-plugin"] }
|
const config = { plugin: ["some-other-plugin"] }
|
||||||
@@ -88,18 +64,6 @@ describe("detectCurrentConfig - single package detection", () => {
|
|||||||
expect(result.isInstalled).toBe(false)
|
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", () => {
|
it("detects OpenCode Go from the existing omo config", () => {
|
||||||
// given
|
// given
|
||||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
|
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
|
||||||
@@ -166,38 +130,6 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
|||||||
expect(savedConfig.plugin).not.toContain("oh-my-opencode@3.10.0")
|
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 () => {
|
it("adds new plugin when none exists", async () => {
|
||||||
// given
|
// given
|
||||||
const config = {}
|
const config = {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs"
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
|
||||||
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
|
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||||
|
|
||||||
export interface PluginInfo {
|
export interface PluginInfo {
|
||||||
registered: boolean
|
registered: boolean
|
||||||
@@ -23,33 +24,18 @@ function detectConfigPath(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePluginVersion(entry: string): string | null {
|
function parsePluginVersion(entry: string): string | null {
|
||||||
// Check for current package name
|
if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null
|
||||||
if (entry.startsWith(`${PLUGIN_NAME}@`)) {
|
const value = entry.slice(PACKAGE_NAME.length + 1)
|
||||||
const value = entry.slice(PLUGIN_NAME.length + 1)
|
if (!value || value === "latest") return null
|
||||||
if (!value || value === "latest") return null
|
return value
|
||||||
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 {
|
function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
// Check for current package name
|
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||||
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) {
|
|
||||||
return { entry, isLocalDev: false }
|
return { entry, isLocalDev: false }
|
||||||
}
|
}
|
||||||
// Check for legacy package name
|
if (entry.startsWith("file://") && entry.includes(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 }
|
return { entry, isLocalDev: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +76,7 @@ export function getPluginInfo(): PluginInfo {
|
|||||||
registered: true,
|
registered: true,
|
||||||
configPath,
|
configPath,
|
||||||
entry: pluginEntry.entry,
|
entry: pluginEntry.entry,
|
||||||
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion ?? ""),
|
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion),
|
||||||
pinnedVersion,
|
pinnedVersion,
|
||||||
isLocalDev: pluginEntry.isLocalDev,
|
isLocalDev: pluginEntry.isLocalDev,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
|
|||||||
|
|
||||||
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
|
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
|
||||||
|
|
||||||
const ULTIMATE_FALLBACK = "opencode/gpt-5-nano"
|
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
|
||||||
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
|
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
src/cli/run/agent-resolver.test.ts
Normal file
88
src/cli/run/agent-resolver.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import { resolveRunAgent } from "./agent-resolver"
|
||||||
|
|
||||||
|
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveRunAgent", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves unknown explicit agents while honoring priority over env and config", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig({ default_run_agent: "prometheus" })
|
||||||
|
const env = { OPENCODE_DEFAULT_AGENT: "Atlas" }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const agent = resolveRunAgent({ message: "test", agent: " custom-agent " }, config, env)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(agent).toBe("custom-agent")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back when an env-selected display-name agent is disabled", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig({ disabled_agents: ["Atlas (Plan Executor)"] })
|
||||||
|
const env = { OPENCODE_DEFAULT_AGENT: "Atlas (Plan Executor)" }
|
||||||
|
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const agent = resolveRunAgent({ message: "test" }, config, env)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(agent).toBe("Sisyphus (Ultraworker)")
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("disabled")
|
||||||
|
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("Sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats sisyphus_agent.disabled as disabling the config default agent", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig({
|
||||||
|
default_run_agent: "sisyphus",
|
||||||
|
sisyphus_agent: { disabled: true },
|
||||||
|
})
|
||||||
|
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const agent = resolveRunAgent({ message: "test" }, config, {})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(agent).toBe("Hephaestus (Deep Agent)")
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("disabled")
|
||||||
|
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("Hephaestus")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the default core agent when a requested core agent is disabled", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig({ disabled_agents: ["Hephaestus"] })
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const agent = resolveRunAgent({ message: "test", agent: "Hephaestus" }, config, {})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(agent).toBe("Sisyphus (Ultraworker)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still returns sisyphus when every core agent is disabled", () => {
|
||||||
|
//#given
|
||||||
|
const config = createConfig({
|
||||||
|
disabled_agents: ["sisyphus", "hephaestus", "prometheus", "atlas"],
|
||||||
|
})
|
||||||
|
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const agent = resolveRunAgent({ message: "test", agent: "Atlas" }, config, {})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(agent).toBe("Sisyphus (Ultraworker)")
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("no enabled core agent was found")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-displ
|
|||||||
|
|
||||||
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
|
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
|
||||||
const DEFAULT_AGENT = "sisyphus"
|
const DEFAULT_AGENT = "sisyphus"
|
||||||
|
const ENV_AGENT_KEYS = ["OPENCODE_AGENT", "OPENCODE_DEFAULT_AGENT"] as const
|
||||||
|
|
||||||
type EnvVars = Record<string, string | undefined>
|
type EnvVars = Record<string, string | undefined>
|
||||||
type CoreAgentKey = (typeof CORE_AGENT_ORDER)[number]
|
type CoreAgentKey = (typeof CORE_AGENT_ORDER)[number]
|
||||||
@@ -54,7 +55,9 @@ export const resolveRunAgent = (
|
|||||||
env: EnvVars = process.env
|
env: EnvVars = process.env
|
||||||
): string => {
|
): string => {
|
||||||
const cliAgent = normalizeAgentName(options.agent)
|
const cliAgent = normalizeAgentName(options.agent)
|
||||||
const envAgent = normalizeAgentName(env.OPENCODE_DEFAULT_AGENT)
|
const envAgent = ENV_AGENT_KEYS
|
||||||
|
.map((key) => normalizeAgentName(env[key]))
|
||||||
|
.find((agent) => agent !== undefined)
|
||||||
const configAgent = normalizeAgentName(pluginConfig.default_run_agent)
|
const configAgent = normalizeAgentName(pluginConfig.default_run_agent)
|
||||||
const resolved =
|
const resolved =
|
||||||
cliAgent ??
|
cliAgent ??
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ function getDeltaMessageId(props?: {
|
|||||||
return props?.messageID
|
return props?.messageID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRender(ctx: RunContext): boolean {
|
||||||
|
return ctx.renderOutput !== false
|
||||||
|
}
|
||||||
|
|
||||||
function renderCompletionMetaLine(state: EventState, messageID: string): void {
|
function renderCompletionMetaLine(state: EventState, messageID: string): void {
|
||||||
if (state.completionMetaPrintedByMessageId[messageID]) return
|
if (state.completionMetaPrintedByMessageId[messageID]) return
|
||||||
|
|
||||||
@@ -95,7 +99,9 @@ export function handleSessionError(ctx: RunContext, payload: EventPayload, state
|
|||||||
if (getSessionId(props) === ctx.sessionID) {
|
if (getSessionId(props) === ctx.sessionID) {
|
||||||
state.mainSessionError = true
|
state.mainSessionError = true
|
||||||
state.lastError = serializeError(props?.error)
|
state.lastError = serializeError(props?.error)
|
||||||
console.error(pc.red(`\n[session.error] ${state.lastError}`))
|
if (shouldRender(ctx)) {
|
||||||
|
console.error(pc.red(`\n[session.error] ${state.lastError}`))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +128,11 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
|
if (!shouldRender(ctx)) {
|
||||||
|
state.lastReasoningText = part.text ?? ""
|
||||||
|
state.hasReceivedMeaningfulWork = true
|
||||||
|
return
|
||||||
|
}
|
||||||
ensureThinkBlockOpen(state)
|
ensureThinkBlockOpen(state)
|
||||||
const reasoningText = part.text ?? ""
|
const reasoningText = part.text ?? ""
|
||||||
const newText = reasoningText.slice(state.lastReasoningText.length)
|
const newText = reasoningText.slice(state.lastReasoningText.length)
|
||||||
@@ -139,15 +150,17 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
|
|||||||
|
|
||||||
if (part.type === "text" && part.text) {
|
if (part.type === "text" && part.text) {
|
||||||
const newText = part.text.slice(state.lastPartText.length)
|
const newText = part.text.slice(state.lastPartText.length)
|
||||||
if (newText) {
|
if (newText && shouldRender(ctx)) {
|
||||||
const padded = writePaddedText(newText, state.textAtLineStart)
|
const padded = writePaddedText(newText, state.textAtLineStart)
|
||||||
process.stdout.write(padded.output)
|
process.stdout.write(padded.output)
|
||||||
state.textAtLineStart = padded.atLineStart
|
state.textAtLineStart = padded.atLineStart
|
||||||
|
}
|
||||||
|
if (newText) {
|
||||||
state.hasReceivedMeaningfulWork = true
|
state.hasReceivedMeaningfulWork = true
|
||||||
}
|
}
|
||||||
state.lastPartText = part.text
|
state.lastPartText = part.text
|
||||||
|
|
||||||
if (part.time?.end) {
|
if (part.time?.end && shouldRender(ctx)) {
|
||||||
const messageID = part.messageID ?? state.currentMessageId
|
const messageID = part.messageID ?? state.currentMessageId
|
||||||
if (messageID) {
|
if (messageID) {
|
||||||
renderCompletionMetaLine(state, messageID)
|
renderCompletionMetaLine(state, messageID)
|
||||||
@@ -180,6 +193,11 @@ export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, s
|
|||||||
if (!delta) return
|
if (!delta) return
|
||||||
|
|
||||||
if (partType === "reasoning") {
|
if (partType === "reasoning") {
|
||||||
|
if (!shouldRender(ctx)) {
|
||||||
|
state.lastReasoningText += delta
|
||||||
|
state.hasReceivedMeaningfulWork = true
|
||||||
|
return
|
||||||
|
}
|
||||||
ensureThinkBlockOpen(state)
|
ensureThinkBlockOpen(state)
|
||||||
const padded = writePaddedText(delta, state.thinkingAtLineStart)
|
const padded = writePaddedText(delta, state.thinkingAtLineStart)
|
||||||
process.stdout.write(pc.dim(padded.output))
|
process.stdout.write(pc.dim(padded.output))
|
||||||
@@ -191,9 +209,11 @@ export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, s
|
|||||||
|
|
||||||
closeThinkBlockIfNeeded(state)
|
closeThinkBlockIfNeeded(state)
|
||||||
|
|
||||||
const padded = writePaddedText(delta, state.textAtLineStart)
|
if (shouldRender(ctx)) {
|
||||||
process.stdout.write(padded.output)
|
const padded = writePaddedText(delta, state.textAtLineStart)
|
||||||
state.textAtLineStart = padded.atLineStart
|
process.stdout.write(padded.output)
|
||||||
|
state.textAtLineStart = padded.atLineStart
|
||||||
|
}
|
||||||
state.lastPartText += delta
|
state.lastPartText += delta
|
||||||
state.hasReceivedMeaningfulWork = true
|
state.hasReceivedMeaningfulWork = true
|
||||||
}
|
}
|
||||||
@@ -209,16 +229,18 @@ function handleToolPart(
|
|||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
if (state.currentTool !== null) return
|
if (state.currentTool !== null) return
|
||||||
state.currentTool = toolName
|
state.currentTool = toolName
|
||||||
const header = formatToolHeader(toolName, part.state?.input ?? {})
|
|
||||||
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
|
||||||
state.hasReceivedMeaningfulWork = true
|
state.hasReceivedMeaningfulWork = true
|
||||||
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
if (shouldRender(_ctx)) {
|
||||||
|
const header = formatToolHeader(toolName, part.state?.input ?? {})
|
||||||
|
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
||||||
|
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "completed" || status === "error") {
|
if (status === "completed" || status === "error") {
|
||||||
if (state.currentTool === null) return
|
if (state.currentTool === null) return
|
||||||
const output = part.state?.output || ""
|
const output = part.state?.output || ""
|
||||||
if (output.trim()) {
|
if (output.trim() && shouldRender(_ctx)) {
|
||||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
||||||
const padded = writePaddedText(output, true)
|
const padded = writePaddedText(output, true)
|
||||||
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
||||||
@@ -271,7 +293,9 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
|
|||||||
state.currentAgent = agent
|
state.currentAgent = agent
|
||||||
state.currentModel = model
|
state.currentModel = model
|
||||||
state.currentVariant = variant
|
state.currentVariant = variant
|
||||||
renderAgentHeader(agent, model, variant, state.agentColorsByName)
|
if (shouldRender(ctx)) {
|
||||||
|
renderAgentHeader(agent, model, variant, state.agentColorsByName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,11 +311,12 @@ export function handleToolExecute(ctx: RunContext, payload: EventPayload, state:
|
|||||||
|
|
||||||
const toolName = props?.name || "unknown"
|
const toolName = props?.name || "unknown"
|
||||||
state.currentTool = toolName
|
state.currentTool = toolName
|
||||||
const header = formatToolHeader(toolName, props?.input ?? {})
|
|
||||||
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
|
||||||
|
|
||||||
state.hasReceivedMeaningfulWork = true
|
state.hasReceivedMeaningfulWork = true
|
||||||
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
if (shouldRender(ctx)) {
|
||||||
|
const header = formatToolHeader(toolName, props?.input ?? {})
|
||||||
|
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
||||||
|
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||||
@@ -305,7 +330,7 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state:
|
|||||||
if (state.currentTool === null) return
|
if (state.currentTool === null) return
|
||||||
|
|
||||||
const output = props?.output || ""
|
const output = props?.output || ""
|
||||||
if (output.trim()) {
|
if (output.trim() && shouldRender(ctx)) {
|
||||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
||||||
const padded = writePaddedText(output, true)
|
const padded = writePaddedText(output, true)
|
||||||
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { RunContext, EventPayload } from "./types"
|
import type {
|
||||||
|
EventPayload,
|
||||||
|
MessagePartDeltaProps,
|
||||||
|
MessagePartUpdatedProps,
|
||||||
|
RunContext,
|
||||||
|
RunEventObserver,
|
||||||
|
SessionErrorEvent,
|
||||||
|
SessionIdleEvent,
|
||||||
|
SessionQuestionEvent,
|
||||||
|
StreamEvent,
|
||||||
|
ToolCompletedEvent,
|
||||||
|
ToolExecuteProps,
|
||||||
|
ToolResultProps,
|
||||||
|
ToolStartedEvent,
|
||||||
|
} from "./types"
|
||||||
import type { EventState } from "./event-state"
|
import type { EventState } from "./event-state"
|
||||||
import { logEventVerbose } from "./event-formatting"
|
import { logEventVerbose } from "./event-formatting"
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +28,133 @@ import {
|
|||||||
handleTuiToast,
|
handleTuiToast,
|
||||||
} from "./event-handlers"
|
} from "./event-handlers"
|
||||||
|
|
||||||
|
const QUESTION_TOOL_NAMES = new Set(["question", "ask_user_question", "askuserquestion"])
|
||||||
|
|
||||||
|
async function emitObservedEvent(
|
||||||
|
observer: RunEventObserver | undefined,
|
||||||
|
event: StreamEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!observer) return
|
||||||
|
|
||||||
|
await observer.onEvent?.(event)
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
await observer.onIdle?.(event as SessionIdleEvent)
|
||||||
|
}
|
||||||
|
if (event.type === "session.question") {
|
||||||
|
await observer.onQuestion?.(event as SessionQuestionEvent)
|
||||||
|
}
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
await observer.onError?.(event as SessionErrorEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventSessionId(payload: EventPayload): string | undefined {
|
||||||
|
const props = payload.properties as Record<string, unknown> | undefined
|
||||||
|
if (!props) return undefined
|
||||||
|
if (typeof props.sessionID === "string") return props.sessionID
|
||||||
|
if (typeof props.sessionId === "string") return props.sessionId
|
||||||
|
const info = props.info as Record<string, unknown> | undefined
|
||||||
|
if (typeof info?.sessionID === "string") return info.sessionID
|
||||||
|
if (typeof info?.sessionId === "string") return info.sessionId
|
||||||
|
const part = props.part as Record<string, unknown> | undefined
|
||||||
|
if (typeof part?.sessionID === "string") return part.sessionID
|
||||||
|
if (typeof part?.sessionId === "string") return part.sessionId
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionText(input: unknown): string | undefined {
|
||||||
|
const args = input as { questions?: Array<{ question?: unknown }> } | undefined
|
||||||
|
const question = args?.questions?.[0]?.question
|
||||||
|
return typeof question === "string" && question.length > 0 ? question : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolStartFromPayload(
|
||||||
|
payload: EventPayload,
|
||||||
|
sessionId: string,
|
||||||
|
fallbackToolName: string,
|
||||||
|
): ToolStartedEvent | SessionQuestionEvent | undefined {
|
||||||
|
if (payload.type === "tool.execute") {
|
||||||
|
const props = payload.properties as ToolExecuteProps | undefined
|
||||||
|
const toolName = props?.name ?? fallbackToolName
|
||||||
|
if (QUESTION_TOOL_NAMES.has(toolName.toLowerCase())) {
|
||||||
|
return {
|
||||||
|
type: "session.question",
|
||||||
|
sessionId,
|
||||||
|
toolName,
|
||||||
|
input: props?.input,
|
||||||
|
question: getQuestionText(props?.input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "tool.started",
|
||||||
|
sessionId,
|
||||||
|
toolName,
|
||||||
|
input: props?.input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const props = payload.properties as MessagePartUpdatedProps | undefined
|
||||||
|
const toolName = props?.part?.tool ?? props?.part?.name ?? fallbackToolName
|
||||||
|
if (!toolName) return undefined
|
||||||
|
const input = props?.part?.state?.input
|
||||||
|
if (QUESTION_TOOL_NAMES.has(toolName.toLowerCase())) {
|
||||||
|
return {
|
||||||
|
type: "session.question",
|
||||||
|
sessionId,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
question: getQuestionText(input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "tool.started",
|
||||||
|
sessionId,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCompletedFromPayload(
|
||||||
|
payload: EventPayload,
|
||||||
|
sessionId: string,
|
||||||
|
fallbackToolName: string,
|
||||||
|
): ToolCompletedEvent | undefined {
|
||||||
|
if (payload.type === "tool.result") {
|
||||||
|
const props = payload.properties as ToolResultProps | undefined
|
||||||
|
return {
|
||||||
|
type: "tool.completed",
|
||||||
|
sessionId,
|
||||||
|
toolName: props?.name ?? fallbackToolName,
|
||||||
|
output: props?.output,
|
||||||
|
status: "completed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const props = payload.properties as MessagePartUpdatedProps | undefined
|
||||||
|
const status = props?.part?.state?.status
|
||||||
|
if (status !== "completed" && status !== "error") return undefined
|
||||||
|
return {
|
||||||
|
type: "tool.completed",
|
||||||
|
sessionId,
|
||||||
|
toolName: props?.part?.tool ?? props?.part?.name ?? fallbackToolName,
|
||||||
|
output: props?.part?.state?.output,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export async function processEvents(
|
export async function processEvents(
|
||||||
ctx: RunContext,
|
ctx: RunContext,
|
||||||
stream: AsyncIterable<unknown>,
|
stream: AsyncIterable<unknown>,
|
||||||
state: EventState
|
state: EventState,
|
||||||
|
observer?: RunEventObserver,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
if (ctx.abortController.signal.aborted) break
|
if (ctx.abortController.signal.aborted) break
|
||||||
@@ -37,6 +174,18 @@ export async function processEvents(
|
|||||||
|
|
||||||
// Update last event timestamp for watchdog detection
|
// Update last event timestamp for watchdog detection
|
||||||
state.lastEventTimestamp = Date.now()
|
state.lastEventTimestamp = Date.now()
|
||||||
|
const previousIdle = state.mainSessionIdle
|
||||||
|
const previousError = state.mainSessionError
|
||||||
|
const previousTool = state.currentTool
|
||||||
|
const sessionId = getEventSessionId(payload) ?? ctx.sessionID
|
||||||
|
|
||||||
|
if (observer?.includeRawEvents) {
|
||||||
|
await emitObservedEvent(observer, {
|
||||||
|
type: "raw",
|
||||||
|
sessionId,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
handleSessionError(ctx, payload, state)
|
handleSessionError(ctx, payload, state)
|
||||||
handleSessionIdle(ctx, payload, state)
|
handleSessionIdle(ctx, payload, state)
|
||||||
@@ -47,8 +196,74 @@ export async function processEvents(
|
|||||||
handleToolExecute(ctx, payload, state)
|
handleToolExecute(ctx, payload, state)
|
||||||
handleToolResult(ctx, payload, state)
|
handleToolResult(ctx, payload, state)
|
||||||
handleTuiToast(ctx, payload, state)
|
handleTuiToast(ctx, payload, state)
|
||||||
|
|
||||||
|
if (!previousIdle && state.mainSessionIdle) {
|
||||||
|
await emitObservedEvent(observer, {
|
||||||
|
type: "session.idle",
|
||||||
|
sessionId: ctx.sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousError && state.mainSessionError) {
|
||||||
|
await emitObservedEvent(observer, {
|
||||||
|
type: "session.error",
|
||||||
|
sessionId: ctx.sessionID,
|
||||||
|
error: state.lastError ?? "Unknown session error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "message.part.delta") {
|
||||||
|
const props = payload.properties as MessagePartDeltaProps | undefined
|
||||||
|
if (
|
||||||
|
sessionId === ctx.sessionID
|
||||||
|
&& props?.field === "text"
|
||||||
|
&& typeof props.delta === "string"
|
||||||
|
&& props.delta.length > 0
|
||||||
|
) {
|
||||||
|
await emitObservedEvent(observer, {
|
||||||
|
type: "message.delta",
|
||||||
|
sessionId: ctx.sessionID,
|
||||||
|
messageId: props.messageID,
|
||||||
|
partId: props.partID,
|
||||||
|
delta: props.delta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const props = payload.properties as MessagePartUpdatedProps | undefined
|
||||||
|
if (
|
||||||
|
sessionId === ctx.sessionID
|
||||||
|
&& props?.part?.type === "text"
|
||||||
|
&& typeof props.part.text === "string"
|
||||||
|
&& props.part.time?.end
|
||||||
|
) {
|
||||||
|
await emitObservedEvent(observer, {
|
||||||
|
type: "message.completed",
|
||||||
|
sessionId: ctx.sessionID,
|
||||||
|
messageId: props.part.messageID,
|
||||||
|
partId: props.part.id,
|
||||||
|
text: props.part.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousTool === null && state.currentTool !== null && sessionId === ctx.sessionID) {
|
||||||
|
const toolEvent = getToolStartFromPayload(payload, ctx.sessionID, state.currentTool)
|
||||||
|
if (toolEvent) {
|
||||||
|
await emitObservedEvent(observer, toolEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousTool !== null && state.currentTool === null && sessionId === ctx.sessionID) {
|
||||||
|
const toolEvent = getToolCompletedFromPayload(payload, ctx.sessionID, previousTool)
|
||||||
|
if (toolEvent) {
|
||||||
|
await emitObservedEvent(observer, toolEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(pc.red(`[event error] ${err}`))
|
const error = ctx.logger?.error ?? console.error
|
||||||
|
error(pc.red(`[event error] ${err}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ export { resolveRunAgent } from "./agent-resolver"
|
|||||||
export { resolveRunModel } from "./model-resolver"
|
export { resolveRunModel } from "./model-resolver"
|
||||||
export { createServerConnection } from "./server-connection"
|
export { createServerConnection } from "./server-connection"
|
||||||
export { resolveSession } from "./session-resolver"
|
export { resolveSession } from "./session-resolver"
|
||||||
|
export { executeRunSession, waitForEventProcessorShutdown } from "./run-engine"
|
||||||
export { createJsonOutputManager } from "./json-output"
|
export { createJsonOutputManager } from "./json-output"
|
||||||
export { executeOnCompleteHook } from "./on-complete-hook"
|
export { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
export { createEventState, processEvents, serializeError } from "./events"
|
export { createEventState, processEvents, serializeError } from "./events"
|
||||||
export type { EventState } from "./events"
|
export type { EventState } from "./events"
|
||||||
export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"
|
export type {
|
||||||
|
RunContext,
|
||||||
|
RunEventObserver,
|
||||||
|
RunOptions,
|
||||||
|
RunResult,
|
||||||
|
ServerConnection,
|
||||||
|
StreamEvent,
|
||||||
|
} from "./types"
|
||||||
|
|||||||
142
src/cli/run/run-engine.test.ts
Normal file
142
src/cli/run/run-engine.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, it, mock } from "bun:test"
|
||||||
|
import { executeRunSession } from "./run-engine"
|
||||||
|
import type { OpencodeClient, StreamEvent } from "./types"
|
||||||
|
|
||||||
|
function toAsyncIterable(values: unknown[]): AsyncIterable<unknown> {
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
for (const value of values) {
|
||||||
|
yield value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("executeRunSession", () => {
|
||||||
|
it("allows SDK sessions to enable questions and emits normalized events", async () => {
|
||||||
|
const seenEvents: StreamEvent[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
create: mock(() => Promise.resolve({ data: { id: "ses_sdk" } })),
|
||||||
|
promptAsync: mock(() => Promise.resolve({})),
|
||||||
|
status: mock(() => Promise.resolve({ data: { ses_sdk: { type: "idle" } } })),
|
||||||
|
todo: mock(() => Promise.resolve({ data: [] })),
|
||||||
|
children: mock(() => Promise.resolve({ data: [] })),
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
subscribe: mock(() => Promise.resolve({
|
||||||
|
stream: toAsyncIterable([
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "Prometheus (Plan Builder)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool.execute",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
name: "question",
|
||||||
|
input: {
|
||||||
|
questions: [{ question: "Which agent should run?" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
messageID: "msg_1",
|
||||||
|
partID: "part_1",
|
||||||
|
field: "text",
|
||||||
|
delta: "hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool.result",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
name: "question",
|
||||||
|
output: "waiting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "part_1",
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
messageID: "msg_1",
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
time: { end: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_sdk",
|
||||||
|
status: { type: "idle" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
|
||||||
|
const result = await executeRunSession({
|
||||||
|
client,
|
||||||
|
directory: "/repo",
|
||||||
|
message: "hello",
|
||||||
|
agent: "prometheus",
|
||||||
|
questionPermission: "allow",
|
||||||
|
questionToolEnabled: true,
|
||||||
|
renderOutput: false,
|
||||||
|
logger: { log: () => {}, error: () => {} },
|
||||||
|
pluginConfig: {},
|
||||||
|
pollOptions: {
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
minStabilizationMs: 0,
|
||||||
|
},
|
||||||
|
eventObserver: {
|
||||||
|
onEvent: async (event) => {
|
||||||
|
seenEvents.push(event)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0)
|
||||||
|
expect(result.result.success).toBe(true)
|
||||||
|
expect(client.session.create).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
title: "oh-my-opencode run",
|
||||||
|
permission: [
|
||||||
|
{ permission: "question", action: "allow", pattern: "*" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
query: { directory: "/repo" },
|
||||||
|
})
|
||||||
|
expect(client.session.promptAsync).toHaveBeenCalledWith({
|
||||||
|
path: { id: "ses_sdk" },
|
||||||
|
body: {
|
||||||
|
agent: "Prometheus (Plan Builder)",
|
||||||
|
tools: { question: true },
|
||||||
|
parts: [{ type: "text", text: "hello" }],
|
||||||
|
},
|
||||||
|
query: { directory: "/repo" },
|
||||||
|
})
|
||||||
|
expect(seenEvents.map((event) => event.type)).toContain("session.started")
|
||||||
|
expect(seenEvents.map((event) => event.type)).toContain("session.question")
|
||||||
|
expect(seenEvents.map((event) => event.type)).toContain("message.delta")
|
||||||
|
expect(seenEvents.map((event) => event.type)).toContain("message.completed")
|
||||||
|
expect(seenEvents.map((event) => event.type)).toContain("session.completed")
|
||||||
|
})
|
||||||
|
})
|
||||||
204
src/cli/run/run-engine.ts
Normal file
204
src/cli/run/run-engine.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import pc from "picocolors"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import { loadPluginConfig } from "../../plugin-config"
|
||||||
|
import { createEventState, processEvents, serializeError } from "./events"
|
||||||
|
import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||||
|
import { pollForCompletion, type PollOptions } from "./poll-for-completion"
|
||||||
|
import { resolveRunAgent } from "./agent-resolver"
|
||||||
|
import { resolveRunModel } from "./model-resolver"
|
||||||
|
import { resolveSession } from "./session-resolver"
|
||||||
|
import type {
|
||||||
|
OpencodeClient,
|
||||||
|
RunContext,
|
||||||
|
RunEventObserver,
|
||||||
|
RunLogger,
|
||||||
|
RunResult,
|
||||||
|
SessionCompletedEvent,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||||
|
|
||||||
|
export interface ExecuteRunSessionOptions {
|
||||||
|
client: OpencodeClient
|
||||||
|
message: string
|
||||||
|
directory: string
|
||||||
|
agent?: string
|
||||||
|
model?: string
|
||||||
|
sessionId?: string
|
||||||
|
verbose?: boolean
|
||||||
|
questionPermission?: "allow" | "deny"
|
||||||
|
questionToolEnabled?: boolean
|
||||||
|
pluginConfig?: OhMyOpenCodeConfig
|
||||||
|
logger?: RunLogger
|
||||||
|
renderOutput?: boolean
|
||||||
|
eventObserver?: RunEventObserver
|
||||||
|
pollOptions?: PollOptions
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteRunSessionResult {
|
||||||
|
exitCode: number
|
||||||
|
result: RunResult
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForEventProcessorShutdown(
|
||||||
|
eventProcessor: Promise<void>,
|
||||||
|
timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
const completed = await Promise.race([
|
||||||
|
eventProcessor.then(() => true),
|
||||||
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
||||||
|
])
|
||||||
|
|
||||||
|
void completed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitCompletionEvent(
|
||||||
|
observer: RunEventObserver | undefined,
|
||||||
|
result: RunResult,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!observer) return
|
||||||
|
|
||||||
|
const event: SessionCompletedEvent = {
|
||||||
|
type: "session.completed",
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
await observer.onEvent?.(event)
|
||||||
|
await observer.onComplete?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRunSession(
|
||||||
|
options: ExecuteRunSessionOptions,
|
||||||
|
): Promise<ExecuteRunSessionResult> {
|
||||||
|
const {
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
directory,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
sessionId,
|
||||||
|
verbose = false,
|
||||||
|
questionPermission = "deny",
|
||||||
|
questionToolEnabled = false,
|
||||||
|
pluginConfig = loadPluginConfig(directory, { command: "run" }),
|
||||||
|
logger,
|
||||||
|
renderOutput = true,
|
||||||
|
eventObserver,
|
||||||
|
pollOptions,
|
||||||
|
signal,
|
||||||
|
} = options
|
||||||
|
const log = logger?.log ?? console.log
|
||||||
|
|
||||||
|
const resolvedAgent = resolveRunAgent({ message, agent }, pluginConfig)
|
||||||
|
const resolvedModel = resolveRunModel(model)
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const startTime = Date.now()
|
||||||
|
let resolvedSessionId: string | undefined
|
||||||
|
|
||||||
|
// Check if signal was already aborted before setting up listener
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardAbort = () => abortController.abort()
|
||||||
|
signal?.addEventListener("abort", forwardAbort, { once: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolvedSessionId = await resolveSession({
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
directory,
|
||||||
|
questionPermission,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (renderOutput) {
|
||||||
|
log(pc.dim(`Session: ${resolvedSessionId}`))
|
||||||
|
if (resolvedModel) {
|
||||||
|
log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await eventObserver?.onEvent?.({
|
||||||
|
type: "session.started",
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
resumed: Boolean(sessionId),
|
||||||
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx: RunContext = {
|
||||||
|
client,
|
||||||
|
sessionID: resolvedSessionId,
|
||||||
|
directory,
|
||||||
|
abortController,
|
||||||
|
verbose,
|
||||||
|
renderOutput,
|
||||||
|
logger,
|
||||||
|
}
|
||||||
|
const events = await client.event.subscribe({ query: { directory } })
|
||||||
|
const eventState = createEventState()
|
||||||
|
if (renderOutput) {
|
||||||
|
eventState.agentColorsByName = await loadAgentProfileColors(client)
|
||||||
|
}
|
||||||
|
const eventProcessor = processEvents(
|
||||||
|
ctx,
|
||||||
|
events.stream,
|
||||||
|
eventState,
|
||||||
|
eventObserver,
|
||||||
|
).catch(() => {})
|
||||||
|
|
||||||
|
await client.session.promptAsync({
|
||||||
|
path: { id: resolvedSessionId },
|
||||||
|
body: {
|
||||||
|
agent: resolvedAgent,
|
||||||
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||||
|
tools: {
|
||||||
|
question: questionToolEnabled,
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: message }],
|
||||||
|
},
|
||||||
|
query: { directory },
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await pollForCompletion(ctx, eventState, abortController, pollOptions)
|
||||||
|
abortController.abort()
|
||||||
|
await waitForEventProcessorShutdown(eventProcessor)
|
||||||
|
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
success: exitCode === 0,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
messageCount: eventState.messageCount,
|
||||||
|
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
await emitCompletionEvent(eventObserver, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode,
|
||||||
|
result,
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
abortController.abort()
|
||||||
|
const serialized = serializeError(error)
|
||||||
|
await eventObserver?.onEvent?.({
|
||||||
|
type: "session.error",
|
||||||
|
sessionId: resolvedSessionId ?? sessionId ?? "",
|
||||||
|
error: serialized,
|
||||||
|
})
|
||||||
|
await eventObserver?.onError?.({
|
||||||
|
type: "session.error",
|
||||||
|
sessionId: resolvedSessionId ?? sessionId ?? "",
|
||||||
|
error: serialized,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", forwardAbort)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"
|
import { describe, it, expect } from "bun:test"
|
||||||
import type { OhMyOpenCodeConfig } from "../../config"
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
|
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
|
||||||
|
|
||||||
@@ -37,6 +37,38 @@ describe("resolveRunAgent", () => {
|
|||||||
expect(agent).toBe("Atlas (Plan Executor)")
|
expect(agent).toBe("Atlas (Plan Executor)")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("prefers OPENCODE_AGENT over OPENCODE_DEFAULT_AGENT", () => {
|
||||||
|
// given
|
||||||
|
const config = createConfig({ default_run_agent: "prometheus" })
|
||||||
|
const env = {
|
||||||
|
OPENCODE_AGENT: "oracle",
|
||||||
|
OPENCODE_DEFAULT_AGENT: "Atlas",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const agent = resolveRunAgent({ message: "test" }, config, env)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(agent).toBe("oracle")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports specialist agents from env and config inputs", () => {
|
||||||
|
// given
|
||||||
|
const env = { OPENCODE_AGENT: " explore " }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const envAgent = resolveRunAgent({ message: "test" }, createConfig(), env)
|
||||||
|
const configAgent = resolveRunAgent(
|
||||||
|
{ message: "test" },
|
||||||
|
createConfig({ default_run_agent: "oracle" }),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(envAgent).toBe("explore")
|
||||||
|
expect(configAgent).toBe("oracle")
|
||||||
|
})
|
||||||
|
|
||||||
it("uses config agent over default", () => {
|
it("uses config agent over default", () => {
|
||||||
// given
|
// given
|
||||||
const config = createConfig({ default_run_agent: "Prometheus" })
|
const config = createConfig({ default_run_agent: "Prometheus" })
|
||||||
@@ -80,9 +112,21 @@ describe("resolveRunAgent", () => {
|
|||||||
// then
|
// then
|
||||||
expect(agent).toBe("Sisyphus (Ultraworker)")
|
expect(agent).toBe("Sisyphus (Ultraworker)")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("falls back when requested specialist agent is disabled", () => {
|
||||||
|
// given
|
||||||
|
const config = createConfig({ disabled_agents: ["oracle"] })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const agent = resolveRunAgent({ message: "test", agent: "oracle" }, config, {})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(agent).toBe("Sisyphus (Ultraworker)")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("waitForEventProcessorShutdown", () => {
|
describe("waitForEventProcessorShutdown", () => {
|
||||||
|
|
||||||
it("returns quickly when event processor completes", async () => {
|
it("returns quickly when event processor completes", async () => {
|
||||||
//#given
|
//#given
|
||||||
const eventProcessor = new Promise<void>((resolve) => {
|
const eventProcessor = new Promise<void>((resolve) => {
|
||||||
@@ -114,44 +158,3 @@ describe("waitForEventProcessorShutdown", () => {
|
|||||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
|
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { RunOptions, RunContext } from "./types"
|
import type { RunOptions } from "./types"
|
||||||
import { createEventState, processEvents, serializeError } from "./events"
|
|
||||||
import { loadPluginConfig } from "../../plugin-config"
|
|
||||||
import { createServerConnection } from "./server-connection"
|
|
||||||
import { resolveSession } from "./session-resolver"
|
|
||||||
import { createJsonOutputManager } from "./json-output"
|
import { createJsonOutputManager } from "./json-output"
|
||||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
import { resolveRunAgent } from "./agent-resolver"
|
import { createServerConnection } from "./server-connection"
|
||||||
import { resolveRunModel } from "./model-resolver"
|
import {
|
||||||
import { pollForCompletion } from "./poll-for-completion"
|
executeRunSession,
|
||||||
import { loadAgentProfileColors } from "./agent-profile-colors"
|
waitForEventProcessorShutdown,
|
||||||
import { suppressRunInput } from "./stdin-suppression"
|
} from "./run-engine"
|
||||||
import { createTimestampedStdoutController } from "./timestamp-output"
|
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||||
|
import { serializeError } from "./events"
|
||||||
|
import { suppressRunInput } from "./stdin-suppression"
|
||||||
|
|
||||||
export { resolveRunAgent }
|
export { resolveRunAgent } from "./agent-resolver"
|
||||||
|
export { waitForEventProcessorShutdown }
|
||||||
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
|
|
||||||
|
|
||||||
export async function waitForEventProcessorShutdown(
|
|
||||||
eventProcessor: Promise<void>,
|
|
||||||
timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS,
|
|
||||||
): Promise<void> {
|
|
||||||
const completed = await Promise.race([
|
|
||||||
eventProcessor.then(() => true),
|
|
||||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
|
||||||
])
|
|
||||||
|
|
||||||
void completed
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function run(options: RunOptions): Promise<number> {
|
export async function run(options: RunOptions): Promise<number> {
|
||||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
const {
|
const {
|
||||||
message,
|
|
||||||
directory = process.cwd(),
|
directory = process.cwd(),
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -45,26 +28,19 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
: createTimestampedStdoutController()
|
: createTimestampedStdoutController()
|
||||||
timestampOutput?.enable()
|
timestampOutput?.enable()
|
||||||
|
|
||||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
|
||||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedModel = resolveRunModel(options.model)
|
const { client, cleanup } = await createServerConnection({
|
||||||
|
|
||||||
const { client, cleanup: serverCleanup } = await createServerConnection({
|
|
||||||
port: options.port,
|
port: options.port,
|
||||||
attach: options.attach,
|
attach: options.attach,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
serverCleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreInput = suppressRunInput()
|
const restoreInput = suppressRunInput()
|
||||||
const handleSigint = () => {
|
const handleSigint = () => {
|
||||||
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
||||||
|
abortController.abort()
|
||||||
restoreInput()
|
restoreInput()
|
||||||
cleanup()
|
cleanup()
|
||||||
process.exit(130)
|
process.exit(130)
|
||||||
@@ -73,81 +49,38 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
process.on("SIGINT", handleSigint)
|
process.on("SIGINT", handleSigint)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionID = await resolveSession({
|
const { exitCode, result } = await executeRunSession({
|
||||||
client,
|
client,
|
||||||
|
message: options.message,
|
||||||
|
directory,
|
||||||
|
agent: options.agent,
|
||||||
|
model: options.model,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
directory,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(pc.dim(`Session: ${sessionID}`))
|
|
||||||
|
|
||||||
if (resolvedModel) {
|
|
||||||
console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx: RunContext = {
|
|
||||||
client,
|
|
||||||
sessionID,
|
|
||||||
directory,
|
|
||||||
abortController,
|
|
||||||
verbose: options.verbose ?? false,
|
verbose: options.verbose ?? false,
|
||||||
}
|
questionPermission: "deny",
|
||||||
const events = await client.event.subscribe({ query: { directory } })
|
questionToolEnabled: false,
|
||||||
const eventState = createEventState()
|
renderOutput: true,
|
||||||
eventState.agentColorsByName = await loadAgentProfileColors(client)
|
|
||||||
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
|
|
||||||
() => {},
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.session.promptAsync({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: {
|
|
||||||
agent: resolvedAgent,
|
|
||||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
||||||
tools: {
|
|
||||||
question: false,
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: message }],
|
|
||||||
},
|
|
||||||
query: { directory },
|
|
||||||
})
|
})
|
||||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
|
||||||
|
|
||||||
// Abort the event stream to stop the processor
|
|
||||||
abortController.abort()
|
|
||||||
|
|
||||||
await waitForEventProcessorShutdown(eventProcessor)
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime
|
|
||||||
|
|
||||||
if (options.onComplete) {
|
if (options.onComplete) {
|
||||||
await executeOnCompleteHook({
|
await executeOnCompleteHook({
|
||||||
command: options.onComplete,
|
command: options.onComplete,
|
||||||
sessionId: sessionID,
|
sessionId: result.sessionId,
|
||||||
exitCode,
|
exitCode,
|
||||||
durationMs,
|
durationMs: result.durationMs,
|
||||||
messageCount: eventState.messageCount,
|
messageCount: result.messageCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jsonManager) {
|
if (jsonManager) {
|
||||||
jsonManager.emitResult({
|
jsonManager.emitResult(result)
|
||||||
sessionId: sessionID,
|
|
||||||
success: exitCode === 0,
|
|
||||||
durationMs,
|
|
||||||
messageCount: eventState.messageCount,
|
|
||||||
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return exitCode
|
return exitCode
|
||||||
} catch (err) {
|
|
||||||
cleanup()
|
|
||||||
throw err
|
|
||||||
} finally {
|
} finally {
|
||||||
process.removeListener("SIGINT", handleSigint)
|
process.removeListener("SIGINT", handleSigint)
|
||||||
restoreInput()
|
restoreInput()
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (jsonManager) jsonManager.restore()
|
if (jsonManager) jsonManager.restore()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { ServerConnection } from "./types"
|
import type { RunLogger, ServerConnection } from "./types"
|
||||||
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
||||||
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
|
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
|
||||||
|
|
||||||
@@ -20,13 +20,18 @@ function isPortRangeExhausted(error: unknown): boolean {
|
|||||||
return error.message.includes("No available port found in range")
|
return error.message.includes("No available port found in range")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(options: { signal: AbortSignal, port: number }): Promise<ServerConnection> {
|
async function startServer(options: {
|
||||||
const { signal, port } = options
|
signal: AbortSignal
|
||||||
|
port: number
|
||||||
|
logger?: RunLogger
|
||||||
|
}): Promise<ServerConnection> {
|
||||||
|
const { signal, port, logger } = options
|
||||||
|
const log = logger?.log?.bind(logger) ?? console.log
|
||||||
const { client, server } = await withWorkingOpencodePath(() =>
|
const { client, server } = await withWorkingOpencodePath(() =>
|
||||||
createOpencode({ signal, port, hostname: "127.0.0.1" }),
|
createOpencode({ signal, port, hostname: "127.0.0.1" }),
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
|
log(pc.dim("Server listening at"), pc.cyan(server.url))
|
||||||
return { client, cleanup: () => server.close() }
|
return { client, cleanup: () => server.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +39,13 @@ export async function createServerConnection(options: {
|
|||||||
port?: number
|
port?: number
|
||||||
attach?: string
|
attach?: string
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
|
logger?: RunLogger
|
||||||
}): Promise<ServerConnection> {
|
}): Promise<ServerConnection> {
|
||||||
const { port, attach, signal } = options
|
const { port, attach, signal, logger } = options
|
||||||
|
const log = logger?.log ?? console.log
|
||||||
|
|
||||||
if (attach !== undefined) {
|
if (attach !== undefined) {
|
||||||
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
|
log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
|
||||||
const client = createOpencodeClient({ baseUrl: attach })
|
const client = createOpencodeClient({ baseUrl: attach })
|
||||||
return { client, cleanup: () => {} }
|
return { client, cleanup: () => {} }
|
||||||
}
|
}
|
||||||
@@ -51,9 +58,9 @@ export async function createServerConnection(options: {
|
|||||||
const available = await isPortAvailable(port, "127.0.0.1")
|
const available = await isPortAvailable(port, "127.0.0.1")
|
||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
|
log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
|
||||||
try {
|
try {
|
||||||
return await startServer({ signal, port })
|
return await startServer({ signal, port, logger })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isPortStartFailure(error, port)) {
|
if (!isPortStartFailure(error, port)) {
|
||||||
throw error
|
throw error
|
||||||
@@ -64,13 +71,13 @@ export async function createServerConnection(options: {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
|
log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
|
||||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||||
return { client, cleanup: () => {} }
|
return { client, cleanup: () => {} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
|
log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
|
||||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||||
return { client, cleanup: () => {} }
|
return { client, cleanup: () => {} }
|
||||||
}
|
}
|
||||||
@@ -91,26 +98,26 @@ export async function createServerConnection(options: {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
|
log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
|
||||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
|
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
|
||||||
return { client, cleanup: () => {} }
|
return { client, cleanup: () => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasAutoSelected) {
|
if (wasAutoSelected) {
|
||||||
console.log(pc.dim("Auto-selected port"), pc.cyan(selectedPort.toString()))
|
log(pc.dim("Auto-selected port"), pc.cyan(selectedPort.toString()))
|
||||||
} else {
|
} else {
|
||||||
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
|
log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await startServer({ signal, port: selectedPort })
|
return await startServer({ signal, port: selectedPort, logger })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isPortStartFailure(error, selectedPort)) {
|
if (!isPortStartFailure(error, selectedPort)) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const { port: retryPort } = await getAvailableServerPort(selectedPort + 1, "127.0.0.1")
|
const { port: retryPort } = await getAvailableServerPort(selectedPort + 1, "127.0.0.1")
|
||||||
console.log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
|
log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
|
||||||
return await startServer({ signal, port: retryPort })
|
return await startServer({ signal, port: retryPort, logger })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient, RunLogger } from "./types"
|
||||||
import { serializeError } from "./events"
|
import { serializeError } from "./events"
|
||||||
|
|
||||||
const SESSION_CREATE_MAX_RETRIES = 3
|
const SESSION_CREATE_MAX_RETRIES = 3
|
||||||
@@ -9,8 +9,18 @@ export async function resolveSession(options: {
|
|||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
directory: string
|
directory: string
|
||||||
|
questionPermission?: "allow" | "deny"
|
||||||
|
logger?: RunLogger
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const { client, sessionId, directory } = options
|
const {
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
directory,
|
||||||
|
questionPermission = "deny",
|
||||||
|
logger,
|
||||||
|
} = options
|
||||||
|
const log = logger?.log ?? console.log
|
||||||
|
const error = logger?.error ?? console.error
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const res = await client.session.get({
|
const res = await client.session.get({
|
||||||
@@ -27,23 +37,22 @@ export async function resolveSession(options: {
|
|||||||
const res = await client.session.create({
|
const res = await client.session.create({
|
||||||
body: {
|
body: {
|
||||||
title: "oh-my-opencode run",
|
title: "oh-my-opencode run",
|
||||||
// In CLI run mode there's no TUI to answer questions.
|
|
||||||
permission: [
|
permission: [
|
||||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
{ permission: "question", action: questionPermission, pattern: "*" },
|
||||||
],
|
],
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
console.error(
|
error(
|
||||||
pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`)
|
pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`)
|
||||||
)
|
)
|
||||||
console.error(pc.dim(` Error: ${serializeError(res.error)}`))
|
error(pc.dim(` Error: ${serializeError(res.error)}`))
|
||||||
|
|
||||||
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
||||||
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
||||||
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
log(pc.dim(` Retrying in ${delay}ms...`))
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -53,7 +62,7 @@ export async function resolveSession(options: {
|
|||||||
return res.data.id
|
return res.data.id
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
error(
|
||||||
pc.yellow(
|
pc.yellow(
|
||||||
`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned`
|
`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned`
|
||||||
)
|
)
|
||||||
@@ -61,7 +70,7 @@ export async function resolveSession(options: {
|
|||||||
|
|
||||||
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
||||||
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
||||||
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
log(pc.dim(` Retrying in ${delay}ms...`))
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface RunOptions {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunLogger {
|
||||||
|
log?: (...args: unknown[]) => void
|
||||||
|
error?: (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerConnection {
|
export interface ServerConnection {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
cleanup: () => void
|
cleanup: () => void
|
||||||
@@ -34,6 +39,99 @@ export interface RunContext {
|
|||||||
directory: string
|
directory: string
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
|
renderOutput?: boolean
|
||||||
|
logger?: RunLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStartedEvent {
|
||||||
|
type: "session.started"
|
||||||
|
sessionId: string
|
||||||
|
agent: string
|
||||||
|
resumed: boolean
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageDeltaEvent {
|
||||||
|
type: "message.delta"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageCompletedEvent {
|
||||||
|
type: "message.completed"
|
||||||
|
sessionId: string
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolStartedEvent {
|
||||||
|
type: "tool.started"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCompletedEvent {
|
||||||
|
type: "tool.completed"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
output?: string
|
||||||
|
status: "completed" | "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionIdleEvent {
|
||||||
|
type: "session.idle"
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionQuestionEvent {
|
||||||
|
type: "session.question"
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
input?: unknown
|
||||||
|
question?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCompletedEvent {
|
||||||
|
type: "session.completed"
|
||||||
|
sessionId: string
|
||||||
|
result: RunResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionErrorEvent {
|
||||||
|
type: "session.error"
|
||||||
|
sessionId: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawStreamEvent {
|
||||||
|
type: "raw"
|
||||||
|
sessionId: string
|
||||||
|
payload: EventPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| SessionStartedEvent
|
||||||
|
| MessageDeltaEvent
|
||||||
|
| MessageCompletedEvent
|
||||||
|
| ToolStartedEvent
|
||||||
|
| ToolCompletedEvent
|
||||||
|
| SessionIdleEvent
|
||||||
|
| SessionQuestionEvent
|
||||||
|
| SessionCompletedEvent
|
||||||
|
| SessionErrorEvent
|
||||||
|
| RawStreamEvent
|
||||||
|
|
||||||
|
export interface RunEventObserver {
|
||||||
|
includeRawEvents?: boolean
|
||||||
|
onEvent?: (event: StreamEvent) => void | Promise<void>
|
||||||
|
onIdle?: (event: SessionIdleEvent) => void | Promise<void>
|
||||||
|
onQuestion?: (event: SessionQuestionEvent) => void | Promise<void>
|
||||||
|
onComplete?: (event: SessionCompletedEvent) => void | Promise<void>
|
||||||
|
onError?: (event: SessionErrorEvent) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Todo {
|
export interface Todo {
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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,12 +1,5 @@
|
|||||||
import { z } from "zod"
|
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({
|
export const BackgroundTaskConfigSchema = z.object({
|
||||||
defaultConcurrency: z.number().min(1).optional(),
|
defaultConcurrency: z.number().min(1).optional(),
|
||||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||||
@@ -18,9 +11,6 @@ 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) */
|
/** 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(),
|
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||||
syncPollTimeoutMs: 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>
|
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const HookNameSchema = z.enum([
|
|||||||
"anthropic-effort",
|
"anthropic-effort",
|
||||||
"hashline-read-enhancer",
|
"hashline-read-enhancer",
|
||||||
"read-image-resizer",
|
"read-image-resizer",
|
||||||
"todo-description-override",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
/** Enable new task system (default: false) */
|
/** Enable new task system (default: false) */
|
||||||
new_task_system_enabled: z.boolean().optional(),
|
new_task_system_enabled: z.boolean().optional(),
|
||||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
/** Default agent name for `oh-my-opencode run` (env fallback: OPENCODE_DEFAULT_AGENT, after OPENCODE_AGENT) */
|
||||||
default_run_agent: z.string().optional(),
|
default_run_agent: z.string().optional(),
|
||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
disabled_agents: z.array(z.string()).optional(),
|
disabled_agents: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import type { BackgroundTask, LaunchInput } from "./types"
|
import type { BackgroundTask, LaunchInput } from "./types"
|
||||||
|
|
||||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
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 MIN_STABILITY_TIME_MS = 10 * 1000
|
||||||
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
|
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
|
||||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_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_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||||
export const MIN_IDLE_TIME_MS = 5000
|
export const MIN_IDLE_TIME_MS = 5000
|
||||||
export const POLLING_INTERVAL_MS = 3000
|
export const POLLING_INTERVAL_MS = 3000
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
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",
|
prompt: "Test",
|
||||||
agent: "test-agent",
|
agent: "test-agent",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt: new Date(Date.now() - 25 * 60 * 1000),
|
startedAt: new Date(Date.now() - 300_000),
|
||||||
progress: {
|
progress: {
|
||||||
toolCalls: 1,
|
toolCalls: 1,
|
||||||
lastUpdate: new Date(Date.now() - 21 * 60 * 1000),
|
lastUpdate: new Date(Date.now() - 200_000),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
POLLING_INTERVAL_MS,
|
POLLING_INTERVAL_MS,
|
||||||
TASK_CLEANUP_DELAY_MS,
|
TASK_CLEANUP_DELAY_MS,
|
||||||
TASK_TTL_MS,
|
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
|
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { subagentSessions } from "../claude-code-session-state"
|
||||||
@@ -52,11 +51,6 @@ import { join } from "node:path"
|
|||||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
import {
|
|
||||||
detectRepetitiveToolUse,
|
|
||||||
recordToolCall,
|
|
||||||
resolveCircuitBreakerSettings,
|
|
||||||
} from "./loop-detector"
|
|
||||||
import {
|
import {
|
||||||
createSubagentDepthLimitError,
|
createSubagentDepthLimitError,
|
||||||
createSubagentDescendantLimitError,
|
createSubagentDescendantLimitError,
|
||||||
@@ -70,11 +64,9 @@ type OpencodeClient = PluginInput["client"]
|
|||||||
|
|
||||||
|
|
||||||
interface MessagePartInfo {
|
interface MessagePartInfo {
|
||||||
id?: string
|
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
type?: string
|
type?: string
|
||||||
tool?: string
|
tool?: string
|
||||||
state?: { status?: string; input?: Record<string, unknown> }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventProperties {
|
interface EventProperties {
|
||||||
@@ -88,19 +80,6 @@ interface Event {
|
|||||||
properties?: EventProperties
|
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 {
|
interface Todo {
|
||||||
content: string
|
content: string
|
||||||
status: string
|
status: string
|
||||||
@@ -121,8 +100,6 @@ export interface SubagentSessionCreatedEvent {
|
|||||||
|
|
||||||
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
|
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
|
||||||
|
|
||||||
const MAX_TASK_REMOVAL_RESCHEDULES = 6
|
|
||||||
|
|
||||||
export class BackgroundManager {
|
export class BackgroundManager {
|
||||||
|
|
||||||
|
|
||||||
@@ -743,8 +720,6 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
existingTask.progress = {
|
existingTask.progress = {
|
||||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||||
toolCallWindow: existingTask.progress?.toolCallWindow,
|
|
||||||
countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
|
|
||||||
lastUpdate: new Date(),
|
lastUpdate: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +852,8 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||||
const partInfo = resolveMessagePartInfo(props)
|
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||||
|
const partInfo = props as unknown as MessagePartInfo
|
||||||
const sessionID = partInfo?.sessionID
|
const sessionID = partInfo?.sessionID
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
@@ -900,66 +876,8 @@ export class BackgroundManager {
|
|||||||
task.progress.lastUpdate = new Date()
|
task.progress.lastUpdate = new Date()
|
||||||
|
|
||||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
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.toolCalls += 1
|
||||||
task.progress.lastTool = partInfo.tool
|
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.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,7 +1188,7 @@ export class BackgroundManager {
|
|||||||
this.completedTaskSummaries.delete(parentSessionID)
|
this.completedTaskSummaries.delete(parentSessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
|
private scheduleTaskRemoval(taskId: string): void {
|
||||||
const existingTimer = this.completionTimers.get(taskId)
|
const existingTimer = this.completionTimers.get(taskId)
|
||||||
if (existingTimer) {
|
if (existingTimer) {
|
||||||
clearTimeout(existingTimer)
|
clearTimeout(existingTimer)
|
||||||
@@ -1280,29 +1198,17 @@ export class BackgroundManager {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
this.completionTimers.delete(taskId)
|
this.completionTimers.delete(taskId)
|
||||||
const task = this.tasks.get(taskId)
|
const task = this.tasks.get(taskId)
|
||||||
if (!task) return
|
if (task) {
|
||||||
|
this.clearNotificationsForTask(taskId)
|
||||||
if (task.parentSessionID) {
|
this.tasks.delete(taskId)
|
||||||
const siblings = this.getTasksByParentSession(task.parentSessionID)
|
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||||
const runningOrPendingSiblings = siblings.filter(
|
if (task.sessionID) {
|
||||||
sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
|
subagentSessions.delete(task.sessionID)
|
||||||
)
|
SessionCategoryRegistry.remove(task.sessionID)
|
||||||
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)
|
}, TASK_CLEANUP_DELAY_MS)
|
||||||
|
|
||||||
this.completionTimers.set(taskId, timer)
|
this.completionTimers.set(taskId, timer)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
declare const require: (name: string) => any
|
||||||
|
const { describe, test, expect, afterEach } = require("bun:test")
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||||
import { BackgroundManager } from "./manager"
|
import { BackgroundManager } from "./manager"
|
||||||
@@ -156,19 +157,17 @@ function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||||
describe("#given 3 tasks for same parent and task A completed first", () => {
|
describe("#given 2 tasks for same parent and task A completed", () => {
|
||||||
test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => {
|
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 () => {
|
||||||
// given
|
// given
|
||||||
const { manager } = createManager(false)
|
const { manager } = createManager(false)
|
||||||
managerUnderTest = manager
|
managerUnderTest = manager
|
||||||
fakeTimers = installFakeTimers()
|
fakeTimers = installFakeTimers()
|
||||||
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date() })
|
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||||
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
|
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(taskA.id, taskA)
|
||||||
getTasks(manager).set(taskB.id, taskB)
|
getTasks(manager).set(taskB.id, taskB)
|
||||||
getTasks(manager).set(taskC.id, taskC)
|
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
|
||||||
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id, taskC.id]))
|
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await notifyParentSessionForTest(manager, taskA)
|
await notifyParentSessionForTest(manager, taskA)
|
||||||
@@ -178,23 +177,8 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
|
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
|
||||||
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)
|
expect(getTasks(manager).has(taskA.id)).toBe(false)
|
||||||
|
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||||
DEFAULT_STALE_TIMEOUT_MS,
|
DEFAULT_STALE_TIMEOUT_MS,
|
||||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||||
TERMINAL_TASK_TTL_MS,
|
|
||||||
TASK_TTL_MS,
|
TASK_TTL_MS,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
|
|
||||||
|
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||||
"completed",
|
"completed",
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -9,17 +9,9 @@ export type BackgroundTaskStatus =
|
|||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "interrupt"
|
| "interrupt"
|
||||||
|
|
||||||
export interface ToolCallWindow {
|
|
||||||
toolSignatures: string[]
|
|
||||||
windowSize: number
|
|
||||||
thresholdPercent: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskProgress {
|
export interface TaskProgress {
|
||||||
toolCalls: number
|
toolCalls: number
|
||||||
lastTool?: string
|
lastTool?: string
|
||||||
toolCallWindow?: ToolCallWindow
|
|
||||||
countedToolPartIDs?: string[]
|
|
||||||
lastUpdate: Date
|
lastUpdate: Date
|
||||||
lastMessage?: string
|
lastMessage?: string
|
||||||
lastMessageAt?: Date
|
lastMessageAt?: Date
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ describe("boulder-state", () => {
|
|||||||
expect(progress.isComplete).toBe(true)
|
expect(progress.isComplete).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should return isComplete false for plan with content but no checkboxes", () => {
|
test("should return isComplete true for empty plan", () => {
|
||||||
// given - plan with no checkboxes
|
// given - plan with no checkboxes
|
||||||
const planPath = join(TEST_DIR, "empty-plan.md")
|
const planPath = join(TEST_DIR, "empty-plan.md")
|
||||||
writeFileSync(planPath, "# Plan\nNo tasks here")
|
writeFileSync(planPath, "# Plan\nNo tasks here")
|
||||||
@@ -361,7 +361,7 @@ describe("boulder-state", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(progress.total).toBe(0)
|
expect(progress.total).toBe(0)
|
||||||
expect(progress.isComplete).toBe(false)
|
expect(progress.isComplete).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should handle non-existent file", () => {
|
test("should handle non-existent file", () => {
|
||||||
|
|||||||
@@ -59,13 +59,10 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
|
|||||||
if (!Array.isArray(state.session_ids)) {
|
if (!Array.isArray(state.session_ids)) {
|
||||||
state.session_ids = []
|
state.session_ids = []
|
||||||
}
|
}
|
||||||
const originalSessionIds = [...state.session_ids]
|
|
||||||
state.session_ids.push(sessionId)
|
state.session_ids.push(sessionId)
|
||||||
if (writeBoulderState(directory, state)) {
|
if (writeBoulderState(directory, state)) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
state.session_ids = originalSessionIds
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
@@ -133,7 +130,7 @@ export function getPlanProgress(planPath: string): PlanProgress {
|
|||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
completed,
|
completed,
|
||||||
isComplete: total > 0 && completed === total,
|
isComplete: total === 0 || completed === total,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { total: 0, completed: 0, isComplete: true }
|
return { total: 0, completed: 0, isComplete: true }
|
||||||
|
|||||||
@@ -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
|
- \`--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 and valid: hook pre-sets worktree_path in boulder.json
|
||||||
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
|
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
|
||||||
- If omitted: work directly in the current project directory (no worktree)
|
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
|
||||||
|
|
||||||
## WHAT TO DO
|
## 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 ONE plan: auto-select it
|
||||||
- If MULTIPLE plans: show list with timestamps, ask user to select
|
- If MULTIPLE plans: show list with timestamps, ask user to select
|
||||||
|
|
||||||
4. **Worktree Setup** (ONLY when \`--worktree\` was explicitly specified and \`worktree_path\` not already set in boulder.json):
|
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
|
||||||
1. \`git worktree list --porcelain\` — see available worktrees
|
1. \`git worktree list --porcelain\` — see available worktrees
|
||||||
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
||||||
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
|
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
|
||||||
@@ -86,38 +86,6 @@ Reading plan and beginning execution...
|
|||||||
|
|
||||||
- The session_id is injected by the hook - use it directly
|
- The session_id is injected by the hook - use it directly
|
||||||
- Always update boulder.json BEFORE starting work
|
- Always update boulder.json BEFORE starting work
|
||||||
- If worktree_path is set in boulder.json, all work happens inside that worktree directory
|
- Always set worktree_path in boulder.json before executing any tasks
|
||||||
- Read the FULL plan file before delegating any tasks
|
- 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,10 +75,6 @@ describe("mapClaudeModelToOpenCode", () => {
|
|||||||
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
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", () => {
|
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" })
|
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,16 +20,7 @@ function mapClaudeModelString(model: string | undefined): string | undefined {
|
|||||||
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
|
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
|
||||||
if (aliasResult) return aliasResult
|
if (aliasResult) return aliasResult
|
||||||
|
|
||||||
if (trimmed.includes("/")) {
|
if (trimmed.includes("/")) return trimmed
|
||||||
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)
|
const normalized = normalizeModelID(trimmed)
|
||||||
|
|
||||||
|
|||||||
@@ -153,25 +153,3 @@ 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,16 +72,8 @@ function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): st
|
|||||||
|
|
||||||
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
|
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
|
||||||
return codeBlock
|
return codeBlock
|
||||||
.split("\n")
|
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||||
.map((line) => {
|
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||||
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(
|
function buildCommitFooterInjection(
|
||||||
|
|||||||
@@ -199,236 +199,3 @@ 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,28 +1,10 @@
|
|||||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
// 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[] = [
|
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||||
// npm/pnpm/yarn config patterns (original)
|
|
||||||
/^NPM_CONFIG_/i,
|
/^NPM_CONFIG_/i,
|
||||||
/^npm_config_/,
|
/^npm_config_/,
|
||||||
/^YARN_/,
|
/^YARN_/,
|
||||||
/^PNPM_/,
|
/^PNPM_/,
|
||||||
/^NO_UPDATE_NOTIFIER$/,
|
/^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(
|
export function createCleanMcpEnvironment(
|
||||||
|
|||||||
@@ -279,116 +279,6 @@ 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", () => {
|
describe("updateTaskModelBySession", () => {
|
||||||
test("updates task model info and shows fallback toast", () => {
|
test("updates task model info and shows fallback toast", () => {
|
||||||
// given - task without model info
|
// given - task without model info
|
||||||
|
|||||||
@@ -127,13 +127,6 @@ export class TaskToastManager {
|
|||||||
const queued = this.getQueuedTasks()
|
const queued = this.getQueuedTasks()
|
||||||
const concurrencyInfo = this.getConcurrencyInfo()
|
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 lines: string[] = []
|
||||||
|
|
||||||
const isFallback = newTask.modelInfo && (
|
const isFallback = newTask.modelInfo && (
|
||||||
@@ -158,9 +151,9 @@ export class TaskToastManager {
|
|||||||
const duration = this.formatDuration(task.startedAt)
|
const duration = this.formatDuration(task.startedAt)
|
||||||
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
|
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
|
||||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||||
const taskId = formatTaskIdentifier(task)
|
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - ${duration}${isNew}`)
|
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - ${duration}${isNew}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +162,10 @@ export class TaskToastManager {
|
|||||||
lines.push(`Queued (${queued.length}):`)
|
lines.push(`Queued (${queued.length}):`)
|
||||||
for (const task of queued) {
|
for (const task of queued) {
|
||||||
const bgIcon = task.isBackground ? "[Q]" : "[W]"
|
const bgIcon = task.isBackground ? "[Q]" : "[W]"
|
||||||
const taskId = formatTaskIdentifier(task)
|
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - Queued${isNew}`)
|
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - Queued${isNew}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ describe("createAutoSlashCommandHook leak prevention", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("#given hook with sessionProcessedCommandExecutions", () => {
|
describe("#given hook with sessionProcessedCommandExecutions", () => {
|
||||||
describe("#when same command executed twice after fallback dedup window", () => {
|
describe("#when same command executed twice within TTL for same session", () => {
|
||||||
it("#then second execution is treated as intentional rerun", async () => {
|
it("#then second execution is deduplicated", async () => {
|
||||||
//#given
|
//#given
|
||||||
const nowSpy = spyOn(Date, "now")
|
const nowSpy = spyOn(Date, "now")
|
||||||
try {
|
try {
|
||||||
@@ -68,61 +68,6 @@ describe("createAutoSlashCommandHook leak prevention", () => {
|
|||||||
const firstOutput = createCommandOutput("first")
|
const firstOutput = createCommandOutput("first")
|
||||||
const secondOutput = createCommandOutput("second")
|
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
|
//#when
|
||||||
nowSpy.mockReturnValue(0)
|
nowSpy.mockReturnValue(0)
|
||||||
await hook["command.execute.before"](input, firstOutput)
|
await hook["command.execute.before"](input, firstOutput)
|
||||||
@@ -138,6 +83,32 @@ 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", () => {
|
describe("#given hook with entries from multiple sessions", () => {
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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,7 +41,6 @@ export interface ExecutorOptions {
|
|||||||
skills?: LoadedSkill[]
|
skills?: LoadedSkill[]
|
||||||
pluginsEnabled?: boolean
|
pluginsEnabled?: boolean
|
||||||
enabledPluginsOverride?: Record<string, boolean>
|
enabledPluginsOverride?: Record<string, boolean>
|
||||||
agent?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterDiscoveredCommandsByScope(
|
function filterDiscoveredCommandsByScope(
|
||||||
@@ -61,12 +60,12 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
|||||||
const skillCommands = skills.map(skillToCommandInfo)
|
const skillCommands = skills.map(skillToCommandInfo)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...skillCommands,
|
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||||
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||||
|
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||||
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||||
|
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||||
|
...skillCommands,
|
||||||
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -142,15 +141,6 @@ 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 {
|
try {
|
||||||
const template = await formatCommandTemplate(command, parsed.args)
|
const template = await formatCommandTemplate(command, parsed.args)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import type {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null
|
return typeof value === "object" && value !== null
|
||||||
}
|
}
|
||||||
@@ -37,33 +35,6 @@ function getDeletedSessionID(properties: unknown): string | null {
|
|||||||
return typeof info.id === "string" ? info.id : 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 {
|
export interface AutoSlashCommandHookOptions {
|
||||||
skills?: LoadedSkill[]
|
skills?: LoadedSkill[]
|
||||||
pluginsEnabled?: boolean
|
pluginsEnabled?: boolean
|
||||||
@@ -125,12 +96,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
args: parsed.args,
|
args: parsed.args,
|
||||||
})
|
})
|
||||||
|
|
||||||
const executionOptions: ExecutorOptions = {
|
const result = await executeSlashCommand(parsed, executorOptions)
|
||||||
...executorOptions,
|
|
||||||
agent: input.agent,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await executeSlashCommand(parsed, executionOptions)
|
|
||||||
|
|
||||||
const idx = findSlashCommandPartIndex(output.parts)
|
const idx = findSlashCommandPartIndex(output.parts)
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
@@ -159,10 +125,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
input: CommandExecuteBeforeInput,
|
input: CommandExecuteBeforeInput,
|
||||||
output: CommandExecuteBeforeOutput
|
output: CommandExecuteBeforeOutput
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const eventID = getCommandExecutionEventID(input)
|
const commandKey = `${input.sessionID}:${input.command.toLowerCase()}:${input.arguments || ""}`
|
||||||
const commandKey = eventID
|
|
||||||
? `${input.sessionID}:event:${eventID}`
|
|
||||||
: `${input.sessionID}:fallback:${input.command.toLowerCase()}:${input.arguments || ""}`
|
|
||||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,12 +142,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const executionOptions: ExecutorOptions = {
|
const result = await executeSlashCommand(parsed, executorOptions)
|
||||||
...executorOptions,
|
|
||||||
agent: input.agent,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await executeSlashCommand(parsed, executionOptions)
|
|
||||||
|
|
||||||
if (!result.success || !result.replacementText) {
|
if (!result.success || !result.replacementText) {
|
||||||
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
||||||
@@ -195,10 +153,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionProcessedCommandExecutions.add(
|
sessionProcessedCommandExecutions.add(commandKey)
|
||||||
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}`
|
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 {
|
export interface ProcessedCommandStore {
|
||||||
has(commandKey: string): boolean
|
has(commandKey: string): boolean
|
||||||
add(commandKey: string, ttlMs?: number): void
|
add(commandKey: string): void
|
||||||
cleanupSession(sessionID: string): void
|
cleanupSession(sessionID: string): void
|
||||||
clear(): void
|
clear(): void
|
||||||
}
|
}
|
||||||
@@ -38,11 +38,11 @@ export function createProcessedCommandStore(): ProcessedCommandStore {
|
|||||||
entries = pruneExpiredEntries(entries, now)
|
entries = pruneExpiredEntries(entries, now)
|
||||||
return entries.has(commandKey)
|
return entries.has(commandKey)
|
||||||
},
|
},
|
||||||
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
|
add(commandKey: string): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
entries = pruneExpiredEntries(entries, now)
|
entries = pruneExpiredEntries(entries, now)
|
||||||
entries.delete(commandKey)
|
entries.delete(commandKey)
|
||||||
entries.set(commandKey, now + ttlMs)
|
entries.set(commandKey, now + PROCESSED_COMMAND_TTL_MS)
|
||||||
entries = trimProcessedEntries(entries)
|
entries = trimProcessedEntries(entries)
|
||||||
},
|
},
|
||||||
cleanupSession(sessionID: string): void {
|
cleanupSession(sessionID: string): void {
|
||||||
|
|||||||
@@ -26,15 +26,6 @@ export interface CommandExecuteBeforeInput {
|
|||||||
command: string
|
command: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
arguments: string
|
arguments: string
|
||||||
agent?: string
|
|
||||||
messageID?: string
|
|
||||||
messageId?: string
|
|
||||||
eventID?: string
|
|
||||||
eventId?: string
|
|
||||||
invocationID?: string
|
|
||||||
invocationId?: string
|
|
||||||
commandID?: string
|
|
||||||
commandId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecuteBeforeOutput {
|
export interface CommandExecuteBeforeOutput {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { existsSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
import { runBunInstallWithDetails } from "../../../cli/config-manager"
|
import { runBunInstallWithDetails } from "../../../cli/config-manager"
|
||||||
import { log } from "../../../shared/logger"
|
import { log } from "../../../shared/logger"
|
||||||
import { getOpenCodeCacheDir, getOpenCodeConfigPaths } from "../../../shared"
|
|
||||||
import { invalidatePackage } from "../cache"
|
import { invalidatePackage } from "../cache"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
import { extractChannel } from "../version-channel"
|
import { extractChannel } from "../version-channel"
|
||||||
@@ -14,36 +11,9 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
|
|||||||
return `Update available: ${latestVersion} (version pinned, update manually)`
|
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 {
|
try {
|
||||||
const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir })
|
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||||
if (!result.success && result.error) {
|
if (!result.success && result.error) {
|
||||||
log("[auto-update-checker] bun install error:", result.error)
|
log("[auto-update-checker] bun install error:", result.error)
|
||||||
}
|
}
|
||||||
@@ -112,8 +82,7 @@ export async function runBackgroundUpdateCheck(
|
|||||||
|
|
||||||
invalidatePackage(PACKAGE_NAME)
|
invalidatePackage(PACKAGE_NAME)
|
||||||
|
|
||||||
const activeWorkspace = resolveActiveInstallWorkspace()
|
const installSuccess = await runBunInstallSafe()
|
||||||
const installSuccess = await runBunInstallSafe(activeWorkspace)
|
|
||||||
|
|
||||||
if (installSuccess) {
|
if (installSuccess) {
|
||||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
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,4 +52,3 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
|||||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
|
||||||
|
|||||||
@@ -1,96 +1,11 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
|
||||||
import type { RalphLoopState } from "./types"
|
import type { RalphLoopState } from "./types"
|
||||||
import { handleFailedVerification } from "./verification-failure-handler"
|
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 = {
|
type LoopStateController = {
|
||||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePendingVerification(
|
export async function handlePendingVerification(
|
||||||
@@ -118,29 +33,6 @@ export async function handlePendingVerification(
|
|||||||
} = input
|
} = input
|
||||||
|
|
||||||
if (matchesParentSession || (verificationSessionID && matchesVerificationSession)) {
|
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, {
|
const restarted = await handleFailedVerification(ctx, {
|
||||||
state,
|
state,
|
||||||
loopState,
|
loopState,
|
||||||
|
|||||||
@@ -136,13 +136,6 @@ export function createRalphLoopEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.verification_pending) {
|
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, {
|
await handlePendingVerification(ctx, {
|
||||||
sessionID,
|
sessionID,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { classifyErrorType, extractAutoRetrySignal, extractStatusCode, isRetryableError } from "./error-classifier"
|
import { classifyErrorType, extractAutoRetrySignal, isRetryableError } from "./error-classifier"
|
||||||
|
|
||||||
describe("runtime-fallback error classifier", () => {
|
describe("runtime-fallback error classifier", () => {
|
||||||
test("detects cooling-down auto-retry status signals", () => {
|
test("detects cooling-down auto-retry status signals", () => {
|
||||||
@@ -97,72 +97,3 @@ describe("runtime-fallback error classifier", () => {
|
|||||||
expect(signal).toBeUndefined()
|
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,15 +33,8 @@ export function extractStatusCode(error: unknown, retryOnErrors?: number[]): num
|
|||||||
|
|
||||||
const errorObj = error as Record<string, unknown>
|
const errorObj = error as Record<string, unknown>
|
||||||
|
|
||||||
const statusCode = [
|
const statusCode = errorObj.statusCode ?? errorObj.status ?? (errorObj.data as Record<string, unknown>)?.statusCode
|
||||||
errorObj.statusCode,
|
if (typeof statusCode === "number") {
|
||||||
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
|
return statusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("createSessionStateStore regressions", () => {
|
|||||||
|
|
||||||
describe("#given external activity happens after a successful continuation", () => {
|
describe("#given external activity happens after a successful continuation", () => {
|
||||||
describe("#when todos stay unchanged", () => {
|
describe("#when todos stay unchanged", () => {
|
||||||
test("#then it keeps counting stagnation", () => {
|
test("#then it treats the activity as progress instead of stagnation", () => {
|
||||||
const sessionID = "ses-activity-progress"
|
const sessionID = "ses-activity-progress"
|
||||||
const todos = [
|
const todos = [
|
||||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
@@ -37,9 +37,9 @@ describe("createSessionStateStore regressions", () => {
|
|||||||
trackedState.abortDetectedAt = undefined
|
trackedState.abortDetectedAt = undefined
|
||||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||||
|
|
||||||
expect(progressUpdate.hasProgressed).toBe(false)
|
expect(progressUpdate.hasProgressed).toBe(true)
|
||||||
expect(progressUpdate.progressSource).toBe("none")
|
expect(progressUpdate.progressSource).toBe("activity")
|
||||||
expect(progressUpdate.stagnationCount).toBe(1)
|
expect(progressUpdate.stagnationCount).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -72,7 +72,7 @@ describe("createSessionStateStore regressions", () => {
|
|||||||
|
|
||||||
describe("#given stagnation already halted a session", () => {
|
describe("#given stagnation already halted a session", () => {
|
||||||
describe("#when new activity appears before the next idle check", () => {
|
describe("#when new activity appears before the next idle check", () => {
|
||||||
test("#then it does not reset the stop condition", () => {
|
test("#then it resets the stop condition on the next progress check", () => {
|
||||||
const sessionID = "ses-stagnation-recovery"
|
const sessionID = "ses-stagnation-recovery"
|
||||||
const todos = [
|
const todos = [
|
||||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
@@ -96,9 +96,9 @@ describe("createSessionStateStore regressions", () => {
|
|||||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||||
|
|
||||||
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
|
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||||
expect(progressUpdate.hasProgressed).toBe(false)
|
expect(progressUpdate.hasProgressed).toBe(true)
|
||||||
expect(progressUpdate.progressSource).toBe("none")
|
expect(progressUpdate.progressSource).toBe("activity")
|
||||||
expect(progressUpdate.stagnationCount).toBe(MAX_STAGNATION_COUNT)
|
expect(progressUpdate.stagnationCount).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface TrackedSessionState {
|
|||||||
lastAccessedAt: number
|
lastAccessedAt: number
|
||||||
lastCompletedCount?: number
|
lastCompletedCount?: number
|
||||||
lastTodoSnapshot?: string
|
lastTodoSnapshot?: string
|
||||||
|
activitySignalCount: number
|
||||||
|
lastObservedActivitySignalCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContinuationProgressUpdate {
|
export interface ContinuationProgressUpdate {
|
||||||
@@ -23,7 +25,7 @@ export interface ContinuationProgressUpdate {
|
|||||||
previousStagnationCount: number
|
previousStagnationCount: number
|
||||||
stagnationCount: number
|
stagnationCount: number
|
||||||
hasProgressed: boolean
|
hasProgressed: boolean
|
||||||
progressSource: "none" | "todo"
|
progressSource: "none" | "todo" | "activity"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStateStore {
|
export interface SessionStateStore {
|
||||||
@@ -96,7 +98,17 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
const trackedSession: TrackedSessionState = {
|
const trackedSession: TrackedSessionState = {
|
||||||
state: rawState,
|
state: rawState,
|
||||||
lastAccessedAt: Date.now(),
|
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)
|
sessions.set(sessionID, trackedSession)
|
||||||
return trackedSession
|
return trackedSession
|
||||||
}
|
}
|
||||||
@@ -125,6 +137,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
const previousStagnationCount = state.stagnationCount
|
const previousStagnationCount = state.stagnationCount
|
||||||
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||||
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
||||||
|
const currentActivitySignalCount = trackedSession.activitySignalCount
|
||||||
const hasCompletedMoreTodos =
|
const hasCompletedMoreTodos =
|
||||||
currentCompletedCount !== undefined
|
currentCompletedCount !== undefined
|
||||||
&& trackedSession.lastCompletedCount !== undefined
|
&& trackedSession.lastCompletedCount !== undefined
|
||||||
@@ -133,6 +146,9 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
currentTodoSnapshot !== undefined
|
currentTodoSnapshot !== undefined
|
||||||
&& trackedSession.lastTodoSnapshot !== undefined
|
&& trackedSession.lastTodoSnapshot !== undefined
|
||||||
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
||||||
|
const hasObservedExternalActivity =
|
||||||
|
trackedSession.lastObservedActivitySignalCount !== undefined
|
||||||
|
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
|
||||||
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||||
|
|
||||||
state.lastIncompleteCount = incompleteCount
|
state.lastIncompleteCount = incompleteCount
|
||||||
@@ -142,6 +158,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
if (currentTodoSnapshot !== undefined) {
|
if (currentTodoSnapshot !== undefined) {
|
||||||
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
||||||
}
|
}
|
||||||
|
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
|
||||||
|
|
||||||
if (previousIncompleteCount === undefined) {
|
if (previousIncompleteCount === undefined) {
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
@@ -156,7 +173,9 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
|
|
||||||
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
||||||
? "todo"
|
? "todo"
|
||||||
: "none"
|
: hasObservedExternalActivity
|
||||||
|
? "activity"
|
||||||
|
: "none"
|
||||||
|
|
||||||
if (progressSource !== "none") {
|
if (progressSource !== "none") {
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
@@ -204,6 +223,8 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
state.awaitingPostInjectionProgressCheck = false
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
trackedSession.lastCompletedCount = undefined
|
trackedSession.lastCompletedCount = undefined
|
||||||
trackedSession.lastTodoSnapshot = undefined
|
trackedSession.lastTodoSnapshot = undefined
|
||||||
|
trackedSession.activitySignalCount = 0
|
||||||
|
trackedSession.lastObservedActivitySignalCount = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCountdown(sessionID: string): void {
|
function cancelCountdown(sessionID: string): void {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { describe, expect, it as test } from "bun:test"
|
import { describe, expect, it as test } from "bun:test"
|
||||||
|
|
||||||
import { MAX_STAGNATION_COUNT } from "./constants"
|
import { MAX_STAGNATION_COUNT } from "./constants"
|
||||||
import { handleNonIdleEvent } from "./non-idle-events"
|
|
||||||
import { createSessionStateStore } from "./session-state"
|
|
||||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||||
|
|
||||||
describe("shouldStopForStagnation", () => {
|
describe("shouldStopForStagnation", () => {
|
||||||
@@ -27,7 +25,7 @@ describe("shouldStopForStagnation", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#when todo progress is detected after the halt", () => {
|
describe("#when activity progress is detected after the halt", () => {
|
||||||
test("#then it clears the stop condition", () => {
|
test("#then it clears the stop condition", () => {
|
||||||
const shouldStop = shouldStopForStagnation({
|
const shouldStop = shouldStopForStagnation({
|
||||||
sessionID: "ses-recovered",
|
sessionID: "ses-recovered",
|
||||||
@@ -37,7 +35,7 @@ describe("shouldStopForStagnation", () => {
|
|||||||
previousStagnationCount: MAX_STAGNATION_COUNT,
|
previousStagnationCount: MAX_STAGNATION_COUNT,
|
||||||
stagnationCount: 0,
|
stagnationCount: 0,
|
||||||
hasProgressed: true,
|
hasProgressed: true,
|
||||||
progressSource: "todo",
|
progressSource: "activity",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,60 +43,4 @@ 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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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).`
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { createTodoDescriptionOverrideHook } from "./hook"
|
|
||||||
@@ -71,9 +71,5 @@ export function createPluginInterface(args: {
|
|||||||
ctx,
|
ctx,
|
||||||
hooks,
|
hooks,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
"tool.definition": async (input, output) => {
|
|
||||||
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "bun:test"
|
import { describe, it, expect } from "bun:test"
|
||||||
|
|
||||||
import { createEventHandler } from "./event"
|
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?: unknown } }
|
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
_resetForTesting()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("createEventHandler - idle deduplication", () => {
|
describe("createEventHandler - idle deduplication", () => {
|
||||||
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
|
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
|
||||||
@@ -73,7 +66,7 @@ afterEach(() => {
|
|||||||
//#then - synthetic idle dispatched once
|
//#then - synthetic idle dispatched once
|
||||||
expect(dispatchCalls.length).toBe(1)
|
expect(dispatchCalls.length).toBe(1)
|
||||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||||
|
|
||||||
//#when - real session.idle arrives
|
//#when - real session.idle arrives
|
||||||
await eventHandler({
|
await eventHandler({
|
||||||
@@ -149,7 +142,7 @@ afterEach(() => {
|
|||||||
//#then - real idle dispatched once
|
//#then - real idle dispatched once
|
||||||
expect(dispatchCalls.length).toBe(1)
|
expect(dispatchCalls.length).toBe(1)
|
||||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||||
|
|
||||||
//#when - session.status with idle (generates synthetic idle)
|
//#when - session.status with idle (generates synthetic idle)
|
||||||
await eventHandler({
|
await eventHandler({
|
||||||
@@ -252,7 +245,7 @@ afterEach(() => {
|
|||||||
event: {
|
event: {
|
||||||
type: "message.updated",
|
type: "message.updated",
|
||||||
},
|
},
|
||||||
} as any)
|
})
|
||||||
|
|
||||||
//#then - both maps should be pruned (no dedup should occur for new events)
|
//#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
|
// We verify by checking that a new idle event for same session is dispatched
|
||||||
@@ -294,7 +287,7 @@ afterEach(() => {
|
|||||||
stopContinuationGuard: { event: async () => {} },
|
stopContinuationGuard: { event: async () => {} },
|
||||||
compactionTodoPreserver: { event: async () => {} },
|
compactionTodoPreserver: { event: async () => {} },
|
||||||
atlasHook: { handler: async () => {} },
|
atlasHook: { handler: async () => {} },
|
||||||
} as any,
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await eventHandlerWithMock({
|
await eventHandlerWithMock({
|
||||||
@@ -433,7 +426,7 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
type: "session.deleted",
|
type: "session.deleted",
|
||||||
properties: { info: { id: sessionID } },
|
properties: { info: { id: sessionID } },
|
||||||
},
|
},
|
||||||
} as any)
|
})
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(forwardedEvents.length).toBe(1)
|
expect(forwardedEvents.length).toBe(1)
|
||||||
@@ -442,146 +435,3 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
expect(deletedSessions).toEqual([sessionID])
|
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,12 +421,6 @@ export function createEventHandler(args: {
|
|||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = props?.sessionID as string | undefined;
|
||||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | 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) {
|
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
|
||||||
try {
|
try {
|
||||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
createHashlineReadEnhancerHook,
|
createHashlineReadEnhancerHook,
|
||||||
createReadImageResizerHook,
|
createReadImageResizerHook,
|
||||||
createJsonErrorRecoveryHook,
|
createJsonErrorRecoveryHook,
|
||||||
createTodoDescriptionOverrideHook,
|
|
||||||
} from "../../hooks"
|
} from "../../hooks"
|
||||||
import {
|
import {
|
||||||
getOpenCodeVersion,
|
getOpenCodeVersion,
|
||||||
@@ -36,7 +35,6 @@ export type ToolGuardHooks = {
|
|||||||
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||||
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
||||||
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
|
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
|
||||||
todoDescriptionOverride: ReturnType<typeof createTodoDescriptionOverrideHook> | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolGuardHooks(args: {
|
export function createToolGuardHooks(args: {
|
||||||
@@ -113,10 +111,6 @@ export function createToolGuardHooks(args: {
|
|||||||
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
|
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const todoDescriptionOverride = isHookEnabled("todo-description-override")
|
|
||||||
? safeHook("todo-description-override", () => createTodoDescriptionOverrideHook())
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentChecker,
|
commentChecker,
|
||||||
toolOutputTruncator,
|
toolOutputTruncator,
|
||||||
@@ -129,6 +123,5 @@ export function createToolGuardHooks(args: {
|
|||||||
hashlineReadEnhancer,
|
hashlineReadEnhancer,
|
||||||
jsonErrorRecovery,
|
jsonErrorRecovery,
|
||||||
readImageResizer,
|
readImageResizer,
|
||||||
todoDescriptionOverride,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,50 +48,22 @@ export function createToolExecuteAfterHandler(args: {
|
|||||||
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
|
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
|
||||||
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
||||||
const loopState = directory ? readState(directory) : null
|
const loopState = directory ? readState(directory) : null
|
||||||
const isVerificationContext =
|
|
||||||
|
if (
|
||||||
agent === "oracle"
|
agent === "oracle"
|
||||||
&& !!sessionId
|
&& sessionId
|
||||||
&& !!directory
|
&& verificationAttemptId
|
||||||
|
&& directory
|
||||||
&& loopState?.active === true
|
&& loopState?.active === true
|
||||||
&& loopState.ultrawork === true
|
&& loopState.ultrawork === true
|
||||||
&& loopState.verification_pending === true
|
&& loopState.verification_pending === true
|
||||||
&& loopState.session_id === input.sessionID
|
&& 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
|
&& loopState.verification_attempt_id === verificationAttemptId
|
||||||
) {
|
) {
|
||||||
writeState(directory, {
|
writeState(directory, {
|
||||||
...loopState,
|
...loopState,
|
||||||
verification_session_id: sessionId,
|
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,12 +79,6 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
|
|
||||||
if (shouldInjectOracleVerification) {
|
if (shouldInjectOracleVerification) {
|
||||||
const verificationAttemptId = randomUUID()
|
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, {
|
writeState(ctx.directory, {
|
||||||
...loopState,
|
...loopState,
|
||||||
verification_attempt_id: verificationAttemptId,
|
verification_attempt_id: verificationAttemptId,
|
||||||
|
|||||||
@@ -19,27 +19,6 @@ 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 () => {
|
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()}`)
|
const directory = join(tmpdir(), `tool-before-ulw-${Date.now()}`)
|
||||||
mkdirSync(directory, { recursive: true })
|
mkdirSync(directory, { recursive: true })
|
||||||
@@ -59,7 +38,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||||
})
|
})
|
||||||
const output = { args: createOracleTaskArgs("Check it") }
|
const output = {
|
||||||
|
args: {
|
||||||
|
subagent_type: "oracle",
|
||||||
|
run_in_background: true,
|
||||||
|
prompt: "Check it",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
|
||||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||||
|
|
||||||
@@ -79,7 +64,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||||
})
|
})
|
||||||
const output = { args: createOracleTaskArgs("Check it") }
|
const output = {
|
||||||
|
args: {
|
||||||
|
subagent_type: "oracle",
|
||||||
|
run_in_background: true,
|
||||||
|
prompt: "Check it",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
|
||||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||||
|
|
||||||
@@ -89,7 +80,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
rmSync(directory, { recursive: true, force: true })
|
rmSync(directory, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given ulw loop is awaiting verification #when oracle sync task metadata is persisted #then oracle session id is stored", async () => {
|
test("#given ulw loop is awaiting verification #when oracle task finishes #then oracle session id is stored", async () => {
|
||||||
const directory = join(tmpdir(), `tool-after-ulw-${Date.now()}`)
|
const directory = join(tmpdir(), `tool-after-ulw-${Date.now()}`)
|
||||||
mkdirSync(directory, { recursive: true })
|
mkdirSync(directory, { recursive: true })
|
||||||
writeState(directory, {
|
writeState(directory, {
|
||||||
@@ -108,44 +99,14 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||||
})
|
})
|
||||||
const beforeOutput = { args: createOracleTaskArgs("Check it") }
|
const beforeOutput = {
|
||||||
|
args: {
|
||||||
|
subagent_type: "oracle",
|
||||||
|
run_in_background: true,
|
||||||
|
prompt: "Check it",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
}
|
||||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, beforeOutput)
|
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({
|
const handler = createToolExecuteAfterHandler({
|
||||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||||
@@ -159,13 +120,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
output: "done",
|
output: "done",
|
||||||
metadata: {
|
metadata: {
|
||||||
agent: "oracle",
|
agent: "oracle",
|
||||||
sessionId: "ses-oracle-fallback",
|
prompt: String(beforeOutput.args.prompt),
|
||||||
sync: true,
|
sessionId: "ses-oracle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle-fallback")
|
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
|
||||||
|
|
||||||
clearState(directory)
|
clearState(directory)
|
||||||
rmSync(directory, { recursive: true, force: true })
|
rmSync(directory, { recursive: true, force: true })
|
||||||
@@ -195,11 +156,23 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
|||||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||||
})
|
})
|
||||||
|
|
||||||
const firstOutput = { args: createOracleTaskArgs("Check it") }
|
const firstOutput = {
|
||||||
|
args: {
|
||||||
|
subagent_type: "oracle",
|
||||||
|
run_in_background: true,
|
||||||
|
prompt: "Check it",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
}
|
||||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, firstOutput)
|
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, firstOutput)
|
||||||
const firstAttemptId = readState(directory)?.verification_attempt_id
|
const firstAttemptId = readState(directory)?.verification_attempt_id
|
||||||
|
|
||||||
const secondOutput = { args: createOracleTaskArgs("Check it again") }
|
const secondOutput = {
|
||||||
|
args: {
|
||||||
|
subagent_type: "oracle",
|
||||||
|
run_in_background: true,
|
||||||
|
prompt: "Check it again",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
}
|
||||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-2" }, secondOutput)
|
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-2" }, secondOutput)
|
||||||
const secondAttemptId = readState(directory)?.verification_attempt_id
|
const secondAttemptId = readState(directory)?.verification_attempt_id
|
||||||
|
|
||||||
|
|||||||
@@ -110,16 +110,12 @@ function applyResolvedUltraworkOverride(args: {
|
|||||||
if (!override.providerID || !override.modelID) return
|
if (!override.providerID || !override.modelID) return
|
||||||
|
|
||||||
const targetModel = { providerID: override.providerID, modelID: override.modelID }
|
const targetModel = { providerID: override.providerID, modelID: override.modelID }
|
||||||
const messageId = output.message["id"] as string | undefined
|
|
||||||
if (isSameModel(output.message.model, targetModel)) {
|
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}`)
|
log(`[ultrawork-model-override] Skip override; target model already active: ${override.modelID}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = output.message["id"] as string | undefined
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||||
output.message.model = targetModel
|
output.message.model = targetModel
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const PLUGIN_NAME = "oh-my-opencode"
|
export const PLUGIN_NAME = "oh-my-opencode"
|
||||||
export const LEGACY_PLUGIN_NAME = "oh-my-openagent"
|
|
||||||
export const CONFIG_BASENAME = "oh-my-opencode"
|
export const CONFIG_BASENAME = "oh-my-opencode"
|
||||||
export const LOG_FILENAME = "oh-my-opencode.log"
|
export const LOG_FILENAME = "oh-my-opencode.log"
|
||||||
export const CACHE_DIR_NAME = "oh-my-opencode"
|
export const CACHE_DIR_NAME = "oh-my-opencode"
|
||||||
|
|||||||
@@ -109,44 +109,3 @@ export function buildEnvPrefix(
|
|||||||
return ""
|
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,7 +3,6 @@ import type { TmuxConfig } from "../../../config/schema"
|
|||||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
import type { SpawnPaneResult } from "../types"
|
import type { SpawnPaneResult } from "../types"
|
||||||
import { isInsideTmux } from "./environment"
|
import { isInsideTmux } from "./environment"
|
||||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
|
||||||
|
|
||||||
export async function replaceTmuxPane(
|
export async function replaceTmuxPane(
|
||||||
paneId: string,
|
paneId: string,
|
||||||
@@ -35,9 +34,7 @@ export async function replaceTmuxPane(
|
|||||||
})
|
})
|
||||||
await ctrlCProc.exited
|
await ctrlCProc.exited
|
||||||
|
|
||||||
const shell = process.env.SHELL || "/bin/sh"
|
const opencodeCmd = `zsh -c 'opencode attach ${serverUrl} --session ${sessionId}'`
|
||||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
|
||||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
|
||||||
|
|
||||||
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
|
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
@@ -62,7 +59,6 @@ export async function replaceTmuxPane(
|
|||||||
const titleStderr = await stderrPromise
|
const titleStderr = await stderrPromise
|
||||||
log("[replaceTmuxPane] WARNING: failed to set pane title", {
|
log("[replaceTmuxPane] WARNING: failed to set pane title", {
|
||||||
paneId,
|
paneId,
|
||||||
title,
|
|
||||||
exitCode: titleExitCode,
|
exitCode: titleExitCode,
|
||||||
stderr: titleStderr.trim(),
|
stderr: titleStderr.trim(),
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user