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
This commit is contained in:
@@ -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<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user