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
+51 -3
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from "bun:test"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import {
parseSlashCommand,
detectSlashCommand,
isExcludedCommand,
removeCodeBlocks,
extractPromptText,
findSlashCommandPartIndex,
isExcludedCommand,
parseSlashCommand,
removeCodeBlocks,
} from "./detector"
describe("auto-slash-command detector", () => {
@@ -305,5 +307,51 @@ After`
// then should return empty string
expect(result).toBe("")
})
it("ignores synthetic and internal slash text when extracting prompt text", () => {
// given
const parts = [
{ type: "text", text: "/commit from synthetic", synthetic: true },
{ type: "text", text: `/commit from marker\n${OMO_INTERNAL_INITIATOR_MARKER}` },
{ type: "text", text: "real request" },
]
// when
const result = extractPromptText(parts)
// then
expect(result).toBe("real request")
})
})
describe("findSlashCommandPartIndex", () => {
it("does not select synthetic or internal slash command parts", () => {
// given
const parts = [
{ type: "text", text: "/commit synthetic", synthetic: true },
{ type: "text", text: `/plan internal\n${OMO_INTERNAL_INITIATOR_MARKER}` },
{ type: "text", text: "/real-command" },
]
// when
const result = findSlashCommandPartIndex(parts)
// then
expect(result).toBe(2)
})
it("returns minus one when every slash command part is synthetic or internal", () => {
// given
const parts = [
{ type: "text", text: "/commit synthetic", synthetic: true },
{ type: "text", text: `/plan internal\n${OMO_INTERNAL_INITIATOR_MARKER}` },
]
// when
const result = findSlashCommandPartIndex(parts)
// then
expect(result).toBe(-1)
})
})
})
+6 -12
View File
@@ -1,6 +1,7 @@
import { isRealUserTextPart } from "../../shared/internal-initiator-marker"
import {
SLASH_COMMAND_PATTERN,
EXCLUDED_COMMANDS,
SLASH_COMMAND_PATTERN,
} from "./constants"
import type { ParsedSlashCommand } from "./types"
@@ -56,30 +57,23 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
): string {
const textParts = parts.filter((p) => p.type === "text")
const textParts = parts.filter(isRealUserTextPart)
const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
if (slashPart?.text) {
return slashPart.text
}
const nonSyntheticParts = textParts.filter(
(p) => !(p as { synthetic?: boolean }).synthetic
)
if (nonSyntheticParts.length > 0) {
return nonSyntheticParts.map((p) => p.text || "").join(" ")
}
return textParts.map((p) => p.text || "").join(" ")
}
export function findSlashCommandPartIndex(
parts: Array<{ type: string; text?: string }>
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
): number {
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx]
if (part.type !== "text") continue
if (!isRealUserTextPart(part)) continue
if ((part.text ?? "").trim().startsWith("/")) {
return idx
}
+23 -5
View File
@@ -1,9 +1,11 @@
import { describe, expect, it, beforeEach, afterEach, spyOn, mock } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
@@ -11,9 +13,6 @@ import type {
CommandExecuteBeforeOutput,
} from "./types"
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
type AutoSlashCommandModule = typeof import("./hook")
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
@@ -423,6 +422,25 @@ describe("createAutoSlashCommandHook", () => {
expect(output.parts[0].text).toContain("This is the skill template content")
})
it("does not replace synthetic slash text with a skill template", async () => {
// given
const skill = createTestSkill("my-test-skill", "This is the skill template content")
const hook = createAutoSlashCommandHook({ skills: [skill] })
const sessionID = `test-session-skill-synthetic-${Date.now()}`
const input = createMockInput(sessionID)
const output: AutoSlashCommandHookOutput = {
message: {},
parts: [{ type: "text", text: "/my-test-skill some arguments", synthetic: true }],
}
const originalText = output.parts[0].text
// when
await hook["chat.message"](input, output)
// then
expect(output.parts[0].text).toBe(originalText)
})
it("should inject skill template via command.execute.before", async () => {
// given a hook with a skill
const skill = createTestSkill("my-test-skill", "Skill template for command execute")
+4 -3
View File
@@ -1,8 +1,9 @@
import type { KeywordType } from "../../config/schema/keyword-detector"
import { isRealUserTextPart } from "../../shared/internal-initiator-marker"
import {
KEYWORD_DETECTORS,
CODE_BLOCK_PATTERN,
INLINE_CODE_PATTERN,
KEYWORD_DETECTORS,
} from "./constants"
export interface DetectedKeyword {
@@ -61,10 +62,10 @@ export function detectKeywordsWithType(
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
): string {
return parts
.filter((p) => p.type === "text")
.filter(isRealUserTextPart)
.map((p) => p.text || "")
.join(" ")
}
+8 -9
View File
@@ -6,7 +6,11 @@ import {
subagentSessions,
} from "../../features/claude-code-session-state"
import type { ContextCollector } from "../../features/context-injector"
import { log } from "../../shared"
import {
isRealUserTextPart,
isSyntheticOrInternalOnlyTextParts,
log,
} from "../../shared"
import {
isSystemDirective,
removeSystemReminders,
@@ -22,11 +26,6 @@ function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[
return detected.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan")
}
function isSyntheticTextMessage(parts: Array<{ type: string; text?: string; [key: string]: unknown }>): boolean {
const textParts = parts.filter((part) => part.type === "text" && part.text !== undefined)
return textParts.length > 0 && textParts.every((part) => part.synthetic === true)
}
export function createKeywordDetectorHook(
ctx: PluginInput,
_collector?: ContextCollector,
@@ -56,8 +55,8 @@ export function createKeywordDetectorHook(
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
if (isSyntheticTextMessage(output.parts)) {
log(`[keyword-detector] Skipping synthetic text message`, { sessionID: input.sessionID })
if (isSyntheticOrInternalOnlyTextParts(output.parts)) {
log(`[keyword-detector] Skipping synthetic/internal text message`, { sessionID: input.sessionID })
return
}
@@ -191,7 +190,7 @@ export function createKeywordDetectorHook(
.catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
}
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
const textPartIndex = output.parts.findIndex(isRealUserTextPart)
if (textPartIndex === -1) {
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
return
+27 -5
View File
@@ -1,13 +1,14 @@
/// <reference types="bun-types" />
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createKeywordDetectorHook } from "./index"
import { setMainSession, updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import * as sessionState from "../../features/claude-code-session-state"
import { _resetForTesting, clearSessionAgent, setMainSession, updateSessionAgent } from "../../features/claude-code-session-state"
import { ContextCollector } from "../../features/context-injector"
import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { createKeywordDetectorHook } from "./index"
type ToastOptions = { body: { title: string } }
@@ -159,6 +160,27 @@ describe("keyword-detector message transform", () => {
expect(textPart?.text).toBe('<peer_message from="researcher">search the issue thread and report findings</peer_message>')
expect(textPart?.text).not.toContain("[search-mode]")
})
test("should not prepend mode instructions to internally marked peer messages", async () => {
// given - an internal peer message contains a search keyword but is not user intent
const collector = new ContextCollector()
const sessionID = "internal-peer-message-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const peerText = `<peer_message from="researcher">search the issue thread</peer_message>\n${OMO_INTERNAL_INITIATOR_MARKER}`
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: peerText }],
}
// when
await hook["chat.message"]({ sessionID }, output)
// then
const textPart = output.parts.find((part) => part.type === "text")
expect(textPart?.text).toBe(peerText)
expect(textPart?.text).not.toContain("[search-mode]")
})
})
describe("keyword-detector session filtering", () => {
@@ -3,7 +3,11 @@ import { describe, expect, it } from "bun:test"
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
import { createTeamModeStatusInjector } from "./hook"
function createOutput(sessionID: string, text = "original message"): {
function createOutput(
sessionID: string,
text = "original message",
options?: { synthetic?: boolean }
): {
messages: Array<{
info: { role: string; sessionID: string }
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
@@ -16,7 +20,13 @@ function createOutput(sessionID: string, text = "original message"): {
role: "user",
sessionID,
},
parts: [{ type: "text", text }],
parts: [
{
type: "text",
text,
...(options?.synthetic === true ? { synthetic: true } : {}),
},
],
},
],
}
@@ -111,6 +121,22 @@ describe("createTeamModeStatusInjector", () => {
expect(output.messages[0]?.parts[0]?.text).toBe(".")
})
it("does not inject team mode status for synthetic team prompts", async () => {
// given
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true }))
const output = createOutput("session-team-mode", "team mode please", { synthetic: true })
// when
await hook["experimental.chat.messages.transform"]?.(
{ sessionID: "session-team-mode" },
output,
)
// then
expect(output.messages).toHaveLength(1)
expect(output.messages[0]?.parts[0]?.text).toBe("team mode please")
})
it("does not inject team mode status when the team keyword is disabled", async () => {
// given
const hook = createTeamModeStatusInjector(
+7 -2
View File
@@ -1,5 +1,6 @@
import type { TeamModeConfig } from "../../config/schema/team-mode"
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
import type { TeamModeConfig } from "../../config/schema/team-mode"
import { isRealUserMessage } from "../../shared/internal-initiator-marker"
import { detectKeywordsWithType, extractPromptText } from "../keyword-detector/detector"
type TransformPart = {
@@ -58,7 +59,8 @@ function resolveSessionID(
function findLastUserMessageIndex(messages: MessageWithParts[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.info.role === "user") {
const message = messages[index]
if (message?.info.role === "user") {
return index
}
}
@@ -83,6 +85,9 @@ function latestUserMessageRequestsTeamMode(
if (message === undefined) {
return false
}
if (!isRealUserMessage(message)) {
return false
}
const promptText = extractPromptText(message.parts)
return detectKeywordsWithType(