fix(session): ignore internal synthetic turns
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user