diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index b00784b2e..6ecac08bb 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -17,6 +17,39 @@ import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/s type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } +function createStartWorkTemplateOutput(): ChatMessageHandlerOutput { + return { + message: {}, + parts: [ + { + type: "text", + text: `context\nYou are starting a Sisyphus work session.`, + }, + ], + } +} + +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, + } +} + function createMockHandlerArgs(overrides?: { pluginConfig?: Record shouldOverride?: boolean @@ -214,6 +247,169 @@ describe("createChatMessageHandler - /start-work integration", () => { }) }) +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 = createStartWorkTemplateOutput() + + // 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 for ordinary stopped chat messages", 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) + + // when + await handler(createMockInput("sisyphus"), { + message: {}, + parts: [{ type: "text", text: "continue helping with this bug" }], + }) + + // then + expect(startWorkCalls).toEqual(["test-session"]) + expect(stopContinuationGuard.isStoppedCalls).toHaveLength(0) + expect(stopContinuationGuard.clearCalls).toHaveLength(0) + }) + + 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: createStartWorkTemplateOutput().parts, + }) + 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", + ]) + expect(stopContinuationGuard.clearCalls).toHaveLength(0) + }) +}) + describe("createChatMessageHandler - /ulw-loop raw slash fallback", () => { test("starts ultrawork loop when /ulw-loop arrives through chat.message without native command expansion", async () => { // given diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index 890265877..943165790 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -1,7 +1,7 @@ import type { OhMyOpenCodeConfig } from "../config" import type { PluginContext } from "./types" -import { isModelCacheAvailable } from "../shared" +import { isModelCacheAvailable, log } from "../shared" import { getAgentConfigKey } from "../shared/agent-display-names" import { getSessionModel, setSessionModel } from "../shared/session-model-state" import { getMainSessionID, setSessionAgent, subagentSessions } from "../features/claude-code-session-state" @@ -26,6 +26,7 @@ export type ChatMessageInput = { type StartWorkHookOutput = { parts: Array<{ type: string; text?: string }> } type SessionModelOverride = { providerID: string; modelID: string } +const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session." type RawLoopCommand = | { command: "ralph-loop" | "ulw-loop"; args: string } @@ -125,6 +126,37 @@ function parseRawLoopSlashCommand(promptText: string): RawLoopCommand | null { return null } +function extractPromptText(parts: ChatMessagePart[]): string { + return ( + parts + ?.filter((part) => part.type === "text" && part.text) + .map((part) => part.text) + .join("\n") + .trim() || "" + ) +} + +function isStartWorkFallbackTemplate(promptText: string): boolean { + return ( + promptText.includes("") && + promptText.includes(START_WORK_TEMPLATE_MARKER) + ) +} + +function clearStoppedContinuationBeforeWorkStart( + hooks: CreatedHooks, + sessionID: string, + command: "start-work" | "ralph-loop" | "ulw-loop" +): void { + if (hooks.stopContinuationGuard?.isStopped(sessionID)) { + hooks.stopContinuationGuard.clear(sessionID) + log("[stop-continuation] Stop state cleared by chat.message work-starting command", { + sessionID, + command, + }) + } +} + export function createChatMessageHandler(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig @@ -207,6 +239,10 @@ export function createChatMessageHandler(args: { await hooks.noSisyphusGpt?.["chat.message"]?.(input, output) await hooks.noHephaestusNonGpt?.["chat.message"]?.(input, output) if (hooks.startWork && isStartWorkHookOutput(output)) { + const promptText = extractPromptText(output.parts) + if (isStartWorkFallbackTemplate(promptText)) { + clearStoppedContinuationBeforeWorkStart(hooks, input.sessionID, "start-work") + } await hooks.startWork["chat.message"]?.(input, output) } @@ -226,12 +262,7 @@ export function createChatMessageHandler(args: { if (hooks.ralphLoop && output.message[NATIVE_LOOP_TRIGGERED_FLAG] !== true) { const parts = output.parts - const promptText = - parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || "" + const promptText = extractPromptText(parts) const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") && @@ -252,7 +283,9 @@ export function createChatMessageHandler(args: { const rawTask = taskMatch?.[1]?.trim() || rawLoopCommand?.args || "" const parsedArguments = parseRalphLoopArguments(rawTask) const ultrawork = isUlwLoopTemplate || rawLoopCommand?.command === "ulw-loop" + const command = ultrawork ? "ulw-loop" : "ralph-loop" + clearStoppedContinuationBeforeWorkStart(hooks, input.sessionID, command) hooks.ralphLoop.startLoop(input.sessionID, parsedArguments.prompt, { ultrawork, maxIterations: parsedArguments.maxIterations,