diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index ea92c5c46..2599ff4bd 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -11,6 +11,7 @@ import { Index, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { composerEnabled, composerProbe } from "@/testing/session-composer" import { useLanguage } from "@/context/language" +import type { SessionTodoItem } from "@/pages/session/todos/todo-model" const currentToken = "\u0000current\u0000" const totalToken = "\u0000total\u0000" @@ -42,7 +43,7 @@ function dot(status: Todo["status"]) { export function SessionTodoDock(props: { sessionID?: string - todos: Todo[] + todos: SessionTodoItem[] collapseLabel: string expandLabel: string dockProgress: number @@ -230,7 +231,7 @@ export function SessionTodoDock(props: { ) } -function TodoList(props: { todos: Todo[] }) { +function TodoList(props: { todos: SessionTodoItem[] }) { const [store, setStore] = createStore({ stuck: false, }) diff --git a/packages/app/src/pages/session/session-status-extractors.test.ts b/packages/app/src/pages/session/session-status-extractors.test.ts index 58d9d72e4..f51f2d46e 100644 --- a/packages/app/src/pages/session/session-status-extractors.test.ts +++ b/packages/app/src/pages/session/session-status-extractors.test.ts @@ -71,6 +71,30 @@ describe("extractTodos", () => { ) expect(extractTodos([part])).toEqual([{ content: "ok", status: "pending", priority: "low" }]) }) + + it("prefers resolved metadata todos with ids over idless tool input", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "A", status: "pending", priority: "medium" }] }, + metadata: { todos: [{ id: "todo_1", content: "A", status: "pending", priority: "medium" }] }, + }), + ) + + expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }]) + }) + + it("falls back to tool input when metadata todos are malformed", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "A", status: "pending", priority: "medium" }] }, + metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] }, + }), + ) + + expect(extractTodos([part])).toEqual([{ content: "A", status: "pending", priority: "medium" }]) + }) }) const webfetchPart = (url: string): Part => toolPart("webfetch", completedState({ input: { url } })) diff --git a/packages/app/src/pages/session/session-status-extractors.ts b/packages/app/src/pages/session/session-status-extractors.ts index f8eaddceb..72f453e4d 100644 --- a/packages/app/src/pages/session/session-status-extractors.ts +++ b/packages/app/src/pages/session/session-status-extractors.ts @@ -17,6 +17,7 @@ export const TOOL_WEBFETCH = "webfetch" export const TOOL_WEBSEARCH = "websearch" export interface TodoItem { + id?: string content: string status: string priority: string @@ -29,7 +30,20 @@ function isToolPart(part: Part): part is Extract { function isValidTodo(value: unknown): value is TodoItem { if (typeof value !== "object" || value === null) return false const v = value as Partial - return typeof v.content === "string" && typeof v.status === "string" && typeof v.priority === "string" + return ( + (v.id === undefined || typeof v.id === "string") && + typeof v.content === "string" && + typeof v.status === "string" && + typeof v.priority === "string" + ) +} + +function todosFromMetadata(part: Extract): TodoItem[] | undefined { + const metadata = part.state.status === "completed" ? part.state.metadata : undefined + const todos = (metadata as { todos?: unknown } | undefined)?.todos + if (!Array.isArray(todos)) return undefined + const valid = todos.filter(isValidTodo) + return valid.length === todos.length ? valid : undefined } export function extractTodos(parts: Part[]): TodoItem[] { @@ -38,6 +52,12 @@ export function extractTodos(parts: Part[]): TodoItem[] { if (!isToolPart(part)) continue if (part.tool !== TOOL_TODOWRITE) continue if (part.state.status !== "completed") continue + const metadataTodos = todosFromMetadata(part) + if (metadataTodos) { + latest = metadataTodos + continue + } + const rawInput = part.state.input if (typeof rawInput !== "object" || rawInput === null) continue const todos = (rawInput as { todos?: unknown }).todos diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index 1d9ea8d1d..a5bc85ddb 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Part, ToolState } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2/client" import { selectSessionTodos } from "./session-todos" +import type { SessionTodoItem } from "./todos/todo-model" const completedState = ( overrides: Partial> = {}, @@ -26,7 +27,14 @@ const toolPart = (tool: string, state: ToolState = completedState()): Part => state, }) as Part -const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({ +const todo = (content: string, status: SessionTodoItem["status"] = "pending"): SessionTodoItem => ({ + content, + status, + priority: "medium", +}) + +const backendTodo = (content: string, status: Todo["status"] = "pending"): Todo => ({ + id: `todo_${content}`, content, status, priority: "medium", @@ -36,7 +44,7 @@ describe("selectSessionTodos", () => { test("prefers message-derived todos over lagging backend todos", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))] - expect(selectSessionTodos({ backend: [todo("from backend", "pending")], parts })).toEqual([ + expect(selectSessionTodos({ backend: [backendTodo("from backend", "pending")], parts })).toEqual([ todo("from parts", "in_progress"), ]) }) diff --git a/packages/app/src/pages/session/session-todos.ts b/packages/app/src/pages/session/session-todos.ts index 2c6f7e955..ebf3cb515 100644 --- a/packages/app/src/pages/session/session-todos.ts +++ b/packages/app/src/pages/session/session-todos.ts @@ -1,7 +1,6 @@ export { selectSessionTodoDataSnapshot, selectSessionTodoDockSnapshot, - selectSessionTodoSnapshot, selectSessionTodos, type SessionTodoSource, } from "./todos/todo-source" diff --git a/packages/app/src/pages/session/todos/todo-dock-machine.test.ts b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts index 0420e557c..2ae99ce97 100644 --- a/packages/app/src/pages/session/todos/todo-dock-machine.test.ts +++ b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts @@ -67,6 +67,24 @@ describe("reduceTodoDockState", () => { expect(reduceTodoDockState(completing, { type: "snapshot", input: terminal("s", "completed") })).toBe(completing) }) + test("terminal replacement with same statuses but new lifecycle signature updates completing state", () => { + const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active("s") }) + const completing = reduceTodoDockState(shown, { + type: "snapshot", + input: terminal("s", JSON.stringify([["todo_1", "completed"]])), + }) + + const replaced = reduceTodoDockState(completing, { + type: "snapshot", + input: terminal("s", JSON.stringify([["todo_2", "completed"]])), + }) + + expect(replaced).toMatchObject({ + kind: "visible-completing", + lifecycleSignature: JSON.stringify([["todo_2", "completed"]]), + }) + }) + test("empty hides immediately", () => { const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }) diff --git a/packages/app/src/pages/session/todos/todo-model.test.ts b/packages/app/src/pages/session/todos/todo-model.test.ts index 8aabf4624..c6a07f471 100644 --- a/packages/app/src/pages/session/todos/todo-model.test.ts +++ b/packages/app/src/pages/session/todos/todo-model.test.ts @@ -2,7 +2,24 @@ import { describe, expect, test } from "bun:test" import type { Todo } from "@opencode-ai/sdk/v2/client" import { todoDisplaySignature, todoLifecycleSignature, todoPhase } from "./todo-model" -const todo = (content: string, status: Todo["status"] = "pending", priority: Todo["priority"] = "medium"): Todo => ({ +const todo = ( + content: string, + status: Todo["status"] = "pending", + priority: Todo["priority"] = "medium", + id?: string, +): Todo => + ({ + id, + content, + status, + priority, + }) as Todo + +const idlessTodo = ( + content: string, + status: Todo["status"] = "pending", + priority: Todo["priority"] = "medium", +): Pick => ({ content, status, priority, @@ -25,8 +42,8 @@ describe("todoPhase", () => { describe("todoLifecycleSignature", () => { test("ignores content and priority refreshes", () => { - expect(todoLifecycleSignature([todo("first", "completed", "high")])).toBe( - todoLifecycleSignature([todo("first refreshed", "completed", "low")]), + expect(todoLifecycleSignature([todo("first", "completed", "high", "todo_1")])).toBe( + todoLifecycleSignature([todo("first refreshed", "completed", "low", "todo_1")]), ) }) @@ -38,6 +55,29 @@ describe("todoLifecycleSignature", () => { todoLifecycleSignature([todo("first", "completed"), todo("second", "completed")]), ) }) + + test("changes when stable ids change with the same statuses", () => { + expect(todoLifecycleSignature([todo("first", "pending", "medium", "todo_1")])).not.toBe( + todoLifecycleSignature([todo("second", "pending", "medium", "todo_2")]), + ) + }) + + test("falls back to status-only signatures when ids are missing", () => { + expect(todoLifecycleSignature([idlessTodo("first", "completed", "high")])).toBe( + todoLifecycleSignature([idlessTodo("first refreshed", "completed", "low")]), + ) + }) + + test("falls back to status-only signatures when any todo is missing an id", () => { + expect( + todoLifecycleSignature([todo("first", "pending", "medium", "todo_1"), idlessTodo("second", "completed")]), + ).toBe( + todoLifecycleSignature([ + todo("first refreshed", "pending", "low", "todo_2"), + idlessTodo("second refreshed", "completed"), + ]), + ) + }) }) describe("todoDisplaySignature", () => { diff --git a/packages/app/src/pages/session/todos/todo-model.ts b/packages/app/src/pages/session/todos/todo-model.ts index 2397b93e0..5147f9d85 100644 --- a/packages/app/src/pages/session/todos/todo-model.ts +++ b/packages/app/src/pages/session/todos/todo-model.ts @@ -4,10 +4,12 @@ export type TodoPhase = "empty" | "active" | "terminal" export type TodoSourceKind = "primary-backend" | "primary-parts" | "fallback-backend" | "fallback-parts" | "none" +export type SessionTodoItem = Pick & Partial> + export type TodoSnapshot = { sessionID?: string source: TodoSourceKind - items: Todo[] + items: SessionTodoItem[] phase: TodoPhase lifecycleSignature: string displaySignature: string @@ -24,7 +26,9 @@ export function todoPhase(todos: readonly Pick[]): TodoPhase { return todos.every(isTerminalTodo) ? "terminal" : "active" } -export function todoLifecycleSignature(todos: readonly Pick[]): string { +export function todoLifecycleSignature(todos: readonly Pick[]): string { + const hasStableIDs = todos.every((todo) => typeof todo.id === "string" && todo.id.length > 0) + if (hasStableIDs) return JSON.stringify(todos.map((todo) => [todo.id, todo.status])) return JSON.stringify(todos.map((todo) => [todo.status])) } @@ -35,7 +39,7 @@ export function todoDisplaySignature(todos: readonly Pick ({ content, status, priority: "medium", -}) +}) as Todo describe("selectSessionTodoDataSnapshot", () => { test("returns completed-only parts for status summary display", () => { diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index 3e731ef19..737344ee7 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -1,6 +1,6 @@ import type { Part, Todo } from "@opencode-ai/sdk/v2" import { extractTodos } from "@/pages/session/session-status-extractors" -import { todoPhase, todoSnapshot, type TodoSnapshot } from "./todo-model" +import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model" export type SessionTodoSource = { sessionID?: string @@ -13,7 +13,7 @@ export type SelectSessionTodosInput = { fallback?: SessionTodoSource } -const partTodos = (parts: Part[]) => extractTodos(parts) as Todo[] +const partTodos = (parts: Part[]) => extractTodos(parts) // Data snapshots are for status displays and should preserve the latest todo // list even when it is terminal. Dock snapshots below apply the stricter UI @@ -101,9 +101,6 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [], dockEligible: false }) } -// Deprecated compatibility alias. Prefer explicit data or dock snapshot names. -export const selectSessionTodoSnapshot = selectSessionTodoDockSnapshot - -export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): Todo[] { +export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): SessionTodoItem[] { return selectSessionTodoDataSnapshot({ primary: input, fallback: input.fallback }).items } diff --git a/packages/app/src/pages/session/todos/use-session-todos.ts b/packages/app/src/pages/session/todos/use-session-todos.ts index 28f6b7669..111f6f644 100644 --- a/packages/app/src/pages/session/todos/use-session-todos.ts +++ b/packages/app/src/pages/session/todos/use-session-todos.ts @@ -1,11 +1,10 @@ import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -import type { Todo } from "@opencode-ai/sdk/v2" import { useGlobalSync } from "@/context/global-sync" import { useSync } from "@/context/sync" import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer" import { reduceTodoDockState, TODO_DOCK_COMPLETING_DELAY_MS, todoDockHiddenState } from "./todo-dock-machine" -import { todoSnapshot, type TodoSnapshot } from "./todo-model" +import { todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model" import { selectSessionTodoDockSnapshot } from "./todo-source" const dockInput = (snapshot: TodoSnapshot, sessionID?: string) => ({ @@ -27,7 +26,7 @@ export function createSessionTodoModel(input: { const [test, setTest] = createStore({ on: false, - todos: undefined as Todo[] | undefined, + todos: undefined as SessionTodoItem[] | undefined, }) const pull = () => { diff --git a/packages/opencode/migration/20260503025430_todo_ids/migration.sql b/packages/opencode/migration/20260503025430_todo_ids/migration.sql new file mode 100644 index 000000000..5c634dbd7 --- /dev/null +++ b/packages/opencode/migration/20260503025430_todo_ids/migration.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_todo` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `content` text NOT NULL, + `status` text NOT NULL, + `priority` text NOT NULL, + `position` integer NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +INSERT INTO `__new_todo`(`id`, `session_id`, `content`, `status`, `priority`, `position`, `time_created`, `time_updated`) +SELECT 'todo_' || printf('%012x', (`time_created` * 4096) + `position`) || lower(hex(randomblob(7))), `session_id`, `content`, `status`, `priority`, `position`, `time_created`, `time_updated` FROM `todo`;--> statement-breakpoint +DROP TABLE `todo`;--> statement-breakpoint +ALTER TABLE `__new_todo` RENAME TO `todo`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `todo_session_idx` ON `todo` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `todo_session_position_idx` ON `todo` (`session_id`,`position`); diff --git a/packages/opencode/migration/20260503025430_todo_ids/snapshot.json b/packages/opencode/migration/20260503025430_todo_ids/snapshot.json new file mode 100644 index 000000000..e5402adf3 --- /dev/null +++ b/packages/opencode/migration/20260503025430_todo_ids/snapshot.json @@ -0,0 +1,1548 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "bc42d7a5-96ec-4d3e-a5a4-969dd11febf9", + "prevIds": [ + "626d5289-4bde-4d93-8814-e3679bc71542" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "created_by_agent_tool", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subagent_type", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'null'", + "generated": null, + "name": "execution_context", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "skill", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "todo_pk", + "table": "todo", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "position", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "todo_session_position_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e324962b..55fe35ff0 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -8,6 +8,7 @@ export namespace Identifier { message: "msg", permission: "per", question: "que", + todo: "todo", user: "usr", part: "prt", pty: "pty", diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34..ff5be8373 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -33,3 +33,13 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema( ) export type PartID = Schema.Schema.Type + +export const TodoID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("todo") }).pipe( + Schema.brand("TodoID"), + withStatics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("todo", id)), + zod: zod(s), + })), +) + +export type TodoID = Schema.Schema.Type diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 3c6d65df8..5dd610124 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,11 +1,11 @@ -import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { SessionEntry } from "../v2/session-entry" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" -import type { SessionID, MessageID, PartID } from "./schema" +import type { SessionID, MessageID, PartID, TodoID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" import { Timestamps } from "../storage/schema.sql" @@ -95,6 +95,7 @@ export const PartTable = sqliteTable( export const TodoTable = sqliteTable( "todo", { + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() @@ -106,8 +107,8 @@ export const TodoTable = sqliteTable( ...Timestamps, }, (table) => [ - primaryKey({ columns: [table.session_id, table.position] }), index("todo_session_idx").on(table.session_id), + uniqueIndex("todo_session_position_idx").on(table.session_id, table.position), ], ) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 998c6081b..7c66a15e2 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,18 +1,26 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { SessionID } from "./schema" +import { SessionID, TodoID as TodoIDSchema } from "./schema" +import type { TodoID as TodoIDType } from "./schema" import { Effect, Layer, Context } from "effect" import z from "zod" import { Database, eq, asc } from "../storage/db" import { TodoTable } from "./session.sql" -export const Info = z - .object({ - content: z.string().describe("Brief description of the task"), - status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), - priority: z.string().describe("Priority level of the task: high, medium, low"), - }) - .meta({ ref: "Todo" }) +export const TodoID = TodoIDSchema +export type TodoID = TodoIDType + +export const Input = z.object({ + id: TodoIDSchema.zod.optional(), + content: z.string().describe("Brief description of the task"), + status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), + priority: z.string().describe("Priority level of the task: high, medium, low"), +}) +export type Input = z.infer + +export const Info = Input.extend({ + id: TodoIDSchema.zod, +}).meta({ ref: "Todo" }) export type Info = z.infer export const Event = { @@ -26,25 +34,71 @@ export const Event = { } export interface Interface { - readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect + readonly update: (input: { sessionID: SessionID; todos: Input[] }) => Effect.Effect readonly get: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionTodo") {} +export function resolveTodoIDs(previous: Info[], incoming: Input[]): Info[] { + const previousByID = new Map(previous.map((todo) => [todo.id, todo])) + const unusedPreviousByExactContent = new Map() + const used = new Set() + + for (const todo of previous) { + const list = unusedPreviousByExactContent.get(todo.content) + if (list) list.push(todo) + else unusedPreviousByExactContent.set(todo.content, [todo]) + } + + return incoming.map((todo) => { + let id: TodoID | undefined + + if (todo.id && previousByID.has(todo.id as TodoID) && !used.has(todo.id as TodoID)) { + id = todo.id as TodoID + } + + // Exact-content reuse is only a legacy/idless fallback. Supplied unknown or + // duplicate ids are treated as untrusted and get fresh identity. + if (!id && !todo.id) { + const candidates = unusedPreviousByExactContent.get(todo.content) + while (candidates?.length) { + const candidate = candidates.shift() + if (!candidate || used.has(candidate.id)) continue + id = candidate.id + break + } + } + + id ??= TodoIDSchema.ascending() + used.add(id) + + return { + id, + content: todo.content, + status: todo.status, + priority: todo.priority, + } + }) +} + export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { + const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Input[] }) { + const previous = yield* get(input.sessionID) + const resolved = resolveTodoIDs(previous, input.todos) + yield* Effect.sync(() => Database.transaction((db) => { db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() - if (input.todos.length === 0) return + if (resolved.length === 0) return db.insert(TodoTable) .values( - input.todos.map((todo, position) => ({ + resolved.map((todo, position) => ({ + id: todo.id, session_id: input.sessionID, content: todo.content, status: todo.status, @@ -55,7 +109,8 @@ export const layer = Layer.effect( .run() }), ) - yield* bus.publish(Event.Updated, input) + yield* bus.publish(Event.Updated, { sessionID: input.sessionID, todos: resolved }) + return resolved }) const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) { @@ -65,6 +120,7 @@ export const layer = Layer.effect( ), ) return rows.map((row) => ({ + id: row.id, content: row.content, status: row.status, priority: row.priority, diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 12884261a..4c14842d8 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -9,6 +9,8 @@ import path from "path" import { existsSync } from "fs" import { Filesystem } from "../util/filesystem" import { Glob } from "../util/glob" +import { TodoID } from "../session/schema" +import { createHash } from "crypto" export namespace JsonMigration { const log = Log.create({ service: "json-migration" }) @@ -105,6 +107,13 @@ export namespace JsonMigration { } } + function legacyTodoID(sessionID: string, position: number) { + const digest = createHash("sha256").update(`${sessionID}:${position}`).digest("hex") + // Historical JSON todos do not have creation metadata, so use a stable + // zero-timestamp prefix plus a slot hash to keep reruns idempotent. + return TodoID.ascending(`todo_000000000000${digest.slice(0, 14)}`) + } + // Pre-scan all files upfront to avoid repeated glob operations log.info("scanning files...") const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([ @@ -307,6 +316,7 @@ export namespace JsonMigration { log.info("migrated parts", { count: stats.parts }) // Migrate todos + const seenTodoIDs = new Set() const todoSessions = todoFiles.map((file) => path.basename(file, ".json")) for (let i = 0; i < todoFiles.length; i += batchSize) { const end = Math.min(i + batchSize, todoFiles.length) @@ -327,7 +337,12 @@ export namespace JsonMigration { for (let position = 0; position < data.length; position++) { const todo = data[position] if (!todo?.content || !todo?.status || !todo?.priority) continue + const storedID = + typeof todo.id === "string" && todo.id.startsWith("todo_") ? TodoID.ascending(todo.id) : undefined + const id = storedID && !seenTodoIDs.has(storedID) ? storedID : legacyTodoID(sessionID, position) + seenTodoIDs.add(id) values.push({ + id, session_id: sessionID, content: todo.content, status: todo.status, diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 18d21cf61..d3646d497 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -7,6 +7,10 @@ import { Todo } from "../session/todo" // here rather than referencing its `.shape` — the LLM-visible JSON Schema is // identical, and it removes the last zod dependency from this tool. const TodoItem = Schema.Struct({ + id: Schema.optional(Schema.String).annotate({ + description: + "Stable id of an existing todo. Preserve it when updating the same task; omit it for new or replaced tasks.", + }), content: Schema.String.annotate({ description: "Brief description of the task" }), status: Schema.String.annotate({ description: "Current status of the task: pending, in_progress, completed, cancelled", @@ -39,16 +43,19 @@ export const TodoWriteTool = Tool.define ({ + ...todo, + id: todo.id as Todo.TodoID | undefined, + })), }) return { - title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, - output: JSON.stringify(params.todos, null, 2), + title: `${todos.filter((x) => x.status !== "completed").length} todos`, + output: JSON.stringify(todos, null, 2), metadata: { - todos: params.todos, + todos, }, } }), diff --git a/packages/opencode/src/tool/todowrite.txt b/packages/opencode/src/tool/todowrite.txt index 36098501b..01bb29d65 100644 --- a/packages/opencode/src/tool/todowrite.txt +++ b/packages/opencode/src/tool/todowrite.txt @@ -12,6 +12,12 @@ Use this tool proactively in these scenarios: 6. After completing a task - Mark it complete and add any new follow-up tasks 7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones. +## Stable Todo IDs + +When updating an existing todo item, preserve its `id` if one is present in the current todo list. +When creating a new todo, replacing a todo with a different logical task, or when you are unsure whether it is the same task, omit `id`; the system will assign one. +Do not invent ids. Only reuse ids that already appeared in the current todo list. + ## When NOT to Use This Tool Skip using this tool when: @@ -162,4 +168,3 @@ The assistant did not use the todo list because this is a single command executi - Create specific, actionable items - Break complex tasks into smaller, manageable steps - Use clear, descriptive task names - diff --git a/packages/opencode/test/session/todo.test.ts b/packages/opencode/test/session/todo.test.ts new file mode 100644 index 000000000..606f7aef8 --- /dev/null +++ b/packages/opencode/test/session/todo.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Todo } from "../../src/session/todo" +import { tmpdir } from "../fixture/fixture" + +const todo = (content: string, id?: Todo.TodoID): Todo.Input => ({ + id, + content, + status: "pending", + priority: "medium", +}) + +describe("resolveTodoIDs", () => { + test("assigns ids to idless todos", () => { + const resolved = Todo.resolveTodoIDs([], [todo("A")]) + + expect(resolved[0]).toMatchObject({ content: "A", status: "pending", priority: "medium" }) + expect(resolved[0].id).toStartWith("todo_") + }) + + test("preserves existing ids carried by the input", () => { + const previous = Todo.resolveTodoIDs([], [todo("A")]) + const resolved = Todo.resolveTodoIDs(previous, [ + { id: previous[0].id, content: "A refreshed", status: "completed", priority: "low" }, + ]) + + expect(resolved[0].id).toBe(previous[0].id) + }) + + test("creates a new id when same-count and same-status input omits id", () => { + const previous = Todo.resolveTodoIDs([], [todo("A")]) + const replacement = Todo.resolveTodoIDs(previous, [todo("B")]) + + expect(replacement[0].id).not.toBe(previous[0].id) + }) + + test("ignores unknown and duplicate ids", () => { + const previous = Todo.resolveTodoIDs([], [todo("A"), todo("B")]) + const unknown = Todo.TodoID.ascending() + const resolved = Todo.resolveTodoIDs(previous, [ + { ...todo("unknown", unknown), status: "pending" }, + { ...todo("first reuse", previous[0].id), status: "pending" }, + { ...todo("duplicate reuse", previous[0].id), status: "pending" }, + ]) + const previousIDs = previous.map(({ id }) => id) + + expect(resolved[0].id).not.toBe(unknown) + expect(previousIDs).not.toContain(resolved[0].id) + expect(resolved[1].id).toBe(previous[0].id) + expect(previousIDs).not.toContain(resolved[2].id) + }) +}) + +describe("Todo service", () => { + test("update returns ids and get persists them", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "todo ids" }) + const first = await Effect.runPromise( + Todo.Service.use((svc) => + svc.update({ + sessionID: session.id, + todos: [{ content: "A", status: "pending", priority: "medium" }], + }), + ).pipe(Effect.provide(Todo.defaultLayer)), + ) + const stored = await Effect.runPromise( + Todo.Service.use((svc) => svc.get(session.id)).pipe(Effect.provide(Todo.defaultLayer)), + ) + + expect(first[0].id).toStartWith("todo_") + expect(stored).toEqual(first) + + await Session.remove(session.id) + }, + }) + }) + + test("second update preserves id and persists status changes", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "todo update ids" }) + const first = await Effect.runPromise( + Todo.Service.use((svc) => + svc.update({ + sessionID: session.id, + todos: [{ content: "A", status: "pending", priority: "medium" }], + }), + ).pipe(Effect.provide(Todo.defaultLayer)), + ) + const second = await Effect.runPromise( + Todo.Service.use((svc) => + svc.update({ + sessionID: session.id, + todos: [{ id: first[0].id, content: "A", status: "completed", priority: "medium" }], + }), + ).pipe(Effect.provide(Todo.defaultLayer)), + ) + const stored = await Effect.runPromise( + Todo.Service.use((svc) => svc.get(session.id)).pipe(Effect.provide(Todo.defaultLayer)), + ) + + expect(second[0].id).toBe(first[0].id) + expect(stored).toEqual([{ ...second[0], status: "completed" }]) + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index e76401ae7..78419049d 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -11,7 +11,7 @@ import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" import { SessionShareTable } from "../../src/share/share.sql" -import { SessionID, MessageID, PartID } from "../../src/session/schema" +import { SessionID, MessageID, PartID, TodoID } from "../../src/session/schema" // Test fixtures const fixtures = { @@ -462,12 +462,19 @@ describe("JSON to SQLite migration", () => { time: { created: Date.now(), updated: Date.now() }, sandboxes: [], }) + await writeSession(storageDir, "proj_test123abc", { ...fixtures.session }) + await Bun.write( + path.join(storageDir, "todo", "ses_test456def.json"), + JSON.stringify([{ content: "One todo", status: "pending", priority: "high" }]), + ) await JsonMigration.run(db) await JsonMigration.run(db) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing + const todos = db.select().from(TodoTable).all() + expect(todos.length).toBe(1) }) test("migrates todos", async () => { @@ -504,14 +511,43 @@ describe("JSON to SQLite migration", () => { const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() expect(todos.length).toBe(2) + expect(todos[0].id).toBe(TodoID.ascending("todo_1")) expect(todos[0].content).toBe("First todo") expect(todos[0].status).toBe("pending") expect(todos[0].priority).toBe("high") expect(todos[0].position).toBe(0) + expect(todos[1].id).toBe(TodoID.ascending("todo_2")) expect(todos[1].content).toBe("Second todo") expect(todos[1].position).toBe(1) }) + test("replaces duplicate legacy todo ids during migration", async () => { + await writeProject(storageDir, { + id: "proj_test123abc", + worktree: "/test/path", + time: { created: Date.now(), updated: Date.now() }, + sandboxes: [], + }) + await writeSession(storageDir, "proj_test123abc", { ...fixtures.session }) + + await Bun.write( + path.join(storageDir, "todo", "ses_test456def.json"), + JSON.stringify([ + { id: "todo_duplicate", content: "First todo", status: "pending", priority: "high" }, + { id: "todo_duplicate", content: "Second todo", status: "pending", priority: "medium" }, + ]), + ) + + const stats = await JsonMigration.run(db) + + expect(stats?.todos).toBe(2) + const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() + expect(todos.length).toBe(2) + expect(todos[0].id).toBe(TodoID.ascending("todo_duplicate")) + expect(todos[1].id).toStartWith("todo_") + expect(todos[1].id).not.toBe(todos[0].id) + }) + test("todos are ordered by position", async () => { await writeProject(storageDir, { id: "proj_test123abc", @@ -536,6 +572,7 @@ describe("JSON to SQLite migration", () => { expect(todos.length).toBe(3) expect(todos[0].content).toBe("Third") + expect(todos[0].id).toStartWith("todo_") expect(todos[0].position).toBe(0) expect(todos[1].content).toBe("First") expect(todos[1].position).toBe(1) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 535a65f43..1115baa5c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -349,6 +349,7 @@ export type EventQuestionRejected = { } export type Todo = { + id: string /** * Brief description of the task */