Skip to content

Commit da416b3

Browse files
feat(hooks): add category-skill-reminder hook (#1123)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: justsisyphus <[email protected]> Co-authored-by: Sisyphus <[email protected]>
1 parent 90054b2 commit da416b3

File tree

5 files changed

+520
-0
lines changed

5 files changed

+520
-0
lines changed

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const HookNameSchema = z.enum([
7777

7878
"thinking-block-validator",
7979
"ralph-loop",
80+
"category-skill-reminder",
8081

8182
"compaction-context-injector",
8283
"claude-code-hooks",
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
2+
import { createCategorySkillReminderHook } from "./index"
3+
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
4+
import * as sharedModule from "../../shared"
5+
6+
describe("category-skill-reminder hook", () => {
7+
let logCalls: Array<{ msg: string; data?: unknown }>
8+
let logSpy: ReturnType<typeof spyOn>
9+
10+
beforeEach(() => {
11+
_resetForTesting()
12+
logCalls = []
13+
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
14+
logCalls.push({ msg, data })
15+
})
16+
})
17+
18+
afterEach(() => {
19+
logSpy?.mockRestore()
20+
})
21+
22+
function createMockPluginInput() {
23+
return {
24+
client: {
25+
tui: {
26+
showToast: async () => {},
27+
},
28+
},
29+
} as any
30+
}
31+
32+
describe("target agent detection", () => {
33+
test("should inject reminder for sisyphus agent after 3 tool calls", async () => {
34+
// #given - sisyphus agent session with multiple tool calls
35+
const hook = createCategorySkillReminderHook(createMockPluginInput())
36+
const sessionID = "sisyphus-session"
37+
updateSessionAgent(sessionID, "Sisyphus")
38+
39+
const output = { title: "", output: "file content", metadata: {} }
40+
41+
// #when - 3 edit tool calls are made
42+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
43+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
44+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
45+
46+
// #then - reminder should be injected
47+
expect(output.output).toContain("[Category+Skill Reminder]")
48+
expect(output.output).toContain("delegate_task")
49+
50+
clearSessionAgent(sessionID)
51+
})
52+
53+
test("should inject reminder for atlas agent", async () => {
54+
// #given - atlas agent session
55+
const hook = createCategorySkillReminderHook(createMockPluginInput())
56+
const sessionID = "atlas-session"
57+
updateSessionAgent(sessionID, "Atlas")
58+
59+
const output = { title: "", output: "result", metadata: {} }
60+
61+
// #when - 3 tool calls are made
62+
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output)
63+
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output)
64+
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output)
65+
66+
// #then - reminder should be injected
67+
expect(output.output).toContain("[Category+Skill Reminder]")
68+
69+
clearSessionAgent(sessionID)
70+
})
71+
72+
test("should inject reminder for sisyphus-junior agent", async () => {
73+
// #given - sisyphus-junior agent session
74+
const hook = createCategorySkillReminderHook(createMockPluginInput())
75+
const sessionID = "junior-session"
76+
updateSessionAgent(sessionID, "sisyphus-junior")
77+
78+
const output = { title: "", output: "result", metadata: {} }
79+
80+
// #when - 3 tool calls are made
81+
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "1" }, output)
82+
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "2" }, output)
83+
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "3" }, output)
84+
85+
// #then - reminder should be injected
86+
expect(output.output).toContain("[Category+Skill Reminder]")
87+
88+
clearSessionAgent(sessionID)
89+
})
90+
91+
test("should NOT inject reminder for non-target agents", async () => {
92+
// #given - librarian agent session (not a target)
93+
const hook = createCategorySkillReminderHook(createMockPluginInput())
94+
const sessionID = "librarian-session"
95+
updateSessionAgent(sessionID, "librarian")
96+
97+
const output = { title: "", output: "result", metadata: {} }
98+
99+
// #when - 3 tool calls are made
100+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
101+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
102+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
103+
104+
// #then - reminder should NOT be injected
105+
expect(output.output).not.toContain("[Category+Skill Reminder]")
106+
107+
clearSessionAgent(sessionID)
108+
})
109+
110+
test("should detect agent from input.agent when session state is empty", async () => {
111+
// #given - no session state, agent provided in input
112+
const hook = createCategorySkillReminderHook(createMockPluginInput())
113+
const sessionID = "input-agent-session"
114+
115+
const output = { title: "", output: "result", metadata: {} }
116+
117+
// #when - 3 tool calls with agent in input
118+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1", agent: "Sisyphus" }, output)
119+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2", agent: "Sisyphus" }, output)
120+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3", agent: "Sisyphus" }, output)
121+
122+
// #then - reminder should be injected
123+
expect(output.output).toContain("[Category+Skill Reminder]")
124+
})
125+
})
126+
127+
describe("delegation tool tracking", () => {
128+
test("should NOT inject reminder if delegate_task is used", async () => {
129+
// #given - sisyphus agent that uses delegate_task
130+
const hook = createCategorySkillReminderHook(createMockPluginInput())
131+
const sessionID = "delegation-session"
132+
updateSessionAgent(sessionID, "Sisyphus")
133+
134+
const output = { title: "", output: "result", metadata: {} }
135+
136+
// #when - delegate_task is used, then more tool calls
137+
await hook["tool.execute.after"]({ tool: "delegate_task", sessionID, callID: "1" }, output)
138+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
139+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
140+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
141+
142+
// #then - reminder should NOT be injected (delegation was used)
143+
expect(output.output).not.toContain("[Category+Skill Reminder]")
144+
145+
clearSessionAgent(sessionID)
146+
})
147+
148+
test("should NOT inject reminder if call_omo_agent is used", async () => {
149+
// #given - sisyphus agent that uses call_omo_agent
150+
const hook = createCategorySkillReminderHook(createMockPluginInput())
151+
const sessionID = "omo-agent-session"
152+
updateSessionAgent(sessionID, "Sisyphus")
153+
154+
const output = { title: "", output: "result", metadata: {} }
155+
156+
// #when - call_omo_agent is used first
157+
await hook["tool.execute.after"]({ tool: "call_omo_agent", sessionID, callID: "1" }, output)
158+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
159+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
160+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
161+
162+
// #then - reminder should NOT be injected
163+
expect(output.output).not.toContain("[Category+Skill Reminder]")
164+
165+
clearSessionAgent(sessionID)
166+
})
167+
168+
test("should NOT inject reminder if task tool is used", async () => {
169+
// #given - sisyphus agent that uses task tool
170+
const hook = createCategorySkillReminderHook(createMockPluginInput())
171+
const sessionID = "task-session"
172+
updateSessionAgent(sessionID, "Sisyphus")
173+
174+
const output = { title: "", output: "result", metadata: {} }
175+
176+
// #when - task tool is used
177+
await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output)
178+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
179+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
180+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
181+
182+
// #then - reminder should NOT be injected
183+
expect(output.output).not.toContain("[Category+Skill Reminder]")
184+
185+
clearSessionAgent(sessionID)
186+
})
187+
})
188+
189+
describe("tool call counting", () => {
190+
test("should NOT inject reminder before 3 tool calls", async () => {
191+
// #given - sisyphus agent with only 2 tool calls
192+
const hook = createCategorySkillReminderHook(createMockPluginInput())
193+
const sessionID = "few-calls-session"
194+
updateSessionAgent(sessionID, "Sisyphus")
195+
196+
const output = { title: "", output: "result", metadata: {} }
197+
198+
// #when - only 2 tool calls are made
199+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
200+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
201+
202+
// #then - reminder should NOT be injected yet
203+
expect(output.output).not.toContain("[Category+Skill Reminder]")
204+
205+
clearSessionAgent(sessionID)
206+
})
207+
208+
test("should only inject reminder once per session", async () => {
209+
// #given - sisyphus agent session
210+
const hook = createCategorySkillReminderHook(createMockPluginInput())
211+
const sessionID = "once-session"
212+
updateSessionAgent(sessionID, "Sisyphus")
213+
214+
const output1 = { title: "", output: "result1", metadata: {} }
215+
const output2 = { title: "", output: "result2", metadata: {} }
216+
217+
// #when - 6 tool calls are made (should trigger at 3, not again at 6)
218+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1)
219+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1)
220+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
221+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2)
222+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
223+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
224+
225+
// #then - reminder should be in output1 but not output2
226+
expect(output1.output).toContain("[Category+Skill Reminder]")
227+
expect(output2.output).not.toContain("[Category+Skill Reminder]")
228+
229+
clearSessionAgent(sessionID)
230+
})
231+
232+
test("should only count delegatable work tools", async () => {
233+
// #given - sisyphus agent with mixed tool calls
234+
const hook = createCategorySkillReminderHook(createMockPluginInput())
235+
const sessionID = "mixed-tools-session"
236+
updateSessionAgent(sessionID, "Sisyphus")
237+
238+
const output = { title: "", output: "result", metadata: {} }
239+
240+
// #when - non-delegatable tools are called (should not count)
241+
await hook["tool.execute.after"]({ tool: "lsp_goto_definition", sessionID, callID: "1" }, output)
242+
await hook["tool.execute.after"]({ tool: "lsp_find_references", sessionID, callID: "2" }, output)
243+
await hook["tool.execute.after"]({ tool: "lsp_symbols", sessionID, callID: "3" }, output)
244+
245+
// #then - reminder should NOT be injected (LSP tools don't count)
246+
expect(output.output).not.toContain("[Category+Skill Reminder]")
247+
248+
clearSessionAgent(sessionID)
249+
})
250+
})
251+
252+
describe("event handling", () => {
253+
test("should reset state on session.deleted event", async () => {
254+
// #given - sisyphus agent with reminder already shown
255+
const hook = createCategorySkillReminderHook(createMockPluginInput())
256+
const sessionID = "delete-session"
257+
updateSessionAgent(sessionID, "Sisyphus")
258+
259+
const output1 = { title: "", output: "result1", metadata: {} }
260+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1)
261+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1)
262+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
263+
expect(output1.output).toContain("[Category+Skill Reminder]")
264+
265+
// #when - session is deleted and new session starts
266+
await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
267+
268+
const output2 = { title: "", output: "result2", metadata: {} }
269+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2)
270+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
271+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
272+
273+
// #then - reminder should be shown again (state was reset)
274+
expect(output2.output).toContain("[Category+Skill Reminder]")
275+
276+
clearSessionAgent(sessionID)
277+
})
278+
279+
test("should reset state on session.compacted event", async () => {
280+
// #given - sisyphus agent with reminder already shown
281+
const hook = createCategorySkillReminderHook(createMockPluginInput())
282+
const sessionID = "compact-session"
283+
updateSessionAgent(sessionID, "Sisyphus")
284+
285+
const output1 = { title: "", output: "result1", metadata: {} }
286+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1)
287+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1)
288+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
289+
expect(output1.output).toContain("[Category+Skill Reminder]")
290+
291+
// #when - session is compacted
292+
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
293+
294+
const output2 = { title: "", output: "result2", metadata: {} }
295+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2)
296+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
297+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
298+
299+
// #then - reminder should be shown again (state was reset)
300+
expect(output2.output).toContain("[Category+Skill Reminder]")
301+
302+
clearSessionAgent(sessionID)
303+
})
304+
})
305+
306+
describe("case insensitivity", () => {
307+
test("should handle tool names case-insensitively", async () => {
308+
// #given - sisyphus agent with mixed case tool names
309+
const hook = createCategorySkillReminderHook(createMockPluginInput())
310+
const sessionID = "case-session"
311+
updateSessionAgent(sessionID, "Sisyphus")
312+
313+
const output = { title: "", output: "result", metadata: {} }
314+
315+
// #when - tool calls with different cases
316+
await hook["tool.execute.after"]({ tool: "EDIT", sessionID, callID: "1" }, output)
317+
await hook["tool.execute.after"]({ tool: "Edit", sessionID, callID: "2" }, output)
318+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
319+
320+
// #then - reminder should be injected (all counted)
321+
expect(output.output).toContain("[Category+Skill Reminder]")
322+
323+
clearSessionAgent(sessionID)
324+
})
325+
326+
test("should handle delegation tool names case-insensitively", async () => {
327+
// #given - sisyphus agent using DELEGATE_TASK in uppercase
328+
const hook = createCategorySkillReminderHook(createMockPluginInput())
329+
const sessionID = "case-delegate-session"
330+
updateSessionAgent(sessionID, "Sisyphus")
331+
332+
const output = { title: "", output: "result", metadata: {} }
333+
334+
// #when - DELEGATE_TASK in uppercase is used
335+
await hook["tool.execute.after"]({ tool: "DELEGATE_TASK", sessionID, callID: "1" }, output)
336+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
337+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
338+
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
339+
340+
// #then - reminder should NOT be injected (delegation was detected)
341+
expect(output.output).not.toContain("[Category+Skill Reminder]")
342+
343+
clearSessionAgent(sessionID)
344+
})
345+
})
346+
})

0 commit comments

Comments
 (0)