diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 6af8a077..b93b7022 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import { createKeywordDetectorHook } from "./index" -import { setMainSession } from "../../features/claude-code-session-state" +import { setMainSession, updateSessionAgent, clearSessionAgent } from "../../features/claude-code-session-state" import { ContextCollector } from "../../features/context-injector" import * as sharedModule from "../../shared" import * as sessionState from "../../features/claude-code-session-state" @@ -332,3 +332,197 @@ describe("keyword-detector word boundary", () => { expect(toastCalls).not.toContain("Ultrawork Mode Activated") }) }) + +describe("keyword-detector agent-specific ultrawork messages", () => { + let logCalls: Array<{ msg: string; data?: unknown }> + let logSpy: ReturnType + + beforeEach(() => { + setMainSession(undefined) + logCalls = [] + logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { + logCalls.push({ msg, data }) + }) + }) + + afterEach(() => { + logSpy?.mockRestore() + setMainSession(undefined) + }) + + function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as any + } + + test("should use planner-specific ultrawork message when agent is prometheus", async () => { + // #given - collector and prometheus agent + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "prometheus-session" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork plan this feature" }], + } + + // #when - ultrawork keyword detected with prometheus agent + 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") + }) + + test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => { + // #given - collector and agent with 'planner' in name + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "planner-session" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw create a work plan" }], + } + + // #when - ultrawork keyword detected with planner agent + 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") + }) + + test("should use normal ultrawork message when agent is Sisyphus", async () => { + // #given - collector and Sisyphus agent + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "sisyphus-session" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork implement this feature" }], + } + + // #when - ultrawork keyword detected with Sisyphus agent + 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") + }) + + test("should use normal ultrawork message when agent is undefined", async () => { + // #given - collector with no agent specified + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "no-agent-session" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork do something" }], + } + + // #when - ultrawork keyword detected without agent + 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") + }) + + test("should switch from planner to normal message when agent changes", async () => { + // #given - two sessions, one with prometheus, one with sisyphus + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + + // First session with prometheus + const prometheusSessionID = "prometheus-first" + const prometheusOutput = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork plan" }], + } + await hook["chat.message"]({ sessionID: prometheusSessionID, agent: "prometheus" }, prometheusOutput) + + // Second session with sisyphus + const sisyphusSessionID = "sisyphus-second" + const sisyphusOutput = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork implement" }], + } + 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 sisyphusPending = collector.getPending(sisyphusSessionID) + const sisyphusEntry = sisyphusPending.entries.find((e) => e.id === "keyword-ultrawork") + expect(sisyphusEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") + }) + + test("should use session state agent over stale input.agent (bug fix)", async () => { + // #given - same session, agent switched from prometheus to sisyphus in session state + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "same-session-agent-switch" + + // Simulate: session state was updated to sisyphus (by index.ts updateSessionAgent) + updateSessionAgent(sessionID, "Sisyphus") + + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork implement this" }], + } + + // #when - hook receives stale input.agent="prometheus" but session state says "Sisyphus" + 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") + + // cleanup + clearSessionAgent(sessionID) + }) + + test("should fall back to input.agent when session state is empty", async () => { + // #given - no session state, only input.agent available + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "no-session-state" + + // Ensure no session state + clearSessionAgent(sessionID) + + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork plan this" }], + } + + // #when - hook receives input.agent="prometheus" with no session state + 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") + }) +}) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index 428474d5..2fd8272b 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector" import { log } from "../../shared" import { isSystemDirective } from "../../shared/system-directive" -import { getMainSessionID } from "../../features/claude-code-session-state" +import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" export * from "./detector" @@ -30,7 +30,8 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC return } - let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent) + const currentAgent = getSessionAgent(input.sessionID) ?? input.agent + let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), currentAgent) if (detectedKeywords.length === 0) { return diff --git a/src/index.ts b/src/index.ts index 18c03a6f..059dfb50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ import { setMainSession, getMainSessionID, setSessionAgent, + updateSessionAgent, clearSessionAgent, } from "./features/claude-code-session-state"; import { @@ -309,7 +310,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { "chat.message": async (input, output) => { if (input.agent) { - setSessionAgent(input.sessionID, input.agent); + updateSessionAgent(input.sessionID, input.agent); } const message = (output as { message: { variant?: string } }).message @@ -450,7 +451,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; if (sessionID && agent && role === "user") { - setSessionAgent(sessionID, agent); + updateSessionAgent(sessionID, agent); } }