2026-03-24 18:11:27 +09:00
|
|
|
import { afterEach, describe, test, expect } from "bun:test"
|
2026-02-16 13:44:54 +09:00
|
|
|
|
|
|
|
|
import { createChatMessageHandler } from "./chat-message"
|
2026-03-24 18:11:27 +09:00
|
|
|
import { _resetForTesting, setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
|
|
|
|
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
|
2026-02-16 13:44:54 +09:00
|
|
|
|
|
|
|
|
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
|
|
|
|
|
type ChatMessageHandlerOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
|
|
|
|
|
|
|
|
|
|
function createMockHandlerArgs(overrides?: {
|
|
|
|
|
pluginConfig?: Record<string, unknown>
|
|
|
|
|
shouldOverride?: boolean
|
|
|
|
|
}) {
|
|
|
|
|
const appliedSessions: string[] = []
|
|
|
|
|
return {
|
|
|
|
|
ctx: { client: { tui: { showToast: async () => {} } } } as any,
|
|
|
|
|
pluginConfig: (overrides?.pluginConfig ?? {}) as any,
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
shouldOverride: () => overrides?.shouldOverride ?? false,
|
|
|
|
|
markApplied: (sessionID: string) => { appliedSessions.push(sessionID) },
|
|
|
|
|
},
|
|
|
|
|
hooks: {
|
|
|
|
|
stopContinuationGuard: null,
|
2026-02-25 16:26:31 +09:00
|
|
|
backgroundNotificationHook: null,
|
2026-02-16 13:44:54 +09:00
|
|
|
keywordDetector: null,
|
|
|
|
|
claudeCodeHooks: null,
|
|
|
|
|
autoSlashCommand: null,
|
|
|
|
|
startWork: null,
|
|
|
|
|
ralphLoop: null,
|
|
|
|
|
} as any,
|
|
|
|
|
_appliedSessions: appliedSessions,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 18:11:27 +09:00
|
|
|
afterEach(() => {
|
|
|
|
|
_resetForTesting()
|
|
|
|
|
clearSessionModel("test-session")
|
|
|
|
|
clearSessionModel("main-session")
|
|
|
|
|
clearSessionModel("subagent-session")
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 13:44:54 +09:00
|
|
|
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
|
|
|
|
|
return {
|
|
|
|
|
sessionID: "test-session",
|
|
|
|
|
agent,
|
|
|
|
|
model,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMockOutput(variant?: string): ChatMessageHandlerOutput {
|
|
|
|
|
const message: Record<string, unknown> = {}
|
|
|
|
|
if (variant !== undefined) {
|
|
|
|
|
message["variant"] = variant
|
|
|
|
|
}
|
|
|
|
|
return { message, parts: [] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
describe("createChatMessageHandler - TUI variant passthrough", () => {
|
|
|
|
|
test("first message: does not override TUI variant when user has no selection", async () => {
|
|
|
|
|
//#given - first message, no user-selected variant
|
2026-02-16 13:44:54 +09:00
|
|
|
const args = createMockHandlerArgs({ shouldOverride: true })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
|
|
|
|
const output = createMockOutput() // no variant set
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
//#then - TUI sent undefined, should stay undefined (no config override)
|
|
|
|
|
expect(output.message["variant"]).toBeUndefined()
|
2026-02-16 13:44:54 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("first message: preserves user-selected variant when already set", async () => {
|
|
|
|
|
//#given - first message, user already selected "xhigh" variant in OpenCode UI
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: true })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
|
|
|
|
const output = createMockOutput("xhigh") // user selected xhigh
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
//#then - user's xhigh must be preserved
|
2026-02-16 13:44:54 +09:00
|
|
|
expect(output.message["variant"]).toBe("xhigh")
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
test("subsequent message: preserves TUI variant", async () => {
|
|
|
|
|
//#given - not first message, variant already set
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: false })
|
2026-02-16 13:44:54 +09:00
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
2026-02-20 17:47:21 +09:00
|
|
|
const output = createMockOutput("xhigh")
|
2026-02-16 13:44:54 +09:00
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
2026-02-20 17:47:21 +09:00
|
|
|
expect(output.message["variant"]).toBe("xhigh")
|
2026-02-16 13:44:54 +09:00
|
|
|
})
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
test("subsequent message: does not inject variant when TUI sends none", async () => {
|
|
|
|
|
//#given - not first message, no variant from TUI
|
2026-02-16 13:44:54 +09:00
|
|
|
const args = createMockHandlerArgs({ shouldOverride: false })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
2026-02-20 17:47:21 +09:00
|
|
|
const output = createMockOutput() // no variant
|
2026-02-16 13:44:54 +09:00
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
2026-02-20 17:47:21 +09:00
|
|
|
//#then - should stay undefined, not auto-resolved from config
|
|
|
|
|
expect(output.message["variant"]).toBeUndefined()
|
2026-02-16 13:44:54 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("first message: marks gate as applied regardless of variant presence", async () => {
|
|
|
|
|
//#given - first message with user-selected variant
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: true })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
|
|
|
|
const output = createMockOutput("xhigh")
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then - gate should still be marked as applied
|
|
|
|
|
expect(args._appliedSessions).toContain("test-session")
|
|
|
|
|
})
|
2026-02-25 16:26:31 +09:00
|
|
|
|
|
|
|
|
test("injects queued background notifications through chat.message hook", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.backgroundNotificationHook = {
|
|
|
|
|
"chat.message": async (
|
|
|
|
|
_input: { sessionID: string },
|
|
|
|
|
output: ChatMessageHandlerOutput,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
output.parts.push({
|
|
|
|
|
type: "text",
|
|
|
|
|
text: "<system-reminder>[BACKGROUND TASK COMPLETED]</system-reminder>",
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.parts).toHaveLength(1)
|
|
|
|
|
expect(output.parts[0].text).toContain("[BACKGROUND TASK COMPLETED]")
|
|
|
|
|
})
|
2026-03-24 18:11:27 +09:00
|
|
|
|
|
|
|
|
test("reuses the stored model for subsequent messages in the main session when the UI sends none", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: false })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.message["model"]).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
expect(getSessionModel("test-session")).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not reuse a stored model for the first message of a session", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: true })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.message["model"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not reuse the main-session model for subagent sessions", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
setMainSession("main-session")
|
|
|
|
|
setSessionModel("main-session", { providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
subagentSessions.add("subagent-session")
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: false })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = {
|
|
|
|
|
sessionID: "subagent-session",
|
|
|
|
|
agent: "oracle",
|
|
|
|
|
}
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.message["model"]).toBeUndefined()
|
|
|
|
|
expect(getSessionModel("subagent-session")).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not override explicit agent model overrides with stored session model", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
const args = createMockHandlerArgs({
|
|
|
|
|
shouldOverride: false,
|
|
|
|
|
pluginConfig: {
|
|
|
|
|
agents: {
|
|
|
|
|
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.message["model"]).toBeUndefined()
|
|
|
|
|
expect(getSessionModel("test-session")).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("respects a mid-conversation model switch instead of reusing the previous stored model", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
setSessionModel("test-session", { providerID: "anthropic", modelID: "claude-opus-4-6" })
|
|
|
|
|
const args = createMockHandlerArgs({ shouldOverride: false })
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const nextModel = { providerID: "openai", modelID: "gpt-5.4" }
|
|
|
|
|
const input = createMockInput("sisyphus", nextModel)
|
|
|
|
|
const output = createMockOutput()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(output.message["model"]).toBeUndefined()
|
|
|
|
|
expect(getSessionModel("test-session")).toEqual(nextModel)
|
|
|
|
|
})
|
2026-02-16 13:44:54 +09:00
|
|
|
})
|