diff --git a/src/cli/run/types.ts b/src/cli/run/types.ts index 0e032d95..b155642f 100644 --- a/src/cli/run/types.ts +++ b/src/cli/run/types.ts @@ -34,10 +34,10 @@ export interface RunContext { } export interface Todo { - id: string - content: string - status: string - priority: string + id?: string; + content: string; + status: string; + priority: string; } export interface SessionStatus { diff --git a/src/features/background-agent/constants.ts b/src/features/background-agent/constants.ts index 6e985d6d..cd3f3cf4 100644 --- a/src/features/background-agent/constants.ts +++ b/src/features/background-agent/constants.ts @@ -33,10 +33,10 @@ export interface BackgroundEvent { } export interface Todo { - content: string - status: string - priority: string - id: string + content: string; + status: string; + priority: string; + id?: string; } export interface QueueItem { diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts index 249e4644..8cb0463c 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts @@ -1,36 +1,17 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { getMessageDir } from "../../shared/opencode-message-dir" -import { MESSAGE_STORAGE_DIR } from "./storage-paths" - -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE_DIR)) return "" - - const directPath = join(MESSAGE_STORAGE_DIR, sessionID) - if (existsSync(directPath)) { - return directPath - } - - for (const directory of readdirSync(MESSAGE_STORAGE_DIR)) { - const sessionPath = join(MESSAGE_STORAGE_DIR, directory, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - - return "" -} +export { getMessageDir } export function getMessageIds(sessionID: string): string[] { - const messageDir = getMessageDir(sessionID) - if (!messageDir || !existsSync(messageDir)) return [] + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] - const messageIds: string[] = [] - for (const file of readdirSync(messageDir)) { - if (!file.endsWith(".json")) continue - const messageId = file.replace(".json", "") - messageIds.push(messageId) - } + const messageIds: string[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + const messageId = file.replace(".json", "") + messageIds.push(messageId) + } - return messageIds + return messageIds } diff --git a/src/hooks/session-recovery/storage/message-dir.ts b/src/hooks/session-recovery/storage/message-dir.ts index 96f03a27..1a2ecaf0 100644 --- a/src/hooks/session-recovery/storage/message-dir.ts +++ b/src/hooks/session-recovery/storage/message-dir.ts @@ -1,21 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../constants" - -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) { - return directPath - } - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - - return "" -} +export { getMessageDir } from "../../../shared/opencode-message-dir" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 3b9d881c..20c28d6f 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -15,10 +15,10 @@ export interface TodoContinuationEnforcer { } export interface Todo { - content: string - status: string - priority: string - id: string + content: string; + status: string; + priority: string; + id?: string; } export interface SessionState { diff --git a/src/shared/index.ts b/src/shared/index.ts index 07d8bd86..4b135520 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -37,7 +37,7 @@ export { resolveModelPipeline } from "./model-resolution-pipeline" export type { ModelResolutionRequest, ModelResolutionProvenance, - ModelResolutionResult as ModelResolutionPipelineResult, + ModelResolutionPipelineResult, } from "./model-resolution-types" export * from "./model-availability" export * from "./connected-providers-cache" @@ -49,3 +49,4 @@ export * from "./port-utils" export * from "./git-worktree" export * from "./safe-create-hook" export * from "./truncate-description" +export * from "./opencode-message-dir" diff --git a/src/shared/opencode-message-dir.test.ts b/src/shared/opencode-message-dir.test.ts new file mode 100644 index 00000000..251a1f4d --- /dev/null +++ b/src/shared/opencode-message-dir.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { getMessageDir } from "./opencode-message-dir" + +// Mock the constants +vi.mock("../tools/session-manager/constants", () => ({ + MESSAGE_STORAGE: "/mock/message/storage", +})) + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), +})) + +vi.mock("node:path", () => ({ + join: vi.fn(), +})) + +const mockExistsSync = vi.mocked(existsSync) +const mockReaddirSync = vi.mocked(readdirSync) +const mockJoin = vi.mocked(join) + +describe("getMessageDir", () => { + beforeEach(() => { + vi.clearAllMocks() + mockJoin.mockImplementation((...args) => args.join("/")) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when MESSAGE_STORAGE does not exist", () => { + // given + mockExistsSync.mockReturnValue(false) + + // when + const result = getMessageDir("session123") + + // then + expect(result).toBe(null) + expect(mockExistsSync).toHaveBeenCalledWith("/mock/message/storage") + }) + + it("returns direct path when session exists directly", () => { + // given + mockExistsSync.mockImplementation((path) => path === "/mock/message/storage" || path === "/mock/message/storage/session123") + + // when + const result = getMessageDir("session123") + + // then + expect(result).toBe("/mock/message/storage/session123") + expect(mockExistsSync).toHaveBeenCalledWith("/mock/message/storage") + expect(mockExistsSync).toHaveBeenCalledWith("/mock/message/storage/session123") + }) + + it("returns subdirectory path when session exists in subdirectory", () => { + // given + mockExistsSync.mockImplementation((path) => { + return path === "/mock/message/storage" || path === "/mock/message/storage/subdir/session123" + }) + mockReaddirSync.mockReturnValue(["subdir"]) + + // when + const result = getMessageDir("session123") + + // then + expect(result).toBe("/mock/message/storage/subdir/session123") + expect(mockReaddirSync).toHaveBeenCalledWith("/mock/message/storage") + }) + + it("returns null when session not found anywhere", () => { + // given + mockExistsSync.mockImplementation((path) => path === "/mock/message/storage") + mockReaddirSync.mockReturnValue(["subdir1", "subdir2"]) + + // when + const result = getMessageDir("session123") + + // then + expect(result).toBe(null) + }) + + it("returns null when readdirSync throws", () => { + // given + mockExistsSync.mockImplementation((path) => path === "/mock/message/storage") + mockReaddirSync.mockImplementation(() => { + throw new Error("Permission denied") + }) + + // when + const result = getMessageDir("session123") + + // then + expect(result).toBe(null) + }) +}) \ No newline at end of file diff --git a/src/shared/opencode-message-dir.ts b/src/shared/opencode-message-dir.ts new file mode 100644 index 00000000..f2b81594 --- /dev/null +++ b/src/shared/opencode-message-dir.ts @@ -0,0 +1,25 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../tools/session-manager/constants" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) { + return directPath + } + + try { + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + } catch { + return null + } + + return null +} \ No newline at end of file diff --git a/src/shared/session-utils.ts b/src/shared/session-utils.ts index eb983974..40e73bb2 100644 --- a/src/shared/session-utils.ts +++ b/src/shared/session-utils.ts @@ -3,20 +3,7 @@ import * as os from "node:os" import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +import { getMessageDir } from "./opencode-message-dir" export function isCallerOrchestrator(sessionID?: string): boolean { if (!sessionID) return false diff --git a/src/tools/session-manager/session-formatter.ts b/src/tools/session-manager/session-formatter.ts index 33faae9c..f1a359aa 100644 --- a/src/tools/session-manager/session-formatter.ts +++ b/src/tools/session-manager/session-formatter.ts @@ -44,7 +44,7 @@ export async function formatSessionList(sessionIDs: string[]): Promise { export function formatSessionMessages( messages: SessionMessage[], includeTodos?: boolean, - todos?: Array<{ id: string; content: string; status: string }> + todos?: Array<{ id?: string; content: string; status: string }> ): string { if (messages.length === 0) { return "No messages found in this session." diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts index 8ed93f00..38ea0a0b 100644 --- a/src/tools/session-manager/storage.ts +++ b/src/tools/session-manager/storage.ts @@ -73,8 +73,8 @@ export async function getAllSessions(): Promise { return [...new Set(sessions)] } -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null const directPath = join(MESSAGE_STORAGE, sessionID) if (existsSync(directPath)) { @@ -89,14 +89,14 @@ export function getMessageDir(sessionID: string): string { } } } catch { - return "" + return null } - return "" + return null } export function sessionExists(sessionID: string): boolean { - return getMessageDir(sessionID) !== "" + return getMessageDir(sessionID) !== null } export async function readSessionMessages(sessionID: string): Promise { diff --git a/src/tools/session-manager/types.ts b/src/tools/session-manager/types.ts index becaf13b..635b9a75 100644 --- a/src/tools/session-manager/types.ts +++ b/src/tools/session-manager/types.ts @@ -34,10 +34,10 @@ export interface SessionInfo { } export interface TodoItem { - id: string - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - priority?: string + id?: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + priority?: string; } export interface SearchResult { diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts index ed53f51d..8c4468d5 100644 --- a/src/tools/task/todo-sync.test.ts +++ b/src/tools/task/todo-sync.test.ts @@ -471,7 +471,7 @@ describe("syncAllTasksToTodos", () => { expect(mockCtx.client.session.todo).toHaveBeenCalled(); }); - it("handles undefined sessionID", async () => { + it("preserves todos without id field", async () => { // given const tasks: Task[] = [ { @@ -483,14 +483,23 @@ describe("syncAllTasksToTodos", () => { blockedBy: [], }, ]; - mockCtx.client.session.todo.mockResolvedValue([]); + const currentTodos: TodoInfo[] = [ + { + id: "T-1", + content: "Task 1", + status: "pending", + }, + { + content: "Todo without id", + status: "pending", + }, + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); // when - await syncAllTasksToTodos(mockCtx, tasks); + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ - path: { id: "" }, - }); + expect(mockCtx.client.session.todo).toHaveBeenCalled(); }); }); diff --git a/src/tools/task/todo-sync.ts b/src/tools/task/todo-sync.ts index 3243e723..05075e2d 100644 --- a/src/tools/task/todo-sync.ts +++ b/src/tools/task/todo-sync.ts @@ -3,7 +3,7 @@ import { log } from "../../shared/logger"; import type { Task } from "../../features/claude-tasks/types.ts"; export interface TodoInfo { - id: string; + id?: string; content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; priority?: "low" | "medium" | "high"; @@ -100,7 +100,7 @@ export async function syncTaskTodoUpdate( path: { id: sessionID }, }); const currentTodos = extractTodos(response); - const nextTodos = currentTodos.filter((todo) => todo.id !== task.id); + const nextTodos = currentTodos.filter((todo) => !todo.id || todo.id !== task.id); const todo = syncTaskToTodo(task); if (todo) { @@ -150,10 +150,10 @@ export async function syncAllTasksToTodos( } const finalTodos: TodoInfo[] = []; - const newTodoIds = new Set(newTodos.map((t) => t.id)); + const newTodoIds = new Set(newTodos.map((t) => t.id).filter((id) => id !== undefined)); for (const existing of currentTodos) { - if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) { + if ((!existing.id || !newTodoIds.has(existing.id)) && !tasksToRemove.has(existing.id || "")) { finalTodos.push(existing); } }