From 06d5c41aa807475d7c75a67d1fae2e7e8f99dcdb Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 24 Aug 2026 13:19:06 +0800 Subject: [PATCH 1/5] chore: record delivery binding for issue429 --- .specgit.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index bb65f8745..3796b1ea1 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: init-stamp-is +delivery: surface-todo-state context: - kind: branch - branch: fix/415-init-stamp-is + kind: worktree + label: opencode-dag-todoremind + branch: feat/429-surface-todo-state issues: - - 415 -pr: 416 + - 429 From fb3684eed436cd5d7b484738f68d029f924d99fd Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 24 Aug 2026 13:19:11 +0800 Subject: [PATCH 2/5] chore: record delivery binding for surface-todo-state --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 3796b1ea1..00a364725 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -6,3 +6,4 @@ context: branch: feat/429-surface-todo-state issues: - 429 +pr: 430 From 715afd83165742552146ca492558bae5e2c8cba0 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 24 Aug 2026 13:23:20 +0800 Subject: [PATCH 3/5] feat(session): surface todo state before non-todo tool calls Extends the issue-#389 todo reminder feature with the pre-tool-call seam it originally proposed and deliberately deferred. The per-step reminder covers tool-free steps; this adds mid-turn coverage so long multi-tool turns keep seeing the uncompleted list between calls. - TodoReminders.preToolCall: returns the rendered reminder before a non-todowrite tool call, at most once per assistant turn (turn-scoped marker keyed by assistant messageID), never for todowrite itself - tools.ts: both PreToolUse sites (native tools + MCP tools) prepend the reminder to additionalContexts ahead of any hook contexts Turn-scoped dedup answers #389's objection to this seam (N parallel calls -> N duplicate reminders); pure-reasoning steps stay covered by the existing per-step injection, so the two seams complement instead of compete. Tests: first-call injects, same-turn repeat skips, new turn re-arms, todowrite never injects nor consumes the turn shot, settled list stays quiet. --- .../opencode/src/session/todo-reminders.ts | 37 ++++++++- packages/opencode/src/session/tools.ts | 24 +++++- .../test/session/todo-reminders.test.ts | 75 +++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/todo-reminders.ts b/packages/opencode/src/session/todo-reminders.ts index 926528813..85e4ac6e7 100644 --- a/packages/opencode/src/session/todo-reminders.ts +++ b/packages/opencode/src/session/todo-reminders.ts @@ -18,6 +18,13 @@ * - freshness guard: the session's last assistant message already contains * a successful todowrite call — the model just updated the list itself, * so this step's request does not nag about it + * + * Second surfacing point (#429): before a non-todowrite tool call executes, + * the current uncompleted list is returned once per assistant turn so long + * multi-tool turns re-see it mid-flight. Turn-scoped dedup keeps parallel + * tool calls from repeating the same reminder N times — the original reason + * #389 refined this seam away; pure-reasoning steps stay covered by the + * per-step injection above. */ import { Effect } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -27,6 +34,14 @@ import { Todo } from "./todo" const TODO_WRITE_TOOL = "todowrite" +// sessionID -> assistant messageID that already received the pre-tool-call +// reminder this turn. One entry per live session, overwritten each turn. +const remindedTurns = new Map() + +function uncompletedOf(todos: Todo.Info[]): Todo.Info[] { + return todos.filter((item) => item.status !== "completed" && item.status !== "cancelled") +} + function turnJustUpdatedTodos(messages: SessionV1.WithParts[]): boolean { const lastAssistant = messages.findLast((msg) => msg.info.role === "assistant") if (!lastAssistant) return false @@ -51,7 +66,7 @@ export const apply = Effect.fn("TodoReminders.apply")(function* (input: { }) { const todo = yield* Todo.Service const todos = yield* todo.get(input.sessionID) - const uncompleted = todos.filter((item) => item.status !== "completed" && item.status !== "cancelled") + const uncompleted = uncompletedOf(todos) if (uncompleted.length === 0) return input.messages const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages @@ -67,4 +82,24 @@ export const apply = Effect.fn("TodoReminders.apply")(function* (input: { return input.messages }) +/** + * Reminder string for the pre-tool-call seam, or undefined when the call must + * stay clean: todowrite itself, a turn already surfaced this turn, or nothing + * uncompleted. Marks the turn only when a string is actually returned. + */ +export const preToolCall = Effect.fn("TodoReminders.preToolCall")(function* (input: { + sessionID: SessionID + messageID: string + tool: string +}) { + if (input.tool === TODO_WRITE_TOOL) return undefined + if (remindedTurns.get(input.sessionID) === input.messageID) return undefined + const todo = yield* Todo.Service + const todos = yield* todo.get(input.sessionID) + const uncompleted = uncompletedOf(todos) + if (uncompleted.length === 0) return undefined + remindedTurns.set(input.sessionID, input.messageID) + return renderReminder(uncompleted) +}) + export * as TodoReminders from "./todo-reminders" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index c4f237060..c0aa7494d 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -22,6 +22,7 @@ import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionProcessor } from "./processor" import { PartID } from "./schema" +import { TodoReminders } from "./todo-reminders" import { EffectBridge } from "@/effect/bridge" import { SessionContext } from "@/effect/session-context" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -128,6 +129,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ) // SettingsHook PreToolUse let preContexts: string[] = [] + // Native todo surfacing (#429): once per assistant turn, before + // any non-todowrite tool result, re-show the uncompleted list. + const todoReminder = yield* TodoReminders.preToolCall({ + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + tool: item.id, + }) if (settingsHook) { const preResult = yield* settingsHook .trigger( @@ -187,8 +195,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } // PreToolUse additionalContexts: prepend so the model sees any hook-injected // gate/reminder before the tool result (mirrors PostToolUse surfacing below). - if (preContexts.length) { - output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` + const preLines = [todoReminder, ...preContexts].filter((line): line is string => Boolean(line)) + if (preLines.length) { + output.output = `${preLines.join("\n\n")}\n\n${output.output ?? ""}` } yield* plugin.trigger( "tool.execute.after", @@ -522,6 +531,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ) // SettingsHook PreToolUse let preContexts: string[] = [] + // Native todo surfacing (#429): same per-turn contract as native tools. + const mcpTodoReminder = yield* TodoReminders.preToolCall({ + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + tool: key, + }) if (settingsHook) { const preResult = yield* settingsHook .trigger( @@ -643,8 +658,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } // PreToolUse additionalContexts: prepend so the model sees any hook-injected // gate/reminder before the tool result (mirrors PostToolUse surfacing below). - if (preContexts.length) { - output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` + const preLines = [mcpTodoReminder, ...preContexts].filter((line): line is string => Boolean(line)) + if (preLines.length) { + output.output = `${preLines.join("\n\n")}\n\n${output.output ?? ""}` } // SettingsHook PostToolUse if (settingsHook) { diff --git a/packages/opencode/test/session/todo-reminders.test.ts b/packages/opencode/test/session/todo-reminders.test.ts index 0d5c8fb9e..db4a86ec7 100644 --- a/packages/opencode/test/session/todo-reminders.test.ts +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -293,3 +293,78 @@ describe("TodoReminders run-loop guarantees (issue #389 review)", () => { }), ) }) + +describe("TodoReminders.preToolCall (#429)", () => { + const sessionID = SessionID.make("ses_1") + const todos: Todo.Info[] = [ + { content: "ship feature", status: "in_progress", priority: "high" }, + { content: "done already", status: "completed", priority: "low" }, + ] + + runtime.effect("returns the reminder before a non-todowrite tool call", () => + Effect.gen(function* () { + const reminder = yield* TodoReminders.preToolCall({ + sessionID, + messageID: "msg_pre_1", + tool: "bash", + }).pipe(Effect.provide(makeTodoLayer(todos))) + expect(reminder).toContain("[todo reminder]") + expect(reminder).toContain("ship feature") + expect(reminder).toContain("todowrite") + }), + ) + + runtime.effect("injects at most once per assistant turn", () => + Effect.gen(function* () { + const layer = makeTodoLayer(todos) + const first = yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_2", tool: "bash" }).pipe( + Effect.provide(layer), + ) + const second = yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_2", tool: "read" }).pipe( + Effect.provide(layer), + ) + expect(first).toBeDefined() + expect(second).toBeUndefined() + }), + ) + + runtime.effect("re-arms on a new assistant turn", () => + Effect.gen(function* () { + const layer = makeTodoLayer(todos) + yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_3", tool: "bash" }).pipe( + Effect.provide(layer), + ) + const next = yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_4", tool: "read" }).pipe( + Effect.provide(layer), + ) + expect(next).toBeDefined() + }), + ) + + runtime.effect("never surfaces for todowrite and leaves the turn unmarked", () => + Effect.gen(function* () { + const layer = makeTodoLayer(todos) + const write = yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_5", tool: "todowrite" }).pipe( + Effect.provide(layer), + ) + expect(write).toBeUndefined() + // todowrite must not consume the turn's single shot either. + const other = yield* TodoReminders.preToolCall({ sessionID, messageID: "msg_pre_5", tool: "grep" }).pipe( + Effect.provide(layer), + ) + expect(other).toBeDefined() + }), + ) + + runtime.effect("returns undefined when nothing is uncompleted", () => + Effect.gen(function* () { + const settled: Todo.Info[] = [{ content: "a", status: "completed", priority: "low" }] + const none = yield* TodoReminders.preToolCall({ + sessionID, + messageID: "msg_pre_6", + tool: "bash", + }).pipe(Effect.provide(makeTodoLayer(settled))) + expect(none).toBeUndefined() + }), + ) +}) From 2dcc78903f6a47230c624957f22d035a01cb7bc2 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 24 Aug 2026 13:51:48 +0800 Subject: [PATCH 4/5] chore: record branch-context delivery binding --- .specgit.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 00a364725..d5493fc86 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 delivery: surface-todo-state context: - kind: worktree - label: opencode-dag-todoremind + kind: branch branch: feat/429-surface-todo-state issues: - 429 From 44a2191a4cf364b7fbab6cc5f43fbf0100ab1e5b Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 24 Aug 2026 16:53:24 +0800 Subject: [PATCH 5/5] fix(session): degrade todo reminder when Todo service is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snapshot-tool-race failed deterministically with the pre-tool-call reminder: minimal server fixtures do not provide Todo.Service, so the unconditional yield* died and took the whole tool execution down with it — the bash command never ran. The reminder is decoration on top of execution; it now degrades to no-reminder on any non-interrupt failure while interrupts still propagate. Found by the existing snapshot race gate; no new test seam needed. --- packages/opencode/src/session/todo-reminders.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/todo-reminders.ts b/packages/opencode/src/session/todo-reminders.ts index 85e4ac6e7..48f85a325 100644 --- a/packages/opencode/src/session/todo-reminders.ts +++ b/packages/opencode/src/session/todo-reminders.ts @@ -26,7 +26,7 @@ * #389 refined this seam away; pure-reasoning steps stay covered by the * per-step injection above. */ -import { Effect } from "effect" +import { Cause, Effect } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { SessionID } from "./schema" import { PartID } from "./schema" @@ -94,9 +94,17 @@ export const preToolCall = Effect.fn("TodoReminders.preToolCall")(function* (inp }) { if (input.tool === TODO_WRITE_TOOL) return undefined if (remindedTurns.get(input.sessionID) === input.messageID) return undefined - const todo = yield* Todo.Service - const todos = yield* todo.get(input.sessionID) - const uncompleted = uncompletedOf(todos) + // The reminder is decoration on top of tool execution: a missing or failing + // Todo service must degrade to "no reminder", never kill the tool call. + const uncompleted = yield* Effect.gen(function* () { + const todo = yield* Todo.Service + const todos = yield* todo.get(input.sessionID) + return uncompletedOf(todos) + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed([] as Todo.Info[]), + ), + ) if (uncompleted.length === 0) return undefined remindedTurns.set(input.sessionID, input.messageID) return renderReminder(uncompleted)