From 9287abe1575f86752c84831cbefab4823907cf7c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 11 Apr 2026 21:24:03 +0900 Subject: [PATCH] fix(stop-continuation): clear chat.message fallback stop state before work resumes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/chat-message.test.ts | 164 ++++++++++++++++++++++++++++++++ src/plugin/chat-message.ts | 19 +++- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index b00784b2e..85ab7fc89 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -17,6 +17,27 @@ import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/s type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } +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 +235,149 @@ 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: 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) + }) +}) + 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..68bdaccda 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" @@ -125,6 +125,20 @@ function parseRawLoopSlashCommand(promptText: string): RawLoopCommand | null { return null } +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 +221,7 @@ export function createChatMessageHandler(args: { await hooks.noSisyphusGpt?.["chat.message"]?.(input, output) await hooks.noHephaestusNonGpt?.["chat.message"]?.(input, output) if (hooks.startWork && isStartWorkHookOutput(output)) { + clearStoppedContinuationBeforeWorkStart(hooks, input.sessionID, "start-work") await hooks.startWork["chat.message"]?.(input, output) } @@ -252,7 +267,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,