fix(session): ignore internal synthetic turns

This commit is contained in:
YeonGyu-Kim
2026-05-15 23:16:05 +09:00
parent e8de8b79a8
commit c580b8f2ce
14 changed files with 464 additions and 80 deletions
+81 -3
View File
@@ -1,9 +1,11 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { beforeEach, describe, expect, it } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { ContextCollector } from "./collector"
import {
createContextInjectorHook,
createContextInjectorMessagesTransformHook,
} from "./injector"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("createContextInjectorMessagesTransformHook", () => {
let collector: ContextCollector
@@ -15,7 +17,8 @@ describe("createContextInjectorMessagesTransformHook", () => {
const createMockMessage = (
role: "user" | "assistant",
text: string,
sessionID: string
sessionID: string,
options?: { synthetic?: boolean }
) => ({
info: {
id: `msg_${Date.now()}_${Math.random()}`,
@@ -33,6 +36,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
messageID: `msg_${Date.now()}`,
type: "text" as const,
text,
...(options?.synthetic === true ? { synthetic: true } : {}),
},
],
})
@@ -146,6 +150,80 @@ describe("createContextInjectorMessagesTransformHook", () => {
expect(collector.hasPending(sessionID)).toBe(true)
})
it("does not consume pending context through chat.message when the only text part is synthetic", async () => {
// given
const hook = createContextInjectorHook(collector)
const sessionID = "ses_chat_message_synthetic"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const output = {
message: {},
parts: [{ type: "text", text: "Synthetic hook message", synthetic: true }],
}
// when
await hook["chat.message"]({ sessionID }, output)
// then
expect(output.parts[0]?.text).toBe("Synthetic hook message")
expect(collector.hasPending(sessionID)).toBe(true)
})
it("does not consume pending context when the latest user message is synthetic", async () => {
// given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_synthetic_latest"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const messages = [
createMockMessage("user", "Real user message", sessionID),
createMockMessage("user", "Synthetic hook message", sessionID, { synthetic: true }),
]
const originalMessages = structuredClone(messages)
const output = unsafeTestValue({ messages })
// when
await hook["experimental.chat.messages.transform"]!({}, output)
// then
expect(output.messages).toEqual(originalMessages)
expect(collector.hasPending(sessionID)).toBe(true)
})
it("does not consume pending context when the latest user message is internally marked", async () => {
// given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform_internal_latest"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const messages = [
createMockMessage("user", "Real user message", sessionID),
createMockMessage(
"user",
`Internal prompt\n${OMO_INTERNAL_INITIATOR_MARKER}`,
sessionID,
),
]
const originalMessages = structuredClone(messages)
const output = unsafeTestValue({ messages })
// when
await hook["experimental.chat.messages.transform"]!({}, output)
// then
expect(output.messages).toEqual(originalMessages)
expect(collector.hasPending(sessionID)).toBe(true)
})
it("consumes context after injection", async () => {
// given
const hook = createContextInjectorMessagesTransformHook(collector)
+21 -12
View File
@@ -1,7 +1,7 @@
import type { ContextCollector } from "./collector"
import type { Message, Part } from "@opencode-ai/sdk"
import { log } from "../../shared"
import { isRealUserMessage, isRealUserTextPart, log } from "../../shared"
import { getMainSessionID } from "../claude-code-session-state"
import type { ContextCollector } from "./collector"
interface OutputPart {
type: string
@@ -23,7 +23,7 @@ export function injectPendingContext(
return { injected: false, contextLength: 0 }
}
const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text !== undefined)
const textPartIndex = parts.findIndex(isRealUserTextPart)
if (textPartIndex === -1) {
return { injected: false, contextLength: 0 }
}
@@ -102,7 +102,8 @@ export function createContextInjectorMessagesTransformHook(
let lastUserMessageIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info.role === "user") {
const message = messages[i]
if (message?.info.role === "user") {
lastUserMessageIndex = i
break
}
@@ -114,6 +115,15 @@ export function createContextInjectorMessagesTransformHook(
}
const lastUserMessage = messages[lastUserMessageIndex]
if (lastUserMessage === undefined) {
return
}
if (!isRealUserMessage(lastUserMessage)) {
log("[context-injector] Latest user message is synthetic/internal, skipping injection", {
sessionID: getSessionIDFromMessageInfo(lastUserMessage.info) ?? getMainSessionID(),
})
return
}
const messageSessionID = getSessionIDFromMessageInfo(lastUserMessage.info)
const sessionID = messageSessionID ?? getMainSessionID()
log("[DEBUG] Extracted sessionID", {
@@ -136,13 +146,8 @@ export function createContextInjectorMessagesTransformHook(
return
}
const pending = collector.consume(sessionID)
if (!pending.hasContent) {
return
}
const textPartIndex = lastUserMessage.parts.findIndex(
(p) => p.type === "text" && hasText(p)
(p) => isRealUserTextPart(p) && hasText(p)
)
if (textPartIndex === -1) {
@@ -153,14 +158,18 @@ export function createContextInjectorMessagesTransformHook(
return
}
// synthetic part pattern (minimal fields)
const pending = collector.consume(sessionID)
if (!pending.hasContent) {
return
}
const syntheticPart = {
id: `synthetic_hook_${sessionID}`,
messageID: lastUserMessage.info.id,
sessionID: messageSessionID ?? "",
type: "text" as const,
text: pending.merged,
synthetic: true, // hidden in UI
synthetic: true,
}
lastUserMessage.parts.splice(textPartIndex, 0, syntheticPart as Part)