diff --git a/src/features/builtin-commands/commands.test.ts b/src/features/builtin-commands/commands.test.ts index 668027368..15231bb44 100644 --- a/src/features/builtin-commands/commands.test.ts +++ b/src/features/builtin-commands/commands.test.ts @@ -59,6 +59,16 @@ describe("loadBuiltinCommands", () => { //#then expect(commands.handoff.description).toContain("context summary") }) + + test("should preassign Sisyphus as the native agent for start-work", () => { + //#given - no disabled commands + + //#when + const commands = loadBuiltinCommands() + + //#then + expect(commands["start-work"].agent).toBe("sisyphus") + }) }) describe("loadBuiltinCommands — remove-ai-slops", () => { diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts index e3b0bb52d..3c0f1e432 100644 --- a/src/features/builtin-commands/commands.ts +++ b/src/features/builtin-commands/commands.ts @@ -58,7 +58,7 @@ ${REFACTOR_TEMPLATE} }, "start-work": { description: "(builtin) Start Sisyphus work session from Prometheus plan", - agent: "atlas", + agent: "sisyphus", template: ` ${START_WORK_TEMPLATE} diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index af284f6f8..193d83bb8 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { createStartWorkHook } from "./index" +import { getAgentListDisplayName } from "../../shared/agent-display-names" import { writeBoulderState, clearBoulderState, @@ -24,6 +25,22 @@ describe("start-work hook", () => { } as Parameters[0] } + function createStartWorkPrompt(options?: { + sessionContext?: string + userRequest?: string + }): string { + const sessionContext = options?.sessionContext ?? "" + const userRequest = options?.userRequest ?? "" + + return ` +You are starting a Sisyphus work session. + + +${sessionContext}${userRequest ? ` + +${userRequest}` : ""}` + } + beforeEach(() => { sessionState._resetForTesting() sessionState.registerAgentName("atlas") @@ -65,6 +82,24 @@ describe("start-work hook", () => { expect(output.parts[0].text).toBe("Just a regular message") }) + test("should ignore plain session-context blocks without the start-work marker", async () => { + // given + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [{ type: "text", text: "Some context here" }], + } + + // when + await hook["chat.message"]( + { sessionID: "session-123" }, + output + ) + + // then + expect(output.parts[0].text).toBe("Some context here") + expect(readBoulderState(testDir)).toBeNull() + }) + test("should detect start-work command via session-context tag", async () => { // given - hook and start-work message const hook = createStartWorkHook(createMockPluginInput()) @@ -72,7 +107,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: "Some context here", + text: createStartWorkPrompt({ sessionContext: "Some context here" }), }, ], } @@ -102,7 +137,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -123,7 +158,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: "Session: $SESSION_ID", + text: createStartWorkPrompt({ sessionContext: "Session: $SESSION_ID" }), }, ], } @@ -146,7 +181,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: "Time: $TIMESTAMP", + text: createStartWorkPrompt({ sessionContext: "Time: $TIMESTAMP" }), }, ], } @@ -177,7 +212,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -205,7 +240,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -233,7 +268,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -274,9 +309,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: ` -new-plan -`, + text: createStartWorkPrompt({ userRequest: "new-plan" }), }, ], } @@ -306,9 +339,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: ` -my-feature-plan ultrawork -`, + text: createStartWorkPrompt({ userRequest: "my-feature-plan ultrawork" }), }, ], } @@ -337,9 +368,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: ` -api-refactor ulw -`, + text: createStartWorkPrompt({ userRequest: "api-refactor ulw" }), }, ], } @@ -368,9 +397,7 @@ describe("start-work hook", () => { parts: [ { type: "text", - text: ` -feature-implementation -`, + text: createStartWorkPrompt({ userRequest: "feature-implementation" }), }, ], } @@ -394,7 +421,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -408,12 +435,12 @@ describe("start-work hook", () => { updateSpy.mockRestore() }) - test("should stamp the outgoing message with Atlas so follow-up events keep the handoff", async () => { + test("should stamp the outgoing message with Atlas list key so follow-up events keep the handoff", async () => { // given const hook = createStartWorkHook(createMockPluginInput()) const output = { message: {} as Record, - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -423,7 +450,7 @@ describe("start-work hook", () => { ) // then - expect(output.message.agent).toBe("Atlas (Plan Executor)") + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) }) test("should keep the current agent when Atlas is unavailable", async () => { @@ -435,7 +462,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { message: {} as Record, - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -463,7 +490,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { message: {} as Record, - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -498,7 +525,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { message: {} as Record, - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -532,7 +559,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when @@ -553,7 +580,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "\n--worktree /validated/worktree\n" }], + parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /validated/worktree" }) }], } // when @@ -575,7 +602,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "\n--worktree /valid/wt\n" }], + parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /valid/wt" }) }], } // when @@ -595,7 +622,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "\n--worktree /nonexistent/wt\n" }], + parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /nonexistent/wt" }) }], } // when @@ -624,7 +651,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "\n--worktree /new/wt\n" }], + parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /new/wt" }) }], } // when @@ -651,7 +678,7 @@ describe("start-work hook", () => { const hook = createStartWorkHook(createMockPluginInput()) const output = { - parts: [{ type: "text", text: "" }], + parts: [{ type: "text", text: createStartWorkPrompt() }], } // when diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index 7f9f0bcdb..d0445d7f1 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -11,18 +11,33 @@ import { clearBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" -import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getAgentConfigKey, + getAgentDisplayName, + getAgentListDisplayName, +} from "../../shared/agent-display-names" +import { + getSessionAgent, + isAgentRegistered, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { detectWorktreePath } from "./worktree-detector" import { parseUserRequest } from "./parse-user-request" export const HOOK_NAME = "start-work" as const +const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session." interface StartWorkHookInput { sessionID: string messageID?: string } +interface StartWorkCommandExecuteBeforeInput { + sessionID: string + command: string + arguments: string +} + interface StartWorkHookOutput { message?: Record parts: Array<{ type: string; text?: string }> @@ -67,61 +82,76 @@ function resolveWorktreeContext( } export function createStartWorkHook(ctx: PluginInput) { - return { - "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise => { - const parts = output.parts - const promptText = - parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || "" + const processStartWork = async ( + input: StartWorkHookInput, + output: StartWorkHookOutput, + ): Promise => { + const parts = output.parts + const promptText = + parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" - if (!promptText.includes("")) return + if ( + !promptText.includes("") + || !promptText.includes(START_WORK_TEMPLATE_MARKER) + ) { + return + } - log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID }) - const currentSessionAgent = getSessionAgent(input.sessionID) - const activeAgent = isAgentRegistered("atlas") - ? "atlas" - : currentSessionAgent && getAgentConfigKey(currentSessionAgent) !== "prometheus" - ? currentSessionAgent + log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID }) + const currentSessionAgent = getSessionAgent(input.sessionID) + const currentSessionAgentKey = currentSessionAgent + ? getAgentConfigKey(currentSessionAgent) + : undefined + const activeAgent = currentSessionAgent + && currentSessionAgentKey + && currentSessionAgentKey !== "prometheus" + && currentSessionAgentKey !== "atlas" + ? currentSessionAgent + : isAgentRegistered("atlas") + ? "atlas" : "sisyphus" - const activeAgentDisplayName = getAgentDisplayName(activeAgent) - updateSessionAgent(input.sessionID, activeAgent) - if (output.message) { - output.message["agent"] = activeAgentDisplayName - } + const activeAgentDisplayName = activeAgent === "atlas" + ? getAgentListDisplayName(activeAgent) + : getAgentDisplayName(activeAgent) + updateSessionAgent(input.sessionID, activeAgent) + if (output.message) { + output.message["agent"] = activeAgentDisplayName + } - const existingState = readBoulderState(ctx.directory) - const sessionId = input.sessionID - const timestamp = new Date().toISOString() + const existingState = readBoulderState(ctx.directory) + const sessionId = input.sessionID + const timestamp = new Date().toISOString() - const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) - const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) + const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) + const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) - let contextInfo = "" + let contextInfo = "" - if (explicitPlanName) { - log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID }) + if (explicitPlanName) { + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID }) - const allPlans = findPrometheusPlans(ctx.directory) - const matchedPlan = findPlanByName(allPlans, explicitPlanName) + const allPlans = findPrometheusPlans(ctx.directory) + const matchedPlan = findPlanByName(allPlans, explicitPlanName) - if (matchedPlan) { - const progress = getPlanProgress(matchedPlan) + if (matchedPlan) { + const progress = getPlanProgress(matchedPlan) - if (progress.isComplete) { - contextInfo = ` + if (progress.isComplete) { + contextInfo = ` ## Plan Already Complete The requested plan "${getPlanName(matchedPlan)}" has been completed. All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` - } else { - if (existingState) clearBoulderState(ctx.directory) - const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath) - writeBoulderState(ctx.directory, newState) + } else { + if (existingState) clearBoulderState(ctx.directory) + const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath) + writeBoulderState(ctx.directory, newState) - contextInfo = ` + contextInfo = ` ## Auto-Selected Plan **Plan**: ${getPlanName(matchedPlan)} @@ -132,18 +162,18 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` ${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` - } - } else { - const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) - if (incompletePlans.length > 0) { - const planList = incompletePlans - .map((p, i) => { - const prog = getPlanProgress(p) - return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` - }) - .join("\n") + } + } else { + const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) + if (incompletePlans.length > 0) { + const planList = incompletePlans + .map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }) + .join("\n") - contextInfo = ` + contextInfo = ` ## Plan Not Found Could not find a plan matching "${explicitPlanName}". @@ -152,39 +182,39 @@ Available incomplete plans: ${planList} Ask the user which plan to work on.` - } else { - contextInfo = ` + } else { + contextInfo = ` ## Plan Not Found Could not find a plan matching "${explicitPlanName}". No incomplete plans available. Create a new plan with: /plan "your task"` - } } - } else if (existingState) { - const progress = getPlanProgress(existingState.active_plan) + } + } else if (existingState) { + const progress = getPlanProgress(existingState.active_plan) - if (!progress.isComplete) { - const effectiveWorktree = worktreePath ?? existingState.worktree_path - const sessionAlreadyTracked = existingState.session_ids.includes(sessionId) - const updatedSessions = sessionAlreadyTracked - ? existingState.session_ids - : [...existingState.session_ids, sessionId] - const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined + if (!progress.isComplete) { + const effectiveWorktree = worktreePath ?? existingState.worktree_path + const sessionAlreadyTracked = existingState.session_ids.includes(sessionId) + const updatedSessions = sessionAlreadyTracked + ? existingState.session_ids + : [...existingState.session_ids, sessionId] + const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined - if (shouldRewriteState) { - writeBoulderState(ctx.directory, { - ...existingState, - agent: activeAgent, - ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), - session_ids: updatedSessions, - }) - } else if (!sessionAlreadyTracked) { - appendSessionId(ctx.directory, sessionId) - } + if (shouldRewriteState) { + writeBoulderState(ctx.directory, { + ...existingState, + agent: activeAgent, + ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), + session_ids: updatedSessions, + }) + } else if (!sessionAlreadyTracked) { + appendSessionId(ctx.directory, sessionId) + } - const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock + const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock - contextInfo = ` + contextInfo = ` ## Active Work Session Found **Status**: RESUMING existing work @@ -197,41 +227,41 @@ ${worktreeDisplay} The current session (${sessionId}) has been added to session_ids. Read the plan file and continue from the first unchecked task.` - } else { - contextInfo = ` + } else { + contextInfo = ` ## Previous Work Complete The previous plan (${existingState.plan_name}) has been completed. Looking for new plans...` - } } + } - if ( - (!existingState && !explicitPlanName) || - (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) - ) { - const plans = findPrometheusPlans(ctx.directory) - const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) + if ( + (!existingState && !explicitPlanName) || + (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) + ) { + const plans = findPrometheusPlans(ctx.directory) + const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) - if (plans.length === 0) { - contextInfo += ` + if (plans.length === 0) { + contextInfo += ` ## No Plans Found No Prometheus plan files found at .sisyphus/plans/ Use Prometheus to create a work plan first: /plan "your task"` - } else if (incompletePlans.length === 0) { - contextInfo += ` + } else if (incompletePlans.length === 0) { + contextInfo += ` ## All Plans Complete All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` - } else if (incompletePlans.length === 1) { - const planPath = incompletePlans[0] - const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) - writeBoulderState(ctx.directory, newState) + } else if (incompletePlans.length === 1) { + const planPath = incompletePlans[0] + const progress = getPlanProgress(planPath) + const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) + writeBoulderState(ctx.directory, newState) - contextInfo += ` + contextInfo += ` ## Auto-Selected Plan @@ -243,16 +273,16 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta ${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` - } else { - const planList = incompletePlans - .map((p, i) => { - const progress = getPlanProgress(p) - const modified = new Date(statSync(p).mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }) - .join("\n") + } else { + const planList = incompletePlans + .map((p, i) => { + const progress = getPlanProgress(p) + const modified = new Date(statSync(p).mtimeMs).toISOString() + return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` + }) + .join("\n") - contextInfo += ` + contextInfo += ` ## Multiple Plans Found @@ -265,23 +295,34 @@ ${planList} Ask the user which plan to work on. Present the options above and wait for their response. ${worktreeBlock} ` - } } + } - const idx = output.parts.findIndex((p) => p.type === "text" && p.text) - if (idx >= 0 && output.parts[idx].text) { - output.parts[idx].text = output.parts[idx].text - .replace(/\$SESSION_ID/g, sessionId) - .replace(/\$TIMESTAMP/g, timestamp) + const idx = output.parts.findIndex((p) => p.type === "text" && p.text) + if (idx >= 0 && output.parts[idx].text) { + output.parts[idx].text = output.parts[idx].text + .replace(/\$SESSION_ID/g, sessionId) + .replace(/\$TIMESTAMP/g, timestamp) - output.parts[idx].text += `\n\n---\n${contextInfo}` - } + output.parts[idx].text += `\n\n---\n${contextInfo}` + } - log(`[${HOOK_NAME}] Context injected`, { - sessionID: input.sessionID, - hasExistingState: !!existingState, - worktreePath, - }) + log(`[${HOOK_NAME}] Context injected`, { + sessionID: input.sessionID, + hasExistingState: !!existingState, + worktreePath, + }) + } + + return { + "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise => { + await processStartWork(input, output) + }, + "command.execute.before": async ( + input: StartWorkCommandExecuteBeforeInput, + output: StartWorkHookOutput, + ): Promise => { + await processStartWork(input, output) }, } } diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts new file mode 100644 index 000000000..e9dfee568 --- /dev/null +++ b/src/plugin-interface.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { createPluginInterface } from "./plugin-interface" +import { createAutoSlashCommandHook } from "./hooks/auto-slash-command" +import { createStartWorkHook } from "./hooks/start-work" +import { getAgentListDisplayName } from "./shared/agent-display-names" +import { readBoulderState } from "./features/boulder-state" +import { + _resetForTesting, + getSessionAgent, + registerAgentName, + updateSessionAgent, +} from "./features/claude-code-session-state" + +describe("createPluginInterface - command.execute.before", () => { + let testDir = "" + + beforeEach(() => { + testDir = join(tmpdir(), `plugin-interface-start-work-${randomUUID()}`) + mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1") + _resetForTesting() + registerAgentName("prometheus") + registerAgentName("sisyphus") + }) + + afterEach(() => { + _resetForTesting() + rmSync(testDir, { recursive: true, force: true }) + }) + + test("executes start-work side effects for native command execution", async () => { + // given + updateSessionAgent("ses-command-before", "prometheus") + const pluginInterface = createPluginInterface({ + ctx: { + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never, + pluginConfig: {} as never, + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: {} as never, + hooks: { + autoSlashCommand: createAutoSlashCommandHook({ skills: [] }), + startWork: createStartWorkHook({ + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never), + } as never, + tools: {}, + }) + const output = { + parts: [{ type: "text", text: "original" }], + } + + // when + await pluginInterface["command.execute.before"]?.( + { + command: "start-work", + sessionID: "ses-command-before", + arguments: "", + }, + output as never + ) + + // then + expect(pluginInterface["command.execute.before"]).toBeDefined() + expect(output.parts[0]?.text).toContain("Auto-Selected Plan") + expect(output.parts[0]?.text).toContain("boulder.json has been created") + expect(getSessionAgent("ses-command-before")).toBe("sisyphus") + expect(readBoulderState(testDir)?.agent).toBe("sisyphus") + }) + + test("does not run start-work side effects for other native commands with session context", async () => { + // given + updateSessionAgent("ses-handoff", "prometheus") + const pluginInterface = createPluginInterface({ + ctx: { + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never, + pluginConfig: {} as never, + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: {} as never, + hooks: { + autoSlashCommand: createAutoSlashCommandHook({ skills: [] }), + startWork: createStartWorkHook({ + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never), + } as never, + tools: {}, + }) + const output = { + parts: [{ type: "text", text: "original" }], + } + + // when + await pluginInterface["command.execute.before"]?.( + { + command: "handoff", + sessionID: "ses-handoff", + arguments: "", + }, + output as never + ) + + // then + expect(output.parts[0]?.text).toContain("HANDOFF CONTEXT") + expect(readBoulderState(testDir)).toBeNull() + expect(getSessionAgent("ses-handoff")).toBe("prometheus") + }) + + test("switches native start-work to Atlas when Atlas is registered in config", async () => { + // given + registerAgentName("atlas") + updateSessionAgent("ses-command-atlas", "prometheus") + const pluginInterface = createPluginInterface({ + ctx: { + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never, + pluginConfig: {} as never, + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: {} as never, + hooks: { + autoSlashCommand: createAutoSlashCommandHook({ skills: [] }), + startWork: createStartWorkHook({ + directory: testDir, + client: { tui: { showToast: async () => {} } }, + } as never), + } as never, + tools: {}, + }) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "/start-work" }], + } + + // when + await pluginInterface["chat.message"]?.( + { + sessionID: "ses-command-atlas", + agent: "prometheus", + } as never, + output as never + ) + + // then + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(getSessionAgent("ses-command-atlas")).toBe("atlas") + expect(readBoulderState(testDir)?.agent).toBe("atlas") + }) +}) diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts index d7d65762d..5bcc0c364 100644 --- a/src/plugin-interface.ts +++ b/src/plugin-interface.ts @@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "./config" import { createChatParamsHandler } from "./plugin/chat-params" import { createChatHeadersHandler } from "./plugin/chat-headers" import { createChatMessageHandler } from "./plugin/chat-message" +import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-before" import { createMessagesTransformHandler } from "./plugin/messages-transform" import { createSystemTransformHandler } from "./plugin/system-transform" import { createEventHandler } from "./plugin/event" @@ -42,6 +43,10 @@ export function createPluginInterface(args: { "chat.headers": createChatHeadersHandler({ ctx }), + "command.execute.before": createCommandExecuteBeforeHandler({ + hooks, + }), + "chat.message": createChatMessageHandler({ ctx, pluginConfig, diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index e79eb8d2d..1ef58df06 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -1,7 +1,14 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { afterEach, beforeEach, describe, test, expect } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" import { createChatMessageHandler } from "./chat-message" -import { _resetForTesting, setMainSession, subagentSessions } from "../features/claude-code-session-state" +import { createAutoSlashCommandHook } from "../hooks/auto-slash-command" +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" import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state" type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } @@ -39,6 +46,55 @@ afterEach(() => { clearSessionModel("subagent-session") }) +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 + expect(output.message["agent"]).toBe("Sisyphus (Ultraworker)") + expect(output.parts[0].text).toContain("") + 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") + }) +}) + function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) { return { sessionID: "test-session", diff --git a/src/plugin/command-execute-before.ts b/src/plugin/command-execute-before.ts new file mode 100644 index 000000000..09f17f7ca --- /dev/null +++ b/src/plugin/command-execute-before.ts @@ -0,0 +1,39 @@ +import type { CreatedHooks } from "../create-hooks" + +type CommandExecuteBeforeInput = { + command: string + sessionID: string + arguments: string +} + +type CommandExecuteBeforeOutput = { + parts: Array<{ type: string; text?: string; [key: string]: unknown }> +} + +function hasPartsOutput(value: unknown): value is CommandExecuteBeforeOutput { + if (typeof value !== "object" || value === null) return false + const record = value as Record + const parts = record["parts"] + return Array.isArray(parts) +} + +export function createCommandExecuteBeforeHandler(args: { + hooks: CreatedHooks +}): ( + input: CommandExecuteBeforeInput, + output: CommandExecuteBeforeOutput, +) => Promise { + const { hooks } = args + + return async (input, output): Promise => { + await hooks.autoSlashCommand?.["command.execute.before"]?.(input, output) + + if ( + hooks.startWork + && input.command.toLowerCase() === "start-work" + && hasPartsOutput(output) + ) { + await hooks.startWork["command.execute.before"]?.(input, output) + } + } +}