From b5c1cfb57f3062d649dea8e6a3daa702b0a1d8a3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 7 Jan 2026 02:16:49 +0900 Subject: [PATCH] fix(keyword-detector): use mainSessionID for session check instead of unreliable API The keyword-detector was using ctx.client.session.get() to check parentID for determining subagent sessions, but this API didn't reliably return parentID. This caused non-ultrawork keywords (search, analyze) to be injected in subagent sessions when they should only work in main sessions. Changed to use getMainSessionID() comparison, consistent with other hooks like session-notification and todo-continuation-enforcer. - Replace unreliable parentID API check with mainSessionID comparison - Add comprehensive test coverage for session filtering behavior - Remove unnecessary session.get API call --- src/hooks/keyword-detector/index.test.ts | 125 +++++++++++++++++++++++ src/hooks/keyword-detector/index.ts | 32 +++--- 2 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 src/hooks/keyword-detector/index.test.ts diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts new file mode 100644 index 0000000..83dc08b --- /dev/null +++ b/src/hooks/keyword-detector/index.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" +import { createKeywordDetectorHook } from "./index" +import { setMainSession } from "../../features/claude-code-session-state" +import * as sharedModule from "../../shared" + +describe("keyword-detector session filtering", () => { + let logCalls: Array<{ msg: string; data?: unknown }> + + beforeEach(() => { + setMainSession(undefined) + logCalls = [] + spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { + logCalls.push({ msg, data }) + }) + }) + + afterEach(() => { + setMainSession(undefined) + }) + + function createMockPluginInput(options: { toastCalls?: string[] } = {}) { + const toastCalls = options.toastCalls ?? [] + return { + client: { + tui: { + showToast: async (opts: any) => { + toastCalls.push(opts.body.title) + }, + }, + }, + } as any + } + + test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => { + // #given - main session is set, different session submits search keyword + const mainSessionID = "main-123" + const subagentSessionID = "subagent-456" + setMainSession(mainSessionID) + + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "search mode 찾아줘" }], + } + + // #when - non-main session triggers keyword detection + await hook["chat.message"]( + { sessionID: subagentSessionID }, + output + ) + + // #then - search keyword should be filtered out based on mainSessionID comparison + const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session")) + expect(skipLog).toBeDefined() + }) + + test("should allow ultrawork keywords in non-main session", async () => { + // #given - main session is set, different session submits ultrawork keyword + const mainSessionID = "main-123" + const subagentSessionID = "subagent-456" + setMainSession(mainSessionID) + + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork mode" }], + } + + // #when - non-main session triggers ultrawork keyword + await hook["chat.message"]( + { sessionID: subagentSessionID }, + output + ) + + // #then - ultrawork should still work (variant set to max) + expect(output.message.variant).toBe("max") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) + + test("should allow all keywords in main session", async () => { + // #given - main session submits search keyword + const mainSessionID = "main-123" + setMainSession(mainSessionID) + + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "search mode 찾아줘" }], + } + + // #when - main session triggers keyword detection + await hook["chat.message"]( + { sessionID: mainSessionID }, + output + ) + + // #then - search keyword should be detected (output unchanged but detection happens) + // Note: search keywords don't set variant, they inject messages via context-injector + // This test verifies the detection logic runs without filtering + expect(output.message.variant).toBeUndefined() // search doesn't set variant + }) + + test("should allow all keywords when mainSessionID is not set", async () => { + // #given - no main session set (early startup or standalone mode) + setMainSession(undefined) + + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork search" }], + } + + // #when - any session triggers keyword detection + await hook["chat.message"]( + { sessionID: "any-session" }, + output + ) + + // #then - all keywords should work + expect(output.message.variant).toBe("max") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) +}) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index 27e08b5..efd632c 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector" import { log } from "../../shared" +import { getMainSessionID } from "../../features/claude-code-session-state" export * from "./detector" export * from "./constants" @@ -27,29 +28,20 @@ export function createKeywordDetectorHook(ctx: PluginInput) { return } - // Check if this is a subagent session (has parent) - // Only ultrawork keywords work in subagent sessions + // Only ultrawork keywords work in non-main sessions // Other keywords (search, analyze, etc.) only work in main sessions - try { - const sessionInfo = await ctx.client.session.get({ path: { id: input.sessionID } }) - const isSubagentSession = !!sessionInfo.data?.parentID + const mainSessionID = getMainSessionID() + const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID - if (isSubagentSession) { - // Filter to only ultrawork keywords in subagent sessions - detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork") - if (detectedKeywords.length === 0) { - log(`[keyword-detector] Skipping non-ultrawork keywords in subagent session`, { - sessionID: input.sessionID, - parentID: sessionInfo.data?.parentID, - }) - return - } + if (isNonMainSession) { + detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork") + if (detectedKeywords.length === 0) { + log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, { + sessionID: input.sessionID, + mainSessionID, + }) + return } - } catch (err) { - log(`[keyword-detector] Failed to get session info, proceeding with all keywords`, { - error: err, - sessionID: input.sessionID, - }) } const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")