2026-03-31 20:02:00 -07:00
|
|
|
import { afterEach, beforeEach, describe, test, expect } from "bun:test"
|
2026-04-10 13:43:58 +09:00
|
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
2026-03-31 20:02:00 -07:00
|
|
|
import { tmpdir } from "node:os"
|
|
|
|
|
import { join } from "node:path"
|
|
|
|
|
import { randomUUID } from "node:crypto"
|
2026-02-16 13:44:54 +09:00
|
|
|
|
|
|
|
|
import { createChatMessageHandler } from "./chat-message"
|
2026-03-31 20:02:00 -07:00
|
|
|
import { createAutoSlashCommandHook } from "../hooks/auto-slash-command"
|
2026-04-10 11:40:20 +09:00
|
|
|
import { createKeywordDetectorHook } from "../hooks/keyword-detector"
|
2026-03-31 20:02:00 -07:00
|
|
|
import { createStartWorkHook } from "../hooks/start-work"
|
|
|
|
|
import { readBoulderState } from "../features/boulder-state"
|
|
|
|
|
import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state"
|
2026-04-09 12:21:02 +09:00
|
|
|
import { getAgentListDisplayName } from "../shared/agent-display-names"
|
2026-04-10 13:43:58 +09:00
|
|
|
import { getOmoOpenCodeCacheDir, getOpenCodeCacheDir } from "../shared/data-path"
|
2026-03-24 18:11:27 +09:00
|
|
|
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[] }
|
|
|
|
|
|
2026-04-11 21:24:03 +09:00
|
|
|
function createStopContinuationGuardMock(isStopped: boolean) {
|
|
|
|
|
const clearCalls: string[] = []
|
|
|
|
|
const isStoppedCalls: string[] = []
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
guard: {
|
|
|
|
|
"chat.message": async () => {},
|
|
|
|
|
stop: () => {},
|
|
|
|
|
isStopped: (sessionID: string) => {
|
|
|
|
|
isStoppedCalls.push(sessionID)
|
|
|
|
|
return isStopped
|
|
|
|
|
},
|
|
|
|
|
clear: (sessionID: string) => {
|
|
|
|
|
clearCalls.push(sessionID)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
clearCalls,
|
|
|
|
|
isStoppedCalls,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 13:44:54 +09:00
|
|
|
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-04-10 13:43:58 +09:00
|
|
|
describe("createChatMessageHandler - cache warning behavior", () => {
|
|
|
|
|
let cacheRoot = ""
|
|
|
|
|
let originalXdgCacheHome: string | undefined
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
cacheRoot = join(tmpdir(), `chat-message-cache-${randomUUID()}`)
|
|
|
|
|
originalXdgCacheHome = process.env.XDG_CACHE_HOME
|
|
|
|
|
process.env.XDG_CACHE_HOME = cacheRoot
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
if (originalXdgCacheHome === undefined) {
|
|
|
|
|
delete process.env.XDG_CACHE_HOME
|
|
|
|
|
} else {
|
|
|
|
|
process.env.XDG_CACHE_HOME = originalXdgCacheHome
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existsSync(cacheRoot)) {
|
|
|
|
|
rmSync(cacheRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not show provider cache warning when provider-models cache exists", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const toastCalls: Array<{ body: { title: string; message: string } }> = []
|
|
|
|
|
const providerModelsCachePath = join(getOmoOpenCodeCacheDir(), "provider-models.json")
|
|
|
|
|
mkdirSync(getOmoOpenCodeCacheDir(), { recursive: true })
|
|
|
|
|
writeFileSync(providerModelsCachePath, JSON.stringify({
|
|
|
|
|
models: {
|
|
|
|
|
openai: [{ id: "gpt-5.4" }],
|
|
|
|
|
},
|
|
|
|
|
connected: ["openai"],
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.ctx = {
|
|
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async (input: { body: { title: string; message: string } }) => {
|
|
|
|
|
toastCalls.push(input)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as never
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), createMockOutput())
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(toastCalls).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not show provider cache warning when OpenCode models cache exists", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const toastCalls: Array<{ body: { title: string; message: string } }> = []
|
|
|
|
|
const modelsCachePath = join(getOpenCodeCacheDir(), "models.json")
|
|
|
|
|
mkdirSync(getOpenCodeCacheDir(), { recursive: true })
|
|
|
|
|
writeFileSync(modelsCachePath, JSON.stringify({
|
|
|
|
|
openai: {
|
|
|
|
|
id: "openai",
|
|
|
|
|
models: {
|
|
|
|
|
"gpt-5.4": { id: "gpt-5.4" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.ctx = {
|
|
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async (input: { body: { title: string; message: string } }) => {
|
|
|
|
|
toastCalls.push(input)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as never
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), createMockOutput())
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(toastCalls).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-31 20:02:00 -07:00
|
|
|
describe("createChatMessageHandler - /start-work integration", () => {
|
|
|
|
|
let testDir = ""
|
|
|
|
|
let originalWorkingDirectory = ""
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
testDir = join(tmpdir(), `chat-message-start-work-${randomUUID()}`)
|
|
|
|
|
originalWorkingDirectory = process.cwd()
|
|
|
|
|
mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
|
|
|
|
|
writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
|
|
|
|
|
process.chdir(testDir)
|
|
|
|
|
_resetForTesting()
|
|
|
|
|
registerAgentName("prometheus")
|
|
|
|
|
registerAgentName("sisyphus")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
process.chdir(originalWorkingDirectory)
|
|
|
|
|
rmSync(testDir, { recursive: true, force: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("falls back to Sisyphus through the full chat.message slash-command path when Atlas is unavailable", async () => {
|
|
|
|
|
// given
|
|
|
|
|
updateSessionAgent("test-session", "prometheus")
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] })
|
|
|
|
|
args.hooks.startWork = createStartWorkHook({
|
|
|
|
|
directory: testDir,
|
|
|
|
|
client: { tui: { showToast: async () => {} } },
|
|
|
|
|
} as never)
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("prometheus")
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/start-work" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
// then
|
2026-04-08 15:55:58 +09:00
|
|
|
expect(output.message["agent"]).toBe("sisyphus")
|
2026-03-31 20:02:00 -07:00
|
|
|
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
|
|
|
|
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
|
|
|
|
expect(output.parts[0].text).toContain("boulder.json has been created")
|
|
|
|
|
expect(getSessionAgent("test-session")).toBe("sisyphus")
|
|
|
|
|
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
|
|
|
|
})
|
2026-04-07 19:05:14 +09:00
|
|
|
|
|
|
|
|
test("smoke: resolves quoted human-readable plan names through the full /start-work chat.message path", async () => {
|
|
|
|
|
// given
|
|
|
|
|
writeFileSync(join(testDir, ".sisyphus", "plans", "my-feature-plan.md"), "# Plan\n- [ ] Task 1")
|
|
|
|
|
updateSessionAgent("test-session", "prometheus")
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] })
|
|
|
|
|
args.hooks.startWork = createStartWorkHook({
|
|
|
|
|
directory: testDir,
|
|
|
|
|
client: { tui: { showToast: async () => {} } },
|
|
|
|
|
} as never)
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("prometheus")
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/start-work \"my feature plan\"" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
// then
|
2026-04-08 15:55:58 +09:00
|
|
|
expect(output.message["agent"]).toBe("sisyphus")
|
2026-04-07 19:05:14 +09:00
|
|
|
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
|
|
|
|
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
|
|
|
|
expect(output.parts[0].text).toContain("my-feature-plan")
|
|
|
|
|
expect(readBoulderState(testDir)?.plan_name).toBe("my-feature-plan")
|
|
|
|
|
})
|
2026-03-31 20:02:00 -07:00
|
|
|
})
|
|
|
|
|
|
2026-04-11 21:24:03 +09:00
|
|
|
describe("createChatMessageHandler - stop continuation clearing for raw slash fallback", () => {
|
|
|
|
|
test("clears stop state before raw /start-work resumes work through chat.message", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const stopContinuationGuard = createStopContinuationGuardMock(true)
|
|
|
|
|
const startWorkCalls: string[] = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.stopContinuationGuard = stopContinuationGuard.guard
|
|
|
|
|
args.hooks.startWork = {
|
|
|
|
|
"chat.message": async (input: { sessionID: string }) => {
|
|
|
|
|
startWorkCalls.push(input.sessionID)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/start-work" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startWorkCalls).toEqual(["test-session"])
|
|
|
|
|
expect(stopContinuationGuard.isStoppedCalls).toEqual(["test-session"])
|
|
|
|
|
expect(stopContinuationGuard.clearCalls).toEqual(["test-session"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("clears stop state before raw /ulw-loop resumes work through chat.message", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const stopContinuationGuard = createStopContinuationGuardMock(true)
|
|
|
|
|
const startLoopCalls: Array<{ sessionID: string; prompt: string; ultrawork: boolean }> = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.stopContinuationGuard = stopContinuationGuard.guard
|
|
|
|
|
args.hooks.ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: { ultrawork?: boolean }) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, ultrawork: options?.ultrawork === true })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/ulw-loop ship it" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startLoopCalls).toEqual([
|
|
|
|
|
{ sessionID: "test-session", prompt: "ship it", ultrawork: true },
|
|
|
|
|
])
|
|
|
|
|
expect(stopContinuationGuard.isStoppedCalls).toEqual(["test-session"])
|
|
|
|
|
expect(stopContinuationGuard.clearCalls).toEqual(["test-session"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("clears stop state before raw /ralph-loop resumes work through chat.message", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const stopContinuationGuard = createStopContinuationGuardMock(true)
|
|
|
|
|
const startLoopCalls: Array<{ sessionID: string; prompt: string; ultrawork: boolean }> = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.stopContinuationGuard = stopContinuationGuard.guard
|
|
|
|
|
args.hooks.ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: { ultrawork?: boolean }) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, ultrawork: options?.ultrawork === true })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/ralph-loop keep going" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startLoopCalls).toEqual([
|
|
|
|
|
{ sessionID: "test-session", prompt: "keep going", ultrawork: false },
|
|
|
|
|
])
|
|
|
|
|
expect(stopContinuationGuard.isStoppedCalls).toEqual(["test-session"])
|
|
|
|
|
expect(stopContinuationGuard.clearCalls).toEqual(["test-session"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not clear stop state when the session was not stopped", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const stopContinuationGuard = createStopContinuationGuardMock(false)
|
|
|
|
|
const startWorkCalls: string[] = []
|
|
|
|
|
const startLoopCalls: Array<{ sessionID: string; prompt: string; ultrawork: boolean }> = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.stopContinuationGuard = stopContinuationGuard.guard
|
|
|
|
|
args.hooks.startWork = {
|
|
|
|
|
"chat.message": async (input: { sessionID: string }) => {
|
|
|
|
|
startWorkCalls.push(input.sessionID)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
args.hooks.ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: { ultrawork?: boolean }) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, ultrawork: options?.ultrawork === true })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(createMockInput("sisyphus"), {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/start-work" }],
|
|
|
|
|
})
|
|
|
|
|
await handler(createMockInput("sisyphus"), {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/ulw-loop continue" }],
|
|
|
|
|
})
|
|
|
|
|
await handler(createMockInput("sisyphus"), {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "/ralph-loop continue" }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startWorkCalls).toEqual([
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
])
|
|
|
|
|
expect(startLoopCalls).toEqual([
|
|
|
|
|
{ sessionID: "test-session", prompt: "continue", ultrawork: true },
|
|
|
|
|
{ sessionID: "test-session", prompt: "continue", ultrawork: false },
|
|
|
|
|
])
|
|
|
|
|
expect(stopContinuationGuard.isStoppedCalls).toEqual([
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
"test-session",
|
|
|
|
|
])
|
|
|
|
|
expect(stopContinuationGuard.clearCalls).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-01 17:45:16 -07:00
|
|
|
describe("createChatMessageHandler - /ulw-loop raw slash fallback", () => {
|
|
|
|
|
test("starts ultrawork loop when /ulw-loop arrives through chat.message without native command expansion", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const startLoopCalls: Array<{
|
|
|
|
|
sessionID: string
|
|
|
|
|
prompt: string
|
|
|
|
|
options: Record<string, unknown>
|
|
|
|
|
}> = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] })
|
|
|
|
|
args.hooks.ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: Record<string, unknown>) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, options: options ?? {} })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: '/ulw-loop "Ship feature" --strategy=continue' }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startLoopCalls).toEqual([
|
|
|
|
|
{
|
|
|
|
|
sessionID: "test-session",
|
|
|
|
|
prompt: "Ship feature",
|
|
|
|
|
options: {
|
|
|
|
|
ultrawork: true,
|
|
|
|
|
maxIterations: undefined,
|
|
|
|
|
completionPromise: undefined,
|
|
|
|
|
strategy: "continue",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-04-03 22:13:02 +09:00
|
|
|
|
|
|
|
|
test("starts ultrawork loop when injected messages appear before the raw /ulw-loop command", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const startLoopCalls: Array<{
|
|
|
|
|
sessionID: string
|
|
|
|
|
prompt: string
|
|
|
|
|
options: Record<string, unknown>
|
|
|
|
|
}> = []
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: Record<string, unknown>) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, options: options ?? {} })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
|
text: "[BACKGROUND TASK COMPLETED]\nPlan finished.\n\n---\n\n/ulw-loop \"Ship feature\" --strategy=continue",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startLoopCalls).toEqual([
|
|
|
|
|
{
|
|
|
|
|
sessionID: "test-session",
|
|
|
|
|
prompt: "Ship feature",
|
|
|
|
|
options: {
|
|
|
|
|
ultrawork: true,
|
|
|
|
|
maxIterations: undefined,
|
|
|
|
|
completionPromise: undefined,
|
|
|
|
|
strategy: "continue",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-04-01 17:45:16 -07:00
|
|
|
})
|
|
|
|
|
|
2026-04-10 11:40:20 +09:00
|
|
|
describe("createChatMessageHandler - plain ultrawork keyword routing", () => {
|
|
|
|
|
test("does not start ralph loop when plain ulw text flows through the full chat.message pipeline", async () => {
|
|
|
|
|
// given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
const startLoopCalls: Array<{
|
|
|
|
|
sessionID: string
|
|
|
|
|
prompt: string
|
|
|
|
|
options: Record<string, unknown>
|
|
|
|
|
}> = []
|
|
|
|
|
const ralphLoop = {
|
|
|
|
|
startLoop: (sessionID: string, prompt: string, options?: Record<string, unknown>) => {
|
|
|
|
|
startLoopCalls.push({ sessionID, prompt, options: options ?? {} })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
cancelLoop: () => true,
|
|
|
|
|
}
|
|
|
|
|
const args = createMockHandlerArgs()
|
|
|
|
|
args.hooks.ralphLoop = ralphLoop
|
|
|
|
|
args.hooks.keywordDetector = createKeywordDetectorHook(args.ctx as never, undefined, ralphLoop)
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
|
|
|
|
const input = createMockInput("sisyphus")
|
|
|
|
|
const output: ChatMessageHandlerOutput = {
|
|
|
|
|
message: {},
|
|
|
|
|
parts: [{ type: "text", text: "ulw fix the flaky keyword tests" }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await handler(input, output)
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(startLoopCalls).toHaveLength(0)
|
|
|
|
|
expect(output.parts[0]?.text).toContain("ULTRAWORK MODE ENABLED!")
|
|
|
|
|
expect(output.parts[0]?.text).toContain("ulw fix the flaky keyword tests")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
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" })
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-09 12:21:02 +09:00
|
|
|
test("treats prefixed list-display agent names as explicit model overrides", async () => {
|
2026-04-06 18:07:26 +09:00
|
|
|
//#given
|
|
|
|
|
setMainSession("test-session")
|
|
|
|
|
setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" })
|
|
|
|
|
const args = createMockHandlerArgs({
|
|
|
|
|
shouldOverride: false,
|
|
|
|
|
pluginConfig: {
|
|
|
|
|
agents: {
|
|
|
|
|
prometheus: { model: "anthropic/claude-opus-4-6" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const handler = createChatMessageHandler(args)
|
2026-04-09 12:21:02 +09:00
|
|
|
const input = createMockInput(getAgentListDisplayName("prometheus"))
|
2026-04-06 18:07:26 +09:00
|
|
|
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" })
|
2026-04-07 10:08:04 +09:00
|
|
|
expect(getSessionAgent("test-session")).toBe("Prometheus - Plan Builder")
|
2026-04-06 18:07:26 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-24 18:11:27 +09:00
|
|
|
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
|
|
|
})
|