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:
justsisyphus
2026-01-16 19:06:00 +09:00
parent 7cd59e9c0a
commit 5ee8996a39
3 changed files with 201 additions and 5 deletions

View File

@@ -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")
})
})

View File

@@ -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

View File

@@ -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);
}
}