fix(keyword-detector): use session state for agent-specific ultrawork templates
Bug: When switching from Prometheus to Sisyphus, the Prometheus ultrawork template was still injected because: 1. setSessionAgent() only sets on first call, ignoring subsequent updates 2. keyword-detector relied solely on input.agent which could be stale Fix: - Use updateSessionAgent() instead of setSessionAgent() in index.ts - keyword-detector now uses getSessionAgent() as primary source, fallback to input.agent - Added tests for agent switch scenario
This commit is contained in:
@@ -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<typeof spyOn>
|
||||
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user