fix(start-work): restore atlas handoff
Keep native /start-work resolvable on Sisyphus, but switch the work session back to Atlas when Atlas is registered. Stamp the outgoing agent with Atlas's actual list-display key so config→start-work execution resolves correctly and still falls back to Sisyphus when Atlas is unavailable. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -59,6 +59,16 @@ describe("loadBuiltinCommands", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(commands.handoff.description).toContain("context summary")
|
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", () => {
|
describe("loadBuiltinCommands — remove-ai-slops", () => {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ ${REFACTOR_TEMPLATE}
|
|||||||
},
|
},
|
||||||
"start-work": {
|
"start-work": {
|
||||||
description: "(builtin) Start Sisyphus work session from Prometheus plan",
|
description: "(builtin) Start Sisyphus work session from Prometheus plan",
|
||||||
agent: "atlas",
|
agent: "sisyphus",
|
||||||
template: `<command-instruction>
|
template: `<command-instruction>
|
||||||
${START_WORK_TEMPLATE}
|
${START_WORK_TEMPLATE}
|
||||||
</command-instruction>
|
</command-instruction>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from "node:path"
|
|||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { createStartWorkHook } from "./index"
|
import { createStartWorkHook } from "./index"
|
||||||
|
import { getAgentListDisplayName } from "../../shared/agent-display-names"
|
||||||
import {
|
import {
|
||||||
writeBoulderState,
|
writeBoulderState,
|
||||||
clearBoulderState,
|
clearBoulderState,
|
||||||
@@ -24,6 +25,22 @@ describe("start-work hook", () => {
|
|||||||
} as Parameters<typeof createStartWorkHook>[0]
|
} as Parameters<typeof createStartWorkHook>[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStartWorkPrompt(options?: {
|
||||||
|
sessionContext?: string
|
||||||
|
userRequest?: string
|
||||||
|
}): string {
|
||||||
|
const sessionContext = options?.sessionContext ?? ""
|
||||||
|
const userRequest = options?.userRequest ?? ""
|
||||||
|
|
||||||
|
return `<command-instruction>
|
||||||
|
You are starting a Sisyphus work session.
|
||||||
|
</command-instruction>
|
||||||
|
|
||||||
|
<session-context>${sessionContext}</session-context>${userRequest ? `
|
||||||
|
|
||||||
|
<user-request>${userRequest}</user-request>` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionState._resetForTesting()
|
sessionState._resetForTesting()
|
||||||
sessionState.registerAgentName("atlas")
|
sessionState.registerAgentName("atlas")
|
||||||
@@ -65,6 +82,24 @@ describe("start-work hook", () => {
|
|||||||
expect(output.parts[0].text).toBe("Just a regular message")
|
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: "<session-context>Some context here</session-context>" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["chat.message"](
|
||||||
|
{ sessionID: "session-123" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output.parts[0].text).toBe("<session-context>Some context here</session-context>")
|
||||||
|
expect(readBoulderState(testDir)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test("should detect start-work command via session-context tag", async () => {
|
test("should detect start-work command via session-context tag", async () => {
|
||||||
// given - hook and start-work message
|
// given - hook and start-work message
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
@@ -72,7 +107,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "<session-context>Some context here</session-context>",
|
text: createStartWorkPrompt({ sessionContext: "Some context here" }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -102,7 +137,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -123,7 +158,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "<session-context>Session: $SESSION_ID</session-context>",
|
text: createStartWorkPrompt({ sessionContext: "Session: $SESSION_ID" }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -146,7 +181,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "<session-context>Time: $TIMESTAMP</session-context>",
|
text: createStartWorkPrompt({ sessionContext: "Time: $TIMESTAMP" }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -177,7 +212,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -205,7 +240,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -233,7 +268,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -274,9 +309,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `<session-context>
|
text: createStartWorkPrompt({ userRequest: "new-plan" }),
|
||||||
<user-request>new-plan</user-request>
|
|
||||||
</session-context>`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -306,9 +339,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `<session-context>
|
text: createStartWorkPrompt({ userRequest: "my-feature-plan ultrawork" }),
|
||||||
<user-request>my-feature-plan ultrawork</user-request>
|
|
||||||
</session-context>`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -337,9 +368,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `<session-context>
|
text: createStartWorkPrompt({ userRequest: "api-refactor ulw" }),
|
||||||
<user-request>api-refactor ulw</user-request>
|
|
||||||
</session-context>`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -368,9 +397,7 @@ describe("start-work hook", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `<session-context>
|
text: createStartWorkPrompt({ userRequest: "feature-implementation" }),
|
||||||
<user-request>feature-implementation</user-request>
|
|
||||||
</session-context>`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -394,7 +421,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -408,12 +435,12 @@ describe("start-work hook", () => {
|
|||||||
updateSpy.mockRestore()
|
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
|
// given
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
message: {} as Record<string, unknown>,
|
message: {} as Record<string, unknown>,
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -423,7 +450,7 @@ describe("start-work hook", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then
|
// 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 () => {
|
test("should keep the current agent when Atlas is unavailable", async () => {
|
||||||
@@ -435,7 +462,7 @@ describe("start-work hook", () => {
|
|||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
message: {} as Record<string, unknown>,
|
message: {} as Record<string, unknown>,
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -463,7 +490,7 @@ describe("start-work hook", () => {
|
|||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
message: {} as Record<string, unknown>,
|
message: {} as Record<string, unknown>,
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -498,7 +525,7 @@ describe("start-work hook", () => {
|
|||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
message: {} as Record<string, unknown>,
|
message: {} as Record<string, unknown>,
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -532,7 +559,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -553,7 +580,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /validated/worktree" }) }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -575,7 +602,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /valid/wt" }) }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -595,7 +622,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /nonexistent/wt" }) }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -624,7 +651,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /new/wt" }) }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -651,7 +678,7 @@ describe("start-work hook", () => {
|
|||||||
|
|
||||||
const hook = createStartWorkHook(createMockPluginInput())
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
const output = {
|
const output = {
|
||||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// when
|
// when
|
||||||
|
|||||||
@@ -11,18 +11,33 @@ import {
|
|||||||
clearBoulderState,
|
clearBoulderState,
|
||||||
} from "../../features/boulder-state"
|
} from "../../features/boulder-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
|
import {
|
||||||
import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state"
|
getAgentConfigKey,
|
||||||
|
getAgentDisplayName,
|
||||||
|
getAgentListDisplayName,
|
||||||
|
} from "../../shared/agent-display-names"
|
||||||
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
isAgentRegistered,
|
||||||
|
updateSessionAgent,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
import { detectWorktreePath } from "./worktree-detector"
|
import { detectWorktreePath } from "./worktree-detector"
|
||||||
import { parseUserRequest } from "./parse-user-request"
|
import { parseUserRequest } from "./parse-user-request"
|
||||||
|
|
||||||
export const HOOK_NAME = "start-work" as const
|
export const HOOK_NAME = "start-work" as const
|
||||||
|
const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session."
|
||||||
|
|
||||||
interface StartWorkHookInput {
|
interface StartWorkHookInput {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID?: string
|
messageID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartWorkCommandExecuteBeforeInput {
|
||||||
|
sessionID: string
|
||||||
|
command: string
|
||||||
|
arguments: string
|
||||||
|
}
|
||||||
|
|
||||||
interface StartWorkHookOutput {
|
interface StartWorkHookOutput {
|
||||||
message?: Record<string, unknown>
|
message?: Record<string, unknown>
|
||||||
parts: Array<{ type: string; text?: string }>
|
parts: Array<{ type: string; text?: string }>
|
||||||
@@ -67,61 +82,76 @@ function resolveWorktreeContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStartWorkHook(ctx: PluginInput) {
|
export function createStartWorkHook(ctx: PluginInput) {
|
||||||
return {
|
const processStartWork = async (
|
||||||
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
|
input: StartWorkHookInput,
|
||||||
const parts = output.parts
|
output: StartWorkHookOutput,
|
||||||
const promptText =
|
): Promise<void> => {
|
||||||
parts
|
const parts = output.parts
|
||||||
?.filter((p) => p.type === "text" && p.text)
|
const promptText =
|
||||||
.map((p) => p.text)
|
parts
|
||||||
.join("\n")
|
?.filter((p) => p.type === "text" && p.text)
|
||||||
.trim() || ""
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim() || ""
|
||||||
|
|
||||||
if (!promptText.includes("<session-context>")) return
|
if (
|
||||||
|
!promptText.includes("<session-context>")
|
||||||
|
|| !promptText.includes(START_WORK_TEMPLATE_MARKER)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
||||||
const currentSessionAgent = getSessionAgent(input.sessionID)
|
const currentSessionAgent = getSessionAgent(input.sessionID)
|
||||||
const activeAgent = isAgentRegistered("atlas")
|
const currentSessionAgentKey = currentSessionAgent
|
||||||
? "atlas"
|
? getAgentConfigKey(currentSessionAgent)
|
||||||
: currentSessionAgent && getAgentConfigKey(currentSessionAgent) !== "prometheus"
|
: undefined
|
||||||
? currentSessionAgent
|
const activeAgent = currentSessionAgent
|
||||||
|
&& currentSessionAgentKey
|
||||||
|
&& currentSessionAgentKey !== "prometheus"
|
||||||
|
&& currentSessionAgentKey !== "atlas"
|
||||||
|
? currentSessionAgent
|
||||||
|
: isAgentRegistered("atlas")
|
||||||
|
? "atlas"
|
||||||
: "sisyphus"
|
: "sisyphus"
|
||||||
const activeAgentDisplayName = getAgentDisplayName(activeAgent)
|
const activeAgentDisplayName = activeAgent === "atlas"
|
||||||
updateSessionAgent(input.sessionID, activeAgent)
|
? getAgentListDisplayName(activeAgent)
|
||||||
if (output.message) {
|
: getAgentDisplayName(activeAgent)
|
||||||
output.message["agent"] = activeAgentDisplayName
|
updateSessionAgent(input.sessionID, activeAgent)
|
||||||
}
|
if (output.message) {
|
||||||
|
output.message["agent"] = activeAgentDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
const existingState = readBoulderState(ctx.directory)
|
const existingState = readBoulderState(ctx.directory)
|
||||||
const sessionId = input.sessionID
|
const sessionId = input.sessionID
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
||||||
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
||||||
|
|
||||||
let contextInfo = ""
|
let contextInfo = ""
|
||||||
|
|
||||||
if (explicitPlanName) {
|
if (explicitPlanName) {
|
||||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
|
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
|
||||||
|
|
||||||
const allPlans = findPrometheusPlans(ctx.directory)
|
const allPlans = findPrometheusPlans(ctx.directory)
|
||||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||||
|
|
||||||
if (matchedPlan) {
|
if (matchedPlan) {
|
||||||
const progress = getPlanProgress(matchedPlan)
|
const progress = getPlanProgress(matchedPlan)
|
||||||
|
|
||||||
if (progress.isComplete) {
|
if (progress.isComplete) {
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Plan Already Complete
|
## Plan Already Complete
|
||||||
|
|
||||||
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||||
} else {
|
} else {
|
||||||
if (existingState) clearBoulderState(ctx.directory)
|
if (existingState) clearBoulderState(ctx.directory)
|
||||||
const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
|
const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
|
||||||
writeBoulderState(ctx.directory, newState)
|
writeBoulderState(ctx.directory, newState)
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Auto-Selected Plan
|
## Auto-Selected Plan
|
||||||
|
|
||||||
**Plan**: ${getPlanName(matchedPlan)}
|
**Plan**: ${getPlanName(matchedPlan)}
|
||||||
@@ -132,18 +162,18 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
|||||||
${worktreeBlock}
|
${worktreeBlock}
|
||||||
|
|
||||||
boulder.json has been created. Read the plan and begin execution.`
|
boulder.json has been created. Read the plan and begin execution.`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||||
if (incompletePlans.length > 0) {
|
if (incompletePlans.length > 0) {
|
||||||
const planList = incompletePlans
|
const planList = incompletePlans
|
||||||
.map((p, i) => {
|
.map((p, i) => {
|
||||||
const prog = getPlanProgress(p)
|
const prog = getPlanProgress(p)
|
||||||
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
||||||
})
|
})
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Plan Not Found
|
## Plan Not Found
|
||||||
|
|
||||||
Could not find a plan matching "${explicitPlanName}".
|
Could not find a plan matching "${explicitPlanName}".
|
||||||
@@ -152,39 +182,39 @@ Available incomplete plans:
|
|||||||
${planList}
|
${planList}
|
||||||
|
|
||||||
Ask the user which plan to work on.`
|
Ask the user which plan to work on.`
|
||||||
} else {
|
} else {
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Plan Not Found
|
## Plan Not Found
|
||||||
|
|
||||||
Could not find a plan matching "${explicitPlanName}".
|
Could not find a plan matching "${explicitPlanName}".
|
||||||
No incomplete plans available. Create a new plan with: /plan "your task"`
|
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) {
|
if (!progress.isComplete) {
|
||||||
const effectiveWorktree = worktreePath ?? existingState.worktree_path
|
const effectiveWorktree = worktreePath ?? existingState.worktree_path
|
||||||
const sessionAlreadyTracked = existingState.session_ids.includes(sessionId)
|
const sessionAlreadyTracked = existingState.session_ids.includes(sessionId)
|
||||||
const updatedSessions = sessionAlreadyTracked
|
const updatedSessions = sessionAlreadyTracked
|
||||||
? existingState.session_ids
|
? existingState.session_ids
|
||||||
: [...existingState.session_ids, sessionId]
|
: [...existingState.session_ids, sessionId]
|
||||||
const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined
|
const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined
|
||||||
|
|
||||||
if (shouldRewriteState) {
|
if (shouldRewriteState) {
|
||||||
writeBoulderState(ctx.directory, {
|
writeBoulderState(ctx.directory, {
|
||||||
...existingState,
|
...existingState,
|
||||||
agent: activeAgent,
|
agent: activeAgent,
|
||||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||||
session_ids: updatedSessions,
|
session_ids: updatedSessions,
|
||||||
})
|
})
|
||||||
} else if (!sessionAlreadyTracked) {
|
} else if (!sessionAlreadyTracked) {
|
||||||
appendSessionId(ctx.directory, sessionId)
|
appendSessionId(ctx.directory, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
|
const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
|
||||||
|
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Active Work Session Found
|
## Active Work Session Found
|
||||||
|
|
||||||
**Status**: RESUMING existing work
|
**Status**: RESUMING existing work
|
||||||
@@ -197,41 +227,41 @@ ${worktreeDisplay}
|
|||||||
|
|
||||||
The current session (${sessionId}) has been added to session_ids.
|
The current session (${sessionId}) has been added to session_ids.
|
||||||
Read the plan file and continue from the first unchecked task.`
|
Read the plan file and continue from the first unchecked task.`
|
||||||
} else {
|
} else {
|
||||||
contextInfo = `
|
contextInfo = `
|
||||||
## Previous Work Complete
|
## Previous Work Complete
|
||||||
|
|
||||||
The previous plan (${existingState.plan_name}) has been completed.
|
The previous plan (${existingState.plan_name}) has been completed.
|
||||||
Looking for new plans...`
|
Looking for new plans...`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!existingState && !explicitPlanName) ||
|
(!existingState && !explicitPlanName) ||
|
||||||
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
||||||
) {
|
) {
|
||||||
const plans = findPrometheusPlans(ctx.directory)
|
const plans = findPrometheusPlans(ctx.directory)
|
||||||
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
|
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
|
||||||
|
|
||||||
if (plans.length === 0) {
|
if (plans.length === 0) {
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
## No Plans Found
|
## No Plans Found
|
||||||
|
|
||||||
No Prometheus plan files found at .sisyphus/plans/
|
No Prometheus plan files found at .sisyphus/plans/
|
||||||
Use Prometheus to create a work plan first: /plan "your task"`
|
Use Prometheus to create a work plan first: /plan "your task"`
|
||||||
} else if (incompletePlans.length === 0) {
|
} else if (incompletePlans.length === 0) {
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
|
|
||||||
## All Plans Complete
|
## All Plans Complete
|
||||||
|
|
||||||
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
|
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
|
||||||
} else if (incompletePlans.length === 1) {
|
} else if (incompletePlans.length === 1) {
|
||||||
const planPath = incompletePlans[0]
|
const planPath = incompletePlans[0]
|
||||||
const progress = getPlanProgress(planPath)
|
const progress = getPlanProgress(planPath)
|
||||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||||
writeBoulderState(ctx.directory, newState)
|
writeBoulderState(ctx.directory, newState)
|
||||||
|
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
|
|
||||||
## Auto-Selected Plan
|
## Auto-Selected Plan
|
||||||
|
|
||||||
@@ -243,16 +273,16 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
|
|||||||
${worktreeBlock}
|
${worktreeBlock}
|
||||||
|
|
||||||
boulder.json has been created. Read the plan and begin execution.`
|
boulder.json has been created. Read the plan and begin execution.`
|
||||||
} else {
|
} else {
|
||||||
const planList = incompletePlans
|
const planList = incompletePlans
|
||||||
.map((p, i) => {
|
.map((p, i) => {
|
||||||
const progress = getPlanProgress(p)
|
const progress = getPlanProgress(p)
|
||||||
const modified = new Date(statSync(p).mtimeMs).toISOString()
|
const modified = new Date(statSync(p).mtimeMs).toISOString()
|
||||||
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
||||||
})
|
})
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
||||||
contextInfo += `
|
contextInfo += `
|
||||||
|
|
||||||
<system-reminder>
|
<system-reminder>
|
||||||
## Multiple Plans Found
|
## 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.
|
Ask the user which plan to work on. Present the options above and wait for their response.
|
||||||
${worktreeBlock}
|
${worktreeBlock}
|
||||||
</system-reminder>`
|
</system-reminder>`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||||
if (idx >= 0 && output.parts[idx].text) {
|
if (idx >= 0 && output.parts[idx].text) {
|
||||||
output.parts[idx].text = output.parts[idx].text
|
output.parts[idx].text = output.parts[idx].text
|
||||||
.replace(/\$SESSION_ID/g, sessionId)
|
.replace(/\$SESSION_ID/g, sessionId)
|
||||||
.replace(/\$TIMESTAMP/g, timestamp)
|
.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`, {
|
log(`[${HOOK_NAME}] Context injected`, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
hasExistingState: !!existingState,
|
hasExistingState: !!existingState,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
|
||||||
|
await processStartWork(input, output)
|
||||||
|
},
|
||||||
|
"command.execute.before": async (
|
||||||
|
input: StartWorkCommandExecuteBeforeInput,
|
||||||
|
output: StartWorkHookOutput,
|
||||||
|
): Promise<void> => {
|
||||||
|
await processStartWork(input, output)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "./config"
|
|||||||
import { createChatParamsHandler } from "./plugin/chat-params"
|
import { createChatParamsHandler } from "./plugin/chat-params"
|
||||||
import { createChatHeadersHandler } from "./plugin/chat-headers"
|
import { createChatHeadersHandler } from "./plugin/chat-headers"
|
||||||
import { createChatMessageHandler } from "./plugin/chat-message"
|
import { createChatMessageHandler } from "./plugin/chat-message"
|
||||||
|
import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-before"
|
||||||
import { createMessagesTransformHandler } from "./plugin/messages-transform"
|
import { createMessagesTransformHandler } from "./plugin/messages-transform"
|
||||||
import { createSystemTransformHandler } from "./plugin/system-transform"
|
import { createSystemTransformHandler } from "./plugin/system-transform"
|
||||||
import { createEventHandler } from "./plugin/event"
|
import { createEventHandler } from "./plugin/event"
|
||||||
@@ -42,6 +43,10 @@ export function createPluginInterface(args: {
|
|||||||
|
|
||||||
"chat.headers": createChatHeadersHandler({ ctx }),
|
"chat.headers": createChatHeadersHandler({ ctx }),
|
||||||
|
|
||||||
|
"command.execute.before": createCommandExecuteBeforeHandler({
|
||||||
|
hooks,
|
||||||
|
}),
|
||||||
|
|
||||||
"chat.message": createChatMessageHandler({
|
"chat.message": createChatMessageHandler({
|
||||||
ctx,
|
ctx,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
|
|||||||
@@ -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 { 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"
|
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||||
|
|
||||||
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
|
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
|
||||||
@@ -39,6 +46,55 @@ afterEach(() => {
|
|||||||
clearSessionModel("subagent-session")
|
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("<auto-slash-command>")
|
||||||
|
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 }) {
|
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
|
||||||
return {
|
return {
|
||||||
sessionID: "test-session",
|
sessionID: "test-session",
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
const parts = record["parts"]
|
||||||
|
return Array.isArray(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandExecuteBeforeHandler(args: {
|
||||||
|
hooks: CreatedHooks
|
||||||
|
}): (
|
||||||
|
input: CommandExecuteBeforeInput,
|
||||||
|
output: CommandExecuteBeforeOutput,
|
||||||
|
) => Promise<void> {
|
||||||
|
const { hooks } = args
|
||||||
|
|
||||||
|
return async (input, output): Promise<void> => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user