Merge remote-tracking branch 'origin/dev' into fix/cli-run-premature-exit-with-background-tasks

This commit is contained in:
柯杨
2026-04-22 09:45:56 +08:00
390 changed files with 49705 additions and 41270 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/ — 52 Lifecycle Hooks
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
@@ -84,7 +84,7 @@ describe("executeCompact lock management", () => {
let pluginConfig: ReturnType<typeof OhMyOpenCodeConfigSchema.parse>
const sessionID = "test-session-123"
const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-6" }
const msg = { providerID: "anthropic", modelID: "claude-opus-4-7" }
beforeEach(() => {
// given: Fresh state for each test
@@ -132,7 +132,7 @@ describe("executeCompact lock management", () => {
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
body: { providerID: "anthropic", modelID: "claude-opus-4-7", auto: true },
}),
)
@@ -157,7 +157,7 @@ describe("executeCompact lock management", () => {
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
body: { providerID: "anthropic", modelID: "claude-opus-4-7", auto: true },
}),
)
@@ -352,7 +352,7 @@ describe("executeCompact lock management", () => {
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
body: { providerID: "anthropic", modelID: "claude-opus-4-7", auto: true },
}),
)
+26 -7
View File
@@ -1,4 +1,4 @@
import { log, normalizeModelID } from "../../shared"
import { isProviderUsingOAuth, log, normalizeModelID } from "../../shared"
const OPUS_PATTERN = /claude-.*opus/i
const EFFORT_UNSUPPORTED_PATTERN = /claude-.*haiku/i
@@ -25,6 +25,18 @@ function shouldSkipForInternalAgent(agentName: string | undefined): boolean {
return INTERNAL_SKIP_AGENTS.has(agentName.trim().toLowerCase())
}
/**
* Providers that expose constrained APIs rejecting `output_config.effort: "max"`
* (supported values: low | medium | high). Includes:
* - Anthropic OAuth (Claude Pro/Max via third-party clients)
* - GitHub Copilot (proxied Anthropic, doesn't support "max")
*/
function isConstrainedProvider(providerID: string): boolean {
if (providerID === "github-copilot") return true
if (providerID === "anthropic") return isProviderUsingOAuth(providerID)
return false
}
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
@@ -49,8 +61,9 @@ const MAX_VARIANT_BY_TIER: Record<string, string> = {
default: "high",
}
function clampVariant(variant: string, isOpus: boolean): string {
function clampVariant(variant: string, isOpus: boolean, isConstrained: boolean): string {
if (variant !== "max") return variant
if (isConstrained) return MAX_VARIANT_BY_TIER.default
return isOpus ? MAX_VARIANT_BY_TIER.opus : MAX_VARIANT_BY_TIER.default
}
@@ -65,21 +78,27 @@ export function createAnthropicEffortHook() {
if (isEffortUnsupportedModel(model.modelID)) return
if (message.variant !== "max") return
if (!isClaudeProvider(model.providerID, model.modelID)) return
if (model.providerID === "github-copilot") return
if (shouldSkipForInternalAgent(agent?.name)) return
if (output.options.effort !== undefined) return
const opus = isOpusModel(model.modelID)
const clamped = clampVariant(message.variant, opus)
const constrained = isConstrainedProvider(model.providerID)
const clamped = clampVariant(message.variant, opus, constrained)
output.options.effort = clamped
if (!opus) {
// Override the variant so OpenCode doesn't pass "max" to the API
const shouldOverrideMessageVariant = !opus || constrained
if (shouldOverrideMessageVariant) {
// Override the variant so OpenCode doesn't pass "max" to the API.
// Non-Opus models cap at high; Anthropic OAuth (Claude Pro/Max) also
// caps at high even on Opus because the OAuth API only accepts
// low | medium | high.
;(message as { variant?: string }).variant = clamped
log("anthropic-effort: clamped variant max→high for non-Opus model", {
log("anthropic-effort: clamped variant max→high", {
sessionID: input.sessionID,
provider: model.providerID,
model: model.modelID,
reason: constrained ? "constrained-provider" : "non-opus",
})
} else {
log("anthropic-effort: injected effort=max", {
+116 -7
View File
@@ -1,4 +1,9 @@
import { describe, expect, it } from "bun:test"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import * as path from "node:path"
import { _resetProviderAuthCacheForTesting } from "../../shared/opencode-provider-auth"
import { createAnthropicEffortHook } from "./index"
interface ChatParamsInput {
@@ -24,7 +29,7 @@ function createMockParams(overrides: {
existingOptions?: Record<string, unknown>
}): { input: ChatParamsInput; output: ChatParamsOutput } {
const providerID = overrides.providerID ?? "anthropic"
const modelID = overrides.modelID ?? "claude-opus-4-6"
const modelID = overrides.modelID ?? "claude-opus-4-7"
const variant = "variant" in overrides ? overrides.variant : "max"
const agentName = overrides.agentName ?? "sisyphus"
const existingOptions = overrides.existingOptions ?? {}
@@ -66,7 +71,7 @@ describe("createAnthropicEffortHook", () => {
it("injects effort max for dotted opus ids", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
const { input, output } = createMockParams({ modelID: "claude-opus-4.7" })
await hook["chat.params"](input, output)
@@ -148,20 +153,36 @@ describe("createAnthropicEffortHook", () => {
expect(output.options.effort).toBeUndefined()
})
it("#given github-copilot + claude model #then effort NOT injected", async () => {
it("#given github-copilot + claude opus model #then effort clamped to high (constrained provider)", async () => {
// given
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-opus-4-6",
modelID: "claude-opus-4-7",
})
// when
await hook["chat.params"](input, output)
// then — github-copilot is a constrained provider, clamps max→high
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
it("#given github-copilot + claude sonnet model #then effort clamped to high", async () => {
// given
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-sonnet-4-6",
})
// when
await hook["chat.params"](input, output)
// then
expect(output.options.effort).toBeUndefined()
expect(input.message.variant).toBe("max")
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
describe("#given haiku models (effort unsupported)", () => {
@@ -199,4 +220,92 @@ describe("createAnthropicEffortHook", () => {
expect(output.options.effort).toBe("high")
})
})
describe("#given anthropic OAuth auth (Claude Pro/Max) — regression for #3429", () => {
let tempDataDir: string
const originalXdgDataHome = process.env.XDG_DATA_HOME
function writeAuthFile(providerEntries: Record<string, Record<string, unknown>>): void {
const opencodeDir = path.join(tempDataDir, "opencode")
mkdirSync(opencodeDir, { recursive: true })
writeFileSync(path.join(opencodeDir, "auth.json"), JSON.stringify(providerEntries), "utf-8")
_resetProviderAuthCacheForTesting()
}
beforeAll(() => {
tempDataDir = path.join(tmpdir(), `anthropic-effort-oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`)
mkdirSync(tempDataDir, { recursive: true })
process.env.XDG_DATA_HOME = tempDataDir
})
afterAll(() => {
if (originalXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = originalXdgDataHome
}
rmSync(tempDataDir, { recursive: true, force: true })
_resetProviderAuthCacheForTesting()
})
afterEach(() => {
_resetProviderAuthCacheForTesting()
})
it("clamps opus-4-6 + max to high when anthropic provider uses oauth", async () => {
// given an Anthropic OAuth session and variant=max on an Opus model
writeAuthFile({ anthropic: { type: "oauth" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-7" })
// when chat.params fires
await hook["chat.params"](input, output)
// then effort must be clamped to high so Anthropic's OAuth API accepts it
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
it("clamps dotted opus id + max to high under OAuth", async () => {
// given an Anthropic OAuth session and a dotted opus id
writeAuthFile({ anthropic: { type: "oauth" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4.7" })
// when chat.params fires
await hook["chat.params"](input, output)
// then effort must be clamped to high
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
it("still injects effort=max when anthropic auth is an API key", async () => {
// given an Anthropic API-key session (not OAuth)
writeAuthFile({ anthropic: { type: "api", key: "sk-ant-xxx" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-7" })
// when chat.params fires
await hook["chat.params"](input, output)
// then API-key users keep the original max behaviour for Opus
expect(output.options.effort).toBe("max")
expect(input.message.variant).toBe("max")
})
it("does not clamp when OAuth belongs to a different provider", async () => {
// given OAuth entries for unrelated providers only
writeAuthFile({ "github-copilot": { type: "oauth" }, opencode: { type: "api", key: "sk-x" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-7", providerID: "anthropic" })
// when chat.params fires for the anthropic provider
await hook["chat.params"](input, output)
// then max stays because anthropic itself is not OAuth
expect(output.options.effort).toBe("max")
expect(input.message.variant).toBe("max")
})
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
@@ -58,7 +58,7 @@ describe("atlas hook compaction agent filtering", () => {
join(messageDir, fileName),
JSON.stringify({
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
@@ -91,7 +91,7 @@ describe("Atlas final-wave approval gate regressions", () => {
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
@@ -99,7 +99,7 @@ describe("Atlas final verification approval gate", () => {
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
+3 -2
View File
@@ -79,7 +79,7 @@ describe("atlas hook", () => {
}
const messageData = {
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
}
@@ -957,7 +957,8 @@ session_id: ses_untrusted_999
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined()
expect(output.output).not.toContain('task(session_id="ses_untrusted_999"')
expect(output.output).toContain('task(session_id="<session_id>"')
expect(output.output).not.toContain('task(task_id="ses_untrusted_999"')
expect(output.output).toContain('task(task_id="<session_id>"')
cleanupMessageStorage(sessionID)
})
@@ -90,6 +90,17 @@ describe("extractSessionIdFromMetadata", () => {
expect(result).toBe("ses_plugin_abc123")
})
test("extracts legacy session aliases from tool metadata object", () => {
// given
const metadata = { sessionID: "ses_plugin_alias_123" }
// when
const result = extractSessionIdFromMetadata(metadata)
// then
expect(result).toBe("ses_plugin_alias_123")
})
test("returns undefined for metadata without sessionId", () => {
// given
const metadata = { title: "some task" }
+6 -16
View File
@@ -1,30 +1,20 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { extractTaskLink } from "../../features/tool-metadata-store"
import { log } from "../../shared/logger"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { HOOK_NAME } from "./hook-name"
export function extractSessionIdFromMetadata(metadata: unknown): string | undefined {
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
const value = (metadata as Record<string, unknown>).sessionId
if (typeof value === "string" && value.startsWith("ses_")) {
return value
}
const sessionId = extractTaskLink(metadata, "").sessionId
if (typeof sessionId === "string" && sessionId.startsWith("ses_")) {
return sessionId
}
return undefined
}
export function extractSessionIdFromOutput(output: string): string | undefined {
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1]
if (lastTaskMetadataBlock) {
const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i)
if (taskMetadataSessionMatch) {
return taskMetadataSessionMatch[1]
}
}
const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
return explicitSessionMatches.at(-1)?.[1]
return extractTaskLink(undefined, output).sessionId
}
export async function validateSubagentSessionId(input: {
+2 -2
View File
@@ -29,7 +29,7 @@ Your completion will NOT be recorded until you complete ALL of the following:
If anything fails while closing this out, resume the same session immediately:
\`\`\`typescript
task(session_id="${sessionId}", load_skills=[], prompt="fix: checkbox not recorded correctly")
task(task_id="${sessionId}", load_skills=[], prompt="fix: checkbox not recorded correctly")
\`\`\`
**Your completion is NOT tracked until the checkbox is marked in the plan file.**
@@ -47,7 +47,7 @@ ${VERIFICATION_REMINDER}
**If ANY verification fails, use this immediately:**
\`\`\`
task(session_id="${sessionId}", load_skills=[], prompt="fix: [describe the specific failure]")
task(task_id="${sessionId}", load_skills=[], prompt="fix: [describe the specific failure]")
\`\`\`
${buildReuseHint(sessionId)}`
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import { executeSlashCommand } from "./executor"
const ENV_KEYS = [
@@ -95,6 +96,7 @@ describe("auto-slash command executor plugin dispatch", () => {
let envSnapshot: EnvSnapshot
beforeEach(() => {
clearCommandLoaderCache()
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
@@ -106,6 +108,7 @@ describe("auto-slash command executor plugin dispatch", () => {
})
afterEach(() => {
clearCommandLoaderCache()
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
@@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, afterEach, spyOn, mock } from "bun:te
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import type {
AutoSlashCommandHookInput,
@@ -43,6 +44,7 @@ describe("createAutoSlashCommandHook", () => {
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
beforeEach(async () => {
clearCommandLoaderCache()
mock.restore()
logCalls = []
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
@@ -56,6 +58,7 @@ describe("createAutoSlashCommandHook", () => {
})
afterEach(() => {
clearCommandLoaderCache()
process.chdir(originalWorkingDirectory)
rmSync(tempDir, { recursive: true, force: true })
mock.restore()
+261
View File
@@ -0,0 +1,261 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { describe, expect, mock, test } from "bun:test"
type CreateAutoUpdateCheckerHook = typeof import("./hook").createAutoUpdateCheckerHook
type HookOptions = Parameters<CreateAutoUpdateCheckerHook>[1]
type HookDeps = NonNullable<Parameters<CreateAutoUpdateCheckerHook>[2]>
let latestVersionCallCount = 0
let scheduleDeferredStartupCheckCallCount = 0
const flushMicrotasks = async (count: number): Promise<void> => {
for (let index = 0; index < count; index += 1) {
await Promise.resolve()
}
}
const latestVersionMock = async () => {
latestVersionCallCount += 1
return "3.0.1"
}
const scheduleDeferredStartupCheckMock = (runCheck: () => void) => {
scheduleDeferredStartupCheckCallCount += 1
scheduledCheck = runCheck
}
let scheduledCheck: (() => void) | null = null
mock.module("./checker/latest-version", () => ({
getLatestVersion: latestVersionMock,
}))
mock.module("./hook/deferred-startup-check", () => ({
scheduleDeferredStartupCheck: scheduleDeferredStartupCheckMock,
}))
const createPluginInput = (): PluginInput => ({
client: {} as PluginInput["client"],
directory: "/tmp/project",
project: {} as PluginInput["project"],
worktree: "/tmp/project",
serverUrl: new URL("https://example.com"),
$: {} as PluginInput["$"],
} satisfies PluginInput)
const createDeps = (overrides: Partial<HookDeps> = {}) => {
const showConfigErrorsIfAny = mock(async () => undefined)
const updateAndShowConnectedProvidersCacheStatus = mock(async () => undefined)
const refreshModelCapabilitiesOnStartup = mock(async () => undefined)
const showModelCacheWarningIfNeeded = mock(async () => undefined)
const showLocalDevToast = mock(async () => undefined)
const showVersionToast = mock(async () => undefined)
const runBackgroundUpdateCheck = mock(async () => {
await latestVersionMock()
})
const deps: HookDeps = {
getCachedVersion: () => "3.0.0",
getLocalDevVersion: () => null,
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
log: () => undefined,
...overrides,
}
return {
deps,
mocks: {
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
},
}
}
const createHook = async (
options: HookOptions = {},
overrides: Partial<HookDeps> = {},
) => {
const module = await import("./hook")
const { deps, mocks } = createDeps(overrides)
return {
hook: module.createAutoUpdateCheckerHook(
createPluginInput(),
{
showStartupToast: true,
autoUpdate: false,
...options,
},
deps,
),
mocks,
}
}
const resetDeferredState = (): void => {
latestVersionCallCount = 0
scheduleDeferredStartupCheckCallCount = 0
scheduledCheck = null
}
const runScheduledCheck = async (): Promise<void> => {
scheduledCheck?.()
await flushMicrotasks(8)
}
const triggerSessionCreated = (
hook: ReturnType<CreateAutoUpdateCheckerHook>,
properties?: { info?: { parentID?: string } },
): void => {
hook.event({ event: { type: "session.created", properties } })
}
const triggerSessionIdle = (hook: ReturnType<CreateAutoUpdateCheckerHook>): void => {
hook.event({ event: { type: "session.idle" } })
}
describe("auto-update-checker hook", () => {
test("schedules deferred check on session.created without parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
// when
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
expect(latestVersionCallCount).toBe(1)
})
test("does not schedule deferred check on session.created with parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook, { info: { parentID: "parent-123" } })
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("does not schedule deferred check on session.idle without session.created", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionIdle(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("runs all startup checks after deferred session.created check executes", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.refreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("guards double execution across repeated session.created events", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
// when
await runScheduledCheck()
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("shows localDevToast when local dev version exists", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({}, {
getLocalDevVersion: () => "3.0.0-dev",
})
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showLocalDevToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
})
test("passes correct toast message with sisyphus enabled", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({ isSisyphusEnabled: true })
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledWith(
expect.anything(),
"3.0.0",
expect.stringContaining("Sisyphus"),
)
})
})
+42 -25
View File
@@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import type { AutoUpdateCheckerOptions } from "./types"
import { getCachedVersion, getLocalDevVersion } from "./checker"
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
import { scheduleDeferredStartupCheck } from "./hook/deferred-startup-check"
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
@@ -35,6 +36,20 @@ const defaultDeps: AutoUpdateCheckerDeps = {
log,
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null
}
const getParentID = (properties: unknown): string | undefined => {
if (!isRecord(properties)) return undefined
const { info } = properties
if (!isRecord(info)) return undefined
const { parentID } = info
return typeof parentID === "string" && parentID.length > 0 ? parentID : undefined
}
export function createAutoUpdateCheckerHook(
ctx: PluginInput,
options: AutoUpdateCheckerOptions = {},
@@ -60,44 +75,46 @@ export function createAutoUpdateCheckerHook(
}
let hasChecked = false
let hasScheduled = false
return {
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.created") return
if (isCliRunMode) return
if (hasChecked) return
if (hasChecked || hasScheduled) return
if (getParentID(event.properties)) return
const props = event.properties as { info?: { parentID?: string } } | undefined
if (props?.info?.parentID) return
hasScheduled = true
scheduleDeferredStartupCheck(() => {
hasChecked = true
void (async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
setTimeout(async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
}
deps.log("[auto-update-checker] Local development mode")
return
}
deps.log("[auto-update-checker] Local development mode")
return
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
}, 0)
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
})()
})
},
}
}
@@ -0,0 +1,4 @@
export function scheduleDeferredStartupCheck(runCheck: () => void): void {
const timeout = setTimeout(runCheck, 5000)
timeout.unref?.()
}
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
+62
View File
@@ -0,0 +1,62 @@
# src/hooks/comment-checker/ — AI Slop Comment Blocker
**Generated:** 2026-04-18
## OVERVIEW
Tool Guard tier hook. Runs after `write`/`edit` tools to detect AI-generated comment patterns in code and block them before they land. Backed by `@code-yeongyu/comment-checker` binary (trusted dependency).
## WHAT IT BLOCKS
AI slop comment smells:
- Restating what code literally does (`// increment counter`)
- Filler phrases (`// obviously`, `// clearly`, `// simply`)
- Decorative separators without purpose
- JSDoc on trivially-named functions
- `// TODO:` without context
- Comments contradicting surrounding code
See `@code-yeongyu/comment-checker` for the authoritative blocklist.
## EXECUTION FLOW
```
tool.execute.after (write | edit | hashline edit)
→ extract changed lines from tool output
→ spawn comment-checker binary with changed file path
→ parse findings (line ranges + violation category)
→ if findings → inject tool-level error → agent must fix
```
## KEY FILES
| File | Purpose |
|------|---------|
| `hook.ts` | `createCommentCheckerHook()` — main factory, tool.execute.after handler |
| `comment-checker-runner.ts` | Spawn binary, parse JSON output |
| `changed-line-extractor.ts` | Extract which lines changed from tool result |
| `findings-formatter.ts` | Format violations as actionable error message |
| `binary-resolver.ts` | Locate `comment-checker` binary (node_modules + PATH) |
## CONFIG
```jsonc
// oh-my-opencode.jsonc
{
"comment_checker": {
"enabled": true, // default: true
"severity": "error" // error blocks, warning notifies only
}
}
```
Disable via `"disabled_hooks": ["comment-checker"]`.
## BYPASS FOR LEGITIMATE COMMENTS
Prefix with `// @allow` or mark file scope with `// comment-checker-disable-file` at top. Use sparingly — defeating the purpose.
## RELATED
- Doctor check: `src/cli/doctor/checks/tools.ts` verifies `comment-checker` binary availability
- Postinstall: `postinstall.mjs` downloads binary if missing
@@ -0,0 +1,51 @@
import { describe, expect, it, mock, afterAll } from "bun:test"
const startPendingCallCleanup = mock(() => {})
const initializeCommentCheckerCli = mock(() => {})
mock.module("./cli-runner", () => ({
initializeCommentCheckerCli,
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
isCliPathUsable: () => true,
processWithCli: async () => {},
processApplyPatchEditsWithCli: async () => {},
}))
mock.module("./pending-calls", () => ({
registerPendingCall: () => {},
startPendingCallCleanup,
stopPendingCallCleanup: () => {},
takePendingCall: () => undefined,
}))
afterAll(() => {
mock.restore()
})
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker lazy initialization", () => {
it("initializes CLI and cleanup on first tool hook call only", async () => {
// given
const hooks = createCommentCheckerHooks()
const beforeHook = hooks["tool.execute.before"]
const input = { tool: "write", sessionID: "ses_test", callID: "call_test" }
const output = { args: { filePath: "src/a.ts" } }
// when
expect(startPendingCallCleanup).toHaveBeenCalledTimes(0)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(0)
// then
await beforeHook(input, output)
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
// when
await beforeHook(input, output)
// then
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
})
})
+6 -3
View File
@@ -28,6 +28,7 @@ import {
stopPendingCallCleanup,
takePendingCall,
} from "./pending-calls"
import { ensureCommentCheckerInitialization } from "./initialization-gate"
import * as fs from "fs"
import { tmpdir } from "os"
@@ -48,14 +49,16 @@ function debugLog(...args: unknown[]) {
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
debugLog("createCommentCheckerHooks called", { config })
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
): Promise<void> => {
ensureCommentCheckerInitialization(() => {
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
})
debugLog("tool.execute.before:", {
tool: input.tool,
callID: input.callID,
@@ -0,0 +1,7 @@
let initialized = false
export function ensureCommentCheckerInitialization(initializer: () => void): void {
if (initialized) return
initialized = true
initializer()
}
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { constants, promises as fsPromises } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
@@ -9,10 +9,10 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findAgentsMdUp(input: {
export async function findAgentsMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
@@ -22,7 +22,11 @@ export function findAgentsMdUp(input: {
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
const exists = await fsPromises
.access(agentsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
found.push(agentsPath);
}
}
@@ -84,6 +84,23 @@ describe("processFilePathForAgentsInjection", () => {
expect(output.output).toContain(srcAgentsContent)
})
it("finds AGENTS.md files while walking up directories", async () => {
// given
const { findAgentsMdUp } = await import("./finder")
// when
const agentsPaths = await findAgentsMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(agentsPaths).toEqual([
join(srcDirectory, "AGENTS.md"),
join(componentsDirectory, "AGENTS.md"),
])
})
it("skips root-level AGENTS.md", async () => {
// given
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -31,7 +31,7 @@ export async function processFilePathForAgentsInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const agentsPaths = findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const agentsPath of agentsPaths) {
@@ -39,7 +39,8 @@ export async function processFilePathForAgentsInjection(input: {
if (cache.has(agentsDir)) continue;
try {
const content = readFileSync(agentsPath, "utf-8");
const content = await fsPromises.readFile(agentsPath, "utf-8");
cache.add(agentsDir);
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
@@ -48,7 +49,6 @@ export async function processFilePathForAgentsInjection(input: {
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
cache.add(agentsDir);
dirty = true;
} catch {}
}
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { access } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { README_FILENAME } from "./constants";
@@ -9,17 +9,19 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findReadmeMdUp(input: {
export async function findReadmeMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
while (true) {
const readmePath = join(current, README_FILENAME);
if (existsSync(readmePath)) {
try {
await access(readmePath);
found.push(readmePath);
} catch {
}
if (current === input.rootDir) break;
@@ -133,6 +133,32 @@ describe("processFilePathForReadmeInjection", () => {
expect(output.output).toContain("# Components README")
})
it("returns a promise and finds README.md files from temp fixtures", async () => {
// given
const sourceDirectory = join(testRoot, "src")
const componentsDirectory = join(sourceDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "README.md"), "# Root README")
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
const { findReadmeMdUp } = await import("./finder")
// when
const promise = findReadmeMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(promise).toBeInstanceOf(Promise)
await expect(promise).resolves.toEqual([
join(testRoot, "README.md"),
join(sourceDirectory, "README.md"),
join(componentsDirectory, "README.md"),
])
})
it("does not re-inject already cached directories", async () => {
// given
const sourceDirectory = join(testRoot, "src")
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -31,7 +31,7 @@ export async function processFilePathForReadmeInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const readmePaths = findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
const readmePaths = await findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const readmePath of readmePaths) {
@@ -39,7 +39,7 @@ export async function processFilePathForReadmeInjection(input: {
if (cache.has(readmeDir)) continue;
try {
const content = readFileSync(readmePath, "utf-8");
const content = await readFile(readmePath, "utf-8");
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
+7 -1
View File
@@ -14,7 +14,13 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
export { createThinkModeHook } from "./think-mode";
export { createModelFallbackHook, setPendingModelFallback, clearPendingModelFallback, type ModelFallbackState } from "./model-fallback/hook";
export {
createModelFallbackHook,
setPendingModelFallback,
clearPendingModelFallback,
type ModelFallbackHook,
type ModelFallbackState,
} from "./model-fallback/hook";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
@@ -115,15 +115,15 @@ task(subagent_type="plan", load_skills=[], run_in_background=false, prompt="<gat
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
**Plan agent returns a session_id. USE IT for follow-up interactions.**
**Plan agent returns a task_id. USE IT for follow-up interactions.**
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="Add more detail to Task N")\` |
| Plan agent asks clarifying questions | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="<your answer>")\` |
| Need to refine the plan | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="Add more detail to Task N")\` |
**WHY SESSION_ID IS CRITICAL:**
**WHY TASK_ID IS CRITICAL:**
- Plan agent retains FULL conversation context
- No repeated exploration or context gathering
- Saves 70%+ tokens on follow-ups
@@ -134,7 +134,7 @@ task(subagent_type="plan", load_skills=[], run_in_background=false, prompt="<gat
task(subagent_type="plan", load_skills=[], run_in_background=false, prompt="Here's more info...")
// CORRECT: Resume preserves everything
task(session_id="ses_abc123", load_skills=[], run_in_background=false, prompt="Here's my answer to your question: ...")
task(task_id="ses_abc123", load_skills=[], run_in_background=false, prompt="Here's my answer to your question: ...")
\`\`\`
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
@@ -161,13 +161,13 @@ task(subagent_type="plan", load_skills=[], run_in_background=false, prompt="<gat
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
**Plan agent returns a session_id. USE IT for follow-up interactions.**
**Plan agent returns a task_id. USE IT for follow-up interactions.**
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], run_in_background=false, prompt="Add more detail to Task N")\` |
| Plan agent asks clarifying questions | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="<your answer>")\` |
| Need to refine the plan | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(task_id="{returned_task_id}", load_skills=[], run_in_background=false, prompt="Add more detail to Task N")\` |
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
@@ -0,0 +1,30 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { ModelFallbackStateController } from "./fallback-state-controller"
export type ModelFallbackControllerAccessor = {
register: (controller: ModelFallbackStateController) => void
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
}
export function createModelFallbackControllerAccessor(): ModelFallbackControllerAccessor {
let controller: ModelFallbackStateController | null = null
function register(nextController: ModelFallbackStateController): void {
controller = nextController
}
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
controller?.setSessionFallbackChain(sessionID, fallbackChain)
}
function clearSessionFallbackChain(sessionID: string): void {
controller?.clearSessionFallbackChain(sessionID)
}
return {
register,
setSessionFallbackChain,
clearSessionFallbackChain,
}
}
@@ -0,0 +1,134 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared/logger"
import { getNextReachableFallback } from "./next-fallback"
type ModelFallbackStateLike = {
providerID: string
modelID: string
fallbackChain: FallbackEntry[]
attemptCount: number
pending: boolean
}
export type ModelFallbackStateController = {
lastToastKey: Map<string, string>
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
setPendingModelFallback: (
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
) => boolean
getNextFallback: (sessionID: string) => ReturnType<typeof getNextReachableFallback>
clearPendingModelFallback: (sessionID: string) => void
hasPendingModelFallback: (sessionID: string) => boolean
getFallbackState: (sessionID: string) => ModelFallbackStateLike | undefined
reset: () => void
}
export function createModelFallbackStateController(input: {
pendingModelFallbacks: Map<string, ModelFallbackStateLike>
lastToastKey: Map<string, string>
sessionFallbackChains: Map<string, FallbackEntry[]>
}): ModelFallbackStateController {
const { pendingModelFallbacks, lastToastKey, sessionFallbackChains } = input
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
sessionFallbackChains.set(sessionID, fallbackChain?.length ? fallbackChain : [])
}
function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
}
function setPendingModelFallback(
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
if (!fallbackChain?.length) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (!existing) {
pendingModelFallbacks.set(sessionID, {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
})
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
}
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
function getNextFallback(sessionID: string): ReturnType<typeof getNextReachableFallback> {
const state = pendingModelFallbacks.get(sessionID)
if (!state?.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) return fallback
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
}
function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
}
function hasPendingModelFallback(sessionID: string): boolean {
return pendingModelFallbacks.get(sessionID)?.pending === true
}
function getFallbackState(sessionID: string): ModelFallbackStateLike | undefined {
return pendingModelFallbacks.get(sessionID)
}
function reset(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
}
return {
lastToastKey,
setSessionFallbackChain,
clearSessionFallbackChain,
setPendingModelFallback,
getNextFallback,
clearPendingModelFallback,
hasPendingModelFallback,
getFallbackState,
reset,
}
}
+61 -50
View File
@@ -24,7 +24,7 @@ const selectFallbackProviderMock = mock((providers: string[], preferredProviderI
const transformModelForProviderMock = mock((provider: string, model: string) => {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-opus-4-7", "claude-opus-4.7")
.replace("claude-sonnet-4-6", "claude-sonnet-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
@@ -70,22 +70,23 @@ const {
setPendingModelFallback,
} = await importFreshModelFallbackHookModule()
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
describe("model fallback hook", () => {
let modelFallback: ModelFallbackHook
beforeEach(() => {
modelFallback = createModelFallbackHook()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockClear()
readProviderModelsCacheMock.mockClear()
selectFallbackProviderMock.mockClear()
clearPendingModelFallback("ses_model_fallback_main")
clearPendingModelFallback("ses_model_fallback_ghcp")
clearPendingModelFallback("ses_model_fallback_google")
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -93,16 +94,17 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
modelFallback,
"ses_model_fallback_main",
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-6-thinking",
"claude-opus-4-7-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
@@ -117,13 +119,13 @@ describe("model fallback hook", () => {
//#then
expect(output.message["model"]).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
modelID: "claude-opus-4-7",
})
})
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -132,12 +134,12 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_main"
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-6-thinking"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
).toBe(true)
const firstOutput = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
@@ -149,17 +151,17 @@ describe("model fallback hook", () => {
//#then
expect(firstOutput.message["model"]).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
modelID: "claude-opus-4-7",
})
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-6"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
).toBe(true)
const secondOutput = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
},
parts: [{ type: "text", text: "continue" }],
}
@@ -176,57 +178,60 @@ describe("model fallback hook", () => {
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
//#when
const firstSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-6-thinking",
"claude-opus-4-7-thinking",
)
const secondSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-6-thinking",
"claude-opus-4-7-thinking",
)
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-6" },
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-6",
"claude-opus-4-7",
),
).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
},
parts: [{ type: "text", text: "continue" }],
}
@@ -239,38 +244,39 @@ describe("model fallback hook", () => {
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-6", variant: "max" },
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"quotio",
"claude-opus-4-6",
"claude-opus-4-7",
),
).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
model: { providerID: "quotio", modelID: "claude-opus-4-7" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
//#given
const sessionID = "ses_model_fallback_preferred_provider"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["provider-y"], model: "fallback-model" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"provider-x",
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
providerID: "provider-x",
modelID: "fallback-model",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
//#given
const sessionID = "ses_model_fallback_explicit_none"
clearPendingModelFallback(sessionID)
setSessionFallbackChain(sessionID, undefined)
clearPendingModelFallback(modelFallback, sessionID)
setSessionFallbackChain(modelFallback, sessionID, undefined)
//#when
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Junior",
"anthropic",
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
//#then
expect(set).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("shows toast when fallback is applied", async () => {
@@ -366,16 +374,17 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
hook,
"ses_model_fallback_toast",
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-6-thinking",
"claude-opus-4-7-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
test("transforms model names for github-copilot provider via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_ghcp"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -402,11 +411,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through github-copilot
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Atlas - Plan Executor",
"github-copilot",
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
modelID: "claude-sonnet-4.6",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("preserves canonical google preview model names via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_google"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -446,11 +456,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through google
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Oracle",
"google",
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
modelID: "gemini-3.1-pro-preview",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
})
+91 -103
View File
@@ -1,13 +1,11 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
import { selectFallbackProvider } from "../../shared/model-error-classifier"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { log } from "../../shared/logger"
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
import { getNextReachableFallback } from "./next-fallback"
import {
createModelFallbackStateController,
type ModelFallbackStateController,
} from "./fallback-state-controller"
import type { ModelFallbackControllerAccessor } from "./controller-accessor"
type FallbackToast = (input: {
title: string
@@ -31,30 +29,45 @@ export type ModelFallbackState = {
pending: boolean
}
/**
* Map of sessionID -> pending model fallback state
* When a model error occurs, we store the fallback info here.
* The next chat.message call will use this to switch to the fallback model.
*/
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
type ModelFallbackControllerWithState = Pick<
ModelFallbackStateController,
| "lastToastKey"
| "setSessionFallbackChain"
| "clearSessionFallbackChain"
| "setPendingModelFallback"
| "getNextFallback"
| "clearPendingModelFallback"
| "hasPendingModelFallback"
| "getFallbackState"
| "reset"
>
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain) {
sessionFallbackChains.set(sessionID, [])
return
}
if (fallbackChain.length === 0) {
sessionFallbackChains.set(sessionID, [])
return
}
sessionFallbackChains.set(sessionID, fallbackChain)
export type ModelFallbackHook = ModelFallbackControllerWithState & {
"chat.message": (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
) => Promise<void>
}
export function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
type ModelFallbackHookArgs = {
toast?: FallbackToast
onApplied?: FallbackCallback
controllerAccessor?: ModelFallbackControllerAccessor
}
export function setSessionFallbackChain(
controller: Pick<ModelFallbackStateController, "setSessionFallbackChain">,
sessionID: string,
fallbackChain: FallbackEntry[] | undefined,
): void {
controller.setSessionFallbackChain(sessionID, fallbackChain)
}
export function clearSessionFallbackChain(
controller: Pick<ModelFallbackStateController, "clearSessionFallbackChain">,
sessionID: string,
): void {
controller.clearSessionFallbackChain(sessionID)
}
/**
@@ -62,56 +75,18 @@ export function clearSessionFallbackChain(sessionID: string): void {
* Called when a model error is detected in session.error handler.
*/
export function setPendingModelFallback(
controller: Pick<ModelFallbackStateController, "setPendingModelFallback">,
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const hasSessionFallback = sessionFallbackChains.has(sessionID)
const sessionFallback = sessionFallbackChains.get(sessionID)
const fallbackChain = hasSessionFallback
? sessionFallback
: requirements?.fallbackChain
if (!fallbackChain || fallbackChain.length === 0) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (existing) {
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
// Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
const state: ModelFallbackState = {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
pendingModelFallbacks.set(sessionID, state)
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
return controller.setPendingModelFallback(
sessionID,
agentName,
currentProviderID,
currentModelID,
)
}
/**
@@ -119,55 +94,71 @@ export function setPendingModelFallback(
* Increments attemptCount each time called.
*/
export function getNextFallback(
controller: Pick<ModelFallbackStateController, "getNextFallback">,
sessionID: string,
): { providerID: string; modelID: string; variant?: string } | null {
const state = pendingModelFallbacks.get(sessionID)
if (!state) return null
if (!state.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) {
return fallback
}
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
return controller.getNextFallback(sessionID)
}
/**
* Clears the pending fallback for a session.
* Called after fallback is successfully applied.
*/
export function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
export function clearPendingModelFallback(
controller: Pick<ModelFallbackStateController, "clearPendingModelFallback">,
sessionID: string,
): void {
controller.clearPendingModelFallback(sessionID)
}
/**
* Checks if there's a pending fallback for a session.
*/
export function hasPendingModelFallback(sessionID: string): boolean {
const state = pendingModelFallbacks.get(sessionID)
return state?.pending === true
export function hasPendingModelFallback(
controller: Pick<ModelFallbackStateController, "hasPendingModelFallback">,
sessionID: string,
): boolean {
return controller.hasPendingModelFallback(sessionID)
}
/**
* Gets the current fallback state for a session (for debugging).
*/
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
return pendingModelFallbacks.get(sessionID)
export function getFallbackState(
controller: Pick<ModelFallbackStateController, "getFallbackState">,
sessionID: string,
): ModelFallbackState | undefined {
return controller.getFallbackState(sessionID)
}
/**
* Creates a chat.message hook that applies model fallbacks when pending.
*/
export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplied?: FallbackCallback }) {
export function createModelFallbackHook(args?: ModelFallbackHookArgs): ModelFallbackHook {
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
const controller = createModelFallbackStateController({
pendingModelFallbacks,
lastToastKey,
sessionFallbackChains,
})
args?.controllerAccessor?.register(controller)
const toast = args?.toast
const onApplied = args?.onApplied
return {
lastToastKey: controller.lastToastKey,
setSessionFallbackChain: controller.setSessionFallbackChain,
clearSessionFallbackChain: controller.clearSessionFallbackChain,
setPendingModelFallback: controller.setPendingModelFallback,
getNextFallback: controller.getNextFallback,
clearPendingModelFallback: controller.clearPendingModelFallback,
hasPendingModelFallback: controller.hasPendingModelFallback,
getFallbackState: controller.getFallbackState,
reset: controller.reset,
"chat.message": async (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
@@ -175,7 +166,7 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
const { sessionID } = input
if (!sessionID) return
const fallback = getNextFallback(sessionID)
const fallback = getNextFallback(controller, sessionID)
if (!fallback) return
await applyFallbackToChatMessage({
@@ -184,18 +175,15 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
fallback,
toast,
onApplied,
lastToastKey,
lastToastKey: controller.lastToastKey,
})
},
}
}
/**
* Resets all module-global state for testing.
* Clears pending fallbacks, toast keys, and session chains.
* Resets hook-owned state for testing.
*/
export function _resetForTesting(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
export function _resetForTesting(controller?: Pick<ModelFallbackStateController, "reset">): void {
controller?.reset()
}
+2
View File
@@ -0,0 +1,2 @@
export { createModelFallbackControllerAccessor } from "./controller-accessor"
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
@@ -30,12 +30,12 @@ describe("no-hephaestus-non-gpt hook", () => {
await hook["chat.message"]?.({
sessionID: "ses_1",
agent: HEPHAESTUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output1)
await hook["chat.message"]?.({
sessionID: "ses_1",
agent: HEPHAESTUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output2)
// then - toast is shown and agent is switched to sisyphus
@@ -66,7 +66,7 @@ describe("no-hephaestus-non-gpt hook", () => {
await hook["chat.message"]?.({
sessionID: "ses_opt_out",
agent: HEPHAESTUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output)
// then - warning toast is shown but agent is not switched
@@ -114,7 +114,7 @@ describe("no-hephaestus-non-gpt hook", () => {
await hook["chat.message"]?.({
sessionID: "ses_3",
agent: SISYPHUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output)
// then - no toast
@@ -136,7 +136,7 @@ describe("no-hephaestus-non-gpt hook", () => {
// when - chat.message runs without input.agent
await hook["chat.message"]?.({
sessionID: "ses_4",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output)
// then - toast shown via session-agent fallback, switched to sisyphus
+1 -1
View File
@@ -83,7 +83,7 @@ describe("no-sisyphus-gpt hook", () => {
await hook["chat.message"]?.({
sessionID: "ses_2",
agent: SISYPHUS_DISPLAY,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}, output)
// then - no toast
+131
View File
@@ -0,0 +1,131 @@
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { log } from "../shared/logger"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
} from "./preemptive-compaction-types"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
export async function runPreemptiveCompactionIfNeeded(args: {
ctx: PreemptiveCompactionContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState?: ContextLimitModelCacheState
sessionID: string
tokenCache: Map<string, CachedCompactionState>
compactionInProgress: Set<string>
compactedSessions: Set<string>
lastCompactionTime: Map<string, number>
}): Promise<void> {
const {
ctx,
pluginConfig,
modelCacheState,
sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
} = args
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true },
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
export interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
export interface PreemptiveCompactionClient {
session: {
messages: (input: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
summarize: (input: {
path: { id: string }
body: { providerID: string; modelID: string; auto?: boolean }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "warning"
duration: number
}
}) => Promise<unknown>
}
}
export interface PreemptiveCompactionContext {
client: PreemptiveCompactionClient
directory: string
}
+17 -132
View File
@@ -1,69 +1,16 @@
import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
type PluginInput = {
client: {
session: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: (...args: any[]) => any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
summarize: (...args: any[]) => any
}
tui: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
showToast: (...args: any[]) => any
}
}
directory: string
}
import { runPreemptiveCompactionIfNeeded } from "./preemptive-compaction-trigger"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
TokenInfo,
} from "./preemptive-compaction-types"
export function createPreemptiveCompactionHook(
ctx: PluginInput,
ctx: PreemptiveCompactionContext,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ContextLimitModelCacheState,
) {
@@ -84,78 +31,16 @@ export function createPreemptiveCompactionHook(
input: { tool: string; sessionID: string; callID: string },
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
await runPreemptiveCompactionIfNeeded({
ctx,
pluginConfig,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
sessionID: input.sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
})
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -1,3 +1,4 @@
import { extractTaskLink } from "../../features/tool-metadata-store"
import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
@@ -9,8 +10,6 @@ export interface OracleVerificationEvidence {
const AGENT_LINE_PATTERN = /^Agent:[ \t]*(\S+)$/im
const PROMISE_TAG_PATTERN = /<promise>[ \t]*(\S+?)[ \t]*<\/promise>/is
const TASK_METADATA_PATTERN = /<task_metadata>[ \t]*([\s\S]*?)[ \t]*<\/task_metadata>/is
const SESSION_ID_LINE_PATTERN = /^session_id:[ \t]*(\S+)$/im
export function parseOracleVerificationEvidence(text: string): OracleVerificationEvidence | undefined {
const trimmedText = text.trim()
@@ -36,17 +35,9 @@ export function parseOracleVerificationEvidence(text: string): OracleVerificatio
return undefined
}
const metadataMatch = trimmedText.match(TASK_METADATA_PATTERN)
let sessionID: string | undefined
if (metadataMatch) {
const metadataContent = metadataMatch[1]
const sessionIDMatch = metadataContent.match(SESSION_ID_LINE_PATTERN)
if (sessionIDMatch) {
sessionID = sessionIDMatch[1]?.trim()
}
}
const sessionID = extractTaskLink(undefined, trimmedText).sessionId
return { agent, promise, sessionID }
return { agent, promise, sessionID }
}
export function isOracleVerified(text: string): boolean {
@@ -21,9 +21,9 @@ function createDeferred(): {
}
}
async function waitUntil(condition: () => boolean): Promise<void> {
async function waitUntil(shouldTrigger: () => boolean): Promise<void> {
for (let index = 0; index < 100; index++) {
if (condition()) {
if (shouldTrigger()) {
return
}
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "bun:test";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createSessionCacheStore } from "./cache";
import { RULES_INJECTOR_STORAGE } from "./constants";
import { clearInjectedRules, saveInjectedRules } from "./storage";
const trackedSessionIDs: string[] = [];
function createSessionID(prefix: string): string {
const sessionID = `${prefix}-${randomUUID()}`;
trackedSessionIDs.push(sessionID);
return sessionID;
}
function getStoragePath(sessionID: string): string {
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}
afterEach(() => {
for (const sessionID of trackedSessionIDs.splice(0)) {
clearInjectedRules(sessionID);
}
});
describe("createSessionCacheStore", () => {
it("keeps factory instances isolated for the same session", () => {
// given
const sessionID = createSessionID("cache-isolation");
const firstStore = createSessionCacheStore();
const secondStore = createSessionCacheStore();
const firstCache = firstStore.getSessionCache(sessionID);
// when
firstCache.contentHashes.add("hash:first");
firstCache.realPaths.add("/tmp/first-rule.md");
const secondCache = secondStore.getSessionCache(sessionID);
// then
expect([...secondCache.contentHashes]).toEqual([]);
expect([...secondCache.realPaths]).toEqual([]);
});
it("clears only the targeted session cache and persisted state", () => {
// given
const deletedSessionID = createSessionID("deleted-session");
const retainedSessionID = createSessionID("retained-session");
saveInjectedRules(deletedSessionID, {
contentHashes: new Set(["hash:deleted"]),
realPaths: new Set(["/tmp/deleted-rule.md"]),
});
saveInjectedRules(retainedSessionID, {
contentHashes: new Set(["hash:retained"]),
realPaths: new Set(["/tmp/retained-rule.md"]),
});
const store = createSessionCacheStore();
store.getSessionCache(deletedSessionID);
const retainedCache = store.getSessionCache(retainedSessionID);
// when
store.clearSessionCache(deletedSessionID);
const reloadedRetainedCache = store.getSessionCache(retainedSessionID);
// then
expect(existsSync(getStoragePath(deletedSessionID))).toBe(false);
expect(existsSync(getStoragePath(retainedSessionID))).toBe(true);
expect(reloadedRetainedCache).toBe(retainedCache);
expect([...reloadedRetainedCache.contentHashes]).toEqual(["hash:retained"]);
expect([...reloadedRetainedCache.realPaths]).toEqual(["/tmp/retained-rule.md"]);
});
});
+28
View File
@@ -1,4 +1,6 @@
import { clearInjectedRules, loadInjectedRules } from "./storage";
import { createRuleScanCache } from "./rule-scan-cache";
import type { RuleScanCache } from "./rule-scan-cache";
export type SessionInjectedRulesCache = {
contentHashes: Set<string>;
@@ -25,3 +27,29 @@ export function createSessionCacheStore(): {
return { getSessionCache, clearSessionCache };
}
export function createSessionRuleScanCacheStore(): {
getSessionRuleScanCache: (sessionID: string) => RuleScanCache;
clearSessionRuleScanCache: (sessionID: string) => void;
} {
const sessionCaches = new Map<string, RuleScanCache>();
function getSessionRuleScanCache(sessionID: string): RuleScanCache {
const existingCache = sessionCaches.get(sessionID);
if (existingCache) {
return existingCache;
}
const cache = createRuleScanCache();
sessionCaches.set(sessionID, cache);
return cache;
}
function clearSessionRuleScanCache(sessionID: string): void {
const cache = sessionCaches.get(sessionID);
cache?.clear();
sessionCaches.delete(sessionID);
}
return { getSessionRuleScanCache, clearSessionRuleScanCache };
}
+14 -3
View File
@@ -1,8 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { getRuleInjectionFilePath } from "./output-path";
import { createSessionCacheStore } from "./cache";
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
import { createRuleInjectionProcessor } from "./injector";
import { clearProjectRootCache } from "./project-root-finder";
interface ToolExecuteInput {
tool: string;
@@ -36,15 +37,23 @@ export function createRulesInjectorHook(
) {
const truncator = createDynamicTruncator(ctx, modelCacheState);
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
const { getSessionRuleScanCache, clearSessionRuleScanCache } =
createSessionRuleScanCacheStore();
const { processFilePathForInjection } = createRuleInjectionProcessor({
workspaceDirectory: ctx.directory,
truncator,
getSessionCache,
getSessionRuleScanCache,
ruleFinderOptions: options?.skipClaudeUserRules
? { skipClaudeUserRules: true }
: undefined,
});
function clearSessionState(sessionID: string): void {
clearSessionCache(sessionID);
clearSessionRuleScanCache(sessionID);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput
@@ -73,16 +82,18 @@ export function createRulesInjectorHook(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
clearSessionCache(sessionInfo.id);
clearSessionState(sessionInfo.id);
}
clearProjectRootCache();
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
clearSessionCache(sessionID);
clearSessionState(sessionID);
}
clearProjectRootCache();
}
};
+11 -1
View File
@@ -12,6 +12,7 @@ import {
import { parseRuleFrontmatter } from "./parser";
import { saveInjectedRules } from "./storage";
import type { SessionInjectedRulesCache } from "./cache";
import type { RuleScanCache } from "./rule-scan-cache";
import type { RuleMetadata } from "./types";
type ToolExecuteOutput = {
@@ -56,6 +57,7 @@ export function createRuleInjectionProcessor(deps: {
workspaceDirectory: string;
truncator: DynamicTruncator;
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
ruleFinderOptions?: FindRuleFilesOptions;
readFileSync?: typeof readFileSync;
statSync?: typeof statSync;
@@ -76,6 +78,7 @@ export function createRuleInjectionProcessor(deps: {
workspaceDirectory,
truncator,
getSessionCache,
getSessionRuleScanCache,
ruleFinderOptions,
readFileSync: readRuleFileSync = readFileSync,
statSync: statRuleSync = statSync,
@@ -121,9 +124,16 @@ export function createRuleInjectionProcessor(deps: {
const projectRoot = findProjectRoot(resolved);
const cache = getSessionCache(sessionID);
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
const home = getHomeDir();
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved, ruleFinderOptions);
const ruleFileCandidates = findRuleFiles(
projectRoot,
home,
resolved,
ruleFinderOptions,
ruleScanCache,
);
const toInject: RuleToInject[] = [];
let dirty = false;
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
describe("findProjectRoot", () => {
afterEach(async () => {
const actualFileSystem = await import("node:fs");
mock.module("node:fs", () => actualFileSystem);
});
it("memoizes repeated lookups for the same start path and resets on cache clear", async () => {
// given
const actualFileSystem = await import("node:fs");
const projectRoot = "/workspace/project";
const startPath = `${projectRoot}/src/file.ts`;
const packageJsonPath = `${projectRoot}/package.json`;
const existsSyncSpy = mock((path: string) => path === packageJsonPath);
const statSyncSpy = mock(() => ({ isDirectory: () => false }));
mock.module("node:fs", () => ({
...actualFileSystem,
existsSync: existsSyncSpy,
statSync: statSyncSpy,
}));
const { clearProjectRootCache, findProjectRoot } = await import(
`./project-root-finder.ts?memoization=${Date.now()}`
);
// when
const firstResult = findProjectRoot(startPath);
const firstExistsSyncCallCount = existsSyncSpy.mock.calls.length;
const secondResult = findProjectRoot(startPath);
const secondExistsSyncCallCount = existsSyncSpy.mock.calls.length;
clearProjectRootCache();
const thirdResult = findProjectRoot(startPath);
// then
expect(firstResult).toBe(projectRoot);
expect(secondResult).toBe(projectRoot);
expect(thirdResult).toBe(projectRoot);
expect(firstExistsSyncCallCount).toBeGreaterThan(0);
expect(secondExistsSyncCallCount).toBe(firstExistsSyncCallCount);
expect(existsSyncSpy).toHaveBeenCalledTimes(firstExistsSyncCallCount * 2);
});
});
@@ -2,6 +2,12 @@ import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { PROJECT_MARKERS } from "./constants";
const projectRootCache = new Map<string, string | null>();
export function clearProjectRootCache(): void {
projectRootCache.clear();
}
/**
* Find project root by walking up from startPath.
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
@@ -10,6 +16,16 @@ import { PROJECT_MARKERS } from "./constants";
* @returns Project root path or null if not found
*/
export function findProjectRoot(startPath: string): string | null {
if (projectRootCache.has(startPath)) {
return projectRootCache.get(startPath) ?? null;
}
const projectRoot = findProjectRootWithoutCache(startPath);
projectRootCache.set(startPath, projectRoot);
return projectRoot;
}
function findProjectRootWithoutCache(startPath: string): string | null {
let current: string;
try {
+111 -75
View File
@@ -1,51 +1,108 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, sep } from "node:path";
import {
OPENCODE_USER_RULE_DIRS,
PROJECT_RULE_FILES,
PROJECT_RULE_SUBDIRS,
USER_RULE_DIR,
OPENCODE_USER_RULE_DIRS,
} from "./constants";
import type { RuleFileCandidate } from "./types";
import type { RuleScanCache } from "./rule-scan-cache";
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
import type { RuleFileCandidate } from "./types";
export interface FindRuleFilesOptions {
/**
* When true, skip loading rules from ~/.claude/rules/.
* Use when claude_code integration is disabled to prevent
* Claude Code-specific instructions from leaking into non-Claude agents.
*/
skipClaudeUserRules?: boolean;
}
/**
* Find all rule files for a given context.
* Searches from currentFile upward to projectRoot for rule directories,
* then user-level directory (~/.claude/rules).
*
* IMPORTANT: This searches EVERY directory from file to project root.
* Not just the project root itself.
*
* @param projectRoot - Project root path (or null if outside any project)
* @param homeDir - User home directory
* @param currentFile - Current file being edited (for distance calculation)
* @returns Array of rule file candidates sorted by distance
*/
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
if (!skipClaudeUserRules) {
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
}
return userRuleDirs;
}
function createCacheKey(
projectRoot: string | null,
startDir: string,
skipClaudeUserRules: boolean,
): string {
return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`;
}
function createCachedCandidate(
filePath: string,
projectRoot: string | null,
startDir: string,
userRuleDirs: string[],
): RuleFileCandidate | undefined {
const realPath = safeRealpathSync(filePath);
for (const userRuleDir of userRuleDirs) {
if (filePath.startsWith(`${userRuleDir}${sep}`)) {
return { path: filePath, realPath, isGlobal: true, distance: 9999 };
}
}
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
if (filePath === join(projectRoot, ruleFile)) {
return {
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
};
}
}
}
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
if (filePath.startsWith(`${ruleDir}${sep}`)) {
return { path: filePath, realPath, isGlobal: false, distance };
}
}
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance += 1;
}
return undefined;
}
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
options?: FindRuleFilesOptions,
cache?: RuleScanCache,
): RuleFileCandidate[] {
const startDir = dirname(currentFile);
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
const cachedPaths = cache?.get(cacheKey);
if (cachedPaths) {
return cachedPaths
.map((filePath) => createCachedCandidate(filePath, projectRoot, startDir, userRuleDirs))
.filter((candidate): candidate is RuleFileCandidate => candidate !== undefined);
}
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
// Search from current file's directory up to project root
let currentDir = dirname(currentFile);
let currentDir = startDir;
let distance = 0;
while (true) {
// Search rule directories in current directory
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
const files: string[] = [];
@@ -55,60 +112,41 @@ export function findRuleFiles(
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance,
});
candidates.push({ path: filePath, realPath, isGlobal: false, distance });
}
}
// Stop at project root or filesystem root
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance++;
distance += 1;
}
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
if (existsSync(filePath)) {
try {
const stat = statSync(filePath);
if (stat.isFile()) {
const realPath = safeRealpathSync(filePath);
if (!seenRealPaths.has(realPath)) {
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
}
}
} catch {
// Skip if file can't be read
}
if (!existsSync(filePath)) continue;
try {
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
} catch {
continue;
}
}
}
// Search user-level rule directories
// Always search OpenCode-native dirs (~/.sisyphus/rules, ~/.opencode/rules)
const userRuleDirs: string[] = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
// Only search ~/.claude/rules when claude_code integration is not disabled
if (!options?.skipClaudeUserRules) {
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
}
for (const userRuleDir of userRuleDirs) {
const userFiles: string[] = [];
findRuleFilesRecursive(userRuleDir, userFiles);
@@ -117,23 +155,21 @@ export function findRuleFiles(
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: true,
distance: 9999, // Global rules always have max distance
});
candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999 });
}
}
// Sort by distance (closest first, then global rules last)
candidates.sort((a, b) => {
if (a.isGlobal !== b.isGlobal) {
return a.isGlobal ? 1 : -1;
candidates.sort((left, right) => {
if (left.isGlobal !== right.isGlobal) {
return left.isGlobal ? 1 : -1;
}
return a.distance - b.distance;
return left.distance - right.distance;
});
cache?.set(
cacheKey,
candidates.map((candidate) => candidate.path),
);
return candidates;
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { findRuleFilesRecursive } from "./rule-file-scanner";
const createdDirectories: string[] = [];
afterEach(() => {
for (const directory of createdDirectories.splice(0)) {
if (existsSync(directory)) {
rmSync(directory, { recursive: true, force: true });
}
}
});
describe("findRuleFilesRecursive", () => {
test("returns rule files outside excluded nested directories", () => {
// given
const temporaryDirectory = join(tmpdir(), `perf-d01-${randomUUID()}`);
createdDirectories.push(temporaryDirectory);
const rulesDirectory = join(temporaryDirectory, ".sisyphus", "rules");
mkdirSync(join(rulesDirectory, "node_modules", "fake"), { recursive: true });
mkdirSync(join(rulesDirectory, ".git"), { recursive: true });
writeFileSync(join(rulesDirectory, "foo.md"), "root rule");
writeFileSync(
join(rulesDirectory, "node_modules", "fake", "x.md"),
"ignored node_modules rule",
);
writeFileSync(join(rulesDirectory, ".git", "x.md"), "ignored git rule");
const results: string[] = [];
// when
findRuleFilesRecursive(rulesDirectory, results);
// then
expect(results).toEqual([join(rulesDirectory, "foo.md")]);
});
});
@@ -1,5 +1,6 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { EXCLUDED_DIRS } from "../../shared";
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
function isGitHubInstructionsDir(dir: string): boolean {
@@ -28,6 +29,7 @@ export function findRuleFilesRecursive(dir: string, results: string[]): void {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
findRuleFilesRecursive(fullPath, results);
} else if (entry.isFile()) {
if (isValidRuleFile(entry.name, dir)) {
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function createImportSuffix(): string {
return `?test=${Date.now()}-${Math.random()}`;
}
describe("createRuleScanCache", () => {
afterEach(() => {
mock.restore();
});
it("returns undefined before set, returns stored value, and clears entries", async () => {
// given
const { createRuleScanCache } = await import(`./rule-scan-cache${createImportSuffix()}`);
const cache = createRuleScanCache();
const value = ["a", "b"];
// when
const initialValue = cache.get("k1");
cache.set("k1", value);
const storedValue = cache.get("k1");
cache.clear();
const clearedValue = cache.get("k1");
// then
expect(initialValue).toBeUndefined();
expect(storedValue).toEqual(value);
expect(clearedValue).toBeUndefined();
});
});
describe("findRuleFiles with scan cache", () => {
let testRoot = "";
let homeDir = "";
let projectRoot = "";
let currentFile = "";
let expectedRuleFile = "";
let expectedRuleDir = "";
beforeEach(() => {
testRoot = join(tmpdir(), `rule-scan-cache-test-${Date.now()}`);
homeDir = join(testRoot, "home");
projectRoot = join(testRoot, "project");
currentFile = join(projectRoot, "src", "index.ts");
expectedRuleDir = join(projectRoot, ".github", "instructions");
expectedRuleFile = join(expectedRuleDir, "typescript.instructions.md");
mkdirSync(join(projectRoot, ".git"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
writeFileSync(currentFile, "export const value = 1;\n");
});
afterEach(() => {
mock.restore();
if (existsSync(testRoot)) {
rmSync(testRoot, { recursive: true, force: true });
}
});
it("reuses cached directory scan results for identical inputs", async () => {
// given
const findRuleFilesRecursive = mock((directoryPath: string, results: string[]) => {
if (directoryPath === expectedRuleDir) {
results.push(expectedRuleFile);
}
});
mock.module("./rule-file-scanner", () => ({
findRuleFilesRecursive,
safeRealpathSync: (filePath: string) => filePath,
}));
const { createRuleScanCache } = await import(`./rule-scan-cache${createImportSuffix()}`);
const { findRuleFiles } = await import(`./rule-file-finder${createImportSuffix()}`);
const cache = createRuleScanCache();
// when
const firstCandidates = findRuleFiles(projectRoot, homeDir, currentFile, undefined, cache);
const firstInvocationCount = findRuleFilesRecursive.mock.calls.length;
const secondCandidates = findRuleFiles(projectRoot, homeDir, currentFile, undefined, cache);
// then
expect(firstCandidates).toEqual(secondCandidates);
expect(firstInvocationCount).toBeGreaterThan(0);
expect(findRuleFilesRecursive).toHaveBeenCalledTimes(firstInvocationCount);
});
});
@@ -0,0 +1,21 @@
export type RuleScanCache = {
get: (key: string) => string[] | undefined;
set: (key: string, value: string[]) => void;
clear: () => void;
};
export function createRuleScanCache(): RuleScanCache {
const cache = new Map<string, string[]>();
return {
get(key: string): string[] | undefined {
return cache.get(key);
},
set(key: string, value: string[]): void {
cache.set(key, value);
},
clear(): void {
cache.clear();
},
};
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "bun:test";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
import {
clearInjectedRules,
loadInjectedRules,
saveInjectedRules,
} from "./storage";
const trackedSessionIDs: string[] = [];
function createSessionID(prefix: string): string {
const sessionID = `${prefix}-${randomUUID()}`;
trackedSessionIDs.push(sessionID);
return sessionID;
}
function getStoragePath(sessionID: string): string {
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}
afterEach(() => {
for (const sessionID of trackedSessionIDs.splice(0)) {
clearInjectedRules(sessionID);
}
});
describe("storage", () => {
it("reads back only the requested session data from session-scoped files", () => {
// given
const firstSessionID = createSessionID("storage-first");
const secondSessionID = createSessionID("storage-second");
saveInjectedRules(firstSessionID, {
contentHashes: new Set(["hash:first"]),
realPaths: new Set(["/tmp/first-rule.md"]),
});
saveInjectedRules(secondSessionID, {
contentHashes: new Set(["hash:second"]),
realPaths: new Set(["/tmp/second-rule.md"]),
});
// when
const firstLoaded = loadInjectedRules(firstSessionID);
const secondLoaded = loadInjectedRules(secondSessionID);
// then
expect(existsSync(getStoragePath(firstSessionID))).toBe(true);
expect(existsSync(getStoragePath(secondSessionID))).toBe(true);
expect([...firstLoaded.contentHashes]).toEqual(["hash:first"]);
expect([...firstLoaded.realPaths]).toEqual(["/tmp/first-rule.md"]);
expect([...secondLoaded.contentHashes]).toEqual(["hash:second"]);
expect([...secondLoaded.realPaths]).toEqual(["/tmp/second-rule.md"]);
});
});
+4 -3
View File
@@ -107,9 +107,10 @@ describe("createRuntimeFallbackHook dispose", () => {
globalThis.clearTimeout = originalClearTimeout
})
test("#given runtime-fallback hook created #when dispose() is called #then cleanup interval is cleared", () => {
test("#given runtime-fallback hook handles its first event #when dispose() is called #then cleanup interval is cleared", async () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
await hook.event({ event: { type: "session.created", properties: {} } })
// when
hook.dispose?.()
@@ -125,10 +126,10 @@ describe("createRuntimeFallbackHook dispose", () => {
const fallbackTimeout = setTimeout(() => {}, 60_000)
capturedDeps?.sessionStates.set("session-1", {
originalModel: "anthropic/claude-opus-4-6",
originalModel: "anthropic/claude-opus-4-7",
currentModel: "openai/gpt-5.4",
fallbackIndex: 1,
failedModels: new Map([["anthropic/claude-opus-4-6", 1]]),
failedModels: new Map([["anthropic/claude-opus-4-7", 1]]),
attemptCount: 1,
})
capturedDeps?.sessionLastAccess.set("session-1", Date.now())
@@ -7,7 +7,7 @@ describe("runtime-fallback error classifier", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
@@ -21,7 +21,7 @@ describe("runtime-fallback error classifier", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6 are cooldown [retrying in 7m 56s attempt #1]",
"All credentials for model claude-opus-4-7 are cooldown [retrying in 7m 56s attempt #1]",
}
//#when
@@ -49,7 +49,7 @@ describe("runtime-fallback error classifier", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
@@ -65,8 +65,8 @@ describe("runtime-fallback error classifier", () => {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-opus-4-6",
message: "Model not found: anthropic/claude-opus-4-6.",
modelID: "claude-opus-4-7",
message: "Model not found: anthropic/claude-opus-4-7.",
},
}
@@ -15,7 +15,7 @@ describe("runtime-fallback fallback-models", () => {
const pluginConfig = {
categories: {
quick: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
},
},
} as any
@@ -24,7 +24,7 @@ describe("runtime-fallback fallback-models", () => {
const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-7"])
})
test("uses agent-specific fallback_models when agent is resolved", () => {
@@ -32,7 +32,7 @@ describe("runtime-fallback fallback-models", () => {
const pluginConfig = {
agents: {
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
},
},
} as any
@@ -41,7 +41,7 @@ describe("runtime-fallback fallback-models", () => {
const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-7"])
})
test("does not fall back to another agent chain when agent cannot be resolved", () => {
@@ -52,7 +52,7 @@ describe("runtime-fallback fallback-models", () => {
fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"],
},
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
},
},
} as any
@@ -51,7 +51,7 @@ describe("createRuntimeFallbackHook dispose retry-key cleanup", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
@@ -63,7 +63,7 @@ describe("createRuntimeFallbackHook dispose retry-key cleanup", () => {
status: {
type: "retry",
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
@@ -77,7 +77,7 @@ describe("createRuntimeFallbackHook dispose retry-key cleanup", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
await hook.event(retryEvent)
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import type { HookDeps, RuntimeFallbackInterval, RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook")
const loadPluginConfigMock = mock(() => ({} satisfies OhMyOpenCodeConfig))
const createAutoRetryHelpersMock = mock((_deps: HookDeps) => {
void _deps
return {
abortSessionRequest: async () => {},
clearSessionFallbackTimeout: () => {},
scheduleSessionFallbackTimeout: () => {},
autoRetryWithFallback: async () => {},
resolveAgentForSessionFromContext: async () => undefined,
cleanupStaleSessions: () => {},
}
})
const createEventHandlerMock = mock(() => async () => {})
const createMessageUpdateHandlerMock = mock(() => async () => {})
const createChatMessageHandlerMock = mock(() => async () => {})
function registerModuleMocks(): void {
mock.module("../../plugin-config", () => ({
loadPluginConfig: loadPluginConfigMock,
}))
mock.module("./auto-retry", () => ({
createAutoRetryHelpers: createAutoRetryHelpersMock,
}))
mock.module("./event-handler", () => ({
createEventHandler: createEventHandlerMock,
}))
mock.module("./message-update-handler", () => ({
createMessageUpdateHandler: createMessageUpdateHandlerMock,
}))
mock.module("./chat-message-handler", () => ({
createChatMessageHandler: createChatMessageHandlerMock,
}))
}
function createMockContext(): RuntimeFallbackPluginInput {
return {
client: {
session: {
abort: async () => ({}),
messages: async () => ({}),
promptAsync: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
directory: "/test",
}
}
function createMockInterval(): RuntimeFallbackInterval {
return {
unref: () => {},
}
}
describe("createRuntimeFallbackHook initialization", () => {
const originalSetInterval = globalThis.setInterval
let setIntervalCalls = 0
let createRuntimeFallbackHook: RuntimeFallbackModule["createRuntimeFallbackHook"]
beforeEach(async () => {
mock.restore()
registerModuleMocks()
loadPluginConfigMock.mockClear()
createAutoRetryHelpersMock.mockClear()
createEventHandlerMock.mockClear()
createMessageUpdateHandlerMock.mockClear()
createChatMessageHandlerMock.mockClear()
setIntervalCalls = 0
globalThis.setInterval = ((callback: Parameters<typeof originalSetInterval>[0], delay?: number) => {
void callback
void delay
setIntervalCalls += 1
return createMockInterval() as ReturnType<typeof globalThis.setInterval>
}) as typeof globalThis.setInterval
const cacheBuster = `${Date.now()}-${Math.random()}`
const runtimeFallbackModule: RuntimeFallbackModule = await import(`./hook?test=${cacheBuster}`)
createRuntimeFallbackHook = runtimeFallbackModule.createRuntimeFallbackHook
})
afterEach(() => {
globalThis.setInterval = originalSetInterval
mock.restore()
})
test("#given injected pluginConfig #when the hook factory runs #then loadPluginConfig is not called", () => {
// given
const pluginConfig = {} satisfies OhMyOpenCodeConfig
// when
createRuntimeFallbackHook(createMockContext(), { pluginConfig })
// then
expect(loadPluginConfigMock).not.toHaveBeenCalled()
})
test("#given a fresh hook #when the first event arrives #then cleanup interval starts only once", async () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
// when
expect(setIntervalCalls).toBe(0)
await hook.event({ event: { type: "session.created", properties: {} } })
expect(setIntervalCalls).toBe(1)
await hook.event({ event: { type: "session.error", properties: {} } })
// then
expect(setIntervalCalls).toBe(1)
})
})
+20 -16
View File
@@ -1,7 +1,5 @@
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { loadPluginConfig } from "../../plugin-config"
import { DEFAULT_CONFIG } from "./constants"
import { createAutoRetryHelpers } from "./auto-retry"
import { createEventHandler } from "./event-handler"
import { createMessageUpdateHandler } from "./message-update-handler"
@@ -24,20 +22,11 @@ export function createRuntimeFallbackHook(
notify_on_fallback: options?.config?.notify_on_fallback ?? DEFAULT_CONFIG.notify_on_fallback,
}
let pluginConfig = options?.pluginConfig
if (!pluginConfig) {
try {
pluginConfig = loadPluginConfig(ctx.directory, ctx)
} catch {
log(`[${HOOK_NAME}] Plugin config not available`)
}
}
const deps: HookDeps = {
ctx,
config,
options,
pluginConfig,
pluginConfig: options?.pluginConfig,
sessionStates: new Map(),
sessionLastAccess: new Map(),
sessionRetryInFlight: new Set(),
@@ -51,10 +40,23 @@ export function createRuntimeFallbackHook(
const messageUpdateHandler = createMessageUpdateHandler(deps, helpers)
const chatMessageHandler = createChatMessageHandler(deps)
const cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
cleanupInterval.unref()
let cleanupInterval: RuntimeFallbackInterval | null = null
let intervalStarted = false
const ensureInterval = (): void => {
if (intervalStarted) return
intervalStarted = true
cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
if (typeof cleanupInterval.unref === "function") {
cleanupInterval.unref()
}
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
ensureInterval()
if (event.type === "message.updated") {
if (!config.enabled) return
const props = event.properties as Record<string, unknown> | undefined
@@ -65,7 +67,9 @@ export function createRuntimeFallbackHook(
}
const dispose = () => {
clearInterval(cleanupInterval)
if (cleanupInterval) {
clearInterval(cleanupInterval)
}
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
clearTimeout(fallbackTimeout)
+73 -73
View File
@@ -329,7 +329,7 @@ describe("runtime-fallback", () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"anthropic/claude-opus-4.6",
"anthropic/claude-opus-4.7",
"openai/gpt-5.4",
]),
})
@@ -365,14 +365,14 @@ describe("runtime-fallback", () => {
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.6." } },
error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } },
},
},
})
const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.6", to: "openai/gpt-5.4" })
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.7", to: "openai/gpt-5.4" })
const nonRetryLog = logCalls.find(
(c) => c.msg.includes("Error not retryable") && (c.data as { sessionID?: string } | undefined)?.sessionID === sessionID
@@ -384,7 +384,7 @@ describe("runtime-fallback", () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"anthropic/claude-opus-4.6",
"anthropic/claude-opus-4.7",
"openai/gpt-5.4",
]),
})
@@ -421,8 +421,8 @@ describe("runtime-fallback", () => {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-opus-4.6",
message: "Model not found: anthropic/claude-opus-4.6.",
modelID: "claude-opus-4.7",
message: "Model not found: anthropic/claude-opus-4.7.",
},
},
},
@@ -431,7 +431,7 @@ describe("runtime-fallback", () => {
const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.6", to: "openai/gpt-5.4" })
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.7", to: "openai/gpt-5.4" })
})
test("should bootstrap session.error fallback from session category model and preserve variant", async () => {
@@ -500,7 +500,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "github-copilot/claude-opus-4.6" } },
properties: { info: { id: sessionID, model: "github-copilot/claude-opus-4.7" } },
},
})
@@ -511,7 +511,7 @@ describe("runtime-fallback", () => {
info: {
sessionID,
role: "assistant",
model: "github-copilot/claude-opus-4.6",
model: "github-copilot/claude-opus-4.7",
status:
"Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]",
},
@@ -524,13 +524,13 @@ describe("runtime-fallback", () => {
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "github-copilot/claude-opus-4.6", to: "openai/gpt-5.4" })
expect(fallbackLog?.data).toMatchObject({ from: "github-copilot/claude-opus-4.7", to: "openai/gpt-5.4" })
})
test("should trigger fallback on OpenAI auto-retry signal in message.updated", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
})
const sessionID = "test-session-openai-auto-retry"
@@ -562,7 +562,7 @@ describe("runtime-fallback", () => {
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" })
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-7" })
})
test("should trigger fallback on auto-retry signal in assistant text parts", async () => {
@@ -577,7 +577,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
@@ -588,7 +588,7 @@ describe("runtime-fallback", () => {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
model: "quotio/claude-opus-4-7",
},
parts: [
{
@@ -605,7 +605,7 @@ describe("runtime-fallback", () => {
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.2" })
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
})
test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => {
@@ -620,7 +620,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
@@ -631,7 +631,7 @@ describe("runtime-fallback", () => {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
model: "quotio/claude-opus-4-7",
parts: [
{
type: "text",
@@ -648,7 +648,7 @@ describe("runtime-fallback", () => {
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.2" })
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
})
test("should trigger fallback on session.status auto-retry signal", async () => {
@@ -682,7 +682,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
@@ -695,7 +695,7 @@ describe("runtime-fallback", () => {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
@@ -706,7 +706,7 @@ describe("runtime-fallback", () => {
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.2" })
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
expect(promptCalls.length).toBe(1)
})
@@ -741,7 +741,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
},
})
@@ -754,7 +754,7 @@ describe("runtime-fallback", () => {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
@@ -769,7 +769,7 @@ describe("runtime-fallback", () => {
type: "retry",
next: 475,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]",
message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 55s attempt #1]",
},
},
},
@@ -781,7 +781,7 @@ describe("runtime-fallback", () => {
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 }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
})
const sessionID = "test-session-auto-retry-timeout-disabled"
@@ -1161,8 +1161,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
}
@@ -1212,7 +1212,7 @@ describe("runtime-fallback", () => {
"Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
},
},
model: "github-copilot/claude-opus-4.6",
model: "github-copilot/claude-opus-4.7",
},
},
},
@@ -1251,8 +1251,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
}
@@ -1294,7 +1294,7 @@ describe("runtime-fallback", () => {
info: {
sessionID,
role: "assistant",
model: "github-copilot/claude-opus-4.6",
model: "github-copilot/claude-opus-4.7",
status:
"Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]",
},
@@ -1303,8 +1303,8 @@ describe("runtime-fallback", () => {
})
expect(retriedModels.length).toBeGreaterThanOrEqual(2)
expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.6")
expect(retriedModels[1]).toBe("anthropic/claude-opus-4-6")
expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.7")
expect(retriedModels[1]).toBe("anthropic/claude-opus-4-7")
void sessionErrorPromise
})
@@ -1335,8 +1335,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
session_timeout_ms: 20,
@@ -1372,8 +1372,8 @@ describe("runtime-fallback", () => {
await new Promise((resolve) => setTimeout(resolve, 50))
expect(retriedModels).toContain("github-copilot/claude-opus-4.6")
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
const timeoutLog = logCalls.find((c) => c.msg.includes("Session fallback timeout reached"))
@@ -1401,8 +1401,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
session_timeout_ms: 20,
@@ -1443,15 +1443,15 @@ describe("runtime-fallback", () => {
await hook["chat.message"]?.(
{
sessionID,
model: { providerID: "github-copilot", modelID: "claude-opus-4.6" },
model: { providerID: "github-copilot", modelID: "claude-opus-4.7" },
},
output
)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(retriedModels).toContain("github-copilot/claude-opus-4.6")
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
})
test("should abort in-flight fallback request before advancing on timeout", async () => {
@@ -1486,8 +1486,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
session_timeout_ms: 20,
@@ -1524,8 +1524,8 @@ describe("runtime-fallback", () => {
await new Promise((resolve) => setTimeout(resolve, 50))
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
expect(retriedModels).toContain("github-copilot/claude-opus-4.6")
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
void sessionErrorPromise
})
@@ -1551,8 +1551,8 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"anthropic/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"anthropic/claude-opus-4-7",
"openai/gpt-5.4",
]),
session_timeout_ms: 20,
@@ -1586,7 +1586,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toContain("github-copilot/claude-opus-4.6")
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
await hook.event({
event: {
@@ -1624,9 +1624,9 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4.7",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -1659,7 +1659,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
await hook.event({
event: {
@@ -1695,7 +1695,7 @@ describe("runtime-fallback", () => {
await new Promise((resolve) => setTimeout(resolve, 50))
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
})
test("should not clear fallback timeout on assistant non-error update with Copilot retry signal", async () => {
@@ -1719,9 +1719,9 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4.7",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -1754,7 +1754,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
await hook.event({
event: {
@@ -1796,7 +1796,7 @@ describe("runtime-fallback", () => {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -1846,7 +1846,7 @@ describe("runtime-fallback", () => {
await new Promise((resolve) => setTimeout(resolve, 60))
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
})
test("should not clear fallback timeout on assistant non-error update without user-visible content", async () => {
@@ -1870,9 +1870,9 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4.7",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -1905,7 +1905,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
await hook.event({
event: {
@@ -1914,7 +1914,7 @@ describe("runtime-fallback", () => {
info: {
sessionID,
role: "assistant",
model: "github-copilot/claude-opus-4.6",
model: "github-copilot/claude-opus-4.7",
},
},
},
@@ -1946,9 +1946,9 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4.7",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -1981,7 +1981,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
await hook.event({
event: {
@@ -2022,9 +2022,9 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4.7",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
]),
session_timeout_ms: 20,
}
@@ -2057,7 +2057,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.6"])
expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
await hook.event({
event: {
@@ -2145,7 +2145,7 @@ describe("runtime-fallback", () => {
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
}
)
@@ -2176,7 +2176,7 @@ describe("runtime-fallback", () => {
},
})
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
})
test("does NOT trigger fallback for normal type:error-free messages", async () => {
@@ -2452,7 +2452,7 @@ describe("runtime-fallback", () => {
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", ["github-copilot/claude-opus-4.6"]),
pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", ["github-copilot/claude-opus-4.7"]),
},
)
const sessionID = "test-preserve-agent-on-retry"
@@ -2462,7 +2462,7 @@ describe("runtime-fallback", () => {
type: "session.error",
properties: {
sessionID,
model: "anthropic/claude-opus-4-6",
model: "anthropic/claude-opus-4-7",
error: { statusCode: 503, message: "Service unavailable" },
agent: "prometheus",
},
@@ -2472,7 +2472,7 @@ describe("runtime-fallback", () => {
expect(promptCalls.length).toBe(1)
const callBody = promptCalls[0]?.body as Record<string, unknown>
expect(callBody?.agent).toBe("prometheus")
expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.6" })
expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.7" })
})
})
@@ -92,7 +92,7 @@ describe("runtime-fallback provider matrix quota tests", () => {
//#given
const error = {
name: "AI_APICallError",
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in ~2 weeks]",
message: "All credentials for model claude-opus-4-7 are cooling down [retrying in ~2 weeks]",
provider: "anthropic",
}
@@ -1,4 +1,4 @@
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
import { parseModelString } from "../../shared/model-string-parser"
export function buildRetryModelPayload(
model: string,
@@ -74,12 +74,12 @@ describe("createSessionStatusHandler", () => {
const deps = createDeps()
const abortCalls: string[] = []
const retryCalls: Array<{ sessionID: string; model: string; source: string }> = []
const state = createFallbackState("anthropic/claude-opus-4-6")
const state = createFallbackState("anthropic/claude-opus-4-7")
state.currentModel = "openai/gpt-5.4"
state.fallbackIndex = 0
state.attemptCount = 1
state.pendingFallbackModel = "openai/gpt-5.4"
state.failedModels.set("anthropic/claude-opus-4-6", Date.now())
state.failedModels.set("anthropic/claude-opus-4-7", Date.now())
deps.sessionStates.set(sessionID, state)
const handler = createSessionStatusHandler(deps, createHelpers(abortCalls, retryCalls), deps.sessionStatusRetryKeys)
@@ -0,0 +1,51 @@
type EventProperties = Record<string, unknown> | undefined
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getEventInfo(properties: EventProperties): Record<string, unknown> | undefined {
const info = properties?.info
return isRecord(info) ? info : undefined
}
export function getSessionID(properties: EventProperties): string | undefined {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = getEventInfo(properties)
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
export function getEventToolName(properties: EventProperties): string | undefined {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
export function getQuestionText(properties: EventProperties): string {
const args = properties?.args
if (!isRecord(args)) return ""
const questions = args.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0]
if (!isRecord(firstQuestion)) return ""
const questionText = firstQuestion.question
return typeof questionText === "string" ? questionText : ""
}
+31
View File
@@ -0,0 +1,31 @@
import type { Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { startBackgroundCheck } from "./session-notification-utils"
export function createSessionNotificationInit() {
let platform: Platform | null = null
let defaultSoundPath: string | null = null
let started = false
function initialize(): { platform: Platform; defaultSoundPath: string } {
if (!platform) {
platform = sessionNotificationSender.detectPlatform()
}
if (!defaultSoundPath) {
defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(platform)
}
if (!started) {
startBackgroundCheck(platform)
started = true
}
return {
platform,
defaultSoundPath,
}
}
return {
initialize,
}
}
@@ -93,6 +93,53 @@ describe("session-notification input-needed events", () => {
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
test("lazily detects platform and starts background checks on first idle event", async () => {
const sessionID = "main-idle"
setMainSession(sessionID)
const detectPlatformSpy = spyOn(sender, "detectPlatform")
detectPlatformSpy.mockReturnValue("darwin")
const getDefaultSoundPathSpy = spyOn(sender, "getDefaultSoundPath")
getDefaultSoundPathSpy.mockReturnValue("/System/Library/Sounds/Glass.aiff")
const startBackgroundCheckSpy = spyOn(utils, "startBackgroundCheck")
startBackgroundCheckSpy.mockImplementation(() => {})
// given
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
})
})
export {}
+35 -85
View File
@@ -1,15 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
import {
startBackgroundCheck,
} from "./session-notification-utils"
import { buildReadyNotificationContent } from "./session-notification-content"
import {
type Platform,
} from "./session-notification-sender"
import { type Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import { createSessionNotificationInit } from "./session-notification-init"
interface SessionNotificationConfig {
title?: string
@@ -28,22 +25,15 @@ interface SessionNotificationConfig {
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(
ctx: PluginInput,
config: SessionNotificationConfig = {}
) {
const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
startBackgroundCheck(currentPlatform)
export function createSessionNotification(ctx: PluginInput, config: SessionNotificationConfig = {}) {
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: defaultSoundPath,
soundPath: "",
idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
@@ -51,22 +41,18 @@ export function createSessionNotification(
...config,
}
const sessionNotificationInit = createSessionNotificationInit()
let currentPlatform: Platform | null = null
let defaultSoundPath = mergedConfig.soundPath
const scheduler = createIdleNotificationScheduler({
ctx,
platform: currentPlatform,
platform: "unsupported",
config: mergedConfig,
hasIncompleteTodos,
send: async (hookCtx, platform, sessionID) => {
if (
typeof hookCtx.client.session.get !== "function"
&& typeof hookCtx.client.session.messages !== "function"
) {
await sessionNotificationSender.sendSessionNotification(
hookCtx,
platform,
mergedConfig.title,
mergedConfig.message,
)
if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") {
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message)
return
}
@@ -85,21 +71,13 @@ export function createSessionNotification(
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const ensureNotificationPlatform = (): Platform => {
if (currentPlatform) return currentPlatform
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
const initialized = sessionNotificationInit.initialize()
currentPlatform = initialized.platform
defaultSoundPath = initialized.defaultSoundPath || mergedConfig.soundPath
return currentPlatform
}
const shouldNotifyForSession = (sessionID: string): boolean => {
@@ -113,37 +91,13 @@ export function createSessionNotification(
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.id as string | undefined
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
@@ -151,6 +105,8 @@ export function createSessionNotification(
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.scheduleIdleNotification(sessionID)
@@ -160,26 +116,22 @@ export function createSessionNotification(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = getSessionID({ ...props, info })
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
mergedConfig.title,
mergedConfig.permissionMessage,
)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
return
}
@@ -192,16 +144,16 @@ export function createSessionNotification(
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText)
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
}
}
@@ -211,9 +163,7 @@ export function createSessionNotification(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
scheduler.deleteSession(sessionInfo.id)
}
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
}
}
}
+6 -18
View File
@@ -1,20 +1,7 @@
import { extractTaskLink } from "../../features/tool-metadata-store"
const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"]
const SESSION_ID_PATTERNS = [
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
/session_id: (ses_[a-zA-Z0-9_-]+)/,
/<task_metadata>\s*session_id: (ses_[a-zA-Z0-9_-]+)/,
/sessionId: (ses_[a-zA-Z0-9_-]+)/,
]
function extractSessionId(output: string): string | null {
for (const pattern of SESSION_ID_PATTERNS) {
const match = output.match(pattern)
if (match) return match[1] ?? null
}
return null
}
export function createTaskResumeInfoHook() {
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
@@ -25,12 +12,13 @@ export function createTaskResumeInfoHook() {
if (outputText.startsWith("Error:") || outputText.startsWith("Failed")) return
if (outputText.includes("\nto continue:")) return
const sessionId = extractSessionId(outputText)
if (!sessionId) return
const link = extractTaskLink(output.metadata, outputText)
const taskId = link.taskId ?? link.sessionId
if (!taskId) return
output.output =
outputText.trimEnd() +
`\n\nto continue: task(session_id="${sessionId}", load_skills=[], run_in_background=false, prompt="...")`
`\n\nto continue: task(task_id="${taskId}", load_skills=[], run_in_background=false, prompt="...")`
}
return {
+21 -1
View File
@@ -60,6 +60,7 @@ describe("createTaskResumeInfoHook", () => {
await afterHook(input, output)
expect(output.output).toContain("to continue:")
expect(output.output).toContain('task(task_id="ses_abc123"')
expect(output.output).toContain("ses_abc123")
})
@@ -74,6 +75,25 @@ describe("createTaskResumeInfoHook", () => {
await afterHook(input, output)
expect(output.output).toContain("run_in_background=false")
expect(output.output).toContain('task_id="ses_abc123"')
})
})
})
describe("#given target tool with session metadata object", () => {
describe("#when output text omits session ID but metadata includes it", () => {
it("#then should append resume info from metadata", async () => {
const input = createInput("task")
const output = {
title: "task",
output: "Task completed successfully",
metadata: { sessionID: "ses_meta_123" },
}
await afterHook(input, output)
expect(output.output).toContain("to continue:")
expect(output.output).toContain("ses_meta_123")
})
})
})
@@ -102,7 +122,7 @@ describe("createTaskResumeInfoHook", () => {
const output = {
title: "task",
output:
'Done.\nSession ID: ses_abc123\nto continue: task(session_id="ses_abc123", load_skills=[], prompt="...")',
'Done.\nSession ID: ses_abc123\nto continue: task(task_id="ses_abc123", load_skills=[], run_in_background=false, prompt="...")',
metadata: {},
}
+1 -1
View File
@@ -49,7 +49,7 @@ describe("createThinkModeHook", () => {
const input = createHookInput({
sessionID,
providerID: "github-copilot",
modelID: "claude-opus-4-6",
modelID: "claude-opus-4-7",
})
const output = createHookOutput("Please think deeply about this")
+17 -17
View File
@@ -23,26 +23,26 @@ describe("think-mode switcher", () => {
describe("getHighVariant with dots vs hyphens", () => {
it("should handle dots in Claude version numbers", () => {
// given a Claude model ID with dot format
const variant = getHighVariant("claude-opus-4.6")
const variant = getHighVariant("claude-opus-4.7")
// then should return high variant with hyphen format
expect(variant).toBe("claude-opus-4-6-high")
expect(variant).toBe("claude-opus-4-7-high")
})
it("should handle hyphens in Claude version numbers", () => {
// given a Claude model ID with hyphen format
const variant = getHighVariant("claude-opus-4-6")
const variant = getHighVariant("claude-opus-4-7")
// then should return high variant
expect(variant).toBe("claude-opus-4-6-high")
expect(variant).toBe("claude-opus-4-7-high")
})
it("should handle claude-opus-4-6 high variant", () => {
// given a Claude Opus 4.6 model ID
const variant = getHighVariant("claude-opus-4-6")
it("should handle claude-opus-4-7 high variant", () => {
// given a Claude Opus 4.7 model ID
const variant = getHighVariant("claude-opus-4-7")
// then should return high variant
expect(variant).toBe("claude-opus-4-6-high")
expect(variant).toBe("claude-opus-4-7-high")
})
it("should handle dots in GPT version numbers", () => {
@@ -73,7 +73,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high variants", () => {
// given model IDs that are already high variants
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
expect(getHighVariant("claude-opus-4-7-high")).toBeNull()
expect(getHighVariant("gpt-5-4-high")).toBeNull()
expect(getHighVariant("gemini-3-1-pro-high")).toBeNull()
})
@@ -89,7 +89,7 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant", () => {
it("should detect -high suffix", () => {
// given model IDs with -high suffix
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("claude-opus-4-7-high")).toBe(true)
expect(isAlreadyHighVariant("gpt-5-4-high")).toBe(true)
expect(isAlreadyHighVariant("gemini-3.1-pro-high")).toBe(true)
})
@@ -101,8 +101,8 @@ describe("think-mode switcher", () => {
it("should return false for base models", () => {
// given base model IDs without -high suffix
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4-7")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4.7")).toBe(false)
expect(isAlreadyHighVariant("gpt-5.4")).toBe(false)
expect(isAlreadyHighVariant("gemini-3.1-pro")).toBe(false)
})
@@ -133,10 +133,10 @@ describe("think-mode switcher", () => {
it("should handle prefixes with dots in version numbers", () => {
// given a model ID with prefix and dots
const variant = getHighVariant("vertex_ai/claude-opus-4.6")
const variant = getHighVariant("vertex_ai/claude-opus-4.7")
// then should normalize dots and preserve prefix
expect(variant).toBe("vertex_ai/claude-opus-4-6-high")
expect(variant).toBe("vertex_ai/claude-opus-4-7-high")
})
it("should handle multiple different prefixes", () => {
@@ -167,7 +167,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high prefixed models", () => {
// given prefixed model IDs that are already high
expect(getHighVariant("vertex_ai/claude-opus-4-6-high")).toBeNull()
expect(getHighVariant("vertex_ai/claude-opus-4-7-high")).toBeNull()
expect(getHighVariant("openai/gpt-5-4-high")).toBeNull()
})
})
@@ -175,14 +175,14 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant with prefixes", () => {
it("should detect -high suffix in prefixed models", () => {
// given prefixed model IDs with -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-7-high")).toBe(true)
expect(isAlreadyHighVariant("openai/gpt-5-4-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3.1-pro-high")).toBe(true)
})
it("should return false for prefixed base models", () => {
// given prefixed base model IDs without -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6")).toBe(false)
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-7")).toBe(false)
expect(isAlreadyHighVariant("openai/gpt-5-4")).toBe(false)
})
+1 -1
View File
@@ -45,7 +45,7 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } {
const HIGH_VARIANT_MAP: Record<string, string> = {
// Claude
"claude-sonnet-4-6": "claude-sonnet-4-6-high",
"claude-opus-4-6": "claude-opus-4-6-high",
"claude-opus-4-7": "claude-opus-4-7-high",
// Gemini
"gemini-3-1-pro": "gemini-3-1-pro-high",
"gemini-3-1-pro-low": "gemini-3-1-pro-high",
@@ -8,6 +8,7 @@ declare module "bun:test" {
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
import * as actualSessionStateModule from "./session-state"
import type { SessionStateStore } from "./session-state"
@@ -37,6 +38,12 @@ function createMockPluginInput(): PluginInput {
} as PluginInput
}
function createMockBackgroundManager(): BackgroundManager {
return {
getTasksByParentSession: () => [{ status: "running" }],
} as BackgroundManager
}
function getCreatedSessionStateStore(): SessionStateStore {
if (!createdSessionStateStore) {
throw new Error("expected session state store to be created")
@@ -68,7 +75,7 @@ describe("todo-continuation-enforcer dispose", () => {
enforcer.dispose()
})
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", () => {
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", async () => {
// given
const originalClearInterval = globalThis.clearInterval
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
@@ -78,9 +85,13 @@ describe("todo-continuation-enforcer dispose", () => {
}) as typeof clearInterval
try {
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
const enforcer = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(),
})
const sessionStateStore = getCreatedSessionStateStore()
await enforcer.handler({ event: { type: "session.idle", properties: { sessionID: "session-1" } } })
enforcer.markRecovering("session-1")
enforcer.markRecovering("session-2")
@@ -61,6 +61,7 @@ export function createTodoContinuationHandler(args: {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
sessionStateStore.startPruneInterval()
await handleSessionIdle({
ctx,
sessionID,
@@ -31,6 +31,7 @@ export interface ContinuationProgressUpdate {
export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
startPruneInterval: () => void
recordActivity: (sessionID: string) => void
trackContinuationProgress: (
sessionID: string,
@@ -76,18 +77,26 @@ export function createSessionStateStore(): SessionStateStore {
// Periodic pruning of stale session states to prevent unbounded Map growth
let pruneInterval: TimerHandle | undefined
pruneInterval = setInterval(() => {
const now = Date.now()
for (const [sessionID, tracked] of sessions.entries()) {
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
let pruneIntervalStarted = false
function startPruneInterval(): void {
if (pruneIntervalStarted) {
return
}
pruneIntervalStarted = true
pruneInterval = setInterval(() => {
const now = Date.now()
for (const [sessionID, tracked] of sessions.entries()) {
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
}
}, SESSION_STATE_PRUNE_INTERVAL_MS)
if (typeof pruneInterval === "object" && typeof pruneInterval.unref === "function") {
pruneInterval.unref()
}
}, SESSION_STATE_PRUNE_INTERVAL_MS)
// Allow process to exit naturally even if interval is running
if (typeof pruneInterval === "object" && typeof pruneInterval.unref === "function") {
pruneInterval.unref()
}
function getTrackedSession(sessionID: string): TrackedSessionState {
@@ -272,6 +281,7 @@ export function createSessionStateStore(): SessionStateStore {
return {
getState,
getExistingState,
startPruneInterval,
recordActivity,
trackContinuationProgress,
resetContinuationProgress,
@@ -249,6 +249,33 @@ describe("todo-continuation-enforcer", () => {
_resetForTesting()
})
test("given the first idle event, starts the prune interval lazily", async () => {
// given
const originalSetInterval = globalThis.setInterval
let setIntervalCalls = 0
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
setIntervalCalls += 1
return originalSetInterval(callback, delay, ...args)
}) as typeof setInterval
try {
const sessionID = "main-lazy-prune"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true),
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then
expect(setIntervalCalls).toBe(1)
} finally {
globalThis.setInterval = originalSetInterval
}
})
test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore()
// given - main session with incomplete todos
+10 -2
View File
@@ -76,7 +76,15 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
let canonicalSessionRoot: string | undefined
function getCanonicalSessionRoot(): string {
if (!canonicalSessionRoot) {
canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
}
return canonicalSessionRoot
}
return {
"tool.execute.before": async (input, output) => {
@@ -86,7 +94,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
output,
readPermissionsBySession,
sessionLastAccess,
canonicalSessionRoot,
getCanonicalSessionRoot,
maxTrackedSessions: MAX_TRACKED_SESSIONS,
})
},
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const realFs = await import("node:fs")
const existsSyncMock = mock(realFs.existsSync)
const realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import("./index")
describe("createWriteExistingFileGuardHook", () => {
let tempDir = ""
beforeEach(() => {
// given
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
mkdirSync(tempDir, { recursive: true })
existsSyncMock.mockClear()
realpathNativeMock.mockClear()
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
// given
const existingFile = join(tempDir, "existing.txt")
writeFileSync(existingFile, "content")
// when
const hook = createWriteExistingFileGuardHook({ directory: tempDir } as never)
// then
expect(existsSyncMock).toHaveBeenCalledTimes(0)
expect(realpathNativeMock).toHaveBeenCalledTimes(0)
// when
await expect(
hook["tool.execute.before"]?.(
{
tool: "write",
sessionID: "ses_lazy",
callID: "call_lazy",
} as never,
{ args: { filePath: existingFile, content: "updated" } } as never,
),
).rejects.toThrow("File already exists. Use edit tool instead.")
// then
expect(existsSyncMock).toHaveBeenCalledTimes(3)
expect(realpathNativeMock).toHaveBeenCalledTimes(2)
})
})
@@ -90,10 +90,10 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
output: { args?: unknown }
readPermissionsBySession: Map<string, Set<string>>
sessionLastAccess: Map<string, number>
canonicalSessionRoot: string
getCanonicalSessionRoot: () => string
maxTrackedSessions: number
}): Promise<void> {
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, canonicalSessionRoot, maxTrackedSessions } = params
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
@@ -107,6 +107,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
}
const resolvedPath = resolveInputPath(ctx, filePath)
const canonicalSessionRoot = getCanonicalSessionRoot()
const canonicalPath = toCanonicalPath(resolvedPath)
if (!isPathInsideDirectory(canonicalPath, canonicalSessionRoot)) {
return
+25 -5
View File
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { createAutoUpdateCheckerHook } from "../auto-update-checker/hook"
let scheduledDeferredCheck: (() => void) | null = null
mock.module("../auto-update-checker/hook/deferred-startup-check", () => ({
scheduleDeferredStartupCheck: (runCheck: () => void) => {
scheduledDeferredCheck = runCheck
},
}))
const { createAutoUpdateCheckerHook } = await import("../auto-update-checker/hook")
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
@@ -38,6 +46,12 @@ function runSessionCreatedEvent(
})
}
function drainDeferredCheck(): void {
const run = scheduledDeferredCheck
scheduledDeferredCheck = null
run?.()
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
@@ -51,6 +65,8 @@ beforeEach(() => {
mockGetCachedVersion.mockReturnValue("3.6.0")
mockGetLocalDevVersion.mockReturnValue(null)
scheduledDeferredCheck = null
})
afterEach(() => {
@@ -108,8 +124,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives on primary session
//#when - session.created schedules work and deferred check drains it
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - startup checks, toast, and background check run
@@ -165,9 +182,10 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event is fired twice
//#when - session.created fires twice and deferred check drains once
runSessionCreatedEvent(hook)
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - side effects execute only once
@@ -195,8 +213,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives
//#when - session.created schedules and deferred check drains
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - local dev toast is shown and background check is skipped
@@ -259,8 +278,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives
//#when - session.created schedules and deferred check drains
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - startup toast includes sisyphus wording