feat(boulder-state): add platform-prefixed session ids

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-28 15:45:13 +09:00
parent 615ed40ba3
commit 1c5e251a62
15 changed files with 329 additions and 70 deletions
+89 -15
View File
@@ -20,7 +20,6 @@ import {
getPlanProgress,
getPlanName,
createBoulderState,
findPrometheusPlans,
getTaskSessionState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
@@ -74,7 +73,7 @@ describe("boulder-state", () => {
expect(writeSucceeded).toBe(true)
expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan)
expect(roundTripState?.started_at).toBe(legacyRawState.started_at)
expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids)
expect(roundTripState?.session_ids).toEqual(["opencode:legacy-session"])
expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name)
})
@@ -172,7 +171,7 @@ describe("boulder-state", () => {
const result = readBoulderState(TEST_DIR)
// then
expect(result?.session_origins).toEqual({ "session-1": "direct" })
expect(result?.session_origins).toEqual({ "opencode:session-1": "direct" })
})
test("should keep missing origins empty when multiple sessions are tracked", () => {
@@ -207,7 +206,7 @@ describe("boulder-state", () => {
// then
expect(result).not.toBeNull()
expect(result?.active_plan).toBe("/path/to/plan.md")
expect(result?.session_ids).toEqual(["session-1", "session-2"])
expect(result?.session_ids).toEqual(["opencode:session-1", "opencode:session-2"])
expect(result?.plan_name).toBe("my-plan")
})
@@ -267,7 +266,7 @@ describe("boulder-state", () => {
// then
expect(result).not.toBeNull()
expect(result?.session_ids).toEqual(["session-1", "session-2"])
expect(result?.session_ids).toEqual(["opencode:session-1", "opencode:session-2"])
})
test("should not duplicate existing session id", () => {
@@ -285,7 +284,7 @@ describe("boulder-state", () => {
const result = readBoulderState(TEST_DIR)
// then
expect(result?.session_ids).toEqual(["session-1"])
expect(result?.session_ids).toEqual(["opencode:session-1"])
})
test("should return null when no state exists", () => {
@@ -310,7 +309,7 @@ describe("boulder-state", () => {
//#then - should not crash and should contain the new session
expect(result).not.toBeNull()
expect(result!.session_ids).toContain("ses-new")
expect(result!.session_ids).toContain("opencode:ses-new")
})
test("should persist appended session origin when provided", () => {
@@ -328,8 +327,8 @@ describe("boulder-state", () => {
// then
expect(result?.session_origins).toEqual({
"session-1": "direct",
"session-2": "appended",
"opencode:session-1": "direct",
"opencode:session-2": "appended",
})
})
})
@@ -387,7 +386,7 @@ describe("boulder-state", () => {
// then
expect(result).not.toBeNull()
expect(result?.session_id).toBe("ses_task_123")
expect(result?.session_id).toBe("opencode:ses_task_123")
expect(result?.task_title).toBe("Implement auth flow")
expect(result?.agent).toBe("sisyphus-junior")
expect(result?.category).toBe("deep")
@@ -422,7 +421,7 @@ describe("boulder-state", () => {
const result = getTaskSessionState(TEST_DIR, "todo:1")
// then
expect(result?.session_id).toBe("ses_new")
expect(result?.session_id).toBe("opencode:ses_new")
})
})
@@ -542,7 +541,7 @@ describe("boulder-state", () => {
// then
expect(updated).not.toBeNull()
const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.session_id).toBe("task-session-b")
expect(taskSession?.session_id).toBe("opencode:task-session-b")
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
})
})
@@ -993,7 +992,7 @@ describe("boulder-state", () => {
// then
expect(state.active_plan).toBe(planPath)
expect(state.session_ids).toEqual([sessionId])
expect(state.session_ids).toEqual(["opencode:ses-abc123"])
expect(state.plan_name).toBe("auth-refactor")
expect(state.started_at).toBeDefined()
})
@@ -1010,7 +1009,7 @@ describe("boulder-state", () => {
//#then - state should include the agent field
expect(state.agent).toBe("atlas")
expect(state.active_plan).toBe(planPath)
expect(state.session_ids).toEqual([sessionId])
expect(state.session_ids).toEqual(["opencode:ses-xyz789"])
expect(state.plan_name).toBe("feature")
})
@@ -1023,7 +1022,7 @@ describe("boulder-state", () => {
const state = createBoulderState(planPath, sessionId)
// then
expect(state.session_origins).toEqual({ [sessionId]: "direct" })
expect(state.session_origins).toEqual({ "opencode:ses-origin": "direct" })
})
test("should allow agent to be undefined", () => {
@@ -1080,4 +1079,79 @@ describe("boulder-state", () => {
expect(resolvedPath).toBe(planPath)
})
})
describe("platform-prefixed session ids", () => {
test("#given a fresh state with raw session id #when read back #then opencode prefix is stored", () => {
// given
const planPath = join(TEST_DIR, ".omo", "plans", "raw-session.md")
// when
const state = createBoulderState(planPath, "raw-sess", "atlas", undefined)
writeBoulderState(TEST_DIR, state)
const readBack = readBoulderState(TEST_DIR)
// then
expect(readBack?.session_ids).toEqual(["opencode:raw-sess"])
})
test("#given a fresh state with codex session id #when read back #then codex prefix is preserved", () => {
// given
const planPath = join(TEST_DIR, ".omo", "plans", "codex-session.md")
// when
const state = createBoulderState(planPath, "codex:raw-sess", "atlas", undefined)
writeBoulderState(TEST_DIR, state)
const readBack = readBoulderState(TEST_DIR)
// then
expect(readBack?.session_ids).toEqual(["codex:raw-sess"])
})
test("#given a legacy boulder file with bare session id #when read #then opencode prefix is migrated", () => {
// given
const boulderFile = join(OMO_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/legacy.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: ["legacy-bare-id"],
plan_name: "legacy",
}))
// when
const state = readBoulderState(TEST_DIR)
// then
expect(state?.session_ids).toEqual(["opencode:legacy-bare-id"])
})
test("#given existing prefixed session #when appending raw session #then appended id receives opencode prefix", () => {
// given
writeBoulderState(TEST_DIR, {
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: ["opencode:first"],
plan_name: "plan",
})
// when
appendSessionId(TEST_DIR, "another-raw")
const state = readBoulderState(TEST_DIR)
// then
expect(state?.session_ids).toEqual(["opencode:first", "opencode:another-raw"])
})
test("#given stored work with prefixed session #when looking up by raw id #then matching work is returned", () => {
// given
const planPath = join(TEST_DIR, ".omo", "plans", "lookup.md")
const state = createBoulderState(planPath, "opencode:raw-id", "atlas", undefined)
writeBoulderState(TEST_DIR, state)
// when
const work = getWorkForSession(TEST_DIR, "raw-id")
// then
expect(work?.session_ids).toEqual(["opencode:raw-id"])
})
})
})
+1
View File
@@ -18,6 +18,7 @@ export {
getWorkByPlanName,
getWorkForSession,
getWorkResumeOptions,
normalizeSessionId,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
+3 -1
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSessionId } from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
@@ -7,6 +8,7 @@ export async function isSessionInBoulderLineage(input: {
sessionID: string
boulderSessionIDs: string[]
}): Promise<boolean> {
const normalizedBoulderSessionIDs = input.boulderSessionIDs.map((sessionID) => normalizeSessionId(sessionID))
const visitedSessionIDs = new Set<string>()
let currentSessionID = input.sessionID
@@ -33,7 +35,7 @@ export async function isSessionInBoulderLineage(input: {
return false
}
if (input.boulderSessionIDs.includes(parentSessionID)) {
if (normalizedBoulderSessionIDs.includes(normalizeSessionId(parentSessionID))) {
return true
}
+16 -9
View File
@@ -5,6 +5,7 @@ import {
getPlanProgress,
getWorkForSession,
getTaskSessionState,
normalizeSessionId,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
@@ -77,6 +78,7 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const normalizedSessionID = normalizeSessionId(input.sessionID)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
@@ -95,7 +97,7 @@ async function injectContinuation(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: input.ctx.client,
sessionID: input.sessionID,
sessionOrigin: currentBoulder.session_origins?.[input.sessionID],
sessionOrigin: currentBoulder.session_origins?.[normalizedSessionID],
boulderSessionIDs: currentBoulder.session_ids,
requiredAgent: currentBoulder.agent,
})
@@ -192,7 +194,8 @@ function scheduleRetry(input: {
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const normalizedSessionID = normalizeSessionId(sessionID)
if (!currentBoulder.session_ids?.includes(normalizedSessionID)) return
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
@@ -200,7 +203,7 @@ function scheduleRetry(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: ctx.client,
sessionID,
sessionOrigin: currentBoulder.session_origins?.[sessionID],
sessionOrigin: currentBoulder.session_origins?.[normalizedSessionID],
boulderSessionIDs: currentBoulder.session_ids,
requiredAgent: currentBoulder.agent,
})
@@ -230,6 +233,7 @@ export async function handleAtlasSessionIdle(input: {
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
const normalizedSessionID = normalizeSessionId(sessionID)
const sessionState = getState(sessionID)
log(`[${HOOK_NAME}] session.idle`, { sessionID })
@@ -358,7 +362,7 @@ export async function handleAtlasSessionIdle(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: ctx.client,
sessionID,
sessionOrigin: boulderState.session_origins?.[sessionID],
sessionOrigin: boulderState.session_origins?.[normalizedSessionID],
boulderSessionIDs: boulderState.session_ids,
requiredAgent: boulderState.agent,
})
@@ -477,7 +481,14 @@ async function canContinueTrackedBoulderSession(input: {
boulderSessionIDs: string[]
requiredAgent?: string
}): Promise<boolean> {
const ancestorSessionIDs = input.boulderSessionIDs.filter((trackedSessionID) => trackedSessionID !== input.sessionID)
const normalizedSessionID = normalizeSessionId(input.sessionID)
if (input.sessionOrigin === "direct") {
return true
}
const ancestorSessionIDs = input.boulderSessionIDs
.map((sessionID) => normalizeSessionId(sessionID))
.filter((trackedSessionID) => trackedSessionID !== normalizedSessionID)
if (ancestorSessionIDs.length === 0) {
return true
}
@@ -487,10 +498,6 @@ async function canContinueTrackedBoulderSession(input: {
sessionID: input.sessionID,
boulderSessionIDs: ancestorSessionIDs,
})
if (input.sessionOrigin === "direct") {
return true
}
if (!isTrackedDescendant) {
return false
}
+15 -12
View File
@@ -23,14 +23,14 @@ describe("start-work hook", () => {
let omoDir: string
function createMockPluginInput() {
return {
return unsafeTestValue<Parameters<typeof createStartWorkHook>[0]>({
directory: testDir,
client: {
session: {
messages: async () => ({ data: [] }),
},
},
} as Parameters<typeof createStartWorkHook>[0]
})
}
function createStartWorkPrompt(options?: {
@@ -190,7 +190,7 @@ You are starting a Sisyphus work session.
writeFileSync(planAPath, "# Plan A\n- [ ] Task 1")
writeFileSync(planBPath, "# Plan B\n- [ ] Task 2")
const hook = createStartWorkHook({
const hook = createStartWorkHook(unsafeTestValue<Parameters<typeof createStartWorkHook>[0]>({
directory: testDir,
client: {
session: {
@@ -207,7 +207,7 @@ You are starting a Sisyphus work session.
}),
},
},
} as Parameters<typeof createStartWorkHook>[0])
}))
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
@@ -245,7 +245,7 @@ You are starting a Sisyphus work session.
plan_name: "old-plan",
})
const hook = createStartWorkHook({
const hook = createStartWorkHook(unsafeTestValue<Parameters<typeof createStartWorkHook>[0]>({
directory: testDir,
client: {
session: {
@@ -262,7 +262,7 @@ You are starting a Sisyphus work session.
}),
},
},
} as Parameters<typeof createStartWorkHook>[0])
}))
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
@@ -291,7 +291,7 @@ You are starting a Sisyphus work session.
writeFileSync(planAPath, "# Plan A\n- [ ] Task A")
writeFileSync(planBPath, "# Plan B\n- [ ] Task B")
const hook = createStartWorkHook({
const hook = createStartWorkHook(unsafeTestValue<Parameters<typeof createStartWorkHook>[0]>({
directory: testDir,
client: {
session: {
@@ -313,7 +313,7 @@ You are starting a Sisyphus work session.
}),
},
},
} as Parameters<typeof createStartWorkHook>[0])
}))
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
@@ -899,6 +899,7 @@ You are starting a Sisyphus work session.
session: {
promptAsync: promptAsyncMock,
prompt: async (_request: unknown) => undefined,
get: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
},
@@ -916,7 +917,7 @@ You are starting a Sisyphus work session.
// then
expect(output.message.agent).toBe("atlas")
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
expect(readBoulderState(testDir)?.session_ids).toContain("opencode:session-123")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
promptAsyncMock.mockRestore()
@@ -968,6 +969,7 @@ You are starting a Sisyphus work session.
session: {
promptAsync: promptAsyncMock,
prompt: async (_request: unknown) => undefined,
get: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
},
@@ -1006,7 +1008,7 @@ You are starting a Sisyphus work session.
// then
expect(output.message.agent).toBe("atlas")
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
expect(readBoulderState(testDir)?.session_ids).toContain("opencode:session-123")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
} finally {
@@ -1022,7 +1024,8 @@ You are starting a Sisyphus work session.
let detectSpy: ReturnType<typeof spyOn>
beforeEach(() => {
detectSpy = spyOn(worktreeDetector, "detectWorktreePath").mockReturnValue(null)
detectSpy = spyOn(worktreeDetector, "detectWorktreePath")
detectSpy.mockReturnValue(null)
})
afterEach(() => {
@@ -1138,7 +1141,7 @@ You are starting a Sisyphus work session.
// 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")
expect(state?.session_ids).toContain("opencode:session-456")
})
test("should show existing worktree on resume when no --worktree flag", async () => {
@@ -0,0 +1,58 @@
/// <reference types="bun-types" />
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { readBoulderState, clearBoulderState } from "../../features/boulder-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { createStartWorkHook } from "./start-work-hook"
describe("start-work hook platform session ids", () => {
let testDir: string
function createStartWorkPrompt(): string {
return `<command-instruction>
You are starting a Sisyphus work session.
</command-instruction>
<session-context></session-context>`
}
beforeEach(() => {
testDir = join(tmpdir(), `start-work-hook-session-prefix-${randomUUID()}`)
mkdirSync(join(testDir, ".omo", "plans"), { recursive: true })
clearBoulderState(testDir)
})
afterEach(() => {
clearBoulderState(testDir)
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
}
})
test("#given raw chat session id #when processing start-work template #then boulder stores opencode-prefixed id", async () => {
// given
writeFileSync(join(testDir, ".omo", "plans", "work.md"), "# Work\n- [ ] First task\n")
const hook = createStartWorkHook(unsafeTestValue<Parameters<typeof createStartWorkHook>[0]>({
directory: testDir,
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}))
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
await hook["chat.message"]({ sessionID: "raw-sess" }, output)
const state = readBoulderState(testDir)
// then
expect(state?.session_ids).toEqual(["opencode:raw-sess"])
})
})
+2 -8
View File
@@ -1,14 +1,8 @@
import { statSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin"
import {
readBoulderState,
writeBoulderState,
appendSessionId,
findPrometheusPlans,
getPlanProgress,
createBoulderState,
getPlanName,
clearBoulderState,
normalizeSessionId,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import {
@@ -89,7 +83,7 @@ export function createStartWorkHook(ctx: PluginInput) {
}
const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID
const sessionId = normalizeSessionId(input.sessionID, "opencode")
const timestamp = new Date().toISOString()
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)