Merge branch 'dev' into fix/issue-2232
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i]
|
||||
|
||||
const MULTILINGUAL_KEYWORDS = [
|
||||
"생각", "고민", "검토", "제대로",
|
||||
"생각", "검토", "제대로",
|
||||
"思考", "考虑", "考慮",
|
||||
"思考", "考え", "熟考",
|
||||
"सोच", "विचार",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user