From 42d0d523550b1317087535546f4858de2b6c15b6 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 10 Nov 2026 11:48:44 -0600 Subject: [PATCH] feat(opencode): project native todo updates into checklists - Emit stable todo plans and checklist items after successful updates - Recover todo state from tool results received during reconnect --- .../Adapters/OpenCode2AdapterV2.test.ts | 168 ++++++++++++++++++ .../src/server/v2/adapter.ts | 92 +++++++++- 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 8897ffb98df5e..aae63afa9962e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2891,6 +2891,174 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("projects successful native todo updates into one checklist", () => + Effect.gen(function* () { + const updates = [ + [ + { content: " Inspect code ", status: "completed" }, + { content: "Implement", status: "in_progress" }, + { content: "Verify", status: "pending" }, + { content: "Abandoned", status: "cancelled" }, + ], + [{ content: "Inspect code", status: "completed" }], + [{ content: "Abandoned", status: "cancelled" }], + [], + [{ content: "Follow up", status: "pending" }], + ]; + const tool = (id: string) => ({ + sessionID: SESSION, + assistantMessageID: "msg_assistant", + id, + }); + const call = (id: string, todos: unknown): ReadonlyArray => [ + event("session.tool.input.started", { ...tool(id), name: "todowrite" }), + event("session.tool.called", { ...tool(id), input: { todos }, executed: false }), + ]; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ...call("call-denied", [{ content: "Not saved", status: "pending" }]), + event( + "session.tool.failed", + { + ...tool("call-denied"), + error: { type: "unknown", message: "Unable to update todos" }, + executed: false, + }, + undefined, + 2, + ), + ...call("call-malformed", [{ content: "Not saved", status: "pending" }]), + event( + "session.tool.success", + { + ...tool("call-malformed"), + content: [{ type: "text", text: "Unrecognized todo result" }], + metadata: { todos: [{ content: "Unknown", status: "new-status" }] }, + executed: false, + }, + undefined, + 2, + ), + ...updates.flatMap((todos, index) => [ + ...call(`call-todo-${index}`, todos), + event( + "session.tool.success", + { + ...tool(`call-todo-${index}`), + content: [{ type: "text", text: "Todos updated" }], + metadata: { todos }, + executed: false, + }, + undefined, + 2, + ), + ]), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const plans = collected.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + assert.deepEqual( + plans.map((plan) => plan.steps.map((step) => [step.text, step.status])), + [ + [ + ["Inspect code", "completed"], + ["Implement", "running"], + ["Verify", "pending"], + ], + [["Inspect code", "completed"]], + [], + [], + [["Follow up", "pending"]], + ], + ); + assert.equal(new Set(plans.map((plan) => plan.id)).size, 1); + assert.equal(plans[0]?.steps[0]?.id, plans[1]?.steps[0]?.id); + assert.deepEqual( + plans.map((plan) => plan.status), + ["active", "completed", "completed", "completed", "active"], + ); + const items = collected.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const checklists = items.filter((item) => item.type === "todo_list"); + assert.equal(new Set(checklists.map((item) => item.id)).size, 1); + assert.deepEqual( + checklists.map((item) => item.steps), + plans.map((plan) => plan.steps), + ); + assert.deepEqual( + checklists.map((item) => item.status), + ["completed", "completed", "completed", "completed", "completed"], + ); + assert.isFalse( + items.some( + (item) => + item.type === "dynamic_tool" && item.nativeItemRef?.nativeId?.startsWith("call-todo-"), + ), + ); + assert.deepInclude( + items.find((item) => item.nativeItemRef?.nativeId === "call-denied"), + { type: "dynamic_tool", status: "failed" }, + ); + assert.deepInclude( + items.find((item) => item.nativeItemRef?.nativeId === "call-malformed"), + { type: "dynamic_tool", status: "completed" }, + ); + }).pipe(Effect.scoped), + ); + + it.effect("recovers native todo updates missed during reconnect", () => + Effect.gen(function* () { + const todos = [{ content: "Saved during disconnect", status: "in_progress" }]; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-todo-gap", + name: "todowrite", + executed: false, + state: { + status: "completed", + input: { todos }, + content: [{ type: "text", text: "Todos updated" }], + metadata: { todos }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const plans = collected.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + assert.equal(plans.length, 1); + assert.deepEqual( + plans[0]?.steps.map((step) => [step.text, step.status]), + [["Saved during disconnect", "running"]], + ); + assert.include(turnItems(collected), "todo_list:completed"); + }).pipe(Effect.scoped), + ); + it.effect("stores a shell command that exits non-zero as failed", () => Effect.gen(function* () { const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-exit" }; diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 2cdae4df11723..bbd70cc108cdc 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -27,12 +27,14 @@ import { type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type OrchestrationV2PlanStep, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, type MessageId, type ModelSelection, + type PlanId, type ProviderApprovalDecision, type ProviderInstanceId, type RunId, @@ -101,6 +103,16 @@ const NativeGoal = Schema.NullOr( ); const decodeGoal = Schema.decodeUnknownEffect(NativeGoal); +const NativeTodoList = Schema.Struct({ + todos: Schema.Array( + Schema.Struct({ + content: Schema.String, + status: Schema.Literals(["pending", "in_progress", "completed", "cancelled"]), + }), + ), +}); +const isNativeTodoList = Schema.is(NativeTodoList); + const OpenCode2ProviderCapabilities = { sessions: { supportsMultipleProviderThreadsPerSession: true, @@ -153,8 +165,8 @@ const OpenCode2ProviderCapabilities = { approvalsCanOriginateFromSubagents: true, }, planning: { - emitsPlanUpdated: false, - emitsTodoList: false, + emitsPlanUpdated: true, + emitsTodoList: true, emitsProposedPlan: false, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, @@ -231,6 +243,7 @@ interface ActiveTurn { before: string | null | undefined; compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; compactions: number; + todoPlanId: PlanId | undefined; retry: | { readonly retry: OrchestrationV2ProviderRetry; @@ -1061,6 +1074,65 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); + // Todo updates share the provider event span. + const emitTodo = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + todos: typeof NativeTodoList.Type.todos, + ) { + turn.todoPlanId ??= yield* idAllocator.allocate.plan({ + threadId: turn.input.threadId, + ...(turn.input.runId === null ? {} : { runId: turn.input.runId }), + driver, + }); + const nativeId = `${state.sessionId}:todo:${turn.providerTurn.id}`; + const now = yield* DateTime.now; + const startedAt = turn.startedAt.get(nativeId) ?? now; + turn.startedAt.set(nativeId, startedAt); + const steps: Array = todos.flatMap((todo, index) => + todo.status === "cancelled" + ? [] + : [ + { + id: `${nativeId}:${index + 1}`, + text: todo.content.trim() || `Todo ${index + 1}`, + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "running" + : "pending", + }, + ], + ); + const base = itemBase(state, turn, nativeId, "completed", startedAt, now, now); + yield* emitNode(state, turn, nativeId, "todo_list", "completed", startedAt, now); + yield* emit({ + type: "plan.updated", + driver, + plan: { + id: turn.todoPlanId, + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: base.nodeId, + kind: "todo_list", + status: steps.every((step) => step.status === "completed") ? "completed" : "active", + steps, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + ...base, + type: "todo_list", + title: "Todo list", + planId: turn.todoPlanId, + steps, + }, + }); + }); + const emitTool = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -1074,6 +1146,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ) { const tool = turn.tools.get(id); if (tool === undefined) return; + if (tool.name === "todowrite") { + if (status === "running") return; + if (status === "completed" && isNativeTodoList(result?.metadata)) { + const projected = yield* emitTodo(state, turn, result.metadata.todos).pipe( + Effect.as(true), + Effect.catchTags({ + IdAllocatorV2AllocationError: (error) => + Effect.logWarning("Could not allocate an OpenCode todo list.", error).pipe( + Effect.as(false), + ), + }), + ); + if (projected) return; + } + } const updatedAt = yield* DateTime.now; const startedAt = turn.startedAt.get(id) ?? updatedAt; const completedAt = status === "running" ? null : updatedAt; @@ -1226,6 +1313,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr before: undefined, compaction: undefined, compactions: 0, + todoPlanId: undefined, retry: undefined, interrupted: false, unsent: false,