diff --git a/packages/boulder-state/src/index.ts b/packages/boulder-state/src/index.ts index 604c4a423..ad48dc166 100644 --- a/packages/boulder-state/src/index.ts +++ b/packages/boulder-state/src/index.ts @@ -20,6 +20,7 @@ export { getWorkByPlanName, getWorkForSession, getWorkResumeOptions, + normalizeSessionId, readBoulderState, resolveBoulderPlanPath, resolveBoulderPlanPathForWork, diff --git a/packages/boulder-state/src/storage/index.ts b/packages/boulder-state/src/storage/index.ts index c17fbe2d6..057ad534c 100644 --- a/packages/boulder-state/src/storage/index.ts +++ b/packages/boulder-state/src/storage/index.ts @@ -1,5 +1,6 @@ export { getBoulderFilePath, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "./path" export { findPrometheusPlans, getPlanName, getPlanProgress } from "./plan-progress" +export { normalizeSessionId } from "./shared" export { getActiveWorks, getBoulderWorks, diff --git a/packages/boulder-state/src/storage/read-state.ts b/packages/boulder-state/src/storage/read-state.ts index ee5d3f60d..a68268003 100644 --- a/packages/boulder-state/src/storage/read-state.ts +++ b/packages/boulder-state/src/storage/read-state.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs" import type { BoulderState, BoulderWorkResumeOption, BoulderWorkState, TaskSessionState } from "../types" import { getBoulderFilePath, resolveBoulderPlanPathForWork } from "./path" import { getPlanProgress } from "./plan-progress" -import { buildWorkFromMirror, isValidWorkStatus, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared" +import { buildWorkFromMirror, isValidWorkStatus, normalizeSessionId, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared" export function readBoulderState(directory: string): BoulderState | null { const filePath = getBoulderFilePath(directory) @@ -33,8 +33,9 @@ export function readBoulderState(directory: string): BoulderState | null { } function normalizeState(state: Record): void { + normalizeSessionFields(state) + const sessionIds = Array.isArray(state.session_ids) ? state.session_ids : [] - state.session_ids = sessionIds const sessionOrigins = state.session_origins && typeof state.session_origins === "object" && !Array.isArray(state.session_origins) ? (state.session_origins as Record) @@ -55,6 +56,38 @@ function normalizeState(state: Record): void { if (!state.task_sessions || typeof state.task_sessions !== "object" || Array.isArray(state.task_sessions)) { state.task_sessions = {} } + + normalizeWorkSessionFields(state.works) +} + +function normalizeSessionFields(target: Record): void { + const sessionIds = Array.isArray(target.session_ids) + ? target.session_ids.filter((sessionId): sessionId is string => typeof sessionId === "string").map((sessionId) => normalizeSessionId(sessionId)) + : [] + target.session_ids = sessionIds + + const sessionOrigins = target.session_origins && typeof target.session_origins === "object" && !Array.isArray(target.session_origins) + ? normalizeSessionOrigins(target.session_origins as Record) + : {} + target.session_origins = sessionOrigins +} + +function normalizeSessionOrigins(sessionOrigins: Record): Record { + return Object.fromEntries( + Object.entries(sessionOrigins).map(([sessionId, origin]) => [normalizeSessionId(sessionId), origin]), + ) +} + +function normalizeWorkSessionFields(works: unknown): void { + if (!works || typeof works !== "object" || Array.isArray(works)) { + return + } + + for (const work of Object.values(works)) { + if (work && typeof work === "object" && !Array.isArray(work)) { + normalizeSessionFields(work as Record) + } + } } export function getBoulderWorks(state: BoulderState): BoulderWorkState[] { @@ -113,15 +146,16 @@ export function getWorkForSession(directory: string, sessionId: string): Boulder return null } + const normalizedSessionId = normalizeSessionId(sessionId) const works = getBoulderWorks(state) - .filter((work) => work.session_ids.includes(sessionId)) + .filter((work) => work.session_ids.includes(normalizedSessionId)) .sort((left, right) => (parseIsoToMs(right.updated_at ?? right.started_at) ?? 0) - (parseIsoToMs(left.updated_at ?? left.started_at) ?? 0)) if (works.length > 0) { return works[0] ?? null } - return state.session_ids.includes(sessionId) ? buildWorkFromMirror(state) : null + return state.session_ids.includes(normalizedSessionId) ? buildWorkFromMirror(state) : null } export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] { diff --git a/packages/boulder-state/src/storage/session.ts b/packages/boulder-state/src/storage/session.ts index abecb8b21..12f4755e9 100644 --- a/packages/boulder-state/src/storage/session.ts +++ b/packages/boulder-state/src/storage/session.ts @@ -1,6 +1,6 @@ import type { BoulderSessionOrigin, BoulderState, BoulderWorkState } from "../types" import { getBoulderWorks, readBoulderState } from "./read-state" -import { nowIsoString, projectWorkToMirror } from "./shared" +import { normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared" import { writeBoulderState } from "./write-state" export function appendSessionId( @@ -8,9 +8,10 @@ export function appendSessionId( sessionId: string, origin: "direct" | "appended" = "direct", ): BoulderState | null { + const normalizedSessionId = normalizeSessionId(sessionId) const activeWorkId = readBoulderState(directory)?.active_work_id if (activeWorkId) { - return appendSessionIdForWork(directory, activeWorkId, sessionId, origin) + return appendSessionIdForWork(directory, activeWorkId, normalizedSessionId, origin) } const state = readBoulderState(directory) @@ -22,15 +23,15 @@ export function appendSessionId( state.session_origins = {} } - if (!state.session_ids?.includes(sessionId)) { + if (!state.session_ids?.includes(normalizedSessionId)) { if (!Array.isArray(state.session_ids)) { state.session_ids = [] } const originalSessionIds = [...state.session_ids] const originalSessionOrigins = { ...state.session_origins } - state.session_ids.push(sessionId) - state.session_origins[sessionId] = origin + state.session_ids.push(normalizedSessionId) + state.session_origins[normalizedSessionId] = origin if (writeBoulderState(directory, state)) { return state } @@ -40,8 +41,8 @@ export function appendSessionId( return null } - if (!state.session_origins[sessionId]) { - state.session_origins[sessionId] = origin + if (!state.session_origins[normalizedSessionId]) { + state.session_origins[normalizedSessionId] = origin if (!writeBoulderState(directory, state)) { return null } @@ -56,6 +57,7 @@ export function appendSessionIdForWork( sessionId: string, origin: BoulderSessionOrigin = "direct", ): BoulderState | null { + const normalizedSessionId = normalizeSessionId(sessionId) const state = readBoulderState(directory) if (!state) { return null @@ -69,10 +71,10 @@ export function appendSessionIdForWork( const updatedWork: BoulderWorkState = { ...targetWork, - session_ids: targetWork.session_ids.includes(sessionId) + session_ids: targetWork.session_ids.includes(normalizedSessionId) ? [...targetWork.session_ids] - : [...targetWork.session_ids, sessionId], - session_origins: { ...(targetWork.session_origins ?? {}), [sessionId]: origin }, + : [...targetWork.session_ids, normalizedSessionId], + session_origins: { ...(targetWork.session_origins ?? {}), [normalizedSessionId]: origin }, updated_at: nowIsoString(), } diff --git a/packages/boulder-state/src/storage/shared.ts b/packages/boulder-state/src/storage/shared.ts index 50fd89e7b..00c1811ad 100644 --- a/packages/boulder-state/src/storage/shared.ts +++ b/packages/boulder-state/src/storage/shared.ts @@ -2,6 +2,18 @@ import type { BoulderState, BoulderWorkState, BoulderWorkStatus } from "../types export const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"]) +type SessionPlatform = "codex" | "opencode" + +const SESSION_ID_PREFIX_PATTERN = /^(codex|opencode):/ + +export function normalizeSessionId(sessionId: string, platform: SessionPlatform = "opencode"): string { + if (SESSION_ID_PREFIX_PATTERN.test(sessionId)) { + return sessionId + } + + return `${platform}:${sessionId}` +} + export function nowIsoString(): string { return new Date().toISOString() } diff --git a/packages/boulder-state/src/storage/task.ts b/packages/boulder-state/src/storage/task.ts index c17fedd9b..921b6b7a4 100644 --- a/packages/boulder-state/src/storage/task.ts +++ b/packages/boulder-state/src/storage/task.ts @@ -1,6 +1,6 @@ import type { BoulderState, BoulderWorkState, TaskSessionState } from "../types" import { getBoulderWorks, readBoulderState } from "./read-state" -import { getElapsedMs, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared" +import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared" import { writeBoulderState } from "./write-state" export function upsertTaskSessionState( @@ -24,12 +24,13 @@ export function upsertTaskSessionState( return null } + const normalizedSessionId = normalizeSessionId(input.sessionId) const taskSessions = state.task_sessions ?? {} taskSessions[input.taskKey] = { task_key: input.taskKey, task_label: input.taskLabel, task_title: input.taskTitle, - session_id: input.sessionId, + session_id: normalizedSessionId, ...(input.agent !== undefined ? { agent: input.agent } : {}), ...(input.category !== undefined ? { category: input.category } : {}), updated_at: nowIsoString(), @@ -66,12 +67,13 @@ export function upsertTaskSessionStateForWork( return null } + const normalizedSessionId = normalizeSessionId(input.sessionId) const previousTaskSession = targetWork.task_sessions?.[input.taskKey] const nextTaskSession: TaskSessionState = { task_key: input.taskKey, task_label: input.taskLabel, task_title: input.taskTitle, - session_id: input.sessionId, + session_id: normalizedSessionId, ...(input.agent !== undefined ? { agent: input.agent } : {}), ...(input.category !== undefined ? { category: input.category } : {}), ...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}), @@ -116,7 +118,10 @@ export function startTaskTimer( startedAt?: string }, ): BoulderState | null { - const nextState = upsertTaskSessionStateForWork(directory, workId, input) + const nextState = upsertTaskSessionStateForWork(directory, workId, { + ...input, + sessionId: normalizeSessionId(input.sessionId), + }) if (!nextState) { return null } diff --git a/packages/boulder-state/src/storage/write-state.ts b/packages/boulder-state/src/storage/write-state.ts index 1360b43ad..5cabd8841 100644 --- a/packages/boulder-state/src/storage/write-state.ts +++ b/packages/boulder-state/src/storage/write-state.ts @@ -5,7 +5,7 @@ import type { BoulderState, BoulderWorkState } from "../types" import { getBoulderFilePath } from "./path" import { getPlanName } from "./plan-progress" import { getBoulderWorks, readBoulderState } from "./read-state" -import { getElapsedMs, nowIsoString, projectWorkToMirror } from "./shared" +import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared" export function writeBoulderState(directory: string, state: BoulderState): boolean { const filePath = getBoulderFilePath(directory) @@ -67,6 +67,7 @@ export function generateWorkId(planName: string): string { export function createBoulderState(planPath: string, sessionId: string, agent?: string, worktreePath?: string): BoulderState { const startedAt = nowIsoString() + const normalizedSessionId = normalizeSessionId(sessionId) const workId = generateWorkId(getPlanName(planPath)) const work: BoulderWorkState = { work_id: workId, @@ -75,8 +76,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?: status: "active", started_at: startedAt, updated_at: startedAt, - session_ids: [sessionId], - session_origins: { [sessionId]: "direct" }, + session_ids: [normalizedSessionId], + session_origins: { [normalizedSessionId]: "direct" }, ...(agent !== undefined ? { agent } : {}), ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), task_sessions: {}, @@ -90,8 +91,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?: started_at: startedAt, status: "active", updated_at: startedAt, - session_ids: [sessionId], - session_origins: { [sessionId]: "direct" }, + session_ids: [normalizedSessionId], + session_origins: { [normalizedSessionId]: "direct" }, plan_name: getPlanName(planPath), task_sessions: {}, ...(agent !== undefined ? { agent } : {}), @@ -132,6 +133,7 @@ export function addBoulderWork( const workId = generateWorkId(getPlanName(input.planPath)) const startedAt = input.startedAt ?? nowIsoString() + const normalizedSessionId = normalizeSessionId(input.sessionId) const nextWork: BoulderWorkState = { work_id: workId, active_plan: input.planPath, @@ -139,8 +141,8 @@ export function addBoulderWork( status: "active", started_at: startedAt, updated_at: startedAt, - session_ids: [input.sessionId], - session_origins: { [input.sessionId]: "direct" }, + session_ids: [normalizedSessionId], + session_origins: { [normalizedSessionId]: "direct" }, ...(input.agent !== undefined ? { agent: input.agent } : {}), ...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}), task_sessions: {}, diff --git a/packages/boulder-state/test/normalize-session-id.test.ts b/packages/boulder-state/test/normalize-session-id.test.ts new file mode 100644 index 000000000..05ecd6769 --- /dev/null +++ b/packages/boulder-state/test/normalize-session-id.test.ts @@ -0,0 +1,63 @@ +/// + +import { describe, expect, test } from "bun:test" +import * as boulderState from "../src" + +describe("normalizeSessionId", () => { + test("#given a bare id #when normalized without a platform #then opencode is used by default", () => { + // given + expect(typeof boulderState.normalizeSessionId).toBe("function") + + // when + const normalized = boulderState.normalizeSessionId("sess_abc") + + // then + expect(normalized).toBe("opencode:sess_abc") + }) + + test("#given a bare id and codex platform #when normalized #then codex is used", () => { + // given + expect(typeof boulderState.normalizeSessionId).toBe("function") + + // when + const normalized = boulderState.normalizeSessionId("sess_abc", "codex") + + // then + expect(normalized).toBe("codex:sess_abc") + }) + + test("#given an opencode-prefixed id #when normalized #then the id is unchanged", () => { + // given + const input = "opencode:sess_abc" + expect(typeof boulderState.normalizeSessionId).toBe("function") + + // when + const normalized = boulderState.normalizeSessionId(input) + + // then + expect(normalized).toBe(input) + }) + + test("#given a codex-prefixed id and opencode platform #when normalized #then the existing prefix wins", () => { + // given + expect(typeof boulderState.normalizeSessionId).toBe("function") + + + // when + const normalized = boulderState.normalizeSessionId("codex:sess_abc", "opencode") + + // then + expect(normalized).toBe("codex:sess_abc") + }) + + test("#given an empty id #when normalized #then opencode empty id is preserved", () => { + // given + expect(typeof boulderState.normalizeSessionId).toBe("function") + + // when + const normalized = boulderState.normalizeSessionId("") + + // then + expect(normalized).toBe("opencode:") + }) +}) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 55d66b379..9cbf6c1a6 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -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"]) + }) + }) }) diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index c99e05098..647e23919 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -18,6 +18,7 @@ export { getWorkByPlanName, getWorkForSession, getWorkResumeOptions, + normalizeSessionId, readBoulderState, resolveBoulderPlanPath, resolveBoulderPlanPathForWork, diff --git a/src/hooks/atlas/boulder-session-lineage.ts b/src/hooks/atlas/boulder-session-lineage.ts index 0868f3bb8..c123c634e 100644 --- a/src/hooks/atlas/boulder-session-lineage.ts +++ b/src/hooks/atlas/boulder-session-lineage.ts @@ -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 { + const normalizedBoulderSessionIDs = input.boulderSessionIDs.map((sessionID) => normalizeSessionId(sessionID)) const visitedSessionIDs = new Set() 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 } diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 93b5685de..839e9d3d5 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -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 { 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 { - 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 } diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 7b8f8c2b8..6ca1211d1 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -23,14 +23,14 @@ describe("start-work hook", () => { let omoDir: string function createMockPluginInput() { - return { + return unsafeTestValue[0]>({ directory: testDir, client: { session: { messages: async () => ({ data: [] }), }, }, - } as Parameters[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[0]>({ directory: testDir, client: { session: { @@ -207,7 +207,7 @@ You are starting a Sisyphus work session. }), }, }, - } as Parameters[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[0]>({ directory: testDir, client: { session: { @@ -262,7 +262,7 @@ You are starting a Sisyphus work session. }), }, }, - } as Parameters[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[0]>({ directory: testDir, client: { session: { @@ -313,7 +313,7 @@ You are starting a Sisyphus work session. }), }, }, - } as Parameters[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 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 () => { diff --git a/src/hooks/start-work/start-work-hook.test.ts b/src/hooks/start-work/start-work-hook.test.ts new file mode 100644 index 000000000..007823dd3 --- /dev/null +++ b/src/hooks/start-work/start-work-hook.test.ts @@ -0,0 +1,58 @@ +/// + +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 ` +You are starting a Sisyphus work session. + + +` + } + + 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[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"]) + }) +}) diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index af5c1f41f..6248d5f46 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -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)