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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user