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 1/4] 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) }) From 29e7e97d8f5e41e1b7615751082f0c011e419415 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 10:04:38 +0800 Subject: [PATCH 2/4] test(reminder-hooks): cover delegated sessions across compaction --- src/hooks/agent-usage-reminder/index.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/hooks/agent-usage-reminder/index.test.ts b/src/hooks/agent-usage-reminder/index.test.ts index 23883afd0..84960595e 100644 --- a/src/hooks/agent-usage-reminder/index.test.ts +++ b/src/hooks/agent-usage-reminder/index.test.ts @@ -77,4 +77,20 @@ describe("agent-usage-reminder hook", () => { clearSessionAgent(sessionID); }); + + test("does not re-arm after session.compacted when task delegation already happened", async () => { + const hook = createHook(); + const sessionID = "agent-usage-delegated-session"; + updateSessionAgent(sessionID, "Sisyphus"); + + const output = { title: "", output: "result", metadata: {} }; + + await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output); + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output); + + expect(output.output).not.toContain("[Agent Usage Reminder]"); + + clearSessionAgent(sessionID); + }); }); From 392c20e53a7858762d639cb7772b882c1eb9932b Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 10:25:20 +0800 Subject: [PATCH 3/4] test(reminder-hooks): make delete reset regression diagnostic --- src/hooks/agent-usage-reminder/index.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/hooks/agent-usage-reminder/index.test.ts b/src/hooks/agent-usage-reminder/index.test.ts index 84960595e..9548f63ee 100644 --- a/src/hooks/agent-usage-reminder/index.test.ts +++ b/src/hooks/agent-usage-reminder/index.test.ts @@ -64,16 +64,26 @@ describe("agent-usage-reminder hook", () => { 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: {} }; + const output5 = { title: "", output: "result-5", 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); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "4" }, output4); + expect(output1.output).toContain("[Agent Usage Reminder]"); + expect(output2.output).toContain("[Agent Usage Reminder]"); + expect(output3.output).toContain("[Agent Usage Reminder]"); + expect(output4.output).not.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); + await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "5" }, output5); // then - deletion still resets the state - expect(output2.output).toContain("[Agent Usage Reminder]"); + expect(output5.output).toContain("[Agent Usage Reminder]"); clearSessionAgent(sessionID); }); From 291b1f7b3c30ec1c248c0061268c0e8bf6d6e8f2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 22:59:58 +0900 Subject: [PATCH 4/4] test(reminder-hooks): clean up compaction regressions --- src/hooks/agent-usage-reminder/hook.ts | 1 - src/hooks/agent-usage-reminder/index.test.ts | 7 ++++++- src/hooks/category-skill-reminder/hook.ts | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index c7dcbef34..aa8c2525b 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -120,7 +120,6 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { resetState(sessionID); } } - }; return { diff --git a/src/hooks/agent-usage-reminder/index.test.ts b/src/hooks/agent-usage-reminder/index.test.ts index 9548f63ee..5fb4530bc 100644 --- a/src/hooks/agent-usage-reminder/index.test.ts +++ b/src/hooks/agent-usage-reminder/index.test.ts @@ -2,6 +2,7 @@ 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 { unsafeTestValue } from "../../../test-support/unsafe-test-value"; import * as storage from "./storage"; describe("agent-usage-reminder hook", () => { @@ -23,7 +24,7 @@ describe("agent-usage-reminder hook", () => { }); function createHook() { - return createAgentUsageReminderHook({} as PluginInput); + return createAgentUsageReminderHook(unsafeTestValue({})); } test("caps reminders and does not re-arm after session.compacted", async () => { @@ -89,6 +90,7 @@ describe("agent-usage-reminder hook", () => { }); test("does not re-arm after session.compacted when task delegation already happened", async () => { + // given - an orchestrator session already delegated through task const hook = createHook(); const sessionID = "agent-usage-delegated-session"; updateSessionAgent(sessionID, "Sisyphus"); @@ -96,9 +98,12 @@ describe("agent-usage-reminder hook", () => { const output = { title: "", output: "result", metadata: {} }; await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output); + + // 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: "2" }, output); + // then - compaction does not clear delegated state expect(output.output).not.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 ef5f9cf65..08006ae32 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -126,7 +126,6 @@ export function createCategorySkillReminderHook( sessionStates.delete(sessionID) } } - } return {