Compare commits

..

7 Commits

Author SHA1 Message Date
YeonGyu-Kim
b1946a6397 test(plugin): cover session.status dedupe and runtime fallback gating
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:15:13 +09:00
YeonGyu-Kim
6edbc9638a fix(plugin): gate model-fallback session.status retries
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:15:06 +09:00
YeonGyu-Kim
af32f2e258 test(runtime-fallback): cover session.status retry fallback
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:14:58 +09:00
YeonGyu-Kim
e7e6bd0608 fix(runtime-fallback): route session.status events
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:14:53 +09:00
YeonGyu-Kim
7f86103666 fix(runtime-fallback): add session.status retry handler
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:14:47 +09:00
YeonGyu-Kim
2c6ba98920 fix(runtime-fallback): match cooldown retry status messages
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:14:39 +09:00
YeonGyu-Kim
80dee4d2c9 fix(shared): add stable retry status dedupe helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:14:33 +09:00
31 changed files with 587 additions and 478 deletions

View File

@@ -193,9 +193,10 @@ jobs:
if-no-files-found: error
# =============================================================================
# Job 2: Publish all platforms (oh-my-opencode + oh-my-openagent)
# Job 2: Publish all platforms using OIDC/Provenance
# - Runs on ubuntu-latest for ALL platforms (just downloading artifacts)
# - Uses NODE_AUTH_TOKEN for auth + OIDC for provenance attestation
# - Uses npm Trusted Publishing (OIDC) - no NODE_AUTH_TOKEN needed
# - Fresh OIDC token at publish time avoids timeout issues
# =============================================================================
publish:
needs: build
@@ -207,7 +208,7 @@ 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 }}"
@@ -221,23 +222,9 @@ jobs:
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Check if oh-my-openagent already published
id: check-openagent
run: |
PKG_NAME="oh-my-openagent-${{ 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"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Download artifact
id: download
if: steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true'
if: steps.check.outputs.skip != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
@@ -245,7 +232,7 @@ jobs:
path: .
- name: Extract artifact
if: (steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true') && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
@@ -261,7 +248,7 @@ jobs:
ls -la packages/${PLATFORM}/bin/
- uses: actions/setup-node@v4
if: (steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true') && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
@@ -281,25 +268,3 @@ 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-openagent.outputs.skip != '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

View File

@@ -121,7 +121,7 @@ jobs:
publish-main:
runs-on: ubuntu-latest
needs: [test, typecheck]
if: github.repository == 'code-yeongyu/oh-my-openagent'
if: github.repository == 'code-yeongyu/oh-my-opencode'
outputs:
version: ${{ steps.version.outputs.version }}
dist_tag: ${{ steps.version.outputs.dist_tag }}
@@ -204,7 +204,7 @@ jobs:
bunx tsc --emitDeclarationOnly
bun run build:schema
- name: Publish oh-my-opencode
- name: Publish main package
if: steps.check.outputs.skip != 'true'
run: |
TAG_ARG=""
@@ -213,42 +213,20 @@ jobs:
fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Publish oh-my-openagent
- name: Git commit and tag
if: steps.check.outputs.skip != 'true'
run: |
# Update package name to oh-my-openagent
jq '.name = "oh-my-openagent"' package.json > tmp.json && mv tmp.json package.json
# Update optionalDependencies to use oh-my-openagent naming
jq '.optionalDependencies = {
"oh-my-openagent-darwin-arm64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-darwin-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-darwin-x64-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-arm64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-arm64-musl": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-musl": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-musl-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-windows-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-windows-x64-baseline": "${{ steps.version.outputs.version }}"
}' 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 "oh-my-openagent publish may have failed (package may already exist)"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add package.json assets/oh-my-opencode.schema.json packages/*/package.json || true
git diff --cached --quiet || git commit -m "release: v${{ steps.version.outputs.version }}"
git tag -f "v${{ steps.version.outputs.version }}"
git push origin --tags --force
git push origin HEAD || echo "Branch push failed (non-critical)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Restore package.json
if: steps.check.outputs.skip != 'true'
run: |
# Restore original package name
jq '.name = "oh-my-opencode"' package.json > tmp.json && mv tmp.json package.json
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
trigger-platform:
runs-on: ubuntu-latest

View File

@@ -45,12 +45,12 @@
"license": "SUL-1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/oh-my-openagent.git"
"url": "git+https://github.com/code-yeongyu/oh-my-opencode.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/oh-my-openagent/issues"
"url": "https://github.com/code-yeongyu/oh-my-opencode/issues"
},
"homepage": "https://github.com/code-yeongyu/oh-my-openagent#readme",
"homepage": "https://github.com/code-yeongyu/oh-my-opencode#readme",
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"win32"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"win32"

View File

@@ -2015,22 +2015,6 @@
"created_at": "2026-03-07T13:53:56Z",
"repoId": 1108837393,
"pullRequestNo": 2360
},
{
"name": "crazyrabbit0",
"id": 5244848,
"comment_id": 3936744393,
"created_at": "2026-02-20T19:40:05Z",
"repoId": 1108837393,
"pullRequestNo": 2012
},
{
"name": "vaur94",
"id": 100377859,
"comment_id": 4019104338,
"created_at": "2026-03-08T14:01:19Z",
"repoId": 1108837393,
"pullRequestNo": 2385
}
]
}

View File

@@ -2,7 +2,6 @@ import { describe, test, expect } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
const client = {
@@ -52,105 +51,3 @@ describe("BackgroundManager polling overlap", () => {
expect(statusCallCount).toBe(1)
})
})
function createRunningTask(sessionID: string): BackgroundTask {
return {
id: `bg_test_${sessionID}`,
sessionID,
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
progress: { toolCalls: 0, lastUpdate: new Date() },
}
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
tasks.set(task.id, task)
}
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
const client = {
session: {
status: async () => ({ data: {} }),
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
todo: async () => ({ data: [] }),
messages: async () => ({
data: [{
info: { role: "assistant", finish: "end_turn", id: "msg-2" },
parts: [{ type: "text", text: "done" }],
}, {
info: { role: "user", id: "msg-1" },
parts: [{ type: "text", text: "go" }],
}],
}),
...clientOverrides,
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
}
describe("BackgroundManager pollRunningTasks", () => {
describe("#given a running task whose session is no longer in status response", () => {
test("#when pollRunningTasks runs #then completes the task instead of leaving it running", async () => {
//#given
const manager = createManagerWithClient()
const task = createRunningTask("ses-gone")
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()
})
})
describe("#given a running task whose session status is idle", () => {
test("#when pollRunningTasks runs #then completes the task", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-idle": { type: "idle" } } }),
})
const task = createRunningTask("ses-idle")
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")
})
})
describe("#given a running task whose session status is busy", () => {
test("#when pollRunningTasks runs #then keeps the task running", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-busy": { type: "busy" } } }),
})
const task = createRunningTask("ses-busy")
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("running")
})
})
})

View File

@@ -1501,7 +1501,32 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Handle retry before checking running state
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, "polling (idle status)")
continue
}
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
? (sessionStatus as { message?: string }).message
@@ -1512,40 +1537,12 @@ 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") {
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus.type,
toolCalls: task.progress?.toolCalls ?? 0,
})
continue
}
// Session is idle or no longer in status response (completed/disappeared)
const completionSource = sessionStatus?.type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, completionSource)
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
})
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger"
interface BunLockfile {
@@ -48,22 +48,17 @@ function removeFromBunLock(packageName: string): boolean {
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDirs = [
path.join(USER_CONFIG_DIR, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false
for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgJsonPath)) {

View File

@@ -26,6 +26,11 @@ export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i,
/too.?many.?requests/i,
/quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/cooldown/i,
/exhausted\s+your\s+capacity/i,
/usage\s+limit\s+has\s+been\s+reached/i,
/service.?unavailable/i,
/overloaded/i,

View File

@@ -6,9 +6,11 @@ import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableErro
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { createSessionStatusHandler } from "./session-status-handler"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const sessionStatusHandler = createSessionStatusHandler(deps, helpers)
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
@@ -33,6 +35,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
sessionStatusHandler.clearRetryKey(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
}
@@ -191,6 +194,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(props); return }
if (event.type === "session.idle") { handleSessionIdle(props); return }
if (event.type === "session.status") { await sessionStatusHandler.handleSessionStatus(props); return }
if (event.type === "session.error") { await handleSessionError(props); return }
}
}

View File

@@ -387,6 +387,133 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" })
})
test("should trigger fallback on session.status auto-retry signal", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args: unknown) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
}
)
const sessionID = "test-session-status-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
next: 476,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal in session.status"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.4" })
expect(promptCalls).toHaveLength(1)
})
test("should deduplicate session.status countdown updates for the same retry attempt", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args: unknown) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
}
)
const sessionID = "test-session-status-countdown-dedup"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
next: 476,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
next: 475,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]",
},
},
},
})
expect(promptCalls).toHaveLength(1)
})
test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }),

View File

@@ -0,0 +1,160 @@
import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { isRetryableError } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "../../shared/retry-status-utils"
type SessionStatus = {
type?: string
message?: string
attempt?: number
}
function resolveInitialModel(
props: Record<string, unknown> | undefined,
retryMessage: string,
resolvedAgent: string | undefined,
pluginConfig: HookDeps["pluginConfig"],
): string | undefined {
const eventModel = typeof props?.model === "string" ? props.model : undefined
if (eventModel) {
return eventModel
}
const retryModel = extractRetryStatusModel(retryMessage)
if (retryModel) {
return retryModel
}
const agentConfig = resolvedAgent
? pluginConfig?.agents?.[resolvedAgent as keyof typeof pluginConfig.agents]
: undefined
return typeof agentConfig?.model === "string" ? agentConfig.model : undefined
}
export function createSessionStatusHandler(deps: HookDeps, helpers: AutoRetryHelpers): {
clearRetryKey: (sessionID: string) => void
handleSessionStatus: (props: Record<string, unknown> | undefined) => Promise<void>
} {
const {
config,
pluginConfig,
sessionStates,
sessionLastAccess,
sessionRetryInFlight,
sessionAwaitingFallbackResult,
} = deps
const sessionStatusRetryKeys = new Map<string, string>()
const clearRetryKey = (sessionID: string): void => {
sessionStatusRetryKeys.delete(sessionID)
}
const handleSessionStatus = async (props: Record<string, unknown> | undefined): Promise<void> => {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as SessionStatus | undefined
const agent = props?.agent as string | undefined
const timeoutEnabled = config.timeout_seconds > 0
if (!sessionID || status?.type !== "retry" || !timeoutEnabled) {
return
}
const retryMessage = typeof status.message === "string" ? status.message : ""
if (!retryMessage || !isRetryableError({ message: retryMessage }, config.retry_on_errors)) {
return
}
const currentState = sessionStates.get(sessionID)
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage)
const retryModel =
(typeof props?.model === "string" ? props.model : undefined) ??
extractRetryStatusModel(retryMessage) ??
currentState?.currentModel ??
"unknown-model"
const retryKey = `${retryAttempt}:${retryModel}:${normalizeRetryStatusMessage(retryMessage)}`
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
return
}
sessionStatusRetryKeys.set(sessionID, retryKey)
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to provider session.status retry signal`, {
sessionID,
retryModel,
})
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
sessionRetryInFlight.delete(sessionID)
}
sessionAwaitingFallbackResult.delete(sessionID)
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) {
log(`[${HOOK_NAME}] No fallback models configured`, { sessionID, agent: resolvedAgent ?? agent })
return
}
let state = currentState
if (!state) {
const initialModel = resolveInitialModel(props, retryMessage, resolvedAgent, pluginConfig)
if (!initialModel) {
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
return
}
state = createFallbackState(initialModel)
sessionStates.set(sessionID, state)
}
sessionLastAccess.set(sessionID, Date.now())
if (state.pendingFallbackModel) {
log(`[${HOOK_NAME}] Clearing pending fallback due to provider session.status retry signal`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
state.pendingFallbackModel = undefined
}
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
sessionID,
model: state.currentModel,
retryAttempt,
})
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
return
}
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
return {
clearRetryKey,
handleSessionStatus,
}
}

View File

@@ -9,8 +9,6 @@ type SessionNotificationConfig = {
idleConfirmationDelay: number
skipIfIncompleteTodos: boolean
maxTrackedSessions: number
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createIdleNotificationScheduler(options: {
@@ -26,9 +24,6 @@ export function createIdleNotificationScheduler(options: {
const sessionActivitySinceIdle = new Set<string>()
const notificationVersions = new Map<string, number>()
const executingNotifications = new Set<string>()
const scheduledAt = new Map<string, number>()
const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100
function cleanupOldSessions(): void {
const maxSessions = options.config.maxTrackedSessions
@@ -48,10 +43,6 @@ export function createIdleNotificationScheduler(options: {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach((id) => executingNotifications.delete(id))
}
if (scheduledAt.size > maxSessions) {
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
sessionsToRemove.forEach((id) => scheduledAt.delete(id))
}
}
function cancelPendingNotification(sessionID: string): void {
@@ -60,17 +51,11 @@ export function createIdleNotificationScheduler(options: {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
scheduledAt.delete(sessionID)
sessionActivitySinceIdle.add(sessionID)
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string): void {
const scheduledTime = scheduledAt.get(sessionID)
if (scheduledTime && Date.now() - scheduledTime < activityGracePeriodMs) {
return
}
cancelPendingNotification(sessionID)
if (!executingNotifications.has(sessionID)) {
notifiedSessions.delete(sessionID)
@@ -80,26 +65,22 @@ export function createIdleNotificationScheduler(options: {
async function executeNotification(sessionID: string, version: number): Promise<void> {
if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notifiedSessions.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
@@ -132,7 +113,6 @@ export function createIdleNotificationScheduler(options: {
} finally {
executingNotifications.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
if (sessionActivitySinceIdle.has(sessionID)) {
notifiedSessions.delete(sessionID)
sessionActivitySinceIdle.delete(sessionID)
@@ -146,7 +126,6 @@ export function createIdleNotificationScheduler(options: {
if (executingNotifications.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
scheduledAt.set(sessionID, Date.now())
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
@@ -165,7 +144,6 @@ export function createIdleNotificationScheduler(options: {
sessionActivitySinceIdle.delete(sessionID)
notificationVersions.delete(sessionID)
executingNotifications.delete(sessionID)
scheduledAt.delete(sessionID)
}
return {

View File

@@ -195,9 +195,8 @@ describe("session-notification", () => {
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 100,
idleConfirmationDelay: 100, // Long delay
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle
@@ -273,7 +272,6 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then message.updated fires
@@ -308,7 +306,6 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then tool.execute.before fires
@@ -512,75 +509,4 @@ describe("session-notification", () => {
}
}
})
test("should ignore activity events within grace period", async () => {
// given - main session is set
const mainSessionID = "main-grace"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 100,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// when - activity happens immediately (within grace period)
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// then - notification SHOULD be sent (activity was within grace period, ignored)
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
})
test("should cancel notification for activity after grace period", async () => {
// given - main session is set
const mainSessionID = "main-grace-cancel"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 200,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 50,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// when - wait for grace period to pass
await new Promise((resolve) => setTimeout(resolve, 60))
// when - activity happens after grace period
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 200))
// then - notification should NOT be sent (activity cancelled it after grace period)
expect(notificationCalls).toHaveLength(0)
})
})

View File

@@ -24,8 +24,6 @@ interface SessionNotificationConfig {
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
enforceMainSessionFilter?: boolean
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(
ctx: PluginInput,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import { applyToolConfig } from "./tool-config-handler"
import type { OhMyOpenCodeConfig } from "../config"
@@ -56,109 +56,6 @@ describe("applyToolConfig", () => {
})
})
describe("#given OPENCODE_CONFIG_CONTENT has question set to deny", () => {
let originalConfigContent: string | undefined
let originalCliRunMode: string | undefined
beforeEach(() => {
originalConfigContent = process.env.OPENCODE_CONFIG_CONTENT
originalCliRunMode = process.env.OPENCODE_CLI_RUN_MODE
})
afterEach(() => {
if (originalConfigContent === undefined) {
delete process.env.OPENCODE_CONFIG_CONTENT
} else {
process.env.OPENCODE_CONFIG_CONTENT = originalConfigContent
}
if (originalCliRunMode === undefined) {
delete process.env.OPENCODE_CLI_RUN_MODE
} else {
process.env.OPENCODE_CLI_RUN_MODE = originalCliRunMode
}
})
describe("#when config explicitly denies question permission", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s even without CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "deny" },
})
delete process.env.OPENCODE_CLI_RUN_MODE
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
describe("#when config does not deny question permission", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should allow question for %s in interactive mode",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "allow" },
})
delete process.env.OPENCODE_CLI_RUN_MODE
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("allow")
},
)
})
describe("#when CLI_RUN_MODE is true and config does not deny", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s via CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: {},
})
process.env.OPENCODE_CLI_RUN_MODE = "true"
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
describe("#when config deny overrides CLI_RUN_MODE allow", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s when config says deny regardless of CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "deny" },
})
process.env.OPENCODE_CLI_RUN_MODE = "false"
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
})
describe("#given task_system is disabled", () => {
describe("#when applying tool config", () => {
it.each([

View File

@@ -3,17 +3,6 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
type AgentWithPermission = { permission?: Record<string, unknown> };
function getConfigQuestionPermission(): string | null {
const configContent = process.env.OPENCODE_CONFIG_CONTENT;
if (!configContent) return null;
try {
const parsed = JSON.parse(configContent);
return parsed?.permission?.question ?? null;
} catch {
return null;
}
}
function agentByKey(agentResult: Record<string, unknown>, key: string): AgentWithPermission | undefined {
return (agentResult[key] ?? agentResult[getAgentDisplayName(key)]) as
| AgentWithPermission
@@ -43,11 +32,7 @@ export function applyToolConfig(params: {
};
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true";
const configQuestionPermission = getConfigQuestionPermission();
const questionPermission =
configQuestionPermission === "deny" ? "deny" :
isCliRunMode ? "deny" :
"allow";
const questionPermission = isCliRunMode ? "deny" : "allow";
const librarian = agentByKey(params.agentResult, "librarian");
if (librarian) {

View File

@@ -6,7 +6,7 @@ import { _resetForTesting, setMainSession } from "../features/claude-code-sessio
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
describe("createEventHandler - model fallback", () => {
const createHandler = (args?: { hooks?: any }) => {
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
const abortCalls: string[] = []
const promptCalls: string[] = []
@@ -26,7 +26,7 @@ describe("createEventHandler - model fallback", () => {
},
},
} as any,
pluginConfig: {} as any,
pluginConfig: (args?.pluginConfig ?? {}) as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
@@ -213,6 +213,121 @@ describe("createEventHandler - model fallback", () => {
expect(output.message["variant"]).toBe("max")
})
test("deduplicates session.status countdown updates for the same retry attempt", async () => {
//#given
const sessionID = "ses_status_retry_dedup"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_dedup",
sessionID,
role: "user",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}} [retrying in 27s attempt #1]",
next: 27,
},
},
},
})
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}} [retrying in 26s attempt #1]",
next: 26,
},
},
},
})
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
})
test("does not trigger model fallback from session.status when runtime fallback is enabled", async () => {
//#given
const sessionID = "ses_status_retry_runtime_enabled"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const runtimeFallback = {
event: async () => {},
"chat.message": async () => {},
}
const { handler, abortCalls, promptCalls } = createHandler({
hooks: { modelFallback, runtimeFallback },
pluginConfig: { runtime_fallback: { enabled: true } },
})
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_runtime_enabled",
sessionID,
role: "user",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}} [retrying in 27s attempt #1]",
next: 27,
},
},
},
})
//#then
expect(abortCalls).toEqual([])
expect(promptCalls).toEqual([])
})
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
//#given
const abortCalls: string[] = []

View File

@@ -18,6 +18,7 @@ import {
import { resetMessageCursor } from "../shared";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
@@ -342,10 +343,15 @@ export function createEventHandler(args: {
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
if (sessionID && status?.type === "retry" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
try {
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage);
const retryModel =
extractRetryStatusModel(retryMessage) ??
lastKnownModelBySession.get(sessionID)?.modelID ??
"unknown-model";
const retryKey = `${retryAttempt}:${retryModel}:${normalizeRetryStatusMessage(retryMessage)}`;
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return;
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "./retry-status-utils"
describe("retry-status-utils", () => {
test("extracts retry attempt from explicit status attempt", () => {
//#given
const attempt = 6
//#when
const result = extractRetryAttempt(attempt, "The usage limit has been reached [retrying in 27s attempt #6]")
//#then
expect(result).toBe(6)
})
test("extracts retry model from cooldown status text", () => {
//#given
const message = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]"
//#when
const result = extractRetryStatusModel(message)
//#then
expect(result).toBe("claude-opus-4-6")
})
test("normalizes countdown jitter to a stable cooldown class", () => {
//#given
const firstMessage = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]"
const secondMessage = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]"
//#when
const firstResult = normalizeRetryStatusMessage(firstMessage)
const secondResult = normalizeRetryStatusMessage(secondMessage)
//#then
expect(firstResult).toBe("cooldown")
expect(secondResult).toBe("cooldown")
})
})

View File

@@ -0,0 +1,51 @@
const RETRY_COUNTDOWN_PATTERN = /\[\s*retrying\s+in[^\]]*\]/gi
function collapseWhitespace(value: string): string {
return value.toLowerCase().replace(/\s+/g, " ").trim()
}
export function extractRetryAttempt(attempt: number | undefined, message: string): number | "?" {
if (typeof attempt === "number" && Number.isFinite(attempt)) {
return attempt
}
const parsedAttempt = message.match(/attempt\s*#\s*(\d+)/i)?.[1]
return parsedAttempt ? Number.parseInt(parsedAttempt, 10) : "?"
}
export function extractRetryStatusModel(message: string): string | undefined {
return message.match(/model\s+([a-z0-9._/-]+)(?=\s+(?:are|is)\b)/i)?.[1]?.toLowerCase()
}
export function normalizeRetryStatusMessage(message: string): string {
const normalizedMessage = collapseWhitespace(message.replace(RETRY_COUNTDOWN_PATTERN, " "))
if (!normalizedMessage) {
return "retry"
}
if (/all\s+credentials\s+for\s+model|cool(?:ing)?\s+down|cooldown|exhausted\s+your\s+capacity/.test(normalizedMessage)) {
return "cooldown"
}
if (/too\s+many\s+requests/.test(normalizedMessage)) {
return "too-many-requests"
}
if (/quota\s+will\s+reset\s+after|quota\s*exceeded/.test(normalizedMessage)) {
return "quota"
}
if (/usage\s+limit\s+has\s+been\s+reached|limit\s+reached/.test(normalizedMessage)) {
return "usage-limit"
}
if (/rate\s+limit/.test(normalizedMessage)) {
return "rate-limit"
}
if (/service.?unavailable|temporarily.?unavailable|overloaded/.test(normalizedMessage)) {
return "service-unavailable"
}
return normalizedMessage
}