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")