diff --git a/README.md b/README.md index d937990..b71e6f5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ OpenCode V2 removed the built-in todo tools. This plugin brings them back as a s - **`todowrite`** — create or replace the todo list for the current session. - **`todoread`** — read the current list back. +- **`/todo`** — post the current list into the conversation. Lists are stored per session and a compact summary is injected into the session context while tasks are still open — at every user turn and whenever the list changes mid-run — so the model keeps track of them across long conversations and context compaction. A constant reminder line asks the model to update statuses as steps finish (see `Update reminders` below). @@ -50,7 +51,36 @@ Example output: 4. [-] Update changelog ``` -`todowrite` always receives the complete list and replaces the previous one. Passing `{"todos": []}` clears it. +`todowrite` always receives the complete list and replaces the previous one. Passing `{"todos": []}` clears it. Lists accept up to 100 items of 500 characters each. + +Run `/todo` to post the current list into the conversation. The command reads the newest `todowrite` from the session log (falling back to this plugin's own storage), so a list written by another tool shows up too, and sends the report — counts, current task, full list — as your next message, which the assistant sees and answers. + +## Auto todos + +A project can seed every session with a checklist: `auto-todos.json` at the repository root or under `.opencode/`: + +```json +{ + "todos": [ + { "content": "Run the test suite", "priority": "high", "always": true }, + { "content": "Update the changelog" } + ], + "deduplicate": true +} +``` + +Each entry is merged into the list on every `todowrite`: + +| Field | Effect | +| --- | --- | +| `content` | Required task text; entries over 500 characters are dropped | +| `priority` | Optional: `high`, `medium`, `low` | +| `always` | `true` adds the item to every list; `false` (default) adds it only when the incoming list is empty | +| `match` | Only inject when an existing item contains this text (case-insensitive) | +| `order` | `"first"` or a 0-based index; omitted appends | +| `deduplicate` | Default `true`: skip items whose content contains, or is contained by, an existing item | + +Injection is best-effort: a missing, unreadable or invalid file leaves the model's list untouched, and the merged list never exceeds the `todowrite` limits (100 items, 500 characters per entry). ## Sidebar diff --git a/src/auto-todos.ts b/src/auto-todos.ts new file mode 100644 index 0000000..a7ddcdb --- /dev/null +++ b/src/auto-todos.ts @@ -0,0 +1,162 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { Plugin } from "@opencode/plugin" +import { MAX_TODO_CONTENT_LENGTH, MAX_TODOS, TODO_PRIORITIES, type Todo, type TodoPriority } from "./todos.js" + +/** + * Config-driven todo injection (opencode-auto-todos parity, serkanalgur). + * + * A project opts in with `auto-todos.json` at its root or under + * `.opencode/`: + * + * ```json + * { + * "todos": [{ "content": "Run the test suite", "priority": "high", "order": "first" }], + * "deduplicate": true + * } + * ``` + * + * Entry fields: `content` (required), `priority` (optional), `match` + * (optional: only inject when an existing item contains this string, + * case-insensitive), `order` (`"first"` or a 0-based index, default append), + * `always` (default false: inject only when the incoming list is empty). + * + * Entries obey the same limits as `todowrite`: content over + * `MAX_TODO_CONTENT_LENGTH` is dropped and the merged list never grows past + * `MAX_TODOS`. + * + * Best-effort throughout: no config file, an unreadable file, or a lookup + * failure means the model's list passes through untouched. + */ + +export type AutoTodoEntry = { + content: string + priority?: TodoPriority + match?: string + order?: "first" | number + always?: boolean +} + +export type AutoTodosConfig = { + todos: Array + deduplicate?: boolean +} + +/** Returns a clean config or undefined when the value is unusable. */ +export function normalizeAutoTodosConfig(value: unknown): AutoTodosConfig | undefined { + if (value === null || typeof value !== "object") return undefined + const raw = value as { todos?: unknown; deduplicate?: unknown } + if (!Array.isArray(raw.todos)) return undefined + const todos: Array = [] + for (const entry of raw.todos) { + if (entry === null || typeof entry !== "object") continue + const item = entry as { content?: unknown; priority?: unknown; match?: unknown; order?: unknown; always?: unknown } + if (typeof item.content !== "string" || !item.content.trim()) continue + const content = item.content.trim() + if (content.length > MAX_TODO_CONTENT_LENGTH) continue + const out: AutoTodoEntry = { content } + if (TODO_PRIORITIES.includes(item.priority as TodoPriority)) { + out.priority = item.priority as TodoPriority + } + if (typeof item.match === "string" && item.match.trim()) { + out.match = item.match.trim() + } + if (item.order === "first" || (typeof item.order === "number" && Number.isInteger(item.order) && item.order >= 0)) { + out.order = item.order + } + if (typeof item.always === "boolean") { + out.always = item.always + } + todos.push(out) + } + if (todos.length === 0) return undefined + const config: AutoTodosConfig = { todos } + if (typeof raw.deduplicate === "boolean") { + config.deduplicate = raw.deduplicate + } + return config +} + +/** Project root first, then `.opencode/`. Never throws. */ +export async function readAutoTodosConfig(directory: string): Promise { + for (const file of [join(directory, "auto-todos.json"), join(directory, ".opencode", "auto-todos.json")]) { + try { + const parsed: unknown = JSON.parse(await readFile(file, "utf8")) + const config = normalizeAutoTodosConfig(parsed) + if (config) return config + } catch { + // Missing, unreadable, or invalid — try the next candidate. + } + } + return undefined +} + +function contains(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle.toLowerCase()) +} + +/** + * Merges config entries into a validated list. Pure: never mutates inputs. + * Returns the merged list plus how many entries were injected. + */ +export function applyAutoTodos( + todos: Array, + config: AutoTodosConfig | undefined, +): { todos: Array; injected: number } { + if (!config || config.todos.length === 0) return { todos, injected: 0 } + const deduplicate = config.deduplicate ?? true + const merged = todos.map((t) => ({ ...t })) + const incomingEmpty = merged.length === 0 + const firsts: Array = [] + let injected = 0 + for (const entry of config.todos) { + if (!entry.always && !incomingEmpty) continue + if (firsts.length + merged.length >= MAX_TODOS) break + const seen = [...firsts, ...merged] + if (entry.match && !seen.some((t) => contains(t.content, entry.match as string))) continue + if (deduplicate && seen.some((t) => contains(t.content, entry.content) || contains(entry.content, t.content))) { + continue + } + const todo: Todo = { content: entry.content, status: "pending" } + if (entry.priority) todo.priority = entry.priority + if (entry.order === "first") { + firsts.push(todo) + } else if (typeof entry.order === "number") { + merged.splice(Math.min(entry.order, merged.length), 0, todo) + } else { + merged.push(todo) + } + injected++ + } + return { todos: [...firsts, ...merged], injected } +} + +/** The session's working directory via the typed client. Best-effort. */ +export async function resolveSessionDirectory( + ctx: Plugin.Context, + sessionID: string, +): Promise { + const info = await ctx.session.get({ sessionID }) + const directory = info?.location?.directory + return typeof directory === "string" && directory ? directory : undefined +} + +/** + * Full injection pass for the todowrite path. Returns the list to store: + * the input unchanged when there is nothing to inject or anything fails. + */ +export async function withAutoTodos( + ctx: Plugin.Context, + sessionID: string, + todos: Array, +): Promise> { + try { + const directory = await resolveSessionDirectory(ctx, sessionID) + if (!directory) return todos + const config = await readAutoTodosConfig(directory) + if (!config) return todos + return applyAutoTodos(todos, config).todos + } catch { + return todos + } +} diff --git a/src/index.ts b/src/index.ts index f46da14..c037276 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { } from "./todos.js" import { applyTodoWrite, findItem, itemTimeLabel, timingSummary } from "./timing.js" import { TODO_SETTINGS_KEY, TodolistRpc, normalizeTodoSettings } from "./rpc.js" +import { registerTodoCommand } from "./todo-command.js" +import { withAutoTodos } from "./auto-todos.js" export * from "./todos.js" @@ -83,7 +85,7 @@ const plugin: Plugin.Plugin = { input: TODOS_INPUT_SCHEMA, options: { codemode: false }, execute: async (input, context) => { - const todos = normalizeTodos(input) + const todos = await withAutoTodos(ctx, context.sessionID, normalizeTodos(input)) const at = Date.now() const key = storageKey(context.sessionID) const previous = parseTodoRecord(await ctx.storage.get(key)) @@ -112,6 +114,8 @@ const plugin: Plugin.Plugin = { }) }) + const todoCommand = await registerTodoCommand(ctx) + let settings = normalizeTodoSettings(await ctx.storage.get(TODO_SETTINGS_KEY)) const rpc = await ctx.rpc.register(TodolistRpc, { @@ -203,6 +207,7 @@ const plugin: Plugin.Plugin = { await compaction.dispose() await rpc.dispose() await tools.dispose() + await todoCommand.dispose() } }, } diff --git a/src/todo-command.ts b/src/todo-command.ts new file mode 100644 index 0000000..80327ed --- /dev/null +++ b/src/todo-command.ts @@ -0,0 +1,64 @@ +import type { Plugin } from "@opencode/plugin" +import { parseTodoRecord, renderTodos, storageKey, type Todo } from "./todos.js" +import { latestTodosFromMessages } from "./tui-data.js" + +const TODO_COMMAND_DESCRIPTION = + "Show the current session todo list: completed items marked, current task identified. Same list the todowrite/todoread tools manage." + +/** + * Newest completed `todowrite` in the session log wins: `todowrite` replaces the + * whole list, so the newest completed call IS the current list. The reader is + * reused from tui-data (the same one the TUI strip relies on), so the command + * and the strip can never disagree about what the latest list is. + */ +export async function todosFromMessageSources( + ctx: Plugin.Context, + sessionID: string, +): Promise | undefined> { + const todos = latestTodosFromMessages(await ctx.session.context({ sessionID })) + if (todos) return todos + // Same-plugin storage is the fallback for a log with no completed todowrite. + // Returns undefined - never [] - when neither source has a list. + const record = parseTodoRecord(await ctx.storage.get(storageKey(sessionID))) + return record?.todos +} + +/** On-demand report for /todo: counts, current task, full list. */ +export function renderTodoReport(todos: Array): string { + if (todos.length === 0) { + return "Todo [0/0]: the todo list is empty." + } + const done = todos.filter((t) => t.status === "completed").length + const open = todos.filter((t) => t.status === "pending" || t.status === "in_progress").length + const current = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending") + const currentLine = current + ? `Current task: ${current.content}` + : "Current task: none - every item is completed or cancelled." + return `Todo [${done}/${todos.length}] - ${open} open\n${currentLine}\n${renderTodos(todos)}` +} + +/** Registers the `/todo` command. Returns a `{ dispose }` handle for setup teardown. */ +export async function registerTodoCommand( + ctx: Plugin.Context, +): Promise<{ dispose: () => Promise }> { + const noop = async (): Promise => {} + if (!ctx.command || typeof ctx.command.transform !== "function") { + return { dispose: noop } + } + const command = await ctx.command.transform((editor) => { + editor.add({ + name: "todo", + description: TODO_COMMAND_DESCRIPTION, + execute: async ({ sessionID, prompt, delivery }) => { + const todos = await todosFromMessageSources(ctx, sessionID) + await ctx.session.prompt({ + ...prompt, + sessionID, + text: todos ? renderTodoReport(todos) : "No todo list found for this session.", + delivery, + }) + }, + }) + }) + return { dispose: () => command.dispose() } +} diff --git a/src/todos.ts b/src/todos.ts index d121072..6d46af4 100644 --- a/src/todos.ts +++ b/src/todos.ts @@ -20,6 +20,10 @@ export const TODO_PRIORITIES: ReadonlyArray = ["high", "medium", " export const STORAGE_PREFIX = "todos/" +/** Hard caps so a runaway or adversarial list can't blow up the request. */ +export const MAX_TODOS = 100 +export const MAX_TODO_CONTENT_LENGTH = 500 + export const STATUS_MARK: Record = { pending: "[ ]", in_progress: "[•]", @@ -43,6 +47,9 @@ export function normalizeTodos(input: unknown): Array { if (!Array.isArray(todos)) { throw new Error("`todos` must be an array") } + if (todos.length > MAX_TODOS) { + throw new Error(`at most ${MAX_TODOS} todos per list (got ${todos.length})`) + } return todos.map((entry, index) => { if (entry === null || typeof entry !== "object") { throw new Error(`todo #${index + 1} must be an object`) @@ -52,6 +59,9 @@ export function normalizeTodos(input: unknown): Array { if (!content) { throw new Error(`todo #${index + 1} requires a non-empty \`content\` string`) } + if (content.length > MAX_TODO_CONTENT_LENGTH) { + throw new Error(`todo #${index + 1} \`content\` is over ${MAX_TODO_CONTENT_LENGTH} characters`) + } if (!TODO_STATUSES.includes(item.status as TodoStatus)) { throw new Error(`todo #${index + 1} has invalid \`status\` (expected one of: ${TODO_STATUSES.join(", ")})`) } diff --git a/tests/auto-todos.test.ts b/tests/auto-todos.test.ts new file mode 100644 index 0000000..d64b486 --- /dev/null +++ b/tests/auto-todos.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { Plugin } from "@opencode/plugin" +import plugin from "../src/index" +import { + applyAutoTodos, + normalizeAutoTodosConfig, + readAutoTodosConfig, + type AutoTodosConfig, +} from "../src/auto-todos" +import { MAX_TODO_CONTENT_LENGTH, MAX_TODOS, type Todo } from "../src/todos" + +const T = (content: string, status: Todo["status"] = "pending"): Todo => ({ content, status }) + +test("normalizeAutoTodosConfig accepts a valid config", () => { + const config = normalizeAutoTodosConfig({ + todos: [{ content: "Run tests", priority: "high", order: "first", always: true }], + deduplicate: false, + }) + assert.deepEqual(config, { + todos: [{ content: "Run tests", priority: "high", order: "first", always: true }], + deduplicate: false, + }) +}) + +test("normalizeAutoTodosConfig drops bad entries, undefined when none valid", () => { + assert.equal(normalizeAutoTodosConfig({ todos: [{ content: " " }, null, 7] }), undefined) + assert.equal(normalizeAutoTodosConfig({}), undefined) + assert.equal(normalizeAutoTodosConfig(null), undefined) + const config = normalizeAutoTodosConfig({ todos: [{ content: "Keep", priority: "urgent" }] }) + assert.deepEqual(config?.todos, [{ content: "Keep" }]) +}) + +test("normalizeAutoTodosConfig drops entries over the content cap", () => { + const over = "x".repeat(MAX_TODO_CONTENT_LENGTH + 1) + assert.equal(normalizeAutoTodosConfig({ todos: [{ content: over }] }), undefined) + const atCap = normalizeAutoTodosConfig({ todos: [{ content: "x".repeat(MAX_TODO_CONTENT_LENGTH) }] }) + assert.equal(atCap?.todos.length, 1) +}) + +test("readAutoTodosConfig prefers project root over .opencode/", async () => { + const dir = mkdtempSync(join(tmpdir(), "auto-todos-")) + try { + assert.equal(await readAutoTodosConfig(dir), undefined) + mkdirSync(join(dir, ".opencode")) + writeFileSync(join(dir, ".opencode", "auto-todos.json"), JSON.stringify({ todos: [{ content: "Nested" }] })) + assert.equal((await readAutoTodosConfig(dir))?.todos[0].content, "Nested") + writeFileSync(join(dir, "auto-todos.json"), JSON.stringify({ todos: [{ content: "Root" }] })) + assert.equal((await readAutoTodosConfig(dir))?.todos[0].content, "Root") + writeFileSync(join(dir, "auto-todos.json"), "{ broken") + assert.equal((await readAutoTodosConfig(dir))?.todos[0].content, "Nested") + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("applyAutoTodos injects into an empty list by default", () => { + const config: AutoTodosConfig = { todos: [{ content: "Version bump" }] } + const { todos, injected } = applyAutoTodos([], config) + assert.equal(injected, 1) + assert.deepEqual(todos, [{ content: "Version bump", status: "pending" }]) +}) + +test("applyAutoTodos seeds every default entry when the incoming list is empty", () => { + const config: AutoTodosConfig = { todos: [{ content: "First" }, { content: "Second" }] } + const { todos, injected } = applyAutoTodos([], config) + assert.equal(injected, 2) + assert.deepEqual(todos.map((t) => t.content), ["First", "Second"]) +}) + +test("applyAutoTodos skips non-empty lists unless always", () => { + const config: AutoTodosConfig = { todos: [{ content: "Version bump" }] } + const { todos, injected } = applyAutoTodos([T("Work")], config) + assert.equal(injected, 0) + assert.deepEqual(todos, [T("Work")]) + const always: AutoTodosConfig = { todos: [{ content: "Version bump", always: true }] } + const second = applyAutoTodos([T("Work")], always) + assert.equal(second.injected, 1) + assert.equal(second.todos.length, 2) +}) + +test("applyAutoTodos deduplicates and honors match + order", () => { + const config: AutoTodosConfig = { + todos: [ + { content: "Run tests", order: "first", always: true }, + { content: "Run tests", always: true }, + { content: "Write docs", match: "missing", always: true }, + ], + } + const { todos, injected } = applyAutoTodos([T("Write code")], config) + assert.equal(injected, 1) + assert.equal(todos[0].content, "Run tests") +}) + +test("applyAutoTodos never grows the list past the cap", () => { + const full = Array.from({ length: MAX_TODOS }, (_, i) => T(`Item ${i}`)) + const one: AutoTodosConfig = { todos: [{ content: "Seed", always: true }] } + assert.equal(applyAutoTodos(full, one).injected, 0) + assert.equal(applyAutoTodos(full, one).todos.length, MAX_TODOS) + + const room = Array.from({ length: MAX_TODOS - 1 }, (_, i) => T(`Item ${i}`)) + const many: AutoTodosConfig = { + todos: [ + { content: "A", always: true }, + { content: "B", always: true }, + { content: "C", always: true }, + ], + } + const partial = applyAutoTodos(room, many) + assert.equal(partial.injected, 1) + assert.equal(partial.todos.length, MAX_TODOS) +}) + +type ExecTool = { execute: (input: unknown, ctx: unknown) => Promise<{ content?: string }> } + +function fakeSetupCtx(directory?: string) { + const tools = new Map() + const storage = new Map() + const context = { + tool: { + transform: async (callback: (editor: { add: (tool: unknown) => void }) => void) => { + callback({ add: (tool: unknown) => tools.set((tool as { name: string }).name, tool as ExecTool) }) + return { dispose: async () => {} } + }, + }, + command: { transform: async () => ({ dispose: async () => {} }) }, + session: { + hook: async () => ({ dispose: async () => {} }), + prompt: async () => ({}), + get: async () => (directory ? { location: { directory } } : undefined), + }, + storage: { + get: async (key: string) => storage.get(key), + set: async (key: string, value: unknown) => void storage.set(key, value), + remove: async (key: string) => void storage.delete(key), + }, + rpc: { + register: async () => ({ dispose: async () => {}, events: { emit: async () => {} } }), + }, + event: { subscribe: async function* () {} }, + } as unknown as Plugin.Context + return { context, tools, storage } +} + +test("todowrite merges auto-todos from the session directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "auto-todos-")) + try { + writeFileSync( + join(dir, "auto-todos.json"), + JSON.stringify({ todos: [{ content: "Version bump", always: true }] }), + ) + const { context, tools, storage } = fakeSetupCtx(dir) + await plugin.setup(context) + const written = await tools.get("todowrite")!.execute({ todos: [T("Work")] }, { sessionID: "ses_auto" }) + assert.match(written.content ?? "", /2 items/) + assert.match(written.content ?? "", /Version bump/) + const stored = storage.get("todos/ses_auto") as { todos: Todo[] } + assert.equal(stored.todos.length, 2) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("todowrite passes through untouched without a config file", async () => { + const { context, tools } = fakeSetupCtx() + await plugin.setup(context) + const written = await tools.get("todowrite")!.execute({ todos: [T("Work")] }, { sessionID: "ses_plain" }) + assert.match(written.content ?? "", /1 item/) +}) + +test("two identical writes with an always entry store the same list", async () => { + const dir = mkdtempSync(join(tmpdir(), "auto-todos-")) + try { + writeFileSync( + join(dir, "auto-todos.json"), + JSON.stringify({ todos: [{ content: "Version bump", always: true }] }), + ) + const { context, tools, storage } = fakeSetupCtx(dir) + await plugin.setup(context) + const stored = () => (storage.get("todos/ses_repeat") as { todos: Todo[] }).todos + await tools.get("todowrite")!.execute({ todos: [T("Work")] }, { sessionID: "ses_repeat" }) + const first = stored() + await tools.get("todowrite")!.execute({ todos: [T("Work")] }, { sessionID: "ses_repeat" }) + assert.deepEqual(stored(), first) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/tests/todo-command.test.ts b/tests/todo-command.test.ts new file mode 100644 index 0000000..d5597ea --- /dev/null +++ b/tests/todo-command.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { Plugin } from "@opencode/plugin" +import { registerTodoCommand, renderTodoReport, todosFromMessageSources } from "../src/todo-command" +import type { Todo } from "../src/todos" + +function message(parts: unknown[]) { + return { content: parts } +} + +function tool(name: string, status: string, input: unknown, at?: number) { + return { + type: "tool", + name, + state: { status, input }, + ...(at === undefined ? {} : { time: { created: at } }), + } +} + +function fakeTodoCtx(messages: unknown[], stored?: unknown) { + return { + session: { context: async () => messages, prompt: async () => ({}) }, + storage: { get: async () => stored }, + } as unknown as Plugin.Context +} + +test("todosFromMessageSources returns the newest completed todowrite list", async () => { + const messages = [ + message([tool("todowrite", "completed", { todos: [{ content: "New", status: "in_progress" }] }, 2_000)]), + message([tool("todowrite", "completed", { todos: [{ content: "Old", status: "pending" }] }, 1_000)]), + ] + const todos = await todosFromMessageSources(fakeTodoCtx(messages), "ses_1") + assert.equal(todos?.length, 1) + assert.equal(todos?.[0].content, "New") +}) + +test("todosFromMessageSources returns undefined, never [], when there is no list", async () => { + const messages = [message([tool("other", "completed", {})])] + assert.equal(await todosFromMessageSources(fakeTodoCtx(messages), "ses_1"), undefined) +}) + +test("todosFromMessageSources falls back to same-plugin storage", async () => { + const stored = { todos: [{ content: "Stored", status: "pending" }], updatedAt: 1 } + const todos = await todosFromMessageSources(fakeTodoCtx([], stored), "ses_1") + assert.equal(todos?.[0].content, "Stored") +}) + +test("renderTodoReport counts completed of total and names the current task", () => { + const todos: Array = [ + { content: "Done", status: "completed" }, + { content: "Doing", status: "in_progress" }, + { content: "Next", status: "pending" }, + ] + const report = renderTodoReport(todos) + assert.match(report, /^Todo \[1\/3\] - 2 open/) + assert.match(report, /Current task: Doing/) +}) + +test("renderTodoReport on an empty list says so", () => { + assert.equal(renderTodoReport([]), "Todo [0/0]: the todo list is empty.") +}) + +test("renderTodoReport says so when nothing is open", () => { + const todos: Array = [{ content: "Done", status: "completed" }] + assert.match(renderTodoReport(todos), /Current task: none/) +}) + +type Added = { + execute: (args: { sessionID: string; prompt: unknown; delivery: unknown }) => Promise +} + +function fakeCommandCtx(messages: unknown[]) { + const added: Array = [] + const prompted: Array<{ text?: unknown }> = [] + const ctx = { + command: { + transform: async (callback: (editor: { add: (cmd: Added) => void }) => void) => { + callback({ add: (cmd: Added) => { added.push(cmd) } }) + return { dispose: async () => {} } + }, + }, + session: { + context: async () => messages, + prompt: async (input: { text?: unknown }) => { prompted.push(input); return {} }, + }, + storage: { get: async () => undefined }, + } as unknown as Plugin.Context + return { ctx, added, prompted } +} + +test("registerTodoCommand wires /todo to a session prompt", async () => { + const { ctx, added, prompted } = fakeCommandCtx([]) + const registration = await registerTodoCommand(ctx) + assert.equal(added.length, 1) + await added[0].execute({ sessionID: "ses_1", prompt: {}, delivery: "queue" }) + assert.equal(prompted.length, 1) + assert.equal(prompted[0].text, "No todo list found for this session.") + await registration.dispose() +}) + +test("registerTodoCommand posts the report when the log has a list", async () => { + const messages = [message([tool("todowrite", "completed", { todos: [{ content: "Ship it", status: "in_progress" }] })])] + const { ctx, added, prompted } = fakeCommandCtx(messages) + const registration = await registerTodoCommand(ctx) + await added[0].execute({ sessionID: "ses_1", prompt: {}, delivery: "queue" }) + assert.match(String(prompted[0].text), /^Todo \[0\/1\] - 1 open/) + assert.match(String(prompted[0].text), /Current task: Ship it/) + await registration.dispose() +}) + +test("registerTodoCommand no-ops when command.transform is absent", async () => { + const registration = await registerTodoCommand({} as unknown as Plugin.Context) + await registration.dispose() +}) diff --git a/tests/todolist.test.ts b/tests/todolist.test.ts index 9ab29b5..ce79c55 100644 --- a/tests/todolist.test.ts +++ b/tests/todolist.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { hasOpenTodos, normalizeTodos, parseTodoRecord, renderTodos, storageKey } from "../src/index" +import { MAX_TODO_CONTENT_LENGTH, MAX_TODOS, hasOpenTodos, normalizeTodos, parseTodoRecord, renderTodos, storageKey } from "../src/index" test("storageKey scopes todos to the session", () => { assert.equal(storageKey("ses_abc"), "todos/ses_abc") @@ -28,6 +28,24 @@ test("normalizeTodos rejects malformed input", () => { assert.throws(() => normalizeTodos({ todos: [{ content: "x", status: "pending", priority: "urgent" }] }), /priority/) }) +test("normalizeTodos rejects lists over the cap", () => { + const atCap = Array.from({ length: MAX_TODOS }, (_, i) => ({ content: `Item ${i}`, status: "pending" })) + assert.equal(normalizeTodos({ todos: atCap }).length, MAX_TODOS) + assert.throws( + () => normalizeTodos({ todos: [...atCap, { content: "One more", status: "pending" }] }), + /at most 100/, + ) +}) + +test("normalizeTodos rejects content over the cap", () => { + const atCap = { content: "x".repeat(MAX_TODO_CONTENT_LENGTH), status: "pending" } + assert.equal(normalizeTodos({ todos: [atCap] }).length, 1) + assert.throws( + () => normalizeTodos({ todos: [{ content: "x".repeat(MAX_TODO_CONTENT_LENGTH + 1), status: "pending" }] }), + /over 500/, + ) +}) + test("renderTodos renders status marks and priorities", () => { const list = renderTodos([ { content: "Pending", status: "pending" },