fix(context-injector): inline keyword detection in messages transform hook

Fixes race condition where chat.message runs after experimental.chat.messages.transform,
preventing keyword-detected context from being injected. Moves detection logic inline
into the transform hook for atomic detection and injection.

Changes:
- Add detectKeywordsWithType and extractPromptText utilities to injector
- Detect keywords inline within messages transform hook
- Create synthetic message with merged context before last user message
- Add 4 comprehensive test cases for keyword detection scenarios

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-05 02:02:20 +09:00
parent 56fe32caab
commit 36c42ac92f
2 changed files with 99 additions and 25 deletions

View File

@@ -234,11 +234,11 @@ describe("createContextInjectorMessagesTransformHook", () => {
expect(output.messages[3].parts[0].text).toBe("Second message") expect(output.messages[3].parts[0].text).toBe("Second message")
}) })
it("does nothing when no pending context", async () => { it("does nothing when no pending context and no keywords", async () => {
// #given // #given
const hook = createContextInjectorMessagesTransformHook(collector) const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform2" const sessionID = "ses_transform2"
const messages = [createMockMessage("user", "Message", sessionID)] const messages = [createMockMessage("user", "Hello world", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any const output = { messages } as any
@@ -249,6 +249,63 @@ describe("createContextInjectorMessagesTransformHook", () => {
expect(output.messages.length).toBe(1) expect(output.messages.length).toBe(1)
}) })
it("injects synthetic message when user message contains ulw keyword", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_keyword"
const messages = [createMockMessage("user", "ulw do this task", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(2)
expect(output.messages[0].parts[0].synthetic).toBe(true)
expect(output.messages[0].parts[0].text).toContain("ultrawork")
expect(output.messages[1].parts[0].text).toBe("ulw do this task")
})
it("injects synthetic message when user message contains ultrawork keyword", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_ultrawork"
const messages = [createMockMessage("user", "ultrawork please", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(2)
expect(output.messages[0].parts[0].synthetic).toBe(true)
})
it("combines keyword context with collector context", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_combined"
collector.register(sessionID, {
id: "extra",
source: "custom",
content: "Extra context from collector",
})
const messages = [createMockMessage("user", "ulw combined test", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(2)
const syntheticText = output.messages[0].parts[0].text
expect(syntheticText).toContain("ultrawork")
expect(syntheticText).toContain("Extra context from collector")
})
it("does nothing when no user messages", async () => { it("does nothing when no user messages", async () => {
// #given // #given
const hook = createContextInjectorMessagesTransformHook(collector) const hook = createContextInjectorMessagesTransformHook(collector)

View File

@@ -1,6 +1,7 @@
import type { ContextCollector } from "./collector" import type { ContextCollector } from "./collector"
import type { Message, Part } from "@opencode-ai/sdk" import type { Message, Part } from "@opencode-ai/sdk"
import { log } from "../../shared" import { log } from "../../shared"
import { detectKeywordsWithType, extractPromptText } from "../../hooks/keyword-detector"
interface OutputPart { interface OutputPart {
type: string type: string
@@ -83,22 +84,6 @@ export function createContextInjectorMessagesTransformHook(
return return
} }
const lastMessage = messages[messages.length - 1]
const sessionID = (lastMessage.info as unknown as { sessionID?: string }).sessionID
if (!sessionID) {
log("[context-injector] messages.transform: no sessionID on last message")
return
}
const hasPending = collector.hasPending(sessionID)
log("[context-injector] messages.transform check", {
sessionID,
hasPending,
messageCount: messages.length,
})
if (!hasPending) return
let lastUserMessageIndex = -1 let lastUserMessageIndex = -1
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info.role === "user") { if (messages[i].info.role === "user") {
@@ -112,14 +97,45 @@ export function createContextInjectorMessagesTransformHook(
return return
} }
const pending = collector.consume(sessionID) const lastUserMessage = messages[lastUserMessageIndex]
if (!pending.hasContent) { const sessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
log("[context-injector] messages.transform: pending was empty") if (!sessionID) {
log("[context-injector] messages.transform: no sessionID on last user message")
return return
} }
const refMessage = messages[lastUserMessageIndex] const userParts = lastUserMessage.parts as Array<{ type: string; text?: string }>
const refInfo = refMessage.info as unknown as { const promptText = extractPromptText(userParts)
const detectedKeywords = detectKeywordsWithType(promptText)
const hasPendingFromCollector = collector.hasPending(sessionID)
log("[context-injector] messages.transform check", {
sessionID,
detectedKeywords: detectedKeywords.map((k) => k.type),
hasPendingFromCollector,
messageCount: messages.length,
})
const contextParts: string[] = []
for (const keyword of detectedKeywords) {
contextParts.push(keyword.message)
}
if (hasPendingFromCollector) {
const pending = collector.consume(sessionID)
if (pending.hasContent) {
contextParts.push(pending.merged)
}
}
if (contextParts.length === 0) {
return
}
const mergedContext = contextParts.join("\n\n")
const refInfo = lastUserMessage.info as unknown as {
sessionID?: string sessionID?: string
agent?: string agent?: string
model?: { providerID?: string; modelID?: string } model?: { providerID?: string; modelID?: string }
@@ -146,7 +162,7 @@ export function createContextInjectorMessagesTransformHook(
sessionID: sessionID, sessionID: sessionID,
messageID: syntheticMessageId, messageID: syntheticMessageId,
type: "text", type: "text",
text: pending.merged, text: mergedContext,
synthetic: true, synthetic: true,
time: { start: now, end: now }, time: { start: now, end: now },
} as Part, } as Part,
@@ -158,7 +174,8 @@ export function createContextInjectorMessagesTransformHook(
log("[context-injector] Injected synthetic message", { log("[context-injector] Injected synthetic message", {
sessionID, sessionID,
insertIndex: lastUserMessageIndex, insertIndex: lastUserMessageIndex,
contextLength: pending.merged.length, contextLength: mergedContext.length,
keywordTypes: detectedKeywords.map((k) => k.type),
newMessageCount: messages.length, newMessageCount: messages.length,
}) })
}, },