ab515b77d0
The /stop-continuation command was ineffective because the stop- continuation-guard cleared its stopped state on the very next chat.message event. Since any user message (including normal chat after stopping) triggers chat.message, the continuation would resume immediately. Root cause: the chat.message handler called clear(sessionID) on every user message, treating it as a 'user resumed work' signal. But the user expects /stop-continuation to persist until they explicitly start work again. Changes: - stop-continuation-guard chat.message: no longer clears stop state - tool-execute-before: /start-work, /ralph-loop, /ulw-loop now explicitly clear the stop state (so continuation resumes when user intentionally restarts work) - Updated and added tests: 12 pass (3 new), 125 related tests pass Closes #3276
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { mkdtempSync, rmSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import { tmpdir } from "node:os"
|
|
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
|
import { readContinuationMarker } from "../../features/run-continuation-state"
|
|
import { createStopContinuationGuardHook } from "./index"
|
|
|
|
type CancelCall = {
|
|
taskId: string
|
|
options?: Parameters<BackgroundManager["cancelTask"]>[1]
|
|
}
|
|
|
|
describe("stop-continuation-guard", () => {
|
|
const tempDirs: string[] = []
|
|
|
|
function createTempDir(): string {
|
|
const directory = mkdtempSync(join(tmpdir(), "omo-stop-guard-"))
|
|
tempDirs.push(directory)
|
|
return directory
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (tempDirs.length > 0) {
|
|
const directory = tempDirs.pop()
|
|
if (directory) {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
})
|
|
|
|
function createMockPluginInput() {
|
|
return {
|
|
client: {
|
|
tui: {
|
|
showToast: async () => ({}),
|
|
},
|
|
},
|
|
directory: createTempDir(),
|
|
} as any
|
|
}
|
|
|
|
function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask {
|
|
return {
|
|
id,
|
|
status,
|
|
description: `${id} description`,
|
|
parentSessionID: "parent-session",
|
|
parentMessageID: "parent-message",
|
|
prompt: "prompt",
|
|
agent: "sisyphus-junior",
|
|
}
|
|
}
|
|
|
|
function createMockBackgroundManager(tasks: BackgroundTask[], cancelCalls: CancelCall[]): Pick<BackgroundManager, "getAllDescendantTasks" | "cancelTask"> {
|
|
return {
|
|
getAllDescendantTasks: () => tasks,
|
|
cancelTask: async (taskId: string, options?: Parameters<BackgroundManager["cancelTask"]>[1]) => {
|
|
cancelCalls.push({ taskId, options })
|
|
return true
|
|
},
|
|
}
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
}
|
|
|
|
test("should mark session as stopped", () => {
|
|
// given - a guard hook with no stopped sessions
|
|
const input = createMockPluginInput()
|
|
const guard = createStopContinuationGuardHook(input)
|
|
const sessionID = "test-session-1"
|
|
|
|
// when - we stop continuation for the session
|
|
guard.stop(sessionID)
|
|
|
|
// then - session should be marked as stopped
|
|
expect(guard.isStopped(sessionID)).toBe(true)
|
|
|
|
const marker = readContinuationMarker(input.directory, sessionID)
|
|
expect(marker?.sources.stop?.state).toBe("stopped")
|
|
})
|
|
|
|
test("should return false for non-stopped sessions", () => {
|
|
// given - a guard hook with no stopped sessions
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
|
|
// when - we check a session that was never stopped
|
|
|
|
// then - it should return false
|
|
expect(guard.isStopped("non-existent-session")).toBe(false)
|
|
})
|
|
|
|
test("should clear stopped state for a session", () => {
|
|
// given - a session that was stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-2"
|
|
guard.stop(sessionID)
|
|
|
|
// when - we clear the session
|
|
guard.clear(sessionID)
|
|
|
|
// then - session should no longer be stopped
|
|
expect(guard.isStopped(sessionID)).toBe(false)
|
|
})
|
|
|
|
test("should handle multiple sessions independently", () => {
|
|
// given - multiple sessions with different stop states
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const session1 = "session-1"
|
|
const session2 = "session-2"
|
|
const session3 = "session-3"
|
|
|
|
// when - we stop some sessions but not others
|
|
guard.stop(session1)
|
|
guard.stop(session2)
|
|
|
|
// then - each session has its own state
|
|
expect(guard.isStopped(session1)).toBe(true)
|
|
expect(guard.isStopped(session2)).toBe(true)
|
|
expect(guard.isStopped(session3)).toBe(false)
|
|
})
|
|
|
|
test("should clear session on session.deleted event", async () => {
|
|
// given - a session that was stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-3"
|
|
guard.stop(sessionID)
|
|
|
|
// when - session is deleted
|
|
await guard.event({
|
|
event: {
|
|
type: "session.deleted",
|
|
properties: { info: { id: sessionID } },
|
|
},
|
|
})
|
|
|
|
// then - session should no longer be stopped (cleaned up)
|
|
expect(guard.isStopped(sessionID)).toBe(false)
|
|
})
|
|
|
|
test("should not affect other sessions on session.deleted", async () => {
|
|
// given - multiple stopped sessions
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const session1 = "session-keep"
|
|
const session2 = "session-delete"
|
|
guard.stop(session1)
|
|
guard.stop(session2)
|
|
|
|
// when - one session is deleted
|
|
await guard.event({
|
|
event: {
|
|
type: "session.deleted",
|
|
properties: { info: { id: session2 } },
|
|
},
|
|
})
|
|
|
|
// then - other session should remain stopped
|
|
expect(guard.isStopped(session1)).toBe(true)
|
|
expect(guard.isStopped(session2)).toBe(false)
|
|
})
|
|
|
|
test("should NOT clear stopped state on new user message (chat.message)", async () => {
|
|
// given - a session that was stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-4"
|
|
guard.stop(sessionID)
|
|
expect(guard.isStopped(sessionID)).toBe(true)
|
|
|
|
// when - user sends a new message
|
|
await guard["chat.message"]({ sessionID })
|
|
|
|
// then - stop state should persist (not cleared by user messages)
|
|
// Stop is only cleared by explicit work-starting commands (/start-work, /ralph-loop, /ulw-loop)
|
|
// or session deletion. This prevents /stop-continuation from being ineffective.
|
|
expect(guard.isStopped(sessionID)).toBe(true)
|
|
})
|
|
|
|
test("should persist stop state across multiple user messages", async () => {
|
|
// given - a session that was stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-persist"
|
|
guard.stop(sessionID)
|
|
|
|
// when - user sends multiple messages
|
|
await guard["chat.message"]({ sessionID })
|
|
await guard["chat.message"]({ sessionID })
|
|
await guard["chat.message"]({ sessionID })
|
|
|
|
// then - stop state remains active
|
|
expect(guard.isStopped(sessionID)).toBe(true)
|
|
})
|
|
|
|
test("should clear stop state only via explicit clear() call", () => {
|
|
// given - a session that was stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-explicit-clear"
|
|
guard.stop(sessionID)
|
|
expect(guard.isStopped(sessionID)).toBe(true)
|
|
|
|
// when - clear is called (simulating /start-work or /ralph-loop)
|
|
guard.clear(sessionID)
|
|
|
|
// then - stop state is cleared
|
|
expect(guard.isStopped(sessionID)).toBe(false)
|
|
})
|
|
|
|
test("should not affect non-stopped sessions on chat.message", async () => {
|
|
// given - a session that was never stopped
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
const sessionID = "test-session-5"
|
|
|
|
// when - user sends a message (session was never stopped)
|
|
await guard["chat.message"]({ sessionID })
|
|
|
|
// then - should not throw and session remains not stopped
|
|
expect(guard.isStopped(sessionID)).toBe(false)
|
|
})
|
|
|
|
test("should handle undefined sessionID in chat.message", async () => {
|
|
// given - a guard with a stopped session
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput())
|
|
guard.stop("some-session")
|
|
|
|
// when - chat.message is called without sessionID
|
|
await guard["chat.message"]({ sessionID: undefined })
|
|
|
|
// then - should not throw and stopped session remains stopped
|
|
expect(guard.isStopped("some-session")).toBe(true)
|
|
})
|
|
|
|
test("should cancel only running and pending background tasks on stop", async () => {
|
|
// given - a background manager with mixed task statuses
|
|
const cancelCalls: CancelCall[] = []
|
|
const backgroundManager = createMockBackgroundManager(
|
|
[
|
|
createBackgroundTask("running", "task-running"),
|
|
createBackgroundTask("pending", "task-pending"),
|
|
createBackgroundTask("completed", "task-completed"),
|
|
],
|
|
cancelCalls,
|
|
)
|
|
const guard = createStopContinuationGuardHook(createMockPluginInput(), {
|
|
backgroundManager,
|
|
})
|
|
|
|
// when - stop continuation is triggered
|
|
guard.stop("test-session-bg")
|
|
await flushMicrotasks()
|
|
|
|
// then - only running and pending tasks are cancelled
|
|
expect(cancelCalls).toHaveLength(2)
|
|
expect(cancelCalls[0]?.taskId).toBe("task-running")
|
|
expect(cancelCalls[0]?.options?.abortSession).toBe(true)
|
|
expect(cancelCalls[1]?.taskId).toBe("task-pending")
|
|
expect(cancelCalls[1]?.options?.abortSession).toBe(false)
|
|
})
|
|
})
|