Merge remote-tracking branch 'origin/dev' into fix/cli-run-premature-exit-with-background-tasks
This commit is contained in:
+1
-1
@@ -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 },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,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" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,6 +1,6 @@
|
||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 : ""
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user