Merge branch 'dev' into fix/issue-2232

This commit is contained in:
YeonGyu-Kim
2026-03-11 20:20:04 +09:00
committed by GitHub
210 changed files with 7089 additions and 1274 deletions
+2 -160
View File
@@ -1,18 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { getLastAgentFromSession } from "./session-last-agent"
import { handleAtlasSessionIdle } from "./idle-event"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
export function createAtlasEventHandler(input: {
ctx: PluginInput
options?: AtlasHookOptions
@@ -39,157 +31,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
const isBackgroundTaskSession = subagentSessions.has(sessionID)
// Allow continuation only if: session is in boulder's session_ids OR is a background task
if (!isBackgroundTaskSession && !isBoulderSession) {
log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID })
return
}
const state = getState(sessionID)
const now = Date.now()
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (state.promptFailureCount >= 2) {
const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
state.promptFailureCount = 0
state.lastFailureAt = undefined
}
const backgroundManager = options?.backgroundManager
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (!boulderState) {
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
const sessionAgent = getSessionAgent(sessionID)
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
const effectiveAgent = sessionAgent ?? lastAgent
const lastAgentKey = getAgentConfigKey(effectiveAgent ?? "")
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: effectiveAgent ?? "unknown",
requiredAgent,
})
return
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
if (!state.pendingRetryTimer) {
state.pendingRetryTimer = setTimeout(async () => {
state.pendingRetryTimer = undefined
if (state.promptFailureCount >= 2) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const hasBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
: false
if (hasBgTasks) return
state.lastContinuationInjectedAt = Date.now()
const currentRemaining = currentProgress.total - currentProgress.completed
try {
await injectBoulderContinuation({
ctx,
sessionID,
planName: currentBoulder.plan_name,
remaining: currentRemaining,
total: currentProgress.total,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
backgroundManager,
sessionState: state,
})
} catch (err) {
log(`[${HOOK_NAME}] Delayed retry failed`, { sessionID, error: err })
state.promptFailureCount++
}
}, RETRY_DELAY_MS)
}
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
pendingRetry: !!state.pendingRetryTimer,
})
return
}
state.lastContinuationInjectedAt = now
const remaining = progress.total - progress.completed
try {
await injectBoulderContinuation({
ctx,
sessionID,
planName: boulderState.plan_name,
remaining,
total: progress.total,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
backgroundManager,
sessionState: state,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID, error: err })
state.promptFailureCount++
}
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return
}
+215
View File
@@ -0,0 +1,215 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
const backgroundManager = options?.backgroundManager
return backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
}
function resolveActiveBoulderSession(input: {
directory: string
sessionID: string
}): {
boulderState: BoulderState
progress: PlanProgress
appendedSession: boolean
} | null {
const boulderState = readBoulderState(input.directory)
if (!boulderState) {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
if (boulderState.session_ids.includes(input.sessionID)) {
return { boulderState, progress, appendedSession: false }
}
if (!subagentSessions.has(input.sessionID)) {
return null
}
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
return null
}
return {
boulderState: updatedBoulderState,
progress,
appendedSession: true,
}
}
async function injectContinuation(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
planName: string
progress: { total: number; completed: number }
agent?: string
worktreePath?: string
}): Promise<void> {
const remaining = input.progress.total - input.progress.completed
input.sessionState.lastContinuationInjectedAt = Date.now()
try {
await injectBoulderContinuation({
ctx: input.ctx,
sessionID: input.sessionID,
planName: input.planName,
remaining,
total: input.progress.total,
agent: input.agent,
worktreePath: input.worktreePath,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
})
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
input.sessionState.promptFailureCount += 1
}
}
function scheduleRetry(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
}): void {
const { ctx, sessionID, sessionState, options } = input
if (sessionState.pendingRetryTimer) {
return
}
sessionState.pendingRetryTimer = setTimeout(async () => {
sessionState.pendingRetryTimer = undefined
if (sessionState.promptFailureCount >= 2) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
}, RETRY_DELAY_MS)
}
export async function handleAtlasSessionIdle(input: {
ctx: PluginInput
options?: AtlasHookOptions
getState: (sessionID: string) => SessionState
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const activeBoulderSession = resolveActiveBoulderSession({
directory: ctx.directory,
sessionID,
})
if (!activeBoulderSession) {
log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID })
return
}
const { boulderState, progress, appendedSession } = activeBoulderSession
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (appendedSession) {
log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, {
sessionID,
plan: boulderState.plan_name,
})
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.lastEventWasAbortError) {
sessionState.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (sessionState.promptFailureCount >= 2) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: sessionState.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
sessionState.promptFailureCount = 0
sessionState.lastFailureAt = undefined
}
if (hasRunningBackgroundTasks(sessionID, options)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
scheduleRetry({ ctx, sessionID, sessionState, options })
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt),
pendingRetry: !!sessionState.pendingRetryTimer,
})
return
}
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: boulderState.plan_name,
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
})
}
+76 -14
View File
@@ -846,6 +846,71 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should append subagent session to boulder before injecting continuation", async () => {
// given - active boulder plan with another registered session and current session tracked as subagent
const subagentSessionID = "subagent-session-456"
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
subagentSessions.add(subagentSessionID)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when - subagent session goes idle before parent task output appends it
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// then - session is registered into boulder and continuation is injected
expect(readBoulderState(TEST_DIR)?.session_ids).toContain(subagentSessionID)
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(subagentSessionID)
})
test("should inject when registered boulder session has incomplete tasks even if last agent differs", async () => {
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
agent: "atlas",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when boulder plan is complete", async () => {
// given - boulder state with complete plan
const planPath = join(TEST_DIR, "complete-plan.md")
@@ -1083,10 +1148,9 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
test("should inject when registered atlas boulder session last agent does not match", async () => {
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
@@ -1103,17 +1167,15 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
// given - boulder state expects sisyphus and last agent is sisyphus
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode")
const TEST_USER_CONFIG_DIR = "/tmp/opencode-config"
mock.module("./constants", () => ({
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
USER_CONFIG_DIR: TEST_USER_CONFIG_DIR,
PACKAGE_NAME: "oh-my-opencode",
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "bun.lock"),
JSON.stringify(
{
workspaces: {
"": {
dependencies: { "oh-my-opencode": "latest", other: "1.0.0" },
},
},
packages: {
"oh-my-opencode": {},
other: {},
},
},
null,
2
)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
'{"name":"oh-my-opencode"}'
)
}
describe("invalidatePackage", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
})
it("invalidates the installed package from the OpenCode cache directory", async () => {
const { invalidatePackage } = await import("./cache")
const result = invalidatePackage()
expect(result).toBe(true)
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false)
const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as {
dependencies?: Record<string, string>
}
expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(packageJson.dependencies?.other).toBe("1.0.0")
const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as {
workspaces?: { ""?: { dependencies?: Record<string, string> } }
packages?: Record<string, unknown>
}
expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0")
expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined()
expect(bunLock.packages?.other).toEqual({})
})
})
+40 -35
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger"
interface BunLockfile {
@@ -16,65 +16,70 @@ function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1")
}
function removeFromBunLock(packageName: string): boolean {
const lockPath = path.join(USER_CONFIG_DIR, "bun.lock")
if (!fs.existsSync(lockPath)) return false
function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
try {
const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let modified = false
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
delete lock.workspaces[""].dependencies[packageName]
modified = true
}
if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
modified = true
}
if (modified) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true
}
return modified
return false
} catch {
return false
}
}
function deleteBinaryBunLock(lockPath: string): boolean {
try {
fs.unlinkSync(lockPath)
log(`[auto-update-checker] Removed bun.lockb to force re-resolution`)
return true
} catch {
return false
}
}
function removeFromBunLock(packageName: string): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName)
}
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
if (fs.existsSync(binaryLockPath)) {
return deleteBinaryBunLock(binaryLockPath)
}
return false
}
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
const pkgDirs = [
path.join(USER_CONFIG_DIR, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgJsonPath)) {
const content = fs.readFileSync(pkgJsonPath, "utf-8")
const pkgJson = JSON.parse(content)
if (pkgJson.dependencies?.[packageName]) {
delete pkgJson.dependencies[packageName]
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
dependencyRemoved = true
for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
}
lockRemoved = removeFromBunLock(packageName)
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
if (!packageRemoved && !lockRemoved) {
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
return false
}
+1
View File
@@ -6,3 +6,4 @@ export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
@@ -0,0 +1,226 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "./plugin-entry"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
mock.module("../constants", () => ({
CACHE_DIR: TEST_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
}))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(currentVersion = "3.10.0"): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
)
}
function cleanupTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
}
function readCachePackageJsonVersion(): string | undefined {
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
return pkg.dependencies?.["oh-my-opencode"]
}
describe("syncCachePackageJsonToIntent", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
cleanupTestCache()
})
describe("#given cache package.json with pinned semver version", () => {
describe("#when opencode.json intent is latest tag", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#when opencode.json intent is next tag", () => {
it("#then updates package.json to use next", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@next",
isPinned: false,
pinnedVersion: "next",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("next")
})
})
describe("#when opencode.json has no version (implies latest)", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
})
describe("#given cache package.json already matches intent", () => {
it("#then returns false without modifying package.json", async () => {
//#given
resetTestCache("latest")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#given cache package.json does not exist", () => {
it("#then returns false", async () => {
//#given
cleanupTestCache()
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
})
})
describe("#given plugin not in cache package.json dependencies", () => {
it("#then returns false", async () => {
//#given
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
)
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
})
})
describe("#given user explicitly pinned a different semver", () => {
it("#then updates package.json to new version", async () => {
//#given
resetTestCache("3.9.0")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@3.10.0",
isPinned: true,
pinnedVersion: "3.10.0",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("3.10.0")
})
})
describe("#given other dependencies exist in cache package.json", () => {
it("#then preserves other dependencies while updating the plugin", async () => {
//#given
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
syncCachePackageJsonToIntent(pluginInfo)
//#then
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.other).toBe("1.0.0")
expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest")
})
})
})
@@ -0,0 +1,63 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "../constants"
import { log } from "../../../shared/logger"
import type { PluginEntryInfo } from "./plugin-entry"
interface CachePackageJson {
dependencies?: Record<string, string>
}
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
if (!pluginInfo.pinnedVersion) {
return "latest"
}
return pluginInfo.pinnedVersion
}
/**
* Sync cache package.json to match opencode.json plugin intent before bun install.
*
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
* When auto-update detects a newer version and runs `bun install`, it re-resolves the pinned
* version instead of the user's declared tag, causing updates to silently fail.
*
* @returns true if package.json was updated, false otherwise
*/
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): boolean {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
if (!fs.existsSync(cachePackageJsonPath)) {
log("[auto-update-checker] Cache package.json not found, nothing to sync")
return false
}
try {
const content = fs.readFileSync(cachePackageJsonPath, "utf-8")
const pkgJson = JSON.parse(content) as CachePackageJson
if (!pkgJson.dependencies?.[PACKAGE_NAME]) {
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
return false
}
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
const intentVersion = getIntentVersion(pluginInfo)
if (currentVersion === intentVersion) {
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
return false
}
log(
`[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}"`
)
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
fs.writeFileSync(cachePackageJsonPath, JSON.stringify(pkgJson, null, 2))
return true
} catch (err) {
log("[auto-update-checker] Failed to sync cache package.json:", err)
return false
}
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "bun:test"
import { join } from "node:path"
import { getOpenCodeCacheDir } from "../../shared/data-path"
describe("auto-update-checker constants", () => {
it("uses the OpenCode cache directory for installed package metadata", async () => {
const { CACHE_DIR, INSTALLED_PACKAGE_JSON, PACKAGE_NAME } = await import(`./constants?test=${Date.now()}`)
expect(CACHE_DIR).toBe(getOpenCodeCacheDir())
expect(INSTALLED_PACKAGE_JSON).toBe(
join(getOpenCodeCacheDir(), "node_modules", PACKAGE_NAME, "package.json")
)
})
})
+4 -10
View File
@@ -1,19 +1,13 @@
import * as path from "node:path"
import * as os from "node:os"
import { getOpenCodeConfigDir } from "../../shared"
import { getOpenCodeCacheDir } from "../../shared/data-path"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
function getCacheDir(): string {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
}
return path.join(os.homedir(), ".cache", "opencode")
}
export const CACHE_DIR = getCacheDir()
export const CACHE_DIR = getOpenCodeCacheDir()
export const VERSION_FILE = path.join(CACHE_DIR, "version")
export function getWindowsAppdataDir(): string | null {
@@ -26,7 +20,7 @@ export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")
export const INSTALLED_PACKAGE_JSON = path.join(
USER_CONFIG_DIR,
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
+34 -44
View File
@@ -54,6 +54,26 @@ function createPluginInput() {
} as never
}
async function flushScheduledWork(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
await Promise.resolve()
await Promise.resolve()
}
function runSessionCreatedEvent(
hook: ReturnType<HookFactory>,
properties?: { info?: { parentID?: string } }
): void {
hook.event({
event: {
type: "session.created",
properties,
},
})
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
@@ -85,13 +105,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: undefined } })
await flushScheduledWork()
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -108,12 +123,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives on primary session
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -129,13 +140,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event contains parentID
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: "parent-123" } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -152,17 +158,9 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event is fired twice
hook.event({
event: {
type: "session.created",
},
})
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -179,12 +177,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -206,7 +200,7 @@ describe("createAutoUpdateCheckerHook", () => {
type: "session.deleted",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -225,12 +219,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup toast includes sisyphus wording
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
@@ -51,11 +51,21 @@ mock.module("../../../shared/logger", () => ({ log: () => {} }))
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as PluginInput
async function runCheck(autoUpdate = true): Promise<void> {
const mockContext = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
isUpdate ? `Update to ${version}` : "Up to date"
await runBackgroundUpdateCheck(mockContext, autoUpdate, getToastMessage)
}
function expectNoUpdateEffects(): void {
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
}
describe("runBackgroundUpdateCheck", () => {
beforeEach(() => {
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
@@ -73,61 +83,42 @@ describe("runBackgroundUpdateCheck", () => {
mockRunBunInstall.mockResolvedValue(true)
})
describe("#given no plugin entry found", () => {
it("returns early without showing any toast", async () => {
describe("#given no-op scenarios", () => {
it.each([
{
name: "plugin entry is missing",
setup: () => {
mockFindPluginEntry.mockReturnValue(null)
},
},
{
name: "no cached or pinned version exists",
setup: () => {
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
},
},
{
name: "latest version lookup fails",
setup: () => {
mockGetLatestVersion.mockResolvedValue(null)
},
},
{
name: "current version is already latest",
setup: () => {
mockGetLatestVersion.mockResolvedValue("3.4.0")
},
},
])("returns without user-visible update effects when $name", async ({ setup }) => {
//#given
mockFindPluginEntry.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
})
setup()
describe("#given no version available", () => {
it("returns early when neither cached nor pinned version exists", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
expect(mockGetLatestVersion).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
await runCheck()
describe("#given latest version fetch fails", () => {
it("returns early without toasts", async () => {
//#given
mockGetLatestVersion.mockResolvedValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
it("returns early without any action", async () => {
//#given
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.4.0")
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expectNoUpdateEffects()
})
})
@@ -136,9 +127,13 @@ describe("runBackgroundUpdateCheck", () => {
//#given
const autoUpdate = false
//#when
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
await runCheck(autoUpdate)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.5.0",
expect.any(Function)
)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
@@ -149,7 +144,7 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
@@ -166,7 +161,7 @@ describe("runBackgroundUpdateCheck", () => {
}
)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(capturedToastMessage).toBeDefined()
@@ -184,11 +179,15 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockRunBunInstall.mockResolvedValue(true)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.4.0",
"3.5.0"
)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
})
@@ -198,10 +197,14 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockRunBunInstall.mockResolvedValue(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.5.0",
expect.any(Function)
)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
import { extractChannel } from "../version-channel"
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion } from "../checker"
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion, syncCachePackageJsonToIntent } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
function getPinnedVersionToastMessage(latestVersion: string): string {
@@ -65,6 +65,7 @@ export async function runBackgroundUpdateCheck(
return
}
syncCachePackageJsonToIntent(pluginInfo)
invalidatePackage(PACKAGE_NAME)
const installSuccess = await runBunInstallSafe()
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
const transcriptCalls: Array<[string, unknown]> = []
const appendTranscriptEntry = mock((sessionId: string, entry: unknown) => {
transcriptCalls.push([sessionId, entry])
})
mock.module("../config", () => ({
loadClaudeHooksConfig: async () => ({}),
}))
mock.module("../config-loader", () => ({
loadPluginExtendedConfig: async () => ({}),
}))
mock.module("../post-tool-use", () => ({
executePostToolUseHooks: async () => ({ warnings: [] }),
}))
mock.module("../transcript", () => ({
appendTranscriptEntry,
getTranscriptPath: () => "/tmp/transcript.jsonl",
}))
const { createToolExecuteAfterHandler } = await import("./tool-execute-after-handler")
describe("createToolExecuteAfterHandler", () => {
beforeEach(() => {
appendTranscriptEntry.mockClear()
transcriptCalls.length = 0
})
it("#given diff-heavy metadata #when transcript entry is appended #then it keeps concise output with compact metadata", async () => {
const handler = createToolExecuteAfterHandler(
{
client: {
tui: {
showToast: async () => ({}),
},
},
directory: "/repo",
} as never,
{ disabledHooks: ["PostToolUse"] }
)
await handler(
{ tool: "hashline_edit", sessionID: "ses_test", callID: "call_test" },
{
title: "src/example.ts",
output: "Updated src/example.ts",
metadata: {
filePath: "src/example.ts",
path: "src/duplicate-path.ts",
file: "src/duplicate-file.ts",
sessionId: "ses_oracle",
agent: "oracle",
prompt: "very large hidden prompt",
diff: "x".repeat(5000),
noopEdits: 1,
deduplicatedEdits: 2,
firstChangedLine: 42,
filediff: {
before: "before body",
after: "after body",
additions: 3,
deletions: 4,
},
nested: {
keep: false,
},
},
}
)
expect(appendTranscriptEntry).toHaveBeenCalledTimes(1)
const firstCall = transcriptCalls[0]
const sessionId = firstCall?.[0]
const entry = firstCall?.[1]
expect(sessionId).toBe("ses_test")
expect(entry).toBeDefined()
if (!entry || typeof entry !== "object" || !("tool_output" in entry)) {
throw new Error("expected transcript entry with tool_output")
}
const toolOutput = entry.tool_output
expect(toolOutput).toBeDefined()
if (!isRecord(toolOutput)) {
throw new Error("expected compact tool_output object")
}
expect(entry).toMatchObject({
type: "tool_result",
tool_name: "hashline_edit",
tool_input: {},
tool_output: {
output: "Updated src/example.ts",
filePath: "src/example.ts",
sessionId: "ses_oracle",
agent: "oracle",
noopEdits: 1,
deduplicatedEdits: 2,
firstChangedLine: 42,
filediff: {
additions: 3,
deletions: 4,
},
},
})
expect(entry).toHaveProperty("timestamp")
expect(toolOutput).not.toHaveProperty("diff")
expect(toolOutput).not.toHaveProperty("path")
expect(toolOutput).not.toHaveProperty("file")
expect(toolOutput).not.toHaveProperty("prompt")
expect(toolOutput).not.toHaveProperty("nested")
const filediff = toolOutput.filediff
expect(filediff).toBeDefined()
if (!isRecord(filediff)) {
throw new Error("expected compact filediff object")
}
expect(filediff).not.toHaveProperty("before")
expect(filediff).not.toHaveProperty("after")
})
})
@@ -11,6 +11,65 @@ import { appendTranscriptEntry, getTranscriptPath } from "../transcript"
import type { PluginConfig } from "../types"
import { isHookDisabled } from "../../../shared"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function getStringValue(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
return typeof value === "string" && value.length > 0 ? value : undefined
}
function getNumberValue(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key]
return typeof value === "number" ? value : undefined
}
function buildTranscriptToolOutput(outputText: string, metadata: unknown): Record<string, unknown> {
const compactOutput: Record<string, unknown> = { output: outputText }
if (!isRecord(metadata)) {
return compactOutput
}
const filePath = getStringValue(metadata, "filePath")
?? getStringValue(metadata, "path")
?? getStringValue(metadata, "file")
if (filePath) {
compactOutput.filePath = filePath
}
const sessionId = getStringValue(metadata, "sessionId")
if (sessionId) {
compactOutput.sessionId = sessionId
}
const agent = getStringValue(metadata, "agent")
if (agent) {
compactOutput.agent = agent
}
for (const key of ["noopEdits", "deduplicatedEdits", "firstChangedLine"] as const) {
const value = getNumberValue(metadata, key)
if (value !== undefined) {
compactOutput[key] = value
}
}
const filediff = metadata.filediff
if (isRecord(filediff)) {
const additions = getNumberValue(filediff, "additions")
const deletions = getNumberValue(filediff, "deletions")
if (additions !== undefined || deletions !== undefined) {
compactOutput.filediff = {
...(additions !== undefined ? { additions } : {}),
...(deletions !== undefined ? { deletions } : {}),
}
}
}
return compactOutput
}
export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) {
return async (
input: { tool: string; sessionID: string; callID: string },
@@ -25,17 +84,12 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
const metadata = output.metadata as Record<string, unknown> | undefined
const hasMetadata =
metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
const toolOutput = hasMetadata ? metadata : { output: output.output }
appendTranscriptEntry(input.sessionID, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: cachedInput,
tool_output: toolOutput,
tool_output: buildTranscriptToolOutput(output.output, output.metadata),
})
if (isHookDisabled(config, "PostToolUse")) {
@@ -0,0 +1,93 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { createContextWindowMonitorHook } from "./context-window-monitor"
function createOutput() {
return { title: "", output: "original", metadata: null }
}
describe("context-window-monitor modelContextLimitsCache", () => {
it("does not append reminder below cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_below_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 150000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toBe("original")
})
it("appends reminder above cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_above_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 180000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toContain("context remaining")
expect(output.output).toContain("262,144-token context window")
expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]")
expect(output.output).not.toContain("1,000,000")
})
})
+24 -12
View File
@@ -1,12 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const CONTEXT_WARNING_THRESHOLD = 0.70
type ModelCacheStateLike = {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
@@ -17,11 +17,15 @@ function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
function createContextReminder(actualLimit: number): string {
const limitTokens = actualLimit.toLocaleString()
You are using Anthropic Claude with 1M context window.
You have plenty of context remaining - do NOT rush or skip tasks.
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
You are using a ${limitTokens}-token context window.
You still have context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
}
interface TokenInfo {
input: number
@@ -32,6 +36,7 @@ interface TokenInfo {
interface CachedTokenState {
providerID: string
modelID: string
tokens: TokenInfo
}
@@ -57,25 +62,30 @@ export function createContextWindowMonitorHook(
const cached = tokenCache.get(sessionID)
if (!cached) return
if (!isAnthropicProvider(cached.providerID)) return
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(
`${cached.providerID}/${cached.modelID}`
)
const actualLimit =
cachedLimit ??
(isAnthropicProvider(cached.providerID) ? getAnthropicActualLimit(modelCacheState) : null)
if (!actualLimit) return
const lastTokens = cached.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const actualUsagePercentage =
totalInputTokens / getAnthropicActualLimit(modelCacheState)
const actualUsagePercentage = totalInputTokens / actualLimit
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
remindedSessions.add(sessionID)
const displayUsagePercentage = totalInputTokens / ANTHROPIC_DISPLAY_LIMIT
const usedPct = (displayUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - displayUsagePercentage) * 100).toFixed(1)
const usedPct = (actualUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
const usedTokens = totalInputTokens.toLocaleString()
const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString()
const limitTokens = actualLimit.toLocaleString()
output.output += `\n\n${CONTEXT_REMINDER}
output.output += `\n\n${createContextReminder(actualLimit)}
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
}
@@ -95,6 +105,7 @@ export function createContextWindowMonitorHook(
role?: string
sessionID?: string
providerID?: string
modelID?: string
finish?: boolean
tokens?: TokenInfo
} | undefined
@@ -104,6 +115,7 @@ export function createContextWindowMonitorHook(
tokenCache.set(info.sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
})
}
@@ -18,7 +18,7 @@ export function findAgentsMdUp(input: {
while (true) {
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
// See: https://github.com/code-yeongyu/oh-my-openagent/issues/379
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
+18 -3
View File
@@ -14,6 +14,14 @@ import {
import type { ContextCollector } from "../../features/context-injector"
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
if (typeof message["variant"] === "string") {
return message["variant"]
}
return typeof input.variant === "string" ? input.variant : undefined
}
return {
"chat.message": async (
input: {
@@ -21,6 +29,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
variant?: string
},
output: {
message: Record<string, unknown>
@@ -72,15 +81,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
if (hasUltrawork) {
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
const runtimeVariant = getRuntimeVariant(input, output.message)
const isRuntimeMax = runtimeVariant === "max"
output.message.variant = "max"
log(`[keyword-detector] Ultrawork mode activated`, {
sessionID: input.sessionID,
runtimeVariant,
})
ctx.client.tui
.showToast({
body: {
title: "Ultrawork Mode Activated",
message: "Maximum precision engaged. All agents at your disposal.",
message: isRuntimeMax
? "Maximum precision engaged. All agents at your disposal."
: "Runtime variant preserved. All agents at your disposal.",
variant: "success" as const,
duration: 3000,
},
+9 -9
View File
@@ -169,8 +169,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should still work (variant set to max)
expect(output.message.variant).toBe("max")
// then - ultrawork should still work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -214,12 +214,12 @@ describe("keyword-detector session filtering", () => {
output
)
// then - all keywords should work
expect(output.message.variant).toBe("max")
// then - all keywords should work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should override existing variant when ultrawork keyword is used", async () => {
test("should preserve existing runtime variant when ultrawork keyword is used", async () => {
// given - main session set with pre-existing variant from TUI
setMainSession("main-123")
@@ -236,8 +236,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should override TUI variant to max
expect(output.message.variant).toBe("max")
// then - ultrawork should preserve the already resolved runtime variant
expect(output.message.variant).toBe("low")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
})
@@ -311,8 +311,8 @@ describe("keyword-detector word boundary", () => {
output
)
// then - ultrawork should be triggered
expect(output.message.variant).toBe("max")
// then - ultrawork should be triggered without forcing max
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { createKeywordDetectorHook } from "./index"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
function createMockPluginInput(toastMessages: string[]) {
return {
client: {
tui: {
showToast: async (opts: { body: { message: string } }) => {
toastMessages.push(opts.body.message)
},
},
},
} as any
}
describe("keyword-detector ultrawork runtime variant gating", () => {
test("#given runtime max variant #when ultrawork activates #then maximum precision toast is preserved", async () => {
// given
_resetForTesting()
setMainSession("main-session")
const toastMessages: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
const output = {
message: { variant: "max" } as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork do it" }],
}
// when
await hook["chat.message"]({ sessionID: "main-session", variant: "max" }, output)
// then
expect(output.message.variant).toBe("max")
expect(toastMessages).toEqual(["Maximum precision engaged. All agents at your disposal."])
_resetForTesting()
})
test("#given runtime non-max variant #when ultrawork activates #then variant stays unchanged and toast does not claim max", async () => {
// given
_resetForTesting()
setMainSession("main-session")
const toastMessages: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
const output = {
message: { variant: "medium" } as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork do it" }],
}
// when
await hook["chat.message"]({ sessionID: "main-session", variant: "medium" }, output)
// then
expect(output.message.variant).toBe("medium")
expect(toastMessages).toEqual(["Runtime variant preserved. All agents at your disposal."])
_resetForTesting()
})
})
+116 -1
View File
@@ -140,6 +140,121 @@ describe("model fallback hook", () => {
expect(secondOutput.message["variant"]).toBeUndefined()
})
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
//#when
const firstSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
const secondSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(sessionID)
const hook = createModelFallbackHook() 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" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(sessionID)
const hook = createModelFallbackHook() 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" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"quotio",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
})
test("shows toast when fallback is applied", async () => {
//#given
const toastCalls: Array<{ title: string; message: string }> = []
@@ -199,7 +314,7 @@ describe("model fallback hook", () => {
sessionID,
"Atlas (Plan Executor)",
"github-copilot",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
)
expect(set).toBe(true)
+23 -1
View File
@@ -39,6 +39,12 @@ const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
function canonicalizeModelID(modelID: string): string {
return modelID
.toLowerCase()
.replace(/\./g, "-")
}
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain || fallbackChain.length === 0) {
@@ -77,6 +83,11 @@ export function setPendingModelFallback(
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
@@ -140,13 +151,24 @@ export function getNextFallback(
}
const providerID = selectFallbackProvider(fallback.providers, state.providerID)
const modelID = transformModelForProvider(providerID, fallback.model)
const isNoOpFallback =
providerID.toLowerCase() === state.providerID.toLowerCase() &&
canonicalizeModelID(modelID) === canonicalizeModelID(state.modelID)
if (isNoOpFallback) {
log("[model-fallback] Skipping no-op fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
continue
}
state.pending = false
log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
return {
providerID,
modelID: transformModelForProvider(providerID, fallback.model),
modelID,
variant: fallback.variant,
}
}
+4
View File
@@ -26,6 +26,10 @@ export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i,
/too.?many.?requests/i,
/quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/exhausted\s+your\s+capacity/i,
/usage\s+limit\s+has\s+been\s+reached/i,
/service.?unavailable/i,
/overloaded/i,
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { extractAutoRetrySignal, isRetryableError } from "./error-classifier"
describe("runtime-fallback error classifier", () => {
test("detects cooling-down auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("detects single-word cooldown auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6 are cooldown [retrying in 7m 56s attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("treats cooling-down retry messages as retryable", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const retryable = isRetryableError(error, [400, 403, 408, 429, 500, 502, 503, 504, 529])
//#then
expect(retryable).toBe(true)
})
test("ignores non-retry assistant status text", () => {
//#given
const info = {
status: "Thinking...",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeUndefined()
})
})
@@ -102,7 +102,7 @@ export interface AutoRetrySignal {
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached)/i.test(combined),
/(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
+87 -1
View File
@@ -2,13 +2,15 @@ import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const sessionStatusRetryKeys = new Map<string, string>()
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
@@ -33,6 +35,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
sessionStatusRetryKeys.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
}
@@ -182,6 +185,88 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
}
}
const handleSessionStatus = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
const agent = props?.agent as string | undefined
const model = props?.model as string | undefined
if (!sessionID || status?.type !== "retry") return
const retryMessage = typeof status.message === "string" ? status.message : ""
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
if (!retrySignal) return
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
return
}
sessionStatusRetryKeys.set(sessionID, retryKey)
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] session.status retry skipped — retry already in flight`, { sessionID })
return
}
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return
let state = sessionStates.get(sessionID)
if (!state) {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
const inferredModel = model || (agentConfig?.model as string | undefined)
if (!inferredModel) {
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
return
}
state = createFallbackState(inferredModel)
sessionStates.set(sessionID, state)
}
sessionLastAccess.set(sessionID, Date.now())
if (state.pendingFallbackModel) {
log(`[${HOOK_NAME}] session.status retry skipped (pending fallback in progress)`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
return
}
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
sessionID,
model: state.currentModel,
retryAttempt: status.attempt,
})
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
}
if (!result.success) {
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (!config.enabled) return
@@ -191,6 +276,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(props); return }
if (event.type === "session.idle") { handleSessionIdle(props); return }
if (event.type === "session.status") { await handleSessionStatus(props); return }
if (event.type === "session.error") { await handleSessionError(props); return }
}
}
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "bun:test"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
describe("runtime-fallback fallback-models", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
})
test("uses category fallback_models when session category is registered", () => {
//#given
const sessionID = "ses_runtime_fallback_category"
SessionCategoryRegistry.register(sessionID, "quick")
const pluginConfig = {
categories: {
quick: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("uses agent-specific fallback_models when agent is resolved", () => {
//#given
const pluginConfig = {
agents: {
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("does not fall back to another agent chain when agent cannot be resolved", () => {
//#given
const pluginConfig = {
agents: {
sisyphus: {
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"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_unknown", undefined, pluginConfig)
//#then
expect(result).toEqual([])
})
})
+2 -14
View File
@@ -1,5 +1,5 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { AGENT_NAMES, agentPattern } from "./agent-resolver"
import { agentPattern } from "./agent-resolver"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -51,19 +51,7 @@ export function getFallbackModelsForSession(
if (result) return result
}
const sisyphusFallback = tryGetFallbackFromAgent("sisyphus")
if (sisyphusFallback) {
log(`[${HOOK_NAME}] Using sisyphus fallback models (no agent detected)`, { sessionID })
return sisyphusFallback
}
for (const agentName of AGENT_NAMES) {
const result = tryGetFallbackFromAgent(agentName)
if (result) {
log(`[${HOOK_NAME}] Using ${agentName} fallback models (no agent detected)`, { sessionID })
return result
}
}
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
return []
}
+213
View File
@@ -387,6 +387,219 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" })
})
test("should trigger fallback on auto-retry signal in assistant text parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
},
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-info-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback on session.status auto-retry signal", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal in session.status"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
expect(promptCalls.length).toBe(1)
})
test("should deduplicate session.status countdown updates for the same retry attempt", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-dedup"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 475,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]",
},
},
},
})
expect(promptCalls.length).toBe(1)
})
test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }),
@@ -57,10 +57,20 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
return async (props: Record<string, unknown> | undefined) => {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const retrySignalResult = extractAutoRetrySignal(info)
const retrySignal = retrySignalResult?.signal
const timeoutEnabled = config.timeout_seconds > 0
const parts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
const parts = eventParts && eventParts.length > 0 ? eventParts : infoParts
const retrySignalResult = extractAutoRetrySignal(info)
const partsText = (parts ?? [])
.filter((p) => typeof p?.text === "string")
.map((p) => (p.text ?? "").trim())
.filter((text) => text.length > 0)
.join("\n")
const retrySignalFromParts = partsText
? extractAutoRetrySignal({ message: partsText, status: partsText, summary: partsText })?.signal
: undefined
const retrySignal = retrySignalResult?.signal ?? retrySignalFromParts
const errorContentResult = containsErrorContent(parts)
const error = info?.error ??
(retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ??
@@ -9,6 +9,8 @@ type SessionNotificationConfig = {
idleConfirmationDelay: number
skipIfIncompleteTodos: boolean
maxTrackedSessions: number
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createIdleNotificationScheduler(options: {
@@ -24,6 +26,9 @@ export function createIdleNotificationScheduler(options: {
const sessionActivitySinceIdle = new Set<string>()
const notificationVersions = new Map<string, number>()
const executingNotifications = new Set<string>()
const scheduledAt = new Map<string, number>()
const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100
function cleanupOldSessions(): void {
const maxSessions = options.config.maxTrackedSessions
@@ -43,6 +48,10 @@ export function createIdleNotificationScheduler(options: {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach((id) => executingNotifications.delete(id))
}
if (scheduledAt.size > maxSessions) {
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
sessionsToRemove.forEach((id) => scheduledAt.delete(id))
}
}
function cancelPendingNotification(sessionID: string): void {
@@ -51,11 +60,21 @@ export function createIdleNotificationScheduler(options: {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
scheduledAt.delete(sessionID)
sessionActivitySinceIdle.add(sessionID)
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string): void {
const scheduledTime = scheduledAt.get(sessionID)
if (
activityGracePeriodMs > 0 &&
scheduledTime !== undefined &&
Date.now() - scheduledTime <= activityGracePeriodMs
) {
return
}
cancelPendingNotification(sessionID)
if (!executingNotifications.has(sessionID)) {
notifiedSessions.delete(sessionID)
@@ -65,22 +84,26 @@ export function createIdleNotificationScheduler(options: {
async function executeNotification(sessionID: string, version: number): Promise<void> {
if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notifiedSessions.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
@@ -113,6 +136,7 @@ export function createIdleNotificationScheduler(options: {
} finally {
executingNotifications.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
if (sessionActivitySinceIdle.has(sessionID)) {
notifiedSessions.delete(sessionID)
sessionActivitySinceIdle.delete(sessionID)
@@ -126,6 +150,7 @@ export function createIdleNotificationScheduler(options: {
if (executingNotifications.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
scheduledAt.set(sessionID, Date.now())
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
@@ -144,6 +169,7 @@ export function createIdleNotificationScheduler(options: {
sessionActivitySinceIdle.delete(sessionID)
notificationVersions.delete(sessionID)
executingNotifications.delete(sessionID)
scheduledAt.delete(sessionID)
}
return {
+111 -6
View File
@@ -1,10 +1,13 @@
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"
import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils"
import * as sender from "./session-notification-sender"
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
describe("session-notification", () => {
let notificationCalls: string[]
@@ -31,6 +34,10 @@ describe("session-notification", () => {
}
beforeEach(() => {
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
_resetForTesting()
notificationCalls = []
@@ -42,13 +49,24 @@ describe("session-notification", () => {
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx, _platform, _title, message) => {
notificationCalls.push(message)
})
spyOn(sender, "sendSessionNotification").mockImplementation(
async (
_ctx: Parameters<typeof sender.sendSessionNotification>[0],
_platform: Parameters<typeof sender.sendSessionNotification>[1],
_title: Parameters<typeof sender.sendSessionNotification>[2],
message: Parameters<typeof sender.sendSessionNotification>[3]
) => {
notificationCalls.push(message)
}
)
})
afterEach(() => {
// given - cleanup after each test
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
subagentSessions.clear()
_resetForTesting()
})
@@ -195,8 +213,9 @@ describe("session-notification", () => {
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 100, // Long delay
idleConfirmationDelay: 100,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle
@@ -272,6 +291,7 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then message.updated fires
@@ -306,6 +326,7 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then tool.execute.before fires
@@ -509,4 +530,88 @@ describe("session-notification", () => {
}
}
})
test("should ignore activity events within grace period", async () => {
jest.useFakeTimers()
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"))
try {
// given - a regular session notification is scheduled
const sessionID = "main-grace"
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 100,
enforceMainSessionFilter: false,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// when - activity happens immediately (within grace period)
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID },
},
})
// when - idle confirmation delay passes deterministically
jest.advanceTimersByTime(50)
jest.runOnlyPendingTimers()
await Promise.resolve()
// then - notification SHOULD be sent (activity was within grace period, ignored)
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
} finally {
jest.clearAllTimers()
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
}
})
test("should cancel notification for activity after grace period", async () => {
// given - a regular session notification is scheduled
const sessionID = "main-grace-cancel"
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 200,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 50,
enforceMainSessionFilter: false,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// when - wait for grace period to pass
await new Promise((resolve) => setTimeout(resolve, 60))
// when - activity happens after grace period
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 200))
// then - notification should NOT be sent (activity cancelled it after grace period)
expect(notificationCalls).toHaveLength(0)
})
})
+2
View File
@@ -24,6 +24,8 @@ interface SessionNotificationConfig {
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
enforceMainSessionFilter?: boolean
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(
ctx: PluginInput,
+1 -1
View File
@@ -1,7 +1,7 @@
const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i]
const MULTILINGUAL_KEYWORDS = [
"생각", "고민", "검토", "제대로",
"생각", "검토", "제대로",
"思考", "考虑", "考慮",
"思考", "考え", "熟考",
"सोच", "विचार",
+5 -17
View File
@@ -1,5 +1,5 @@
import { detectThinkKeyword, extractPromptText } from "./detector"
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
import { isAlreadyHighVariant } from "./switcher"
import type { ThinkModeState } from "./types"
import { log } from "../../shared"
@@ -56,22 +56,10 @@ export function createThinkModeHook() {
return
}
const highVariant = getHighVariant(currentModel.modelID)
if (highVariant) {
output.message.model = {
providerID: currentModel.providerID,
modelID: highVariant,
}
output.message.variant = "high"
state.modelSwitched = true
state.variantSet = true
log("Think mode: model switched to high variant", {
sessionID,
from: currentModel.modelID,
to: highVariant,
})
}
output.message.variant = "high"
state.modelSwitched = false
state.variantSet = true
log("Think mode: variant set to high", { sessionID })
thinkModeState.set(sessionID, state)
},
+4 -10
View File
@@ -43,7 +43,7 @@ describe("createThinkModeHook", () => {
clearThinkModeState(sessionID)
})
it("sets high variant and switches model when think keyword is present", async () => {
it("sets high variant when think keyword is present", async () => {
// given
const hook = createThinkModeHook()
const input = createHookInput({
@@ -58,13 +58,10 @@ describe("createThinkModeHook", () => {
// then
expect(output.message.variant).toBe("high")
expect(output.message.model).toEqual({
providerID: "github-copilot",
modelID: "claude-opus-4-6-high",
})
expect(output.message.model).toBeUndefined()
})
it("supports dotted model IDs by switching to normalized high variant", async () => {
it("sets high variant for dotted model IDs", async () => {
// given
const hook = createThinkModeHook()
const input = createHookInput({
@@ -79,10 +76,7 @@ describe("createThinkModeHook", () => {
// then
expect(output.message.variant).toBe("high")
expect(output.message.model).toEqual({
providerID: "github-copilot",
modelID: "gpt-5-4-high",
})
expect(output.message.model).toBeUndefined()
})
it("skips when message variant is already set", async () => {
+14
View File
@@ -4,6 +4,20 @@ import {
isAlreadyHighVariant,
} from "./switcher"
/**
* DEPRECATION NOTICE:
*
* getHighVariant() is no longer used by the think-mode hook.
* The hook now only sets output.message.variant = "high" and lets
* OpenCode's native variant system handle the transformation.
*
* This function is kept for:
* - Potential future validation use
* - Backward compatibility for external consumers
*
* Tests verify the function still works correctly.
*/
describe("think-mode switcher", () => {
describe("Model ID normalization", () => {
describe("getHighVariant with dots vs hyphens", () => {
+14
View File
@@ -19,6 +19,20 @@ describe("createToolOutputTruncatorHook", () => {
hook = createToolOutputTruncatorHook({} as never)
})
it("passes modelContextLimitsCache through to createDynamicTruncator", () => {
const ctx = {} as never
const modelContextLimitsCache = new Map<string, number>()
const modelCacheState = {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
}
truncateSpy.mockClear()
createToolOutputTruncatorHook(ctx, { modelCacheState })
expect(truncateSpy).toHaveBeenLastCalledWith(ctx, modelCacheState)
})
describe("tool.execute.after", () => {
const createInput = (tool: string) => ({
tool,
+4 -1
View File
@@ -27,7 +27,10 @@ const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
}
interface ToolOutputTruncatorOptions {
modelCacheState?: { anthropicContext1MEnabled: boolean }
modelCacheState?: {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
experimental?: ExperimentalConfig
}