Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e18f8773 | ||
|
|
1669c83782 | ||
|
|
09cfd0b408 | ||
|
|
d48ea025f0 | ||
|
|
c5c7ba4eed | ||
|
|
90aa3a306c | ||
|
|
c2f7d059d2 | ||
|
|
7a96a167e6 | ||
|
|
2da19fe608 | ||
|
|
952bd5338d | ||
|
|
57757a345d | ||
|
|
3caae14192 | ||
|
|
55ac653eaa | ||
|
|
1d5652dfa9 | ||
|
|
76c460536d | ||
|
|
b067d4a284 | ||
|
|
94838ec039 | ||
|
|
224ecea8c7 | ||
|
|
5d5755f29d | ||
|
|
1fdce01fd2 | ||
|
|
c8213c970e | ||
|
|
576ff453e5 | ||
|
|
9b8aca45f9 | ||
|
|
f1f20f5a79 | ||
|
|
de40caf76d |
98
.github/workflows/publish-platform.yml
vendored
98
.github/workflows/publish-platform.yml
vendored
@@ -59,20 +59,39 @@ jobs:
|
||||
- name: Check if already published
|
||||
id: check
|
||||
run: |
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
# Convert platform name for output (replace - with _)
|
||||
PLATFORM_KEY="${{ matrix.platform }}"
|
||||
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
|
||||
# Check oh-my-opencode
|
||||
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
|
||||
# Check oh-my-openagent
|
||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
||||
|
||||
echo "oh-my-opencode-${{ matrix.platform }}@${VERSION}: ${OC_STATUS}"
|
||||
echo "oh-my-openagent-${{ matrix.platform }}@${VERSION}: ${OA_STATUS}"
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
echo "→ oh-my-opencode-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
echo "→ oh-my-openagent-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
# Skip build only if BOTH are already published
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
- name: Update version in package.json
|
||||
@@ -207,23 +226,38 @@ jobs:
|
||||
matrix:
|
||||
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
|
||||
steps:
|
||||
- name: Check if oh-my-opencode already published
|
||||
- name: Check if already published
|
||||
id: check
|
||||
run: |
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
|
||||
|
||||
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
|
||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} will be published"
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Need artifact if either package needs publishing
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_all=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip_all=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download artifact
|
||||
id: download
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
if: steps.check.outputs.skip_all != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -231,7 +265,7 @@ jobs:
|
||||
path: .
|
||||
|
||||
- name: Extract artifact
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
mkdir -p packages/${PLATFORM}
|
||||
@@ -247,13 +281,13 @@ jobs:
|
||||
ls -la packages/${PLATFORM}/bin/
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish ${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
|
||||
- name: Publish oh-my-opencode-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
cd packages/${{ matrix.platform }}
|
||||
|
||||
@@ -267,3 +301,25 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Publish oh-my-openagent-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
|
||||
run: |
|
||||
cd packages/${{ matrix.platform }}
|
||||
|
||||
# Rename package for oh-my-openagent
|
||||
jq --arg name "oh-my-openagent-${{ matrix.platform }}" \
|
||||
--arg desc "Platform-specific binary for oh-my-openagent (${{ matrix.platform }})" \
|
||||
'.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \
|
||||
package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
TAG_ARG=""
|
||||
if [ -n "${{ inputs.dist_tag }}" ]; then
|
||||
TAG_ARG="--tag ${{ inputs.dist_tag }}"
|
||||
fi
|
||||
|
||||
npm publish --access public --provenance $TAG_ARG
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
timeout-minutes: 15
|
||||
|
||||
42
.github/workflows/publish.yml
vendored
42
.github/workflows/publish.yml
vendored
@@ -216,6 +216,48 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: Check if oh-my-openagent already published
|
||||
id: check-openagent
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Publish oh-my-openagent
|
||||
if: steps.check-openagent.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# Update package name, version, and optionalDependencies for oh-my-openagent
|
||||
jq --arg v "$VERSION" '
|
||||
.name = "oh-my-openagent" |
|
||||
.version = $v |
|
||||
.optionalDependencies = (
|
||||
.optionalDependencies | to_entries |
|
||||
map(.key = (.key | sub("^oh-my-opencode-"; "oh-my-openagent-")) | .value = $v) |
|
||||
from_entries
|
||||
)
|
||||
' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
TAG_ARG=""
|
||||
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then
|
||||
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}"
|
||||
fi
|
||||
npm publish --access public --provenance $TAG_ARG || echo "::warning::oh-my-openagent publish failed"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
- name: Restore package.json
|
||||
if: steps.check-openagent.outputs.skip != 'true'
|
||||
run: |
|
||||
git checkout -- package.json
|
||||
|
||||
trigger-platform:
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-main
|
||||
|
||||
@@ -136,7 +136,36 @@ fi
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Spawn Subagents
|
||||
## Phase 3: Spawn Subagents (Individual Tool Calls)
|
||||
|
||||
**CRITICAL: Create tasks ONE BY ONE using individual `task_create` tool calls. NEVER batch or script.**
|
||||
|
||||
For each item, execute these steps sequentially:
|
||||
|
||||
### Step 3.1: Create Task Record
|
||||
```typescript
|
||||
task_create(
|
||||
subject="Triage: #{number} {title}",
|
||||
description="GitHub {issue|PR} triage analysis - {type}",
|
||||
metadata={"type": "{ISSUE_QUESTION|ISSUE_BUG|ISSUE_FEATURE|ISSUE_OTHER|PR_BUGFIX|PR_OTHER}", "number": {number}}
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3.2: Spawn Analysis Subagent (Background)
|
||||
```typescript
|
||||
task(
|
||||
category="quick",
|
||||
run_in_background=true,
|
||||
load_skills=[],
|
||||
prompt=SUBAGENT_PROMPT
|
||||
)
|
||||
```
|
||||
|
||||
**ABSOLUTE RULES for Subagents:**
|
||||
- **ONLY ANALYZE** - Never take action on GitHub (no comments, merges, closes)
|
||||
- **READ-ONLY** - Use tools only for reading code/GitHub data
|
||||
- **WRITE REPORT ONLY** - Output goes to `{REPORT_DIR}/{issue|pr}-{number}.md` via Write tool
|
||||
- **EVIDENCE REQUIRED** - Every claim must have GitHub permalink as proof
|
||||
|
||||
```
|
||||
For each item:
|
||||
@@ -170,6 +199,7 @@ ABSOLUTE RULES (violating ANY = critical failure):
|
||||
- Your ONLY writable output: {REPORT_DIR}/{issue|pr}-{number}.md via the Write tool
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
### ISSUE_QUESTION
|
||||
|
||||
@@ -3708,20 +3708,18 @@
|
||||
"circuitBreaker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxToolCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"windowSize": {
|
||||
"consecutiveThreshold": {
|
||||
"type": "integer",
|
||||
"minimum": 5,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"repetitionThresholdPercent": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -2223,6 +2223,22 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import * as os from "node:os"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { resolvePromptAppend } from "./resolve-file-uri"
|
||||
|
||||
const originalHomedir = os.homedir.bind(os)
|
||||
let mockedHomeDir = ""
|
||||
let moduleImportCounter = 0
|
||||
let resolvePromptAppend: typeof import("./resolve-file-uri").resolvePromptAppend
|
||||
|
||||
mock.module("node:os", () => ({
|
||||
...os,
|
||||
homedir: () => mockedHomeDir || originalHomedir(),
|
||||
}))
|
||||
|
||||
describe("resolvePromptAppend", () => {
|
||||
const fixtureRoot = join(tmpdir(), `resolve-file-uri-${Date.now()}`)
|
||||
const configDir = join(fixtureRoot, "config")
|
||||
const homeFixtureDir = join(homedir(), `.resolve-file-uri-home-${Date.now()}`)
|
||||
const homeFixtureRoot = join(fixtureRoot, "home")
|
||||
const homeFixtureDir = join(homeFixtureRoot, "fixture-home")
|
||||
|
||||
const absoluteFilePath = join(fixtureRoot, "absolute.txt")
|
||||
const relativeFilePath = join(configDir, "relative.txt")
|
||||
const spacedFilePath = join(fixtureRoot, "with space.txt")
|
||||
const homeFilePath = join(homeFixtureDir, "home.txt")
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
mockedHomeDir = homeFixtureRoot
|
||||
mkdirSync(fixtureRoot, { recursive: true })
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
mkdirSync(homeFixtureDir, { recursive: true })
|
||||
@@ -23,11 +35,14 @@ describe("resolvePromptAppend", () => {
|
||||
writeFileSync(relativeFilePath, "relative-content", "utf8")
|
||||
writeFileSync(spacedFilePath, "encoded-content", "utf8")
|
||||
writeFileSync(homeFilePath, "home-content", "utf8")
|
||||
|
||||
moduleImportCounter += 1
|
||||
;({ resolvePromptAppend } = await import(`./resolve-file-uri?test=${moduleImportCounter}`))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true })
|
||||
rmSync(homeFixtureDir, { recursive: true, force: true })
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("returns non-file URI strings unchanged", () => {
|
||||
@@ -65,7 +80,7 @@ describe("resolvePromptAppend", () => {
|
||||
|
||||
test("resolves home directory URI path", () => {
|
||||
//#given
|
||||
const input = `file://~/${homeFixtureDir.split("/").pop()}/home.txt`
|
||||
const input = "file://~/fixture-home/home.txt"
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input)
|
||||
|
||||
@@ -45,26 +45,26 @@ export function writePaddedText(
|
||||
return { output: text, atLineStart: text.endsWith("\n") }
|
||||
}
|
||||
|
||||
let output = ""
|
||||
const parts: string[] = []
|
||||
let lineStart = atLineStart
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
if (lineStart) {
|
||||
output += " "
|
||||
parts.push(" ")
|
||||
lineStart = false
|
||||
}
|
||||
|
||||
if (ch === "\n") {
|
||||
output += " \n"
|
||||
parts.push(" \n")
|
||||
lineStart = true
|
||||
continue
|
||||
}
|
||||
|
||||
output += ch
|
||||
parts.push(ch)
|
||||
}
|
||||
|
||||
return { output, atLineStart: lineStart }
|
||||
return { output: parts.join(""), atLineStart: lineStart }
|
||||
}
|
||||
|
||||
function colorizeWithProfileColor(text: string, hexColor?: string): string {
|
||||
|
||||
@@ -8,27 +8,24 @@ describe("BackgroundTaskConfigSchema.circuitBreaker", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
consecutiveThreshold: 10,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.circuitBreaker).toEqual({
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
consecutiveThreshold: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given windowSize below minimum", () => {
|
||||
describe("#given consecutiveThreshold below minimum", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
windowSize: 4,
|
||||
consecutiveThreshold: 4,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -39,14 +36,14 @@ describe("BackgroundTaskConfigSchema.circuitBreaker", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given repetitionThresholdPercent is zero", () => {
|
||||
describe("#given consecutiveThreshold is zero", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
repetitionThresholdPercent: 0,
|
||||
consecutiveThreshold: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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(),
|
||||
consecutiveThreshold: z.number().int().min(5).optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
|
||||
@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer",
|
||||
"todo-description-override",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
@@ -7,8 +7,8 @@ export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
|
||||
export const DEFAULT_MAX_TOOL_CALLS = 200
|
||||
export const DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE = 20
|
||||
export const DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT = 80
|
||||
export const DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD = 20
|
||||
export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true
|
||||
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||
export const MIN_IDLE_TIME_MS = 5000
|
||||
export const POLLING_INTERVAL_MS = 3000
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createToolCallSignature,
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
@@ -17,6 +18,17 @@ function buildWindow(
|
||||
)
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -25,18 +37,91 @@ describe("loop-detector", () => {
|
||||
maxToolCalls: 200,
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
consecutiveThreshold: 7,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
consecutiveThreshold: 7,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no enabled config", () => {
|
||||
test("#when resolved #then enabled defaults to true", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 100,
|
||||
consecutiveThreshold: 5,
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
consecutiveThreshold: 5,
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
consecutiveThreshold: 5,
|
||||
},
|
||||
})
|
||||
|
||||
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", () => {
|
||||
@@ -61,55 +146,93 @@ describe("loop-detector", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the same tool dominates the recent window", () => {
|
||||
describe("#given the same tool is called consecutively", () => {
|
||||
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 window = buildWindow(Array.from({ length: 20 }, () => "read"))
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 8,
|
||||
sampleSize: 10,
|
||||
thresholdPercent: 80,
|
||||
repeatedCount: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
describe("#given consecutive calls are interrupted by different tool", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const window = buildWindow([
|
||||
...Array.from({ length: 19 }, () => "read"),
|
||||
"edit",
|
||||
"read",
|
||||
])
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({ triggered: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given threshold boundary", () => {
|
||||
test("#when below threshold #then it does not trigger", () => {
|
||||
const belowThresholdWindow = buildWindow(Array.from({ length: 19 }, () => "read"))
|
||||
|
||||
const result = detectRepetitiveToolUse(belowThresholdWindow)
|
||||
|
||||
expect(result).toEqual({ triggered: false })
|
||||
})
|
||||
|
||||
test("#when equal to threshold #then it triggers", () => {
|
||||
const atThresholdWindow = buildWindow(Array.from({ length: 20 }, () => "read"))
|
||||
|
||||
const result = detectRepetitiveToolUse(atThresholdWindow)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 7,
|
||||
sampleSize: 8,
|
||||
thresholdPercent: 80,
|
||||
repeatedCount: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
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: 20 }, () => ({
|
||||
tool: "read",
|
||||
input: { filePath: "/src/same.ts" },
|
||||
}))
|
||||
const window = buildWindowWithInputs(calls)
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tool calls with no input", () => {
|
||||
test("#when evaluated #then it triggers", () => {
|
||||
const calls = Array.from({ length: 20 }, () => ({ tool: "read" }))
|
||||
const window = buildWindowWithInputs(calls)
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,96 +1,94 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
|
||||
DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
|
||||
DEFAULT_CIRCUIT_BREAKER_ENABLED,
|
||||
DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD,
|
||||
DEFAULT_MAX_TOOL_CALLS,
|
||||
} from "./constants"
|
||||
import type { ToolCallWindow } from "./types"
|
||||
|
||||
export interface CircuitBreakerSettings {
|
||||
enabled: boolean
|
||||
maxToolCalls: number
|
||||
windowSize: number
|
||||
repetitionThresholdPercent: number
|
||||
consecutiveThreshold: 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,
|
||||
consecutiveThreshold:
|
||||
config?.circuitBreaker?.consecutiveThreshold ?? DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
export function recordToolCall(
|
||||
window: ToolCallWindow | undefined,
|
||||
toolName: string,
|
||||
settings: CircuitBreakerSettings
|
||||
settings: CircuitBreakerSettings,
|
||||
toolInput?: Record<string, unknown> | null
|
||||
): ToolCallWindow {
|
||||
const previous = window?.toolNames ?? []
|
||||
const toolNames = [...previous, toolName].slice(-settings.windowSize)
|
||||
const signature = createToolCallSignature(toolName, toolInput)
|
||||
|
||||
if (window && window.lastSignature === signature) {
|
||||
return {
|
||||
lastSignature: signature,
|
||||
consecutiveCount: window.consecutiveCount + 1,
|
||||
threshold: settings.consecutiveThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolNames,
|
||||
windowSize: settings.windowSize,
|
||||
thresholdPercent: settings.repetitionThresholdPercent,
|
||||
lastSignature: signature,
|
||||
consecutiveCount: 1,
|
||||
threshold: settings.consecutiveThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
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.toolNames.length === 0) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const toolName of window.toolNames) {
|
||||
counts.set(toolName, (counts.get(toolName) ?? 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.toolNames.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) {
|
||||
if (!window || window.consecutiveCount < window.threshold) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
return {
|
||||
triggered: true,
|
||||
toolName: repeatedTool,
|
||||
repeatedCount,
|
||||
sampleSize,
|
||||
thresholdPercent: window.thresholdPercent,
|
||||
toolName: window.lastSignature.split("::")[0],
|
||||
repeatedCount: window.consecutiveCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,11 @@ async function flushAsyncWork() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
describe("#given the same tool is called consecutively", () => {
|
||||
test("#when consecutive tool events arrive #then the task is cancelled", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
consecutiveThreshold: 20,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
@@ -63,38 +62,17 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
}
|
||||
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",
|
||||
]) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: "read" },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("repeatedly called read 16/20 times")
|
||||
expect(task.error).toContain("read 20 consecutive times")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,8 +80,7 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
test("#when the window fills #then the task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
consecutiveThreshold: 10,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
@@ -153,8 +130,7 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 3,
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 95,
|
||||
consecutiveThreshold: 95,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
@@ -193,8 +169,7 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 2,
|
||||
circuitBreaker: {
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 80,
|
||||
consecutiveThreshold: 5,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
@@ -233,7 +208,180 @@ describe("BackgroundManager circuit breaker", () => {
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(1)
|
||||
expect(task.progress?.countedToolPartIDs).toEqual(["tool-1"])
|
||||
expect(task.progress?.countedToolPartIDs).toEqual(new Set(["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: {
|
||||
consecutiveThreshold: 20,
|
||||
},
|
||||
})
|
||||
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: {
|
||||
consecutiveThreshold: 20,
|
||||
},
|
||||
})
|
||||
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("read 20 consecutive times")
|
||||
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,
|
||||
consecutiveThreshold: 20,
|
||||
},
|
||||
})
|
||||
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,
|
||||
consecutiveThreshold: 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)")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,4 +153,42 @@ describe("BackgroundManager pollRunningTasks", () => {
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a running task whose session has terminal non-idle status", () => {
|
||||
test('#when session status is "interrupted" #then completes the task', async () => {
|
||||
//#given
|
||||
const manager = createManagerWithClient({
|
||||
status: async () => ({ data: { "ses-interrupted": { type: "interrupted" } } }),
|
||||
})
|
||||
const task = createRunningTask("ses-interrupted")
|
||||
injectTask(manager, task)
|
||||
|
||||
//#when
|
||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
||||
await poll.call(manager)
|
||||
manager.shutdown()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.completedAt).toBeDefined()
|
||||
})
|
||||
|
||||
test('#when session status is an unknown type #then completes the task', async () => {
|
||||
//#given
|
||||
const manager = createManagerWithClient({
|
||||
status: async () => ({ data: { "ses-unknown": { type: "some-weird-status" } } }),
|
||||
})
|
||||
const task = createRunningTask("ses-unknown")
|
||||
injectTask(manager, task)
|
||||
|
||||
//#when
|
||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
||||
await poll.call(manager)
|
||||
manager.shutdown()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.completedAt).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,10 +52,12 @@ import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
|
||||
import {
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
type CircuitBreakerSettings,
|
||||
} from "./loop-detector"
|
||||
import {
|
||||
createSubagentDepthLimitError,
|
||||
@@ -74,7 +76,7 @@ interface MessagePartInfo {
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
state?: { status?: string }
|
||||
state?: { status?: string; input?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
interface EventProperties {
|
||||
@@ -151,6 +153,7 @@ export class BackgroundManager {
|
||||
private preStartDescendantReservations: Set<string>
|
||||
private enableParentSessionNotifications: boolean
|
||||
readonly taskHistory = new TaskHistory()
|
||||
private cachedCircuitBreakerSettings?: CircuitBreakerSettings
|
||||
|
||||
constructor(
|
||||
ctx: PluginInput,
|
||||
@@ -900,47 +903,49 @@ export class BackgroundManager {
|
||||
task.progress.lastUpdate = new Date()
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
const countedToolPartIDs = task.progress.countedToolPartIDs ?? []
|
||||
const countedToolPartIDs = task.progress.countedToolPartIDs ?? new Set<string>()
|
||||
const shouldCountToolCall =
|
||||
!partInfo.id ||
|
||||
partInfo.state?.status !== "running" ||
|
||||
!countedToolPartIDs.includes(partInfo.id)
|
||||
!countedToolPartIDs.has(partInfo.id)
|
||||
|
||||
if (!shouldCountToolCall) {
|
||||
return
|
||||
}
|
||||
|
||||
if (partInfo.id && partInfo.state?.status === "running") {
|
||||
task.progress.countedToolPartIDs = [...countedToolPartIDs, partInfo.id]
|
||||
countedToolPartIDs.add(partInfo.id)
|
||||
task.progress.countedToolPartIDs = countedToolPartIDs
|
||||
}
|
||||
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
const circuitBreaker = resolveCircuitBreakerSettings(this.config)
|
||||
const circuitBreaker = this.cachedCircuitBreakerSettings ?? (this.cachedCircuitBreakerSettings = resolveCircuitBreakerSettings(this.config))
|
||||
if (partInfo.tool) {
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
task.progress.toolCallWindow,
|
||||
partInfo.tool,
|
||||
circuitBreaker
|
||||
)
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
task.progress.toolCallWindow,
|
||||
partInfo.tool,
|
||||
circuitBreaker,
|
||||
partInfo.state?.input
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
if (circuitBreaker.enabled) {
|
||||
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
|
||||
if (loopDetection.triggered) {
|
||||
log("[background-agent] Circuit breaker: consecutive tool usage detected", {
|
||||
taskId: task.id,
|
||||
agent: task.agent,
|
||||
sessionID,
|
||||
toolName: loopDetection.toolName,
|
||||
repeatedCount: loopDetection.repeatedCount,
|
||||
})
|
||||
void this.cancelTask(task.id, {
|
||||
source: "circuit-breaker",
|
||||
reason: `Subagent called ${loopDetection.toolName} ${loopDetection.repeatedCount} consecutive times (threshold: ${circuitBreaker.consecutiveThreshold}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxToolCalls = circuitBreaker.maxToolCalls
|
||||
@@ -1779,11 +1784,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
}
|
||||
|
||||
// Match sync-session-poller pattern: only skip completion check when
|
||||
// status EXISTS and is not idle (i.e., session is actively running).
|
||||
// When sessionStatus is undefined, the session has completed and dropped
|
||||
// from the status response — fall through to completion detection.
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
// Only skip completion when session status is actively running.
|
||||
// Unknown or terminal statuses (like "interrupted") fall through to completion.
|
||||
if (sessionStatus && isActiveSessionStatus(sessionStatus.type)) {
|
||||
log("[background-agent] Session still running, relying on event-based progress:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
@@ -1793,6 +1796,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
continue
|
||||
}
|
||||
|
||||
// Explicit terminal non-idle status (e.g., "interrupted") — complete immediately,
|
||||
// skipping output validation (session will never produce more output).
|
||||
// Unknown statuses fall through to the idle/gone path with output validation.
|
||||
if (sessionStatus && isTerminalSessionStatus(sessionStatus.type)) {
|
||||
await this.tryCompleteTask(task, `polling (terminal session status: ${sessionStatus.type})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Unknown non-idle status — not active, not terminal, not idle.
|
||||
// Fall through to idle/gone completion path with output validation.
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
log("[background-agent] Unknown session status, treating as potentially idle:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
sessionStatus: sessionStatus.type,
|
||||
})
|
||||
}
|
||||
|
||||
// Session is idle or no longer in status response (completed/disappeared)
|
||||
const completionSource = sessionStatus?.type === "idle"
|
||||
? "polling (idle status)"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
|
||||
|
||||
const mockLog = mock()
|
||||
mock.module("../../shared", () => ({ log: mockLog }))
|
||||
|
||||
describe("isActiveSessionStatus", () => {
|
||||
describe("#given a known active session status", () => {
|
||||
test('#when type is "busy" #then returns true', () => {
|
||||
expect(isActiveSessionStatus("busy")).toBe(true)
|
||||
})
|
||||
|
||||
test('#when type is "retry" #then returns true', () => {
|
||||
expect(isActiveSessionStatus("retry")).toBe(true)
|
||||
})
|
||||
|
||||
test('#when type is "running" #then returns true', () => {
|
||||
expect(isActiveSessionStatus("running")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a known terminal session status", () => {
|
||||
test('#when type is "idle" #then returns false', () => {
|
||||
expect(isActiveSessionStatus("idle")).toBe(false)
|
||||
})
|
||||
|
||||
test('#when type is "interrupted" #then returns false and does not log', () => {
|
||||
mockLog.mockClear()
|
||||
expect(isActiveSessionStatus("interrupted")).toBe(false)
|
||||
expect(mockLog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an unknown session status", () => {
|
||||
test('#when type is an arbitrary unknown string #then returns false and logs warning', () => {
|
||||
mockLog.mockClear()
|
||||
expect(isActiveSessionStatus("some-unknown-status")).toBe(false)
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[background-agent] Unknown session status type encountered:",
|
||||
"some-unknown-status",
|
||||
)
|
||||
})
|
||||
|
||||
test('#when type is empty string #then returns false', () => {
|
||||
expect(isActiveSessionStatus("")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTerminalSessionStatus", () => {
|
||||
test('#when type is "interrupted" #then returns true', () => {
|
||||
expect(isTerminalSessionStatus("interrupted")).toBe(true)
|
||||
})
|
||||
|
||||
test('#when type is "idle" #then returns false (idle is handled separately)', () => {
|
||||
expect(isTerminalSessionStatus("idle")).toBe(false)
|
||||
})
|
||||
|
||||
test('#when type is "busy" #then returns false', () => {
|
||||
expect(isTerminalSessionStatus("busy")).toBe(false)
|
||||
})
|
||||
|
||||
test('#when type is an unknown string #then returns false', () => {
|
||||
expect(isTerminalSessionStatus("some-unknown")).toBe(false)
|
||||
})
|
||||
})
|
||||
20
src/features/background-agent/session-status-classifier.ts
Normal file
20
src/features/background-agent/session-status-classifier.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
const ACTIVE_SESSION_STATUSES = new Set(["busy", "retry", "running"])
|
||||
const KNOWN_TERMINAL_STATUSES = new Set(["idle", "interrupted"])
|
||||
|
||||
export function isActiveSessionStatus(type: string): boolean {
|
||||
if (ACTIVE_SESSION_STATUSES.has(type)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!KNOWN_TERMINAL_STATUSES.has(type)) {
|
||||
log("[background-agent] Unknown session status type encountered:", type)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isTerminalSessionStatus(type: string): boolean {
|
||||
return KNOWN_TERMINAL_STATUSES.has(type) && type !== "idle"
|
||||
}
|
||||
@@ -417,6 +417,56 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(onTaskInterrupted).toHaveBeenCalledWith(task)
|
||||
})
|
||||
|
||||
it('should NOT protect task when session has terminal non-idle status like "interrupted"', async () => {
|
||||
//#given — lastUpdate is 5min old, session is "interrupted" (terminal, not active)
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session status is "interrupted" (terminal)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "interrupted" } },
|
||||
})
|
||||
|
||||
//#then — terminal statuses should not protect from stale timeout
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
|
||||
it('should NOT protect task when session has unknown status type', async () => {
|
||||
//#given — lastUpdate is 5min old, session has an unknown status
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session has unknown status type
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "some-weird-status" } },
|
||||
})
|
||||
|
||||
//#then — unknown statuses should not protect from stale timeout
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pruneStaleTasksAndNotifications", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./constants"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
|
||||
import { isActiveSessionStatus } from "./session-status-classifier"
|
||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||
"completed",
|
||||
"error",
|
||||
@@ -120,7 +121,7 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
if (!startedAt || !sessionID) continue
|
||||
|
||||
const sessionStatus = sessionStatuses?.[sessionID]?.type
|
||||
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
|
||||
const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus(sessionStatus)
|
||||
const runtime = now - startedAt.getTime()
|
||||
|
||||
if (!task.progress?.lastUpdate) {
|
||||
|
||||
@@ -10,16 +10,16 @@ export type BackgroundTaskStatus =
|
||||
| "interrupt"
|
||||
|
||||
export interface ToolCallWindow {
|
||||
toolNames: string[]
|
||||
windowSize: number
|
||||
thresholdPercent: number
|
||||
lastSignature: string
|
||||
consecutiveCount: number
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
toolCalls: number
|
||||
lastTool?: string
|
||||
toolCallWindow?: ToolCallWindow
|
||||
countedToolPartIDs?: string[]
|
||||
countedToolPartIDs?: Set<string>
|
||||
lastUpdate: Date
|
||||
lastMessage?: string
|
||||
lastMessageAt?: Date
|
||||
|
||||
@@ -7,7 +7,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
|
||||
- If specified and valid: hook pre-sets worktree_path in boulder.json
|
||||
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
|
||||
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
|
||||
- If omitted: work directly in the current project directory (no worktree)
|
||||
|
||||
## WHAT TO DO
|
||||
|
||||
@@ -24,7 +24,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
|
||||
- If ONE plan: auto-select it
|
||||
- If MULTIPLE plans: show list with timestamps, ask user to select
|
||||
|
||||
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
|
||||
4. **Worktree Setup** (ONLY when \`--worktree\` was explicitly specified and \`worktree_path\` not already set in boulder.json):
|
||||
1. \`git worktree list --porcelain\` — see available worktrees
|
||||
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
|
||||
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
|
||||
@@ -86,6 +86,38 @@ Reading plan and beginning execution...
|
||||
|
||||
- The session_id is injected by the hook - use it directly
|
||||
- Always update boulder.json BEFORE starting work
|
||||
- Always set worktree_path in boulder.json before executing any tasks
|
||||
- If worktree_path is set in boulder.json, all work happens inside that worktree directory
|
||||
- Read the FULL plan file before delegating any tasks
|
||||
- Follow atlas delegation protocols (7-section format)`
|
||||
- Follow atlas delegation protocols (7-section format)
|
||||
|
||||
## TASK BREAKDOWN (MANDATORY)
|
||||
|
||||
After reading the plan file, you MUST decompose every plan task into granular, implementation-level sub-steps and register ALL of them as task/todo items BEFORE starting any work.
|
||||
|
||||
**How to break down**:
|
||||
- Each plan checkbox item (e.g., \`- [ ] Add user authentication\`) must be split into concrete, actionable sub-tasks
|
||||
- Sub-tasks should be specific enough that each one touches a clear set of files/functions
|
||||
- Include: file to modify, what to change, expected behavior, and how to verify
|
||||
- Do NOT leave any task vague — "implement feature X" is NOT acceptable; "add validateToken() to src/auth/middleware.ts that checks JWT expiry and returns 401" IS acceptable
|
||||
|
||||
**Example breakdown**:
|
||||
Plan task: \`- [ ] Add rate limiting to API\`
|
||||
→ Todo items:
|
||||
1. Create \`src/middleware/rate-limiter.ts\` with sliding window algorithm (max 100 req/min per IP)
|
||||
2. Add RateLimiter middleware to \`src/app.ts\` router chain, before auth middleware
|
||||
3. Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining) to response in \`rate-limiter.ts\`
|
||||
4. Add test: verify 429 response after exceeding limit in \`src/middleware/rate-limiter.test.ts\`
|
||||
5. Add test: verify headers are present on normal responses
|
||||
|
||||
Register these as task/todo items so progress is tracked and visible throughout the session.
|
||||
|
||||
## WORKTREE COMPLETION
|
||||
|
||||
When working in a worktree (\`worktree_path\` is set in boulder.json) and ALL plan tasks are complete:
|
||||
1. Commit all remaining changes in the worktree
|
||||
2. Switch to the main working directory (the original repo, NOT the worktree)
|
||||
3. Merge the worktree branch into the current branch: \`git merge <worktree-branch>\`
|
||||
4. If merge succeeds, clean up: \`git worktree remove <worktree-path>\`
|
||||
5. Remove the boulder.json state
|
||||
|
||||
This is the DEFAULT behavior when \`--worktree\` was used. Skip merge only if the user explicitly instructs otherwise (e.g., asks to create a PR instead).`
|
||||
|
||||
@@ -70,7 +70,7 @@ function isTokenLimitError(text: string): boolean {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
|
||||
@@ -18,9 +18,9 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((fileName) => fileName.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
for (const fileName of files) {
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const fileName = files[i]
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
||||
const parsed = JSON.parse(content) as { agent?: unknown }
|
||||
|
||||
@@ -44,12 +44,6 @@ export interface ExecutorOptions {
|
||||
agent?: string
|
||||
}
|
||||
|
||||
function filterDiscoveredCommandsByScope(
|
||||
commands: DiscoveredCommandInfo[],
|
||||
scope: DiscoveredCommandInfo["scope"],
|
||||
): DiscoveredCommandInfo[] {
|
||||
return commands.filter(command => command.scope === scope)
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const discoveredCommands = discoverCommandsSync(process.cwd(), {
|
||||
@@ -60,14 +54,18 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
const scopeOrder: DiscoveredCommandInfo["scope"][] = ["project", "user", "opencode-project", "opencode", "builtin", "plugin"]
|
||||
const grouped = new Map<string, DiscoveredCommandInfo[]>()
|
||||
for (const cmd of discoveredCommands) {
|
||||
const list = grouped.get(cmd.scope) ?? []
|
||||
list.push(cmd)
|
||||
grouped.set(cmd.scope, list)
|
||||
}
|
||||
const orderedCommands = scopeOrder.flatMap((scope) => grouped.get(scope) ?? [])
|
||||
|
||||
return [
|
||||
...skillCommands,
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
||||
...orderedCommands,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,6 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||
|
||||
@@ -96,6 +94,9 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const postClient: PostToolUseClient = {
|
||||
session: {
|
||||
messages: (opts) => ctx.client.session.messages(opts),
|
||||
|
||||
@@ -43,8 +43,6 @@ export function createToolExecuteBeforeHandler(ctx: PluginInput, config: PluginC
|
||||
log("todowrite: parsed todos string to array", { sessionID: input.sessionID })
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
appendTranscriptEntry(input.sessionID, {
|
||||
type: "tool_use",
|
||||
@@ -59,6 +57,9 @@ export function createToolExecuteBeforeHandler(ctx: PluginInput, config: PluginC
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
|
||||
@@ -3,6 +3,18 @@ import type { CommentCheckerConfig } from "../../config/schema"
|
||||
|
||||
import z from "zod"
|
||||
|
||||
const ApplyPatchMetadataSchema = z.object({
|
||||
files: z.array(
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
movePath: z.string().optional(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
import {
|
||||
initializeCommentCheckerCli,
|
||||
getCommentCheckerCliPathPromise,
|
||||
@@ -104,17 +116,6 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const ApplyPatchMetadataSchema = z.object({
|
||||
files: z.array(
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
movePath: z.string().optional(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
if (toolLower === "apply_patch") {
|
||||
const parsed = ApplyPatchMetadataSchema.safeParse(output.metadata)
|
||||
|
||||
@@ -52,3 +52,4 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
|
||||
@@ -23,6 +23,10 @@ export async function handleDetectedCompletion(
|
||||
const { sessionID, state, loopState, directory, apiTimeoutMs } = input
|
||||
|
||||
if (state.ultrawork && !state.verification_pending) {
|
||||
if (state.verification_session_id) {
|
||||
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const verificationState = loopState.markVerificationPending(sessionID)
|
||||
if (!verificationState) {
|
||||
log(`[${HOOK_NAME}] Failed to transition ultrawork loop to verification`, {
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("ulw-loop verification", () => {
|
||||
const testDir = join(tmpdir(), `ulw-loop-verification-${Date.now()}`)
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
let abortCalls: Array<{ id: string }>
|
||||
let parentTranscriptPath: string
|
||||
let oracleTranscriptPath: string
|
||||
|
||||
@@ -25,6 +26,10 @@ describe("ulw-loop verification", () => {
|
||||
return {}
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
abort: async (opts: { path: { id: string } }) => {
|
||||
abortCalls.push({ id: opts.path.id })
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||
@@ -40,6 +45,7 @@ describe("ulw-loop verification", () => {
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
abortCalls = []
|
||||
parentTranscriptPath = join(testDir, "transcript-parent.jsonl")
|
||||
oracleTranscriptPath = join(testDir, "transcript-oracle.jsonl")
|
||||
|
||||
@@ -385,4 +391,96 @@ describe("ulw-loop verification", () => {
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(promptCalls[1]?.text).toContain("Verification failed")
|
||||
})
|
||||
|
||||
test("#given oracle verification fails #when loop restarts #then old oracle session is aborted", async () => {
|
||||
const sessionMessages: Record<string, unknown[]> = {
|
||||
"session-123": [{}, {}, {}],
|
||||
}
|
||||
const hook = createRalphLoopHook({
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async (opts: { path: { id: string } }) => ({
|
||||
data: sessionMessages[opts.path.id] ?? [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createRalphLoopHook>[0], {
|
||||
getTranscriptPath: (sessionID) => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build API", { ultrawork: true })
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done <promise>DONE</promise>" } })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
writeState(testDir, {
|
||||
...hook.getState()!,
|
||||
verification_session_id: "ses-oracle",
|
||||
})
|
||||
writeFileSync(
|
||||
oracleTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed: missing tests" } })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } })
|
||||
|
||||
expect(abortCalls).toHaveLength(1)
|
||||
expect(abortCalls[0].id).toBe("ses-oracle")
|
||||
})
|
||||
|
||||
test("#given ulw loop re-enters verification #when DONE detected again after failed verification #then previous verification session is aborted", async () => {
|
||||
const sessionMessages: Record<string, unknown[]> = {
|
||||
"session-123": [{}, {}, {}],
|
||||
}
|
||||
const hook = createRalphLoopHook({
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async (opts: { path: { id: string } }) => ({
|
||||
data: sessionMessages[opts.path.id] ?? [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createRalphLoopHook>[0], {
|
||||
getTranscriptPath: (sessionID) => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build API", { ultrawork: true })
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done <promise>DONE</promise>" } })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
writeState(testDir, {
|
||||
...hook.getState()!,
|
||||
verification_session_id: "ses-oracle",
|
||||
})
|
||||
writeFileSync(
|
||||
oracleTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "failed" } })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } })
|
||||
abortCalls.length = 0
|
||||
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "fixed it <promise>DONE</promise>" } })}\n`,
|
||||
)
|
||||
writeState(testDir, {
|
||||
...hook.getState()!,
|
||||
verification_session_id: "ses-oracle-old",
|
||||
})
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
expect(abortCalls).toHaveLength(1)
|
||||
expect(abortCalls[0].id).toBe("ses-oracle-old")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,10 @@ export async function handleFailedVerification(
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.verification_session_id) {
|
||||
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const resumedState = loopState.restartAfterFailedVerification(
|
||||
parentSessionID,
|
||||
messageCountAtStart,
|
||||
|
||||
@@ -28,6 +28,8 @@ export function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_PATTERN = new RegExp(`\\b(${DEFAULT_CONFIG.retry_on_errors.join("|")})\\b`)
|
||||
|
||||
export function extractStatusCode(error: unknown, retryOnErrors?: number[]): number | undefined {
|
||||
if (!error) return undefined
|
||||
|
||||
@@ -45,8 +47,9 @@ export function extractStatusCode(error: unknown, retryOnErrors?: number[]): num
|
||||
return statusCode
|
||||
}
|
||||
|
||||
const codes = retryOnErrors ?? DEFAULT_CONFIG.retry_on_errors
|
||||
const pattern = new RegExp(`\\b(${codes.join("|")})\\b`)
|
||||
const pattern = retryOnErrors
|
||||
? new RegExp(`\\b(${retryOnErrors.join("|")})\\b`)
|
||||
: DEFAULT_RETRY_PATTERN
|
||||
const message = getErrorMessage(error)
|
||||
const statusMatch = message.match(pattern)
|
||||
if (statusMatch) {
|
||||
|
||||
@@ -32,8 +32,10 @@ const MULTILINGUAL_KEYWORDS = [
|
||||
"fikir", "berfikir",
|
||||
]
|
||||
|
||||
const MULTILINGUAL_PATTERNS = MULTILINGUAL_KEYWORDS.map((kw) => new RegExp(kw, "i"))
|
||||
const THINK_PATTERNS = [...ENGLISH_PATTERNS, ...MULTILINGUAL_PATTERNS]
|
||||
const COMBINED_THINK_PATTERN = new RegExp(
|
||||
`\\b(?:ultrathink|think)\\b|${MULTILINGUAL_KEYWORDS.join("|")}`,
|
||||
"i"
|
||||
)
|
||||
|
||||
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
@@ -44,7 +46,7 @@ function removeCodeBlocks(text: string): string {
|
||||
|
||||
export function detectThinkKeyword(text: string): boolean {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return THINK_PATTERNS.some((pattern) => pattern.test(textWithoutCode))
|
||||
return COMBINED_THINK_PATTERN.test(textWithoutCode)
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function handleSessionIdle(args: {
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||
return
|
||||
@@ -104,6 +105,7 @@ export async function handleSessionIdle(args: {
|
||||
|
||||
const incompleteCount = getIncompleteCount(todos)
|
||||
if (incompleteCount === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||
return
|
||||
@@ -124,22 +126,14 @@ export async function handleSessionIdle(args: {
|
||||
}
|
||||
|
||||
if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, {
|
||||
sessionID,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, { sessionID, consecutiveFailures: state.consecutiveFailures })
|
||||
return
|
||||
}
|
||||
|
||||
const effectiveCooldown =
|
||||
CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5))
|
||||
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) {
|
||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, {
|
||||
sessionID,
|
||||
effectiveCooldown,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID, effectiveCooldown, consecutiveFailures: state.consecutiveFailures })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
28
src/hooks/todo-description-override/description.ts
Normal file
28
src/hooks/todo-description-override/description.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work.
|
||||
|
||||
## Todo Format (MANDATORY)
|
||||
|
||||
Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT.
|
||||
|
||||
Format: "[WHERE] [HOW] to [WHY] — expect [RESULT]"
|
||||
|
||||
GOOD:
|
||||
- "src/utils/validation.ts: Add validateEmail() for input sanitization — returns boolean"
|
||||
- "UserService.create(): Call validateEmail() before DB insert — rejects invalid emails with 400"
|
||||
- "validation.test.ts: Add test for missing @ sign — expect validateEmail('foo') to return false"
|
||||
|
||||
BAD:
|
||||
- "Implement email validation" (where? how? what result?)
|
||||
- "Add dark mode" (this is a feature, not a todo)
|
||||
- "Fix auth" (what file? what changes? what's expected?)
|
||||
|
||||
## Granularity Rules
|
||||
|
||||
Each todo MUST be a single atomic action completable in 1-3 tool calls. If it needs more, split it.
|
||||
|
||||
**Size test**: Can you complete this todo by editing one file or running one command? If not, it's too big.
|
||||
|
||||
## Task Management
|
||||
- One in_progress at a time. Complete it before starting the next.
|
||||
- Mark completed immediately after finishing each item.
|
||||
- Skip this tool for single trivial tasks (one-step, obvious action).`
|
||||
14
src/hooks/todo-description-override/hook.ts
Normal file
14
src/hooks/todo-description-override/hook.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { TODOWRITE_DESCRIPTION } from "./description"
|
||||
|
||||
export function createTodoDescriptionOverrideHook() {
|
||||
return {
|
||||
"tool.definition": async (
|
||||
input: { toolID: string },
|
||||
output: { description: string; parameters: unknown },
|
||||
) => {
|
||||
if (input.toolID === "todowrite") {
|
||||
output.description = TODOWRITE_DESCRIPTION
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
40
src/hooks/todo-description-override/index.test.ts
Normal file
40
src/hooks/todo-description-override/index.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { createTodoDescriptionOverrideHook } from "./hook"
|
||||
import { TODOWRITE_DESCRIPTION } from "./description"
|
||||
|
||||
describe("createTodoDescriptionOverrideHook", () => {
|
||||
describe("#given hook is created", () => {
|
||||
describe("#when tool.definition is called with todowrite", () => {
|
||||
it("#then should override the description", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "todowrite" }, output)
|
||||
|
||||
expect(output.description).toBe(TODOWRITE_DESCRIPTION)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when tool.definition is called with non-todowrite tool", () => {
|
||||
it("#then should not modify the description", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "bash" }, output)
|
||||
|
||||
expect(output.description).toBe("original description")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when tool.definition is called with TodoWrite (case-insensitive)", () => {
|
||||
it("#then should not override for different casing since OpenCode sends lowercase", async () => {
|
||||
const hook = createTodoDescriptionOverrideHook()
|
||||
const output = { description: "original description", parameters: {} }
|
||||
|
||||
await hook["tool.definition"]({ toolID: "TodoWrite" }, output)
|
||||
|
||||
expect(output.description).toBe("original description")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/hooks/todo-description-override/index.ts
Normal file
1
src/hooks/todo-description-override/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createTodoDescriptionOverrideHook } from "./hook"
|
||||
@@ -411,10 +411,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
try {
|
||||
symlinkSync(targetFile, symlinkPath)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Skipping symlink test: symlinks are not supported or cannot be created in this environment.",
|
||||
error
|
||||
)
|
||||
// Symlinks not supported in this environment — skip
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ export function createPluginInterface(args: {
|
||||
return {
|
||||
tool: tools,
|
||||
|
||||
"chat.params": async (input: unknown, output: unknown) => {
|
||||
const handler = createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort })
|
||||
await handler(input, output)
|
||||
},
|
||||
"chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }),
|
||||
|
||||
"chat.headers": createChatHeadersHandler({ ctx }),
|
||||
|
||||
@@ -71,5 +68,9 @@ export function createPluginInterface(args: {
|
||||
ctx,
|
||||
hooks,
|
||||
}),
|
||||
|
||||
"tool.definition": async (input, output) => {
|
||||
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createHashlineReadEnhancerHook,
|
||||
createReadImageResizerHook,
|
||||
createJsonErrorRecoveryHook,
|
||||
createTodoDescriptionOverrideHook,
|
||||
} from "../../hooks"
|
||||
import {
|
||||
getOpenCodeVersion,
|
||||
@@ -35,6 +36,7 @@ export type ToolGuardHooks = {
|
||||
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
||||
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
|
||||
todoDescriptionOverride: ReturnType<typeof createTodoDescriptionOverrideHook> | null
|
||||
}
|
||||
|
||||
export function createToolGuardHooks(args: {
|
||||
@@ -111,6 +113,10 @@ export function createToolGuardHooks(args: {
|
||||
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
|
||||
: null
|
||||
|
||||
const todoDescriptionOverride = isHookEnabled("todo-description-override")
|
||||
? safeHook("todo-description-override", () => createTodoDescriptionOverrideHook())
|
||||
: null
|
||||
|
||||
return {
|
||||
commentChecker,
|
||||
toolOutputTruncator,
|
||||
@@ -123,5 +129,6 @@ export function createToolGuardHooks(args: {
|
||||
hashlineReadEnhancer,
|
||||
jsonErrorRecovery,
|
||||
readImageResizer,
|
||||
todoDescriptionOverride,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,30 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeAll, beforeEach, afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { beforeEach, afterEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import * as dataPath from "./data-path"
|
||||
import {
|
||||
createConnectedProvidersCacheStore,
|
||||
} from "./connected-providers-cache"
|
||||
|
||||
let fakeUserCacheRoot = ""
|
||||
let testCacheDir = ""
|
||||
let moduleImportCounter = 0
|
||||
|
||||
const getOmoOpenCodeCacheDirMock = mock(() => testCacheDir)
|
||||
|
||||
let updateConnectedProvidersCache: typeof import("./connected-providers-cache").updateConnectedProvidersCache
|
||||
let readProviderModelsCache: typeof import("./connected-providers-cache").readProviderModelsCache
|
||||
|
||||
async function prepareConnectedProvidersCacheTestModule(): Promise<void> {
|
||||
testCacheDir = mkdtempSync(join(tmpdir(), "connected-providers-cache-test-"))
|
||||
getOmoOpenCodeCacheDirMock.mockClear()
|
||||
mock.module("./data-path", () => ({
|
||||
getOmoOpenCodeCacheDir: getOmoOpenCodeCacheDirMock,
|
||||
}))
|
||||
moduleImportCounter += 1
|
||||
;({ updateConnectedProvidersCache, readProviderModelsCache } = await import(`./connected-providers-cache?test=${moduleImportCounter}`))
|
||||
}
|
||||
let testCacheStore: ReturnType<typeof createConnectedProvidersCacheStore>
|
||||
|
||||
describe("updateConnectedProvidersCache", () => {
|
||||
beforeAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
mock.restore()
|
||||
await prepareConnectedProvidersCacheTestModule()
|
||||
beforeEach(() => {
|
||||
fakeUserCacheRoot = mkdtempSync(join(tmpdir(), "connected-providers-user-cache-"))
|
||||
testCacheDir = join(fakeUserCacheRoot, "oh-my-opencode")
|
||||
testCacheStore = createConnectedProvidersCacheStore(() => testCacheDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
if (existsSync(testCacheDir)) {
|
||||
rmSync(testCacheDir, { recursive: true, force: true })
|
||||
if (existsSync(fakeUserCacheRoot)) {
|
||||
rmSync(fakeUserCacheRoot, { recursive: true, force: true })
|
||||
}
|
||||
fakeUserCacheRoot = ""
|
||||
testCacheDir = ""
|
||||
})
|
||||
|
||||
@@ -76,10 +61,10 @@ describe("updateConnectedProvidersCache", () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
await updateConnectedProvidersCache(mockClient)
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
|
||||
//#then
|
||||
const cache = readProviderModelsCache()
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
expect(cache).not.toBeNull()
|
||||
expect(cache!.connected).toEqual(["openai", "anthropic"])
|
||||
expect(cache!.models).toEqual({
|
||||
@@ -109,10 +94,10 @@ describe("updateConnectedProvidersCache", () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
await updateConnectedProvidersCache(mockClient)
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
|
||||
//#then
|
||||
const cache = readProviderModelsCache()
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
expect(cache).not.toBeNull()
|
||||
expect(cache!.models).toEqual({})
|
||||
})
|
||||
@@ -130,10 +115,10 @@ describe("updateConnectedProvidersCache", () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
await updateConnectedProvidersCache(mockClient)
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
|
||||
//#then
|
||||
const cache = readProviderModelsCache()
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
expect(cache).not.toBeNull()
|
||||
expect(cache!.models).toEqual({})
|
||||
})
|
||||
@@ -143,25 +128,44 @@ describe("updateConnectedProvidersCache", () => {
|
||||
const mockClient = {}
|
||||
|
||||
//#when
|
||||
await updateConnectedProvidersCache(mockClient)
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
|
||||
//#then
|
||||
const cache = readProviderModelsCache()
|
||||
const cache = testCacheStore.readProviderModelsCache()
|
||||
expect(cache).toBeNull()
|
||||
})
|
||||
|
||||
test("does not remove the user's real cache directory during test setup", async () => {
|
||||
test("does not remove unrelated files in the cache directory", async () => {
|
||||
//#given
|
||||
const realCacheDir = join(dataPath.getCacheDir(), "oh-my-opencode")
|
||||
const realCacheDir = join(fakeUserCacheRoot, "oh-my-opencode")
|
||||
const sentinelPath = join(realCacheDir, "connected-providers-cache.test-sentinel.json")
|
||||
mkdirSync(realCacheDir, { recursive: true })
|
||||
writeFileSync(sentinelPath, JSON.stringify({ keep: true }))
|
||||
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [
|
||||
{
|
||||
id: "openai",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
//#when
|
||||
await prepareConnectedProvidersCacheTestModule()
|
||||
await testCacheStore.updateConnectedProvidersCache(mockClient)
|
||||
|
||||
//#then
|
||||
expect(testCacheStore.readConnectedProvidersCache()).toEqual(["openai"])
|
||||
expect(existsSync(sentinelPath)).toBe(true)
|
||||
expect(readFileSync(sentinelPath, "utf-8")).toBe(JSON.stringify({ keep: true }))
|
||||
} finally {
|
||||
|
||||
@@ -25,172 +25,190 @@ interface ProviderModelsCache {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function getCacheFilePath(filename: string): string {
|
||||
return join(dataPath.getOmoOpenCodeCacheDir(), filename)
|
||||
}
|
||||
|
||||
function ensureCacheDir(): void {
|
||||
const cacheDir = dataPath.getOmoOpenCodeCacheDir()
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the connected providers cache.
|
||||
* Returns the list of connected provider IDs, or null if cache doesn't exist.
|
||||
*/
|
||||
export function readConnectedProvidersCache(): string[] | null {
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[connected-providers-cache] Cache file not found", { cacheFile })
|
||||
return null
|
||||
export function createConnectedProvidersCacheStore(
|
||||
getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir
|
||||
) {
|
||||
function getCacheFilePath(filename: string): string {
|
||||
return join(getCacheDir(), filename)
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as ConnectedProvidersCache
|
||||
log("[connected-providers-cache] Read cache", { count: data.connected.length, updatedAt: data.updatedAt })
|
||||
return data.connected
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error reading cache", { error: String(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
let memConnected: string[] | null | undefined
|
||||
let memProviderModels: ProviderModelsCache | null | undefined
|
||||
|
||||
/**
|
||||
* Check if connected providers cache exists.
|
||||
*/
|
||||
export function hasConnectedProvidersCache(): boolean {
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the connected providers cache.
|
||||
*/
|
||||
function writeConnectedProvidersCache(connected: string[]): void {
|
||||
ensureCacheDir()
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
|
||||
const data: ConnectedProvidersCache = {
|
||||
connected,
|
||||
updatedAt: new Date().toISOString(),
|
||||
function ensureCacheDir(): void {
|
||||
const cacheDir = getCacheDir()
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(cacheFile, JSON.stringify(data, null, 2))
|
||||
log("[connected-providers-cache] Cache written", { count: connected.length })
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error writing cache", { error: String(err) })
|
||||
}
|
||||
}
|
||||
function readConnectedProvidersCache(): string[] | null {
|
||||
if (memConnected !== undefined) return memConnected
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
|
||||
/**
|
||||
* Read the provider-models cache.
|
||||
* Returns the cache data, or null if cache doesn't exist.
|
||||
*/
|
||||
export function readProviderModelsCache(): ProviderModelsCache | null {
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[connected-providers-cache] Provider-models cache file not found", { cacheFile })
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as ProviderModelsCache
|
||||
log("[connected-providers-cache] Read provider-models cache", {
|
||||
providerCount: Object.keys(data.models).length,
|
||||
updatedAt: data.updatedAt
|
||||
})
|
||||
return data
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error reading provider-models cache", { error: String(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider-models cache exists.
|
||||
*/
|
||||
export function hasProviderModelsCache(): boolean {
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the provider-models cache.
|
||||
*/
|
||||
export function writeProviderModelsCache(data: { models: Record<string, string[]>; connected: string[] }): void {
|
||||
ensureCacheDir()
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
|
||||
const cacheData: ProviderModelsCache = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2))
|
||||
log("[connected-providers-cache] Provider-models cache written", {
|
||||
providerCount: Object.keys(data.models).length
|
||||
})
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error writing provider-models cache", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the connected providers cache by fetching from the client.
|
||||
* Also updates the provider-models cache with model lists per provider.
|
||||
*/
|
||||
export async function updateConnectedProvidersCache(client: {
|
||||
provider?: {
|
||||
list?: () => Promise<{
|
||||
data?: {
|
||||
connected?: string[]
|
||||
all?: Array<{ id: string; models?: Record<string, unknown> }>
|
||||
}
|
||||
}>
|
||||
}
|
||||
}): Promise<void> {
|
||||
if (!client?.provider?.list) {
|
||||
log("[connected-providers-cache] client.provider.list not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.provider.list()
|
||||
const connected = result.data?.connected ?? []
|
||||
log("[connected-providers-cache] Fetched connected providers", { count: connected.length, providers: connected })
|
||||
|
||||
writeConnectedProvidersCache(connected)
|
||||
|
||||
const modelsByProvider: Record<string, string[]> = {}
|
||||
const allProviders = result.data?.all ?? []
|
||||
|
||||
for (const provider of allProviders) {
|
||||
if (provider.models) {
|
||||
const modelIds = Object.keys(provider.models)
|
||||
if (modelIds.length > 0) {
|
||||
modelsByProvider[provider.id] = modelIds
|
||||
}
|
||||
}
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[connected-providers-cache] Cache file not found", { cacheFile })
|
||||
memConnected = null
|
||||
return null
|
||||
}
|
||||
|
||||
log("[connected-providers-cache] Extracted models from provider list", {
|
||||
providerCount: Object.keys(modelsByProvider).length,
|
||||
totalModels: Object.values(modelsByProvider).reduce((sum, ids) => sum + ids.length, 0),
|
||||
})
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as ConnectedProvidersCache
|
||||
log("[connected-providers-cache] Read cache", { count: data.connected.length, updatedAt: data.updatedAt })
|
||||
memConnected = data.connected
|
||||
return data.connected
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error reading cache", { error: String(err) })
|
||||
memConnected = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
writeProviderModelsCache({
|
||||
models: modelsByProvider,
|
||||
function hasConnectedProvidersCache(): boolean {
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
function writeConnectedProvidersCache(connected: string[]): void {
|
||||
ensureCacheDir()
|
||||
const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
|
||||
|
||||
const data: ConnectedProvidersCache = {
|
||||
connected,
|
||||
})
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error updating cache", { error: String(err) })
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(cacheFile, JSON.stringify(data, null, 2))
|
||||
memConnected = connected
|
||||
log("[connected-providers-cache] Cache written", { count: connected.length })
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error writing cache", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
function readProviderModelsCache(): ProviderModelsCache | null {
|
||||
if (memProviderModels !== undefined) return memProviderModels
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[connected-providers-cache] Provider-models cache file not found", { cacheFile })
|
||||
memProviderModels = null
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as ProviderModelsCache
|
||||
log("[connected-providers-cache] Read provider-models cache", {
|
||||
providerCount: Object.keys(data.models).length,
|
||||
updatedAt: data.updatedAt,
|
||||
})
|
||||
memProviderModels = data
|
||||
return data
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error reading provider-models cache", { error: String(err) })
|
||||
memProviderModels = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasProviderModelsCache(): boolean {
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[]>; connected: string[] }): void {
|
||||
ensureCacheDir()
|
||||
const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
|
||||
|
||||
const cacheData: ProviderModelsCache = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2))
|
||||
memProviderModels = cacheData
|
||||
log("[connected-providers-cache] Provider-models cache written", {
|
||||
providerCount: Object.keys(data.models).length,
|
||||
})
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error writing provider-models cache", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConnectedProvidersCache(client: {
|
||||
provider?: {
|
||||
list?: () => Promise<{
|
||||
data?: {
|
||||
connected?: string[]
|
||||
all?: Array<{ id: string; models?: Record<string, unknown> }>
|
||||
}
|
||||
}>
|
||||
}
|
||||
}): Promise<void> {
|
||||
if (!client?.provider?.list) {
|
||||
log("[connected-providers-cache] client.provider.list not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.provider.list()
|
||||
const connected = result.data?.connected ?? []
|
||||
log("[connected-providers-cache] Fetched connected providers", {
|
||||
count: connected.length,
|
||||
providers: connected,
|
||||
})
|
||||
|
||||
writeConnectedProvidersCache(connected)
|
||||
|
||||
const modelsByProvider: Record<string, string[]> = {}
|
||||
const allProviders = result.data?.all ?? []
|
||||
|
||||
for (const provider of allProviders) {
|
||||
if (provider.models) {
|
||||
const modelIds = Object.keys(provider.models)
|
||||
if (modelIds.length > 0) {
|
||||
modelsByProvider[provider.id] = modelIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[connected-providers-cache] Extracted models from provider list", {
|
||||
providerCount: Object.keys(modelsByProvider).length,
|
||||
totalModels: Object.values(modelsByProvider).reduce((sum, ids) => sum + ids.length, 0),
|
||||
})
|
||||
|
||||
writeProviderModelsCache({
|
||||
models: modelsByProvider,
|
||||
connected,
|
||||
})
|
||||
} catch (err) {
|
||||
log("[connected-providers-cache] Error updating cache", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
readConnectedProvidersCache,
|
||||
hasConnectedProvidersCache,
|
||||
readProviderModelsCache,
|
||||
hasProviderModelsCache,
|
||||
writeProviderModelsCache,
|
||||
updateConnectedProvidersCache,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultConnectedProvidersCacheStore = createConnectedProvidersCacheStore(
|
||||
() => dataPath.getOmoOpenCodeCacheDir()
|
||||
)
|
||||
|
||||
export const {
|
||||
readConnectedProvidersCache,
|
||||
hasConnectedProvidersCache,
|
||||
readProviderModelsCache,
|
||||
hasProviderModelsCache,
|
||||
writeProviderModelsCache,
|
||||
updateConnectedProvidersCache,
|
||||
} = defaultConnectedProvidersCacheStore
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function resolveFileReferencesInText(
|
||||
|
||||
let resolved = text
|
||||
for (const [pattern, replacement] of replacements.entries()) {
|
||||
resolved = resolved.split(pattern).join(replacement)
|
||||
resolved = resolved.replaceAll(pattern, replacement)
|
||||
}
|
||||
|
||||
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
// Shared logging utility for the plugin
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
const logFile = path.join(os.tmpdir(), "oh-my-opencode.log")
|
||||
|
||||
let buffer: string[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const FLUSH_INTERVAL_MS = 500
|
||||
const BUFFER_SIZE_LIMIT = 50
|
||||
|
||||
function flush(): void {
|
||||
if (buffer.length === 0) return
|
||||
const data = buffer.join("")
|
||||
buffer = []
|
||||
try {
|
||||
fs.appendFileSync(logFile, data)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null
|
||||
flush()
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function log(message: string, data?: unknown): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
|
||||
fs.appendFileSync(logFile, logEntry)
|
||||
buffer.push(logEntry)
|
||||
if (buffer.length >= BUFFER_SIZE_LIMIT) {
|
||||
flush()
|
||||
} else {
|
||||
scheduleFlush()
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ function escapeRegexExceptAsterisk(str: string): string {
|
||||
return str.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
const regexCache = new Map<string, RegExp>()
|
||||
|
||||
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||
if (!matcher) {
|
||||
return true
|
||||
@@ -17,8 +19,12 @@ export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||
return patterns.some((p) => {
|
||||
if (p.includes("*")) {
|
||||
// First escape regex special chars (except *), then convert * to .*
|
||||
const escaped = escapeRegexExceptAsterisk(p)
|
||||
const regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`, "i")
|
||||
let regex = regexCache.get(p)
|
||||
if (!regex) {
|
||||
const escaped = escapeRegexExceptAsterisk(p)
|
||||
regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`, "i")
|
||||
regexCache.set(p, regex)
|
||||
}
|
||||
return regex.test(toolName)
|
||||
}
|
||||
return p.toLowerCase() === toolName.toLowerCase()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
@@ -6,96 +6,283 @@ import {
|
||||
DEFAULT_SERVER_PORT,
|
||||
} from "./port-utils"
|
||||
|
||||
const HOSTNAME = "127.0.0.1"
|
||||
const REAL_PORT_SEARCH_WINDOW = 200
|
||||
|
||||
function supportsRealSocketBinding(): boolean {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("probe"),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const canBindRealSockets = supportsRealSocketBinding()
|
||||
|
||||
describe("port-utils", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const port = 59999
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const port = 59998
|
||||
const blocker = Bun.serve({
|
||||
if (canBindRealSockets) {
|
||||
function startRealBlocker(port: number = 0) {
|
||||
return Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
async function findContiguousAvailableStart(length: number): Promise<number> {
|
||||
const probe = startRealBlocker()
|
||||
const seedPort = probe.port
|
||||
probe.stop(true)
|
||||
|
||||
for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) {
|
||||
const checks = await Promise.all(
|
||||
Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME))
|
||||
)
|
||||
if (checks.every(Boolean)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = 59997
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
throw new Error(`Could not find ${length} contiguous available ports`)
|
||||
}
|
||||
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = 59996
|
||||
const blocker = Bun.serve({
|
||||
port: startPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response("blocked"),
|
||||
describe("with real sockets", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const blocker = startRealBlocker()
|
||||
const port = blocker.port
|
||||
blocker.stop(true)
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const blocker = startRealBlocker()
|
||||
const port = blocker.port
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = await findContiguousAvailableStart(1)
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = 59993
|
||||
const blockers = [
|
||||
Bun.serve({ port: startPort, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 1, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 2, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
]
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = await findContiguousAvailableStart(2)
|
||||
const blocker = startRealBlocker(startPort)
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((b) => b.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = 59990
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = await findContiguousAvailableStart(4)
|
||||
const blockers = [
|
||||
startRealBlocker(startPort),
|
||||
startRealBlocker(startPort + 1),
|
||||
startRealBlocker(startPort + 2),
|
||||
]
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = 59989
|
||||
const blocker = Bun.serve({
|
||||
port: preferredPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response("blocked"),
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((blocker) => blocker.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBeGreaterThan(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(1)
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(2)
|
||||
const blocker = startRealBlocker(preferredPort)
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort + 1)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const blockedSockets = new Set<string>()
|
||||
let serveSpy: ReturnType<typeof spyOn>
|
||||
|
||||
function getSocketKey(port: number, hostname: string): string {
|
||||
return `${hostname}:${port}`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
blockedSockets.clear()
|
||||
serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => {
|
||||
if (typeof port !== "number") {
|
||||
throw new Error("Test expected numeric port")
|
||||
}
|
||||
const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME
|
||||
const socketKey = getSocketKey(port, resolvedHostname)
|
||||
|
||||
if (blockedSockets.has(socketKey)) {
|
||||
const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & {
|
||||
code?: string
|
||||
syscall?: string
|
||||
errno?: number
|
||||
address?: string
|
||||
port?: number
|
||||
}
|
||||
error.code = "EADDRINUSE"
|
||||
error.syscall = "listen"
|
||||
error.errno = 0
|
||||
error.address = resolvedHostname
|
||||
error.port = port
|
||||
throw error
|
||||
}
|
||||
|
||||
blockedSockets.add(socketKey)
|
||||
return {
|
||||
stop: (_force?: boolean) => {
|
||||
blockedSockets.delete(socketKey)
|
||||
},
|
||||
} as { stop: (force?: boolean) => void }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
expect(blockedSockets.size).toBe(0)
|
||||
serveSpy.mockRestore()
|
||||
blockedSockets.clear()
|
||||
})
|
||||
|
||||
describe("with mocked sockets fallback", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const port = 59999
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
expect(blockedSockets.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const port = 59998
|
||||
const blocker = Bun.serve({
|
||||
port,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => {
|
||||
const hostname = "192.0.2.10"
|
||||
await isPortAvailable(59995, hostname)
|
||||
|
||||
expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = 59997
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = 59996
|
||||
const blocker = Bun.serve({
|
||||
port: startPort,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = 59993
|
||||
const blockers = [
|
||||
Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
]
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((blocker) => blocker.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = 59990
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = 59989
|
||||
const blocker = Bun.serve({
|
||||
port: preferredPort,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort + 1)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("DEFAULT_SERVER_PORT", () => {
|
||||
it("#given constant #when accessed #then returns 4096", () => {
|
||||
|
||||
@@ -11,6 +11,14 @@ import {
|
||||
} from "./edit-operation-primitives"
|
||||
import { validateLineRefs } from "./validation"
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export interface HashlineApplyReport {
|
||||
content: string
|
||||
noopEdits: number
|
||||
@@ -51,7 +59,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
const next = edit.end
|
||||
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
|
||||
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
@@ -62,7 +70,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
const next = edit.pos
|
||||
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyAppend(lines, edit.lines)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
@@ -73,7 +81,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
const next = edit.pos
|
||||
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
|
||||
: applyPrepend(lines, edit.lines)
|
||||
if (next.join("\n") === lines.join("\n")) {
|
||||
if (arraysEqual(next, lines)) {
|
||||
noopEdits += 1
|
||||
break
|
||||
}
|
||||
|
||||
@@ -86,15 +86,17 @@ export async function* streamHashLinesFromUtf8(
|
||||
pending += text
|
||||
const chunksToYield: string[] = []
|
||||
|
||||
let lastIdx = 0
|
||||
while (true) {
|
||||
const idx = pending.indexOf("\n")
|
||||
const idx = pending.indexOf("\n", lastIdx)
|
||||
if (idx === -1) break
|
||||
const line = pending.slice(0, idx)
|
||||
pending = pending.slice(idx + 1)
|
||||
const line = pending.slice(lastIdx, idx)
|
||||
lastIdx = idx + 1
|
||||
endedWithNewline = true
|
||||
chunksToYield.push(...pushLine(line))
|
||||
}
|
||||
|
||||
pending = pending.slice(lastIdx)
|
||||
if (pending.length > 0) endedWithNewline = false
|
||||
return chunksToYield
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export function generateHashlineDiff(oldContent: string, newContent: string, fil
|
||||
const oldLines = oldContent.split("\n")
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
||||
const parts: string[] = [`--- ${filePath}\n+++ ${filePath}\n`]
|
||||
const maxLines = Math.max(oldLines.length, newLines.length)
|
||||
|
||||
for (let i = 0; i < maxLines; i += 1) {
|
||||
@@ -14,18 +14,18 @@ export function generateHashlineDiff(oldContent: string, newContent: string, fil
|
||||
const hash = computeLineHash(lineNum, newLine)
|
||||
|
||||
if (i >= oldLines.length) {
|
||||
diff += `+ ${lineNum}#${hash}|${newLine}\n`
|
||||
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
|
||||
continue
|
||||
}
|
||||
if (i >= newLines.length) {
|
||||
diff += `- ${lineNum}# |${oldLine}\n`
|
||||
parts.push(`- ${lineNum}# |${oldLine}\n`)
|
||||
continue
|
||||
}
|
||||
if (oldLine !== newLine) {
|
||||
diff += `- ${lineNum}# |${oldLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}|${newLine}\n`
|
||||
parts.push(`- ${lineNum}# |${oldLine}\n`)
|
||||
parts.push(`+ ${lineNum}#${hash}|${newLine}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("isServerInstalled", () => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
console.error(`Failed to clean up temp dir: ${e}`)
|
||||
// cleanup failed — ignored
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -45,6 +45,8 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
|
||||
}
|
||||
}
|
||||
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]))
|
||||
|
||||
// Filter out completed and deleted tasks
|
||||
const activeTasks = allTasks.filter(
|
||||
(task) => task.status !== "completed" && task.status !== "deleted"
|
||||
@@ -54,7 +56,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
|
||||
const summaries: TaskSummary[] = activeTasks.map((task) => {
|
||||
// Filter blockedBy to only include unresolved (non-completed) blockers
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
|
||||
const blockerTask = allTasks.find((t) => t.id === blockerId)
|
||||
const blockerTask = taskMap.get(blockerId)
|
||||
// Include if blocker doesn't exist (missing) or if it's not completed
|
||||
return !blockerTask || blockerTask.status !== "completed"
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user