From 0e18efc7e41623efff72cde4e65aba39a94f7d7f Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Fri, 23 Jan 2026 01:53:59 +0900 Subject: [PATCH] refactor(keyword-detector): change keyword injection from synthetic to direct message transform - Replace collector.register() with direct output.parts[textIndex].text modification - All keyword types (ultrawork, search, analyze) now prepend to user message text - Message format: keyword message + '---' separator + original text - Update tests to verify text transformation instead of collector registration - All 18 tests pass --- src/hooks/keyword-detector/index.test.ts | 112 +++++++++++++---------- src/hooks/keyword-detector/index.ts | 18 ++-- 2 files changed, 71 insertions(+), 59 deletions(-) diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 26c50630..28c7bbea 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -5,7 +5,7 @@ import { ContextCollector } from "../../features/context-injector" import * as sharedModule from "../../shared" import * as sessionState from "../../features/claude-code-session-state" -describe("keyword-detector registers to ContextCollector", () => { +describe("keyword-detector message transform", () => { let logCalls: Array<{ msg: string; data?: unknown }> let logSpy: ReturnType let getMainSessionSpy: ReturnType @@ -33,7 +33,7 @@ describe("keyword-detector registers to ContextCollector", () => { } as any } - test("should register ultrawork keyword to ContextCollector", async () => { + test("should prepend ultrawork message to text part", async () => { // #given - a fresh ContextCollector and keyword-detector hook const collector = new ContextCollector() const hook = createKeywordDetectorHook(createMockPluginInput(), collector) @@ -46,15 +46,15 @@ describe("keyword-detector registers to ContextCollector", () => { // #when - keyword detection runs await hook["chat.message"]({ sessionID }, output) - // #then - ultrawork context should be registered in collector - expect(collector.hasPending(sessionID)).toBe(true) - const pending = collector.getPending(sessionID) - expect(pending.entries.length).toBeGreaterThan(0) - expect(pending.entries[0].source).toBe("keyword-detector") - expect(pending.entries[0].id).toBe("keyword-ultrawork") + // #then - message should be prepended to text part with separator and original text + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("do something") + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") }) - test("should register search keyword to ContextCollector", async () => { + test("should prepend search message to text part", async () => { // #given - mock getMainSessionID to return our session (isolate from global state) const collector = new ContextCollector() const sessionID = "search-test-session" @@ -68,13 +68,15 @@ describe("keyword-detector registers to ContextCollector", () => { // #when - keyword detection runs await hook["chat.message"]({ sessionID }, output) - // #then - search context should be registered in collector - expect(collector.hasPending(sessionID)).toBe(true) - const pending = collector.getPending(sessionID) - expect(pending.entries.some((e) => e.id === "keyword-search")).toBe(true) + // #then - search message should be prepended to text part + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("for the bug") + expect(textPart!.text).toContain("[search-mode]") }) - test("should NOT register to collector when no keywords detected", async () => { + test("should NOT transform when no keywords detected", async () => { // #given - no keywords in message const collector = new ContextCollector() const hook = createKeywordDetectorHook(createMockPluginInput(), collector) @@ -87,8 +89,10 @@ describe("keyword-detector registers to ContextCollector", () => { // #when - keyword detection runs await hook["chat.message"]({ sessionID }, output) - // #then - nothing should be registered - expect(collector.hasPending(sessionID)).toBe(false) + // #then - text should remain unchanged + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("just a normal message") }) }) @@ -375,11 +379,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID, agent: "prometheus" }, output) // #then - should use planner-specific message with "YOU ARE A PLANNER" content - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") - expect(ultraworkEntry!.content).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("plan this feature") }) test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => { @@ -396,10 +401,11 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output) // #then - should use planner-specific message - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("create a work plan") }) test("should use normal ultrawork message when agent is Sisyphus", async () => { @@ -416,11 +422,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID, agent: "Sisyphus" }, output) // #then - should use normal ultrawork message with agent utilization instructions - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") - expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("implement this feature") }) test("should use normal ultrawork message when agent is undefined", async () => { @@ -437,11 +444,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID }, output) // #then - should use normal ultrawork message (default behavior) - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") - expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("do something") }) test("should switch from planner to normal message when agent changes", async () => { @@ -466,13 +474,15 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "Sisyphus" }, sisyphusOutput) // #then - each session should have the correct message type - const prometheusPending = collector.getPending(prometheusSessionID) - const prometheusEntry = prometheusPending.entries.find((e) => e.id === "keyword-ultrawork") - expect(prometheusEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text") + expect(prometheusTextPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(prometheusTextPart!.text).toContain("---") + expect(prometheusTextPart!.text).toContain("plan") - const sisyphusPending = collector.getPending(sisyphusSessionID) - const sisyphusEntry = sisyphusPending.entries.find((e) => e.id === "keyword-ultrawork") - expect(sisyphusEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + const sisyphusTextPart = sisyphusOutput.parts.find(p => p.type === "text") + expect(sisyphusTextPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(sisyphusTextPart!.text).toContain("---") + expect(sisyphusTextPart!.text).toContain("implement") }) test("should use session state agent over stale input.agent (bug fix)", async () => { @@ -493,11 +503,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID, agent: "prometheus" }, output) // #then - should use Sisyphus from session state, NOT prometheus from stale input - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") - expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("implement this") // cleanup clearSessionAgent(sessionID) @@ -521,9 +532,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => { await hook["chat.message"]({ sessionID, agent: "prometheus" }, output) // #then - should use prometheus from input.agent as fallback - const pending = collector.getPending(sessionID) - const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork") - expect(ultraworkEntry).toBeDefined() - expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER") + expect(textPart!.text).toContain("---") + expect(textPart!.text).toContain("plan this") }) }) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index d503765f..1261b6eb 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -80,17 +80,17 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC ) } - if (collector) { - for (const keyword of detectedKeywords) { - collector.register(input.sessionID, { - id: `keyword-${keyword.type}`, - source: "keyword-detector", - content: keyword.message, - priority: keyword.type === "ultrawork" ? "critical" : "high", - }) - } + const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) + if (textPartIndex === -1) { + log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID }) + return } + const allMessages = detectedKeywords.map((k) => k.message).join("\n\n") + const originalText = output.parts[textPartIndex].text ?? "" + + output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}` + log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, { sessionID: input.sessionID, types: detectedKeywords.map((k) => k.type),