Files
oh-my-opencode/src/hooks/start-work/index.test.ts
T
YeonGyu-Kim ae05e76ddf fix(start-work): remove worktree setup prompt when unspecified, add strong worktree active instructions
When no worktree is specified in boulder, stop injecting 'Worktree Setup Required'
instructions. When worktree IS present, inject emphatic instructions ensuring the
agent and all subagents operate exclusively within the worktree directory.
2026-03-06 14:20:32 +09:00

561 lines
19 KiB
TypeScript

import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir, homedir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
import {
writeBoulderState,
clearBoulderState,
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state"
import * as worktreeDetector from "./worktree-detector"
import * as worktreeDetector from "./worktree-detector"
describe("start-work hook", () => {
let testDir: string
let sisyphusDir: string
function createMockPluginInput() {
return {
directory: testDir,
client: {},
} as Parameters<typeof createStartWorkHook>[0]
}
beforeEach(() => {
testDir = join(tmpdir(), `start-work-test-${randomUUID()}`)
sisyphusDir = join(testDir, ".sisyphus")
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true })
}
if (!existsSync(sisyphusDir)) {
mkdirSync(sisyphusDir, { recursive: true })
}
clearBoulderState(testDir)
})
afterEach(() => {
clearBoulderState(testDir)
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
}
})
describe("chat.message handler", () => {
test("should ignore non-start-work commands", async () => {
// given - hook and non-start-work message
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "Just a regular message" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - output should be unchanged
expect(output.parts[0].text).toBe("Just a regular message")
})
test("should detect start-work command via session-context tag", async () => {
// given - hook and start-work message
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: "<session-context>Some context here</session-context>",
},
],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - output should be modified with context info
expect(output.parts[0].text).toContain("---")
})
test("should inject resume info when existing boulder state found", async () => {
// given - existing boulder state with incomplete plan
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(testDir, state)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should show resuming status
expect(output.parts[0].text).toContain("RESUMING")
expect(output.parts[0].text).toContain("test-plan")
})
test("should replace $SESSION_ID placeholder", async () => {
// given - hook and message with placeholder
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: "<session-context>Session: $SESSION_ID</session-context>",
},
],
}
// when
await hook["chat.message"](
{ sessionID: "ses-abc123" },
output
)
// then - placeholder should be replaced
expect(output.parts[0].text).toContain("ses-abc123")
expect(output.parts[0].text).not.toContain("$SESSION_ID")
})
test("should replace $TIMESTAMP placeholder", async () => {
// given - hook and message with placeholder
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: "<session-context>Time: $TIMESTAMP</session-context>",
},
],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - placeholder should be replaced with ISO timestamp
expect(output.parts[0].text).not.toContain("$TIMESTAMP")
expect(output.parts[0].text).toMatch(/\d{4}-\d{2}-\d{2}T/)
})
test("should auto-select when only one incomplete plan among multiple plans", async () => {
// given - multiple plans but only one incomplete
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
// Plan 1: complete (all checked)
const plan1Path = join(plansDir, "plan-complete.md")
writeFileSync(plan1Path, "# Plan Complete\n- [x] Task 1\n- [x] Task 2")
// Plan 2: incomplete (has unchecked)
const plan2Path = join(plansDir, "plan-incomplete.md")
writeFileSync(plan2Path, "# Plan Incomplete\n- [ ] Task 1\n- [x] Task 2")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should auto-select the incomplete plan, not ask user
expect(output.parts[0].text).toContain("Auto-Selected Plan")
expect(output.parts[0].text).toContain("plan-incomplete")
expect(output.parts[0].text).not.toContain("Multiple Plans Found")
})
test("should wrap multiple plans message in system-reminder tag", async () => {
// given - multiple incomplete plans
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const plan1Path = join(plansDir, "plan-a.md")
writeFileSync(plan1Path, "# Plan A\n- [ ] Task 1")
const plan2Path = join(plansDir, "plan-b.md")
writeFileSync(plan2Path, "# Plan B\n- [ ] Task 2")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should use system-reminder tag format
expect(output.parts[0].text).toContain("<system-reminder>")
expect(output.parts[0].text).toContain("</system-reminder>")
expect(output.parts[0].text).toContain("Multiple Plans Found")
})
test("should use 'ask user' prompt style for multiple plans", async () => {
// given - multiple incomplete plans
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const plan1Path = join(plansDir, "plan-x.md")
writeFileSync(plan1Path, "# Plan X\n- [ ] Task 1")
const plan2Path = join(plansDir, "plan-y.md")
writeFileSync(plan2Path, "# Plan Y\n- [ ] Task 2")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should prompt agent to ask user, not ask directly
expect(output.parts[0].text).toContain("Ask the user")
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
})
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
// given - existing boulder state pointing to old plan
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
// Old plan (in boulder state)
const oldPlanPath = join(plansDir, "old-plan.md")
writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Old Task 1")
// New plan (user wants this one)
const newPlanPath = join(plansDir, "new-plan.md")
writeFileSync(newPlanPath, "# New Plan\n- [ ] New Task 1")
// Set up stale boulder state pointing to old plan
const staleState: BoulderState = {
active_plan: oldPlanPath,
started_at: "2026-01-01T10:00:00Z",
session_ids: ["old-session"],
plan_name: "old-plan",
}
writeBoulderState(testDir, staleState)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: `<session-context>
<user-request>new-plan</user-request>
</session-context>`,
},
],
}
// when - user explicitly specifies new-plan
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should select new-plan, NOT resume old-plan
expect(output.parts[0].text).toContain("new-plan")
expect(output.parts[0].text).not.toContain("RESUMING")
expect(output.parts[0].text).not.toContain("old-plan")
})
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
// given - plan with ultrawork keyword in user-request
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "my-feature-plan.md")
writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: `<session-context>
<user-request>my-feature-plan ultrawork</user-request>
</session-context>`,
},
],
}
// when - user specifies plan with ultrawork keyword
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should find plan without ultrawork suffix
expect(output.parts[0].text).toContain("my-feature-plan")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
test("should strip ulw keyword from plan name argument", async () => {
// given - plan with ulw keyword in user-request
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "api-refactor.md")
writeFileSync(planPath, "# API Refactor\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: `<session-context>
<user-request>api-refactor ulw</user-request>
</session-context>`,
},
],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should find plan without ulw suffix
expect(output.parts[0].text).toContain("api-refactor")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
test("should match plan by partial name", async () => {
// given - user specifies partial plan name
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "2026-01-15-feature-implementation.md")
writeFileSync(planPath, "# Feature Implementation\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
{
type: "text",
text: `<session-context>
<user-request>feature-implementation</user-request>
</session-context>`,
},
],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then - should find plan by partial match
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
})
describe("session agent management", () => {
test("should update session agent to Atlas when start-work command is triggered", async () => {
// given
const updateSpy = spyOn(sessionState, "updateSessionAgent")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "ses-prometheus-to-sisyphus" },
output
)
// then
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "atlas")
updateSpy.mockRestore()
})
})
describe("worktree support", () => {
let detectSpy: ReturnType<typeof spyOn>
beforeEach(() => {
detectSpy = spyOn(worktreeDetector, "detectWorktreePath").mockReturnValue(null)
})
afterEach(() => {
detectSpy.mockRestore()
})
test("should NOT inject worktree instructions when no --worktree flag", async () => {
// given - single plan, no worktree flag
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - no worktree instructions should appear
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
expect(output.parts[0].text).not.toContain("Worktree Active")
expect(output.parts[0].text).not.toContain("git worktree list --porcelain")
})
test("should inject worktree path when --worktree flag is valid", async () => {
// given - single plan + valid worktree path
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/validated/worktree")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - strong worktree active instructions shown
expect(output.parts[0].text).toContain("Worktree Active")
expect(output.parts[0].text).toContain("/validated/worktree")
expect(output.parts[0].text).toContain("subagent")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should store worktree_path in boulder when --worktree is valid", async () => {
// given - plan + valid worktree
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/valid/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - boulder.json has worktree_path
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/valid/wt")
})
test("should NOT store worktree_path when --worktree path is invalid", async () => {
// given - plan + invalid worktree path (detectWorktreePath returns null)
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
// detectSpy already returns null by default
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - worktree_path absent, setup instructions present
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBeUndefined()
expect(output.parts[0].text).toContain("needs setup")
expect(output.parts[0].text).toContain("git worktree add /nonexistent/wt")
})
test("should update boulder worktree_path on resume when new --worktree given", async () => {
// given - existing boulder with old worktree, user provides new worktree
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/old/wt",
}
writeBoulderState(testDir, existingState)
detectSpy.mockReturnValue("/new/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-456" }, output)
// then - boulder reflects updated worktree and new session appended
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/new/wt")
expect(state?.session_ids).toContain("session-456")
})
test("should show existing worktree on resume when no --worktree flag", async () => {
// given - existing boulder already has worktree_path, no flag given
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/existing/wt",
}
writeBoulderState(testDir, existingState)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-789" }, output)
// then - shows strong worktree active instructions
expect(output.parts[0].text).toContain("Worktree Active")
expect(output.parts[0].text).toContain("/existing/wt")
expect(output.parts[0].text).toContain("subagent")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
})
})