From 3db1da1e5bfa63296ef0f3fb2a3bb52630e1ba35 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 09:40:09 +0800 Subject: [PATCH] fix(reminder-hooks): preserve suppression state across compaction --- src/hooks/agent-usage-reminder/hook.ts | 10 +-- src/hooks/agent-usage-reminder/index.test.ts | 80 +++++++++++++++++++ src/hooks/category-skill-reminder/hook.ts | 6 -- .../category-skill-reminder/index.test.ts | 28 ++++++- 4 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 src/hooks/agent-usage-reminder/index.test.ts diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index d5ea75ecd..c7dcbef34 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -42,6 +42,8 @@ const ORCHESTRATOR_AGENTS = new Set([ "prometheus", ]); +const MAX_REMINDERS = 3; + function isOrchestratorAgent(agentName: string): boolean { return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName)); } @@ -99,7 +101,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { const state = getOrCreateState(sessionID); - if (state.agentUsed) { + if (state.agentUsed || state.reminderCount >= MAX_REMINDERS) { return; } @@ -119,12 +121,6 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { } } - if (event.type === "session.compacted") { - const sessionID = resolveSessionEventID(props); - if (sessionID) { - resetState(sessionID); - } - } }; return { diff --git a/src/hooks/agent-usage-reminder/index.test.ts b/src/hooks/agent-usage-reminder/index.test.ts new file mode 100644 index 000000000..23883afd0 --- /dev/null +++ b/src/hooks/agent-usage-reminder/index.test.ts @@ -0,0 +1,80 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { createAgentUsageReminderHook } from "./index"; +import { clearSessionAgent, updateSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"; +import * as storage from "./storage"; + +describe("agent-usage-reminder hook", () => { + let loadStateSpy: ReturnType; + let saveStateSpy: ReturnType; + let clearStateSpy: ReturnType; + + beforeEach(() => { + _resetForTesting(); + loadStateSpy = spyOn(storage, "loadAgentUsageState").mockReturnValue(null); + saveStateSpy = spyOn(storage, "saveAgentUsageState").mockImplementation(mock(() => {})); + clearStateSpy = spyOn(storage, "clearAgentUsageState").mockImplementation(mock(() => {})); + }); + + afterEach(() => { + loadStateSpy?.mockRestore(); + saveStateSpy?.mockRestore(); + clearStateSpy?.mockRestore(); + }); + + function createHook() { + return createAgentUsageReminderHook({} as PluginInput); + } + + test("caps reminders and does not re-arm after session.compacted", async () => { + // given - an orchestrator session has already hit the reminder cap + const hook = createHook(); + const sessionID = "agent-usage-compact-session"; + updateSessionAgent(sessionID, "Sisyphus"); + + const output1 = { title: "", output: "result-1", metadata: {} }; + const output2 = { title: "", output: "result-2", metadata: {} }; + const output3 = { title: "", output: "result-3", metadata: {} }; + const output4 = { title: "", output: "result-4", metadata: {} }; + + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "1" }, output1); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output2); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "3" }, output3); + + // then - the first three reminders are shown + expect(output1.output).toContain("[Agent Usage Reminder]"); + expect(output2.output).toContain("[Agent Usage Reminder]"); + expect(output3.output).toContain("[Agent Usage Reminder]"); + + // when - compaction happens and another target tool runs + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "4" }, output4); + + // then - compaction does not reset the reminder cap + expect(output4.output).not.toContain("[Agent Usage Reminder]"); + + clearSessionAgent(sessionID); + }); + + test("resets reminder state on session.deleted", async () => { + // given - an orchestrator session has reminder state + const hook = createHook(); + const sessionID = "agent-usage-delete-session"; + updateSessionAgent(sessionID, "Sisyphus"); + + const output1 = { title: "", output: "result-1", metadata: {} }; + const output2 = { title: "", output: "result-2", metadata: {} }; + + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "1" }, output1); + expect(output1.output).toContain("[Agent Usage Reminder]"); + + // when - the session is deleted and another target tool runs + await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } }); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output2); + + // then - deletion still resets the state + expect(output2.output).toContain("[Agent Usage Reminder]"); + + clearSessionAgent(sessionID); + }); +}); diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index f940e6288..ef5f9cf65 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -127,12 +127,6 @@ export function createCategorySkillReminderHook( } } - if (event.type === "session.compacted") { - const sessionID = resolveSessionEventID(props) - if (sessionID) { - sessionStates.delete(sessionID) - } - } } return { diff --git a/src/hooks/category-skill-reminder/index.test.ts b/src/hooks/category-skill-reminder/index.test.ts index 2bbcf9052..c2b33b7d0 100644 --- a/src/hooks/category-skill-reminder/index.test.ts +++ b/src/hooks/category-skill-reminder/index.test.ts @@ -282,7 +282,7 @@ describe("category-skill-reminder hook", () => { clearSessionAgent(sessionID) }) - test("should reset state on session.compacted event", async () => { + test("should preserve suppression state on session.compacted event", async () => { // given - sisyphus agent with reminder already shown const hook = createHook() const sessionID = "compact-session" @@ -302,8 +302,30 @@ describe("category-skill-reminder hook", () => { await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2) await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2) - // then - reminder should be shown again (state was reset) - expect(output2.output).toContain("[Category+Skill Reminder]") + // then - reminder should NOT be shown again (state remains suppressed) + expect(output2.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should preserve partial tool-call count across session.compacted", async () => { + // given - sisyphus agent with 2 delegatable tool calls + const hook = createHook() + const sessionID = "compact-partial-count-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + expect(output.output).not.toContain("[Category+Skill Reminder]") + + // when - the session compacts before the third tool call + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + + // then - the third call should still trigger the reminder + expect(output.output).toContain("[Category+Skill Reminder]") clearSessionAgent(sessionID) })