fix(claude-code-hooks): cache idle hook config and parent lookups
Reduce repeated session.idle work by reusing hook config loads across a short TTL and by retrying parent session lookup instead of permanently caching transient failures. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
const { beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
const executeStopHooks = mock(async (context: { parentSessionId?: string }) => ({
|
||||
block: false,
|
||||
observedParentSessionId: context.parentSessionId,
|
||||
}))
|
||||
|
||||
mock.module("../config", () => ({
|
||||
clearClaudeHooksConfigCache: () => {},
|
||||
loadClaudeHooksConfig: async () => null,
|
||||
}))
|
||||
|
||||
mock.module("../config-loader", () => ({
|
||||
clearPluginExtendedConfigCache: () => {},
|
||||
loadPluginExtendedConfig: async () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../stop", () => ({
|
||||
executeStopHooks,
|
||||
}))
|
||||
|
||||
const { createSessionEventHandler } = await import("./session-event-handler")
|
||||
|
||||
describe("createSessionEventHandler retry behavior", () => {
|
||||
beforeEach(() => {
|
||||
executeStopHooks.mockClear()
|
||||
})
|
||||
|
||||
test("#given transient parent lookup failure #when the next idle succeeds #then stop hooks receive the later parent session id", async () => {
|
||||
//#given
|
||||
let getCallCount = 0
|
||||
const handler = createSessionEventHandler(
|
||||
{
|
||||
directory: "/repo",
|
||||
client: {
|
||||
session: {
|
||||
get: async () => {
|
||||
getCallCount += 1
|
||||
if (getCallCount === 1) {
|
||||
throw new Error("temporary failure")
|
||||
}
|
||||
return { data: { parentID: "ses_parent" } }
|
||||
},
|
||||
prompt: async () => undefined,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{},
|
||||
)
|
||||
|
||||
//#when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "ses_retry" } } })
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "ses_retry" } } })
|
||||
|
||||
//#then
|
||||
expect(getCallCount).toBe(2)
|
||||
expect(executeStopHooks).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parentSessionId: "ses_parent",
|
||||
}),
|
||||
null,
|
||||
{},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -70,4 +70,70 @@ describe("createSessionEventHandler", () => {
|
||||
|
||||
stopToolInputCacheCleanup()
|
||||
})
|
||||
|
||||
test("#given repeated idle events for one session #when stop hook preparation runs #then parent session lookup is reused", async () => {
|
||||
//#given
|
||||
let getCallCount = 0
|
||||
const handler = createSessionEventHandler(
|
||||
{
|
||||
client: {
|
||||
session: {
|
||||
get: async () => {
|
||||
getCallCount += 1
|
||||
return { data: { parentID: "ses_parent" } }
|
||||
},
|
||||
prompt: async () => undefined,
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{},
|
||||
)
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_reuse" } },
|
||||
})
|
||||
await handler({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_reuse" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(getCallCount).toBe(1)
|
||||
})
|
||||
|
||||
test("#given deleted session #when it idles again #then parent session lookup is fetched again", async () => {
|
||||
//#given
|
||||
let getCallCount = 0
|
||||
const handler = createSessionEventHandler(
|
||||
{
|
||||
client: {
|
||||
session: {
|
||||
get: async () => {
|
||||
getCallCount += 1
|
||||
return { data: { parentID: "ses_parent" } }
|
||||
},
|
||||
prompt: async () => undefined,
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{},
|
||||
)
|
||||
|
||||
await handler({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_reset" } },
|
||||
})
|
||||
await handler({
|
||||
event: { type: "session.deleted", properties: { info: { id: "ses_reset" } } },
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_reset" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(getCallCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ContextCollector } from "../../../features/context-injector"
|
||||
import { loadClaudeHooksConfig } from "../config"
|
||||
import { loadPluginExtendedConfig } from "../config-loader"
|
||||
import { clearClaudeHooksConfigCache, loadClaudeHooksConfig } from "../config"
|
||||
import { clearPluginExtendedConfigCache, loadPluginExtendedConfig } from "../config-loader"
|
||||
import { executeStopHooks, type StopContext } from "../stop"
|
||||
import { clearTranscriptCache } from "../transcript"
|
||||
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
||||
@@ -19,6 +19,8 @@ export function createSessionEventHandler(
|
||||
config: PluginConfig,
|
||||
contextCollector?: ContextCollector,
|
||||
) {
|
||||
const parentSessionIdCache = new Map<string, string | undefined>()
|
||||
|
||||
return async (input: { event: { type: string; properties?: unknown } }) => {
|
||||
const { event } = input
|
||||
|
||||
@@ -38,6 +40,7 @@ export function createSessionEventHandler(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
parentSessionIdCache.delete(sessionInfo.id)
|
||||
clearTranscriptCache(sessionInfo.id)
|
||||
clearToolInputCache(sessionInfo.id)
|
||||
contextCollector?.clear(sessionInfo.id)
|
||||
@@ -62,14 +65,17 @@ export function createSessionEventHandler(
|
||||
const interruptStateBefore = sessionInterruptState.get(sessionID)
|
||||
const interruptedBefore = interruptStateBefore?.interrupted === true
|
||||
|
||||
let parentSessionId: string | undefined
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
} catch {
|
||||
parentSessionId = undefined
|
||||
let parentSessionId = parentSessionIdCache.get(sessionID)
|
||||
if (parentSessionId === undefined && !parentSessionIdCache.has(sessionID)) {
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
parentSessionIdCache.set(sessionID, parentSessionId)
|
||||
} catch {
|
||||
parentSessionId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "Stop")) {
|
||||
@@ -123,6 +129,8 @@ export function createSessionEventHandler(
|
||||
|
||||
export function disposeSessionEventHandler(contextCollector?: ContextCollector): void {
|
||||
clearTranscriptCache()
|
||||
clearClaudeHooksConfigCache()
|
||||
clearPluginExtendedConfigCache()
|
||||
stopToolInputCacheCleanup()
|
||||
contextCollector?.clearAll()
|
||||
clearAllSessionHookState()
|
||||
|
||||
Reference in New Issue
Block a user