From d6679054f6d51f3827b2b6e984941c1d0ea426b1 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Thu, 8 Oct 2026 12:00:09 -0500 Subject: [PATCH 01/10] feat: recognize proposed plans from OpenCode plan mode --- docs/user/providers-opencode.md | 7 +- .../src/server/ProposedPlanBlock.test.ts | 50 ++++ .../src/server/ProposedPlanBlock.ts | 51 ++++ .../src/server/adapter.test.ts | 239 +++++++++++++++++- .../provider-opencode/src/server/adapter.ts | 154 ++++++++++- 5 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 packages/provider-opencode/src/server/ProposedPlanBlock.test.ts create mode 100644 packages/provider-opencode/src/server/ProposedPlanBlock.ts diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9ecee2a0439c2..4c8642217f539 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -20,7 +20,12 @@ OpenCode 2 converts the shared OpenCode database to its own format the first tim runs. Don't run OpenCode 1.x and 2.x side by side on the same machine. Threads you started on 1.x continue on 2.x. -Plan mode uses OpenCode's `plan` agent. +## Plan mode + +Switch a thread to plan mode to use OpenCode's `plan` agent. T3 Code asks it to present +the final plan in a plan block, which shows up as a plan card in the thread. A plan +that was cut off by a failure or interruption is not offered for implementation. To +implement a plan, switch the thread out of plan mode. ## Local or external server diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts new file mode 100644 index 0000000000000..bf8db15360e68 --- /dev/null +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { splitProposedPlanBlock } from "./ProposedPlanBlock.ts"; + +describe("splitProposedPlanBlock", () => { + it("returns plain prose when there is no tag", () => { + expect(splitProposedPlanBlock("Hello there")).toEqual({ + prose: "Hello there", + plan: null, + planComplete: false, + }); + }); + + it("splits a complete block with prose before and after", () => { + expect( + splitProposedPlanBlock("Intro\n\n# Plan\n- a\n\nOutro"), + ).toEqual({ prose: "Intro\n\nOutro", plan: "# Plan\n- a", planComplete: true }); + }); + + it("treats an unclosed block as a streaming plan", () => { + expect(splitProposedPlanBlock("Intro\n\n# Pl")).toEqual({ + prose: "Intro", + plan: "# Pl", + planComplete: false, + }); + }); + + it("hides a trailing partial opening tag", () => { + expect(splitProposedPlanBlock("Intro\n { + const result = splitProposedPlanBlock("Use the tag like so"); + expect(result.plan).toBeNull(); + expect(result.prose).toBe("Use the tag like so"); + }); + + it("only closes on a standalone closing tag", () => { + const result = splitProposedPlanBlock( + "\nUse `` to end it.\n# Rest\n\nDone", + ); + expect(result.plan).toBe("Use `` to end it.\n# Rest"); + expect(result.prose).toBe("Done"); + }); + + it("keeps text without a plan block untouched", () => { + expect(splitProposedPlanBlock(" indented code\n").prose).toBe(" indented code\n"); + }); +}); diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.ts new file mode 100644 index 0000000000000..f9c45f6803810 --- /dev/null +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.ts @@ -0,0 +1,51 @@ +const OPEN_TAG = ""; + +export const PROPOSED_PLAN_BLOCK_INSTRUCTIONS = `When you present the final plan, wrap it in a \`\` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. + +Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. If the user asks for revisions after a prior \`\`, any new \`\` must be a complete replacement. + +Do not ask "should I proceed?" in the final output. The user can switch out of plan mode to implement, or stay in plan mode to keep refining.`; + +export interface ProposedPlanSplit { + readonly prose: string; + readonly plan: string | null; + readonly planComplete: boolean; +} + +/** + * Splits assistant text into prose and an optional `` block. + * Safe to call on partially streamed text: an incomplete block yields the plan + * so far, and a trailing partial opening tag is kept out of the prose. + */ +export function splitProposedPlanBlock(text: string): ProposedPlanSplit { + const openMatch = /(^|\n)[ \t]*[ \t]*(\r?\n|$)/.exec(text); + if (openMatch === null) { + let prose = text; + const lineStart = text.lastIndexOf("\n") + 1; + const lastLine = text.slice(lineStart).trimStart(); + if (lastLine.length > 0 && OPEN_TAG.startsWith(lastLine)) { + prose = text.slice(0, lineStart).trimEnd(); + } + return { prose, plan: null, planComplete: false }; + } + const before = text.slice(0, openMatch.index); + const bodyStart = openMatch.index + openMatch[0].length; + const rest = text.slice(bodyStart); + const closeMatch = /(^|\n)[ \t]*<\/proposed_plan>[ \t]*(\r?\n|$)/.exec(rest); + if (closeMatch === null) { + return { prose: before.trim(), plan: rest, planComplete: false }; + } + const closeIndex = closeMatch.index; + const after = rest.slice(closeIndex + closeMatch[0].length); + return { + prose: [before.trim(), after.trim()].filter(Boolean).join("\n\n"), + plan: rest.slice(0, closeIndex).trim(), + planComplete: true, + }; +} diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c2ec42bd16b5f..49cbada183791 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -148,6 +148,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct suffix: string, nativeSessionId: string, client: object, + interactionMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["interactionMode"] = "default", ) { const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); @@ -156,7 +157,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct model: "anthropic/claude-sonnet", options: [], }; - const policy = runtimePolicy("full-access", { cwd: "/workspace" }); + const policy = runtimePolicy("full-access", { cwd: "/workspace", interactionMode }); const adapter = yield* makeOpenCodeAdapterV2({ instanceId, settings: OPEN_CODE_TEST_SETTINGS, @@ -731,6 +732,242 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("recognizes proposed plan blocks and supersedes earlier ones", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-proposed-plan"; + const harness = yield* makeOpenCodeRuntimeHarness( + "proposed-plan", + nativeSessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }, + "plan", + ); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); + const part = (id: string, text: string, end?: number) => ({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id, + sessionID: nativeSessionId, + messageID: "plan-assistant", + type: "text", + text, + time: { start: 1, ...(end === undefined ? {} : { end }) }, + }, + }, + }); + const delta = (partID: string, value: string) => ({ + type: "message.part.delta", + properties: { + sessionID: nativeSessionId, + messageID: "plan-assistant", + partID, + field: "text", + delta: value, + }, + }); + yield* push(part("p1", "Looking good.")); + yield* push(delta("p1", "\n\n# Plan A")); + yield* push(delta("p1", "\n")); + yield* push(part("p2", "\n# Plan B\n", 3)); + yield* push({ + type: "message.updated", + properties: { + sessionID: nativeSessionId, + info: { + id: "plan-assistant", + sessionID: nativeSessionId, + role: "assistant", + time: { created: 1, completed: 4 }, + }, + }, + }); + yield* push({ type: "session.compacted", properties: { sessionID: nativeSessionId } }); + const events = Array.from(yield* Fiber.join(received)); + const items = events.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const assistant = items.filter((item) => item.type === "assistant_message"); + assert.isTrue(assistant.every((item) => !item.text.includes("proposed_plan"))); + assert.isTrue(assistant.every((item) => !item.text.includes(" item.type === "proposed_plan"); + assert.isTrue(planItems.some((item) => item.streaming)); + const planA = planItems.filter((item) => item.markdown === "# Plan A"); + assert.equal(planA.at(-1)?.streaming, false); + assert.equal(planItems.at(-1)?.markdown, "# Plan B"); + const plans = events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], + ); + const planAId = planA[0]!.planId; + assert.deepEqual( + plans + .filter((plan) => plan.id === planAId) + .map((plan) => plan.status) + .at(-1), + "superseded", + ); + assert.equal(plans.at(-1)?.status, "active"); + assert.equal(plans.at(-1)?.markdown, "# Plan B"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("does not offer an unfinished plan from a failed turn", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-failed-plan"; + const harness = yield* makeOpenCodeRuntimeHarness( + "failed-plan", + nativeSessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }, + "plan", + ); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "failed", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); + yield* push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "p1", + sessionID: nativeSessionId, + messageID: "plan-assistant", + type: "text", + text: "\n# Half a pl", + time: { start: 1 }, + }, + }, + }); + yield* push({ + type: "session.error", + properties: { + sessionID: nativeSessionId, + error: { name: "UnknownError", data: { message: "failed" } }, + }, + }); + const events = Array.from(yield* Fiber.join(received)); + const plans = events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], + ); + assert.equal(plans.at(-1)?.status, "superseded"); + const planItems = events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" + ? [event.turnItem] + : [], + ); + assert.equal(planItems.at(-1)?.streaming, false); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("leaves plan tags alone outside plan mode", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-plain-tags"; + const harness = yield* makeOpenCodeRuntimeHarness("plain-tags", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const text = "Example:\n\n# x\n"; + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "p1", + sessionID: nativeSessionId, + messageID: "assistant", + type: "text", + text, + time: { start: 1, end: 2 }, + }, + }, + }), + ); + const events = Array.from(yield* Fiber.join(received)); + assert.isFalse( + events.some((e) => e.type === "turn_item.updated" && e.turnItem.type === "proposed_plan"), + ); + const last = events.at(-1); + assert.equal( + last?.type === "turn_item.updated" && last.turnItem.type === "assistant_message" + ? last.turnItem.text + : null, + text, + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + // Event order from a live OpenCode 1.18.32 run of a `task` call with // background=true: the task part completes at launch, the root session // settles, and the child session stays busy until its own work ends. diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 327d4d8cef4f3..e07378b428bd8 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -18,6 +18,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, type OrchestrationV2PlanStep, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, @@ -63,6 +64,10 @@ import { summarizeNativeProtocolPayload, } from "@t3tools/provider-core/server/nativeProtocolLogging"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + PROPOSED_PLAN_BLOCK_INSTRUCTIONS, + splitProposedPlanBlock, +} from "./ProposedPlanBlock.ts"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; @@ -148,7 +153,7 @@ const OpenCodeProviderCapabilitiesV2 = { streamsAssistantText: true, streamsReasoning: true, streamsToolOutput: true, - streamsPlanText: false, + streamsPlanText: true, emitsMessageCompleted: true, }, tools: { @@ -171,7 +176,7 @@ const OpenCodeProviderCapabilitiesV2 = { planning: { emitsPlanUpdated: true, emitsTodoList: true, - emitsProposedPlan: false, + emitsProposedPlan: true, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, @@ -293,6 +298,8 @@ interface ActiveOpenCodeTurn { interrupted: boolean; finalized: boolean; planId: PlanId | null; + readonly planMode: boolean; + readonly proposedPlanIdsByPart: Map; admissionGeneration: number; admissionReconciliationGeneration: number | null; admissionPending: boolean; @@ -391,6 +398,7 @@ interface OpenCodeThreadState { parentSubagent: OpenCodeSubagentContext | null; nextChildTurnOrdinal: number; nextAdmissionGeneration: number; + latestProposedPlan: OrchestrationV2PlanArtifact | null; } interface OpenCodeRequestOwner { @@ -1195,16 +1203,122 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); }; + const emitProposedPlan = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + input: { + readonly part: { readonly id: string }; + readonly markdown: string; + readonly completed: boolean; + readonly abandoned?: boolean; + readonly startedAt: DateTime.Utc; + readonly emittedAt: DateTime.Utc; + }, + ) { + const nativeItemId = `${input.part.id}:proposed_plan`; + let planId = turn.proposedPlanIdsByPart.get(nativeItemId); + if (planId === undefined) { + planId = yield* idAllocator.allocate.plan({ + threadId: turn.threadId, + ...(turn.runId === null ? {} : { runId: turn.runId }), + driver: OPENCODE_PROVIDER, + }); + turn.proposedPlanIdsByPart.set(nativeItemId, planId); + } + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: OPENCODE_PROVIDER, + nativeItemId, + }); + const status = input.completed || input.abandoned ? "completed" : "running"; + const completedAt = status === "completed" ? input.emittedAt : null; + const nativeItemRef = providerRef(nativeItemId, "weak"); + const plan: OrchestrationV2PlanArtifact = { + id: planId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + kind: "proposed_plan", + status: input.abandoned ? "superseded" : "active", + markdown: input.markdown, + }; + const previous = state.latestProposedPlan; + if ( + !input.abandoned && + previous !== null && + previous.id !== plan.id && + previous.status !== "completed" + ) { + yield* emitProviderEvent({ + type: "plan.updated", + driver: OPENCODE_PROVIDER, + plan: { ...previous, status: "superseded" }, + }); + } + if (!input.abandoned) state.latestProposedPlan = plan; + yield* emitProviderEvent({ + type: "node.updated", + driver: OPENCODE_PROVIDER, + node: { + id: nodeId, + threadId: turn.threadId, + runId: turn.runId, + parentNodeId: turn.rootNodeId, + rootNodeId: turn.rootNodeId, + kind: "plan", + status, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: input.startedAt, + completedAt, + }, + }); + yield* emitProviderEvent({ type: "plan.updated", driver: OPENCODE_PROVIDER, plan }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: OPENCODE_PROVIDER, + turnItem: { + id: turnItemId, + threadId: turn.threadId, + runId: turn.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + status, + title: null, + startedAt: input.startedAt, + completedAt, + updatedAt: input.emittedAt, + type: "proposed_plan", + planId, + markdown: input.markdown, + streaming: status !== "completed", + }, + }); + }); + const emitTextPart = Effect.fnUntraced(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, part: Extract, - forceCompleted = false, + terminal: TerminalTurnStatus | null = null, ) { if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; if (part.text.length === 0) return; + const split = + part.type === "text" && turn.planMode ? splitProposedPlanBlock(part.text) : null; const emittedAt = yield* DateTime.now; - const isCompleted = forceCompleted || part.time?.end !== undefined; + const isCompleted = terminal !== null || part.time?.end !== undefined; const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; const nativeItemRef = providerRef(part.id); @@ -1216,6 +1330,17 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function driver: OPENCODE_PROVIDER, nativeItemId: part.id, }); + if (split !== null && split.plan !== null) { + yield* emitProposedPlan(state, turn, { + part, + markdown: split.plan, + completed: split.planComplete || terminal === "completed", + abandoned: !split.planComplete && terminal !== null && terminal !== "completed", + startedAt, + emittedAt, + }); + } + if (split !== null && split.prose.length === 0) return; const ordinal = itemOrdinal(turn, part.id); yield* emitProviderEvent({ type: "node.updated", @@ -1251,7 +1376,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function runId: turn.runId, nodeId, role: "assistant", - text: part.text, + text: split?.prose ?? part.text, attachments: [], streaming: !isCompleted, createdAt: startedAt, @@ -1283,7 +1408,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function updatedAt: emittedAt, type: "assistant_message", messageId, - text: part.text, + text: split?.prose ?? part.text, streaming: !isCompleted, }, }); @@ -1435,6 +1560,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function parentSubagent: context, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2060,7 +2186,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const completedAt = yield* DateTime.now; for (const part of turn.parts.values()) { if (part.type === "text" || part.type === "reasoning") { - yield* emitTextPart(state, turn, part, true); + yield* emitTextPart(state, turn, part, status); } } for (const pending of Array.from(pendingRequests.values())) { @@ -2272,6 +2398,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function interrupted: false, finalized: false, planId: null, + planMode: false, + proposedPlanIdsByPart: new Map(), admissionGeneration: 0, admissionReconciliationGeneration: null, admissionPending: false, @@ -2545,7 +2673,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { const part = turn.parts.get(partId); if (part?.type === "text" || part?.type === "reasoning") { - yield* emitTextPart(state, turn, part, true); + yield* emitTextPart(state, turn, part, "completed"); } } }); @@ -2841,6 +2969,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }; threads.set(nativeSession.id, state); return state; @@ -3182,6 +3311,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, ); } + const agent = + getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? + (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const turn: ActiveOpenCodeTurn = { isRoot: true, threadId: turnInput.threadId, @@ -3208,6 +3340,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function interrupted: false, finalized: false, planId: null, + planMode: agent === "plan", + proposedPlanIdsByPart: new Map(), admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, admissionPending: !isCompaction, @@ -3264,12 +3398,10 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function harness: "OpenCode", model: turnInput.modelSelection.model, }), + agent === "plan" ? PROPOSED_PLAN_BLOCK_INSTRUCTIONS : "", ] .filter(Boolean) .join("\n\n"); - const agent = - getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? - (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const variant = getModelSelectionStringOptionValue( turnInput.modelSelection, "variant", From 5b689d917f3c080c45f7bd7c371dbcbc9ca81a3b Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Thu, 8 Oct 2026 12:12:09 -0500 Subject: [PATCH 02/10] fix: hide a CRLF plan opening tag while streaming --- docs/user/providers-opencode.md | 4 ++-- .../provider-opencode/src/server/ProposedPlanBlock.test.ts | 1 + packages/provider-opencode/src/server/ProposedPlanBlock.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 4c8642217f539..bd3c80406680c 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -22,8 +22,8 @@ started on 1.x continue on 2.x. ## Plan mode -Switch a thread to plan mode to use OpenCode's `plan` agent. T3 Code asks it to present -the final plan in a plan block, which shows up as a plan card in the thread. A plan +Switch a thread to plan mode to use OpenCode's `plan` agent. Its final plan appears as +a plan card in the thread. A plan that was cut off by a failure or interruption is not offered for implementation. To implement a plan, switch the thread out of plan mode. diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts index bf8db15360e68..30deb7614fc11 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts @@ -28,6 +28,7 @@ describe("splitProposedPlanBlock", () => { it("hides a trailing partial opening tag", () => { expect(splitProposedPlanBlock("Intro\n\r").prose).toBe("Intro"); }); it("ignores a tag that is not on its own line", () => { diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.ts index f9c45f6803810..801ca8379a3c2 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.ts @@ -28,7 +28,7 @@ export function splitProposedPlanBlock(text: string): ProposedPlanSplit { if (openMatch === null) { let prose = text; const lineStart = text.lastIndexOf("\n") + 1; - const lastLine = text.slice(lineStart).trimStart(); + const lastLine = text.slice(lineStart).trim(); if (lastLine.length > 0 && OPEN_TAG.startsWith(lastLine)) { prose = text.slice(0, lineStart).trimEnd(); } From 3136e78f058e9aebf043fda8de0bc3a1e01362ee Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Thu, 8 Oct 2026 12:29:04 -0500 Subject: [PATCH 03/10] fix: ignore empty proposed plan blocks --- .../src/server/ProposedPlanBlock.test.ts | 14 ++++++++++++++ .../src/server/ProposedPlanBlock.ts | 7 ++++--- .../provider-opencode/src/server/adapter.test.ts | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts index 30deb7614fc11..d2e0704b82003 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts @@ -48,4 +48,18 @@ describe("splitProposedPlanBlock", () => { it("keeps text without a plan block untouched", () => { expect(splitProposedPlanBlock(" indented code\n").prose).toBe(" indented code\n"); }); + + it("yields no plan for an empty block", () => { + expect(splitProposedPlanBlock("Intro\n")).toEqual({ + prose: "Intro", + plan: null, + planComplete: false, + }); + expect(splitProposedPlanBlock("\n\n").plan).toBeNull(); + expect(splitProposedPlanBlock("\n\nDone")).toEqual({ + prose: "Done", + plan: null, + planComplete: false, + }); + }); }); diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.ts index 801ca8379a3c2..f4042c74da3f7 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.ts @@ -39,13 +39,14 @@ export function splitProposedPlanBlock(text: string): ProposedPlanSplit { const rest = text.slice(bodyStart); const closeMatch = /(^|\n)[ \t]*<\/proposed_plan>[ \t]*(\r?\n|$)/.exec(rest); if (closeMatch === null) { - return { prose: before.trim(), plan: rest, planComplete: false }; + return { prose: before.trim(), plan: rest.trim() === "" ? null : rest, planComplete: false }; } const closeIndex = closeMatch.index; const after = rest.slice(closeIndex + closeMatch[0].length); + const plan = rest.slice(0, closeIndex).trim(); return { prose: [before.trim(), after.trim()].filter(Boolean).join("\n\n"), - plan: rest.slice(0, closeIndex).trim(), - planComplete: true, + plan: plan === "" ? null : plan, + planComplete: plan !== "", }; } diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 49cbada183791..338c36dfa8709 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -792,7 +792,7 @@ describe("OpenCodeAdapterV2", () => { yield* push(delta("p1", "\n\n# Plan A")); yield* push(delta("p1", "\n")); - yield* push(part("p2", "\n# Plan B\n", 3)); + yield* push(part("p2", "\n# Plan B", 3)); yield* push({ type: "message.updated", properties: { From 8ed41192e0a2b6cd3a5325e4a136f16640bde2d7 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Fri, 9 Oct 2026 19:41:55 -0500 Subject: [PATCH 04/10] fix: hide a partial plan closing tag while streaming --- .../src/server/ProposedPlanBlock.test.ts | 9 +++++++++ .../provider-opencode/src/server/ProposedPlanBlock.ts | 9 ++++++++- packages/provider-opencode/src/server/adapter.ts | 5 +---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts index d2e0704b82003..7bc3490bc6e5f 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts @@ -17,6 +17,15 @@ describe("splitProposedPlanBlock", () => { ).toEqual({ prose: "Intro\n\nOutro", plan: "# Plan\n- a", planComplete: true }); }); + it("hides a trailing partial closing tag", () => { + expect(splitProposedPlanBlock("\n# Plan\n\n { expect(splitProposedPlanBlock("Intro\n\n# Pl")).toEqual({ prose: "Intro", diff --git a/packages/provider-opencode/src/server/ProposedPlanBlock.ts b/packages/provider-opencode/src/server/ProposedPlanBlock.ts index f4042c74da3f7..ce5eea53a0c81 100644 --- a/packages/provider-opencode/src/server/ProposedPlanBlock.ts +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.ts @@ -1,4 +1,5 @@ const OPEN_TAG = ""; +const CLOSE_TAG = ""; export const PROPOSED_PLAN_BLOCK_INSTRUCTIONS = `When you present the final plan, wrap it in a \`\` block so the client can render it specially: @@ -39,7 +40,13 @@ export function splitProposedPlanBlock(text: string): ProposedPlanSplit { const rest = text.slice(bodyStart); const closeMatch = /(^|\n)[ \t]*<\/proposed_plan>[ \t]*(\r?\n|$)/.exec(rest); if (closeMatch === null) { - return { prose: before.trim(), plan: rest.trim() === "" ? null : rest, planComplete: false }; + let plan = rest; + const lineStart = rest.lastIndexOf("\n") + 1; + const lastLine = rest.slice(lineStart).trim(); + if (lastLine.length > 0 && CLOSE_TAG.startsWith(lastLine)) { + plan = rest.slice(0, lineStart); + } + return { prose: before.trim(), plan: plan.trim() === "" ? null : plan, planComplete: false }; } const closeIndex = closeMatch.index; const after = rest.slice(closeIndex + closeMatch[0].length); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index e07378b428bd8..ab8fcb92e6c85 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -64,10 +64,7 @@ import { summarizeNativeProtocolPayload, } from "@t3tools/provider-core/server/nativeProtocolLogging"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - PROPOSED_PLAN_BLOCK_INSTRUCTIONS, - splitProposedPlanBlock, -} from "./ProposedPlanBlock.ts"; +import { PROPOSED_PLAN_BLOCK_INSTRUCTIONS, splitProposedPlanBlock } from "./ProposedPlanBlock.ts"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; From 672e49a8137f7a359448a601f64a3e1c7017534c Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 26 Oct 2026 12:46:28 -0500 Subject: [PATCH 05/10] fix: recognize proposed plans on the OpenCode V2 runtime --- .../Adapters/OpenCode2AdapterV2.test.ts | 155 +++++++++++++++++- .../src/server/v2/adapter.ts | 124 +++++++++++++- packages/provider-opencode/src/testing.ts | 1 + 3 files changed, 273 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..3eb5480b605b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -45,6 +45,7 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/Pro import { OPENCODE_2_STILL_STOPPING, OPENCODE_PROVIDER, + PROPOSED_PLAN_BLOCK_INSTRUCTIONS, t3McpServerName, } from "@t3tools/provider-opencode/testing"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -314,11 +315,14 @@ const resumed = ( readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; readonly fileSystem?: FileSystem.FileSystem; readonly onPrompt?: (input: unknown) => void; + readonly ownInstructions?: boolean; }, ) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( - withInstructions([ + (options?.ownInstructions === true + ? (all: ReadonlyArray) => all + : withInstructions)([ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), @@ -2419,6 +2423,155 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + const planTurnInput = (thread: OrchestrationV2ProviderThread) => ({ + ...turnInput(thread), + runtimePolicy: { ...policy(), interactionMode: "plan" as const }, + }); + const planTurnSetup = (instructions: string): ReadonlyArray => [ + out("session.switchAgent", { sessionID: SESSION, agent: "plan" }), + reply("session.switchAgent", null), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { sessionID: SESSION, permissions: "" }), + reply("session.update", null), + out("session.instructions.entry.put", { + sessionID: SESSION, + key: "t3-code", + value: instructions, + }), + reply("session.instructions.entry.put", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ]; + const textEnded = (text: string) => + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_plan_assistant", + ordinal: 0, + text, + }); + const planTurn = (entries: ReadonlyArray) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([...planTurnSetup(""), ...entries], { + ownInstructions: true, + }); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(planTurnInput(thread)); + const events = Array.from(yield* Fiber.join(collected)); + return { + events, + plans: events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], + ), + planItems: events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" + ? [event.turnItem] + : [], + ), + messages: events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ), + }; + }).pipe(Effect.scoped); + + it.effect("asks for a proposed plan block in plan mode and drops the ask in build mode", () => + Effect.gen(function* () { + const base = buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }); + const { runtime, thread } = yield* resumed( + [ + ...planTurnSetup(`${base}\n\n${PROPOSED_PLAN_BLOCK_INSTRUCTIONS}`), + event("session.execution.succeeded", { sessionID: SESSION }), + out("session.switchAgent", { sessionID: SESSION, agent: "build" }), + reply("session.switchAgent", null), + out("session.update", { sessionID: SESSION, permissions: "" }), + reply("session.update", null), + out("session.instructions.entry.put", { + sessionID: SESSION, + key: "t3-code", + value: base, + }), + reply("session.instructions.entry.put", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { ownInstructions: true }, + ); + const first = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(planTurnInput(thread)); + assert.equal((yield* Fiber.join(first))?.status, "completed"); + const second = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...turnInput(thread), + runId: RunId.make("run:opencode2-adapter:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + }); + assert.equal((yield* Fiber.join(second))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("shows a tagged plan as a proposed plan without its markup in the message", () => + Effect.gen(function* () { + const { plans, planItems, messages } = yield* planTurn([ + textEnded("Here you go.\n\n# Plan\n- step\n"), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + assert.deepEqual(messages, ["Here you go."]); + assert.equal(plans.at(-1)?.status, "active"); + assert.equal(plans.at(-1)?.markdown, "# Plan\n- step"); + assert.equal(planItems.at(-1)?.streaming, false); + }), + ); + + it.effect("accepts a plan missing its closing tag when the turn succeeds", () => + Effect.gen(function* () { + const { plans, planItems } = yield* planTurn([ + textEnded("\n# Plan"), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + assert.equal(plans.at(-1)?.status, "active"); + assert.equal(planItems.at(-1)?.streaming, false); + }), + ); + + it.effect("supersedes a plan left unclosed by a failed turn", () => + Effect.gen(function* () { + const { plans, planItems } = yield* planTurn([ + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_plan_assistant", + ordinal: 0, + delta: "\n# Half a pl", + }), + event("session.execution.failed", { + sessionID: SESSION, + error: { type: "provider", message: "failed" }, + }), + ]); + assert.equal(plans.at(-1)?.status, "superseded"); + assert.equal(planItems.at(-1)?.streaming, false); + }), + ); + + it.effect("keeps untagged plan mode text as a normal message", () => + Effect.gen(function* () { + const { plans, planItems, messages } = yield* planTurn([ + textEnded("Which database do you use?"), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + assert.deepEqual(messages, ["Which database do you use?"]); + assert.deepEqual([plans.length, planItems.length], [0, 0]); + }), + ); + it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () => Effect.gen(function* () { const child = ThreadId.make( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 9c4ec57c34776..7b8722103cfcb 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -20,6 +20,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, type OrchestrationV2ProviderRetry, @@ -31,6 +32,7 @@ import { type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, type MessageId, + type PlanId, type ModelSelection, type ProviderApprovalDecision, type ProviderInstanceId, @@ -86,6 +88,7 @@ import { import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts"; +import { PROPOSED_PLAN_BLOCK_INSTRUCTIONS, splitProposedPlanBlock } from "../ProposedPlanBlock.ts"; import { openCodeToolTurnItem } from "../toolItems.ts"; const OpenCode2ProviderCapabilities = { @@ -142,7 +145,7 @@ const OpenCode2ProviderCapabilities = { planning: { emitsPlanUpdated: false, emitsTodoList: false, - emitsProposedPlan: false, + emitsProposedPlan: true, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, @@ -224,6 +227,11 @@ interface ActiveTurn { unsent: boolean; readonly steers: Set; readonly settledInbox: Set; + readonly planMode: boolean; + readonly proposedPlans: Map< + string, + { readonly id: PlanId; markdown: string; readonly startedAt: DateTime.Utc; closed: boolean } + >; heldEnd: TurnTerminal | undefined; awaitingStart: boolean; /** Scope child tool IDs because model-generated IDs can repeat parent IDs. */ @@ -311,6 +319,7 @@ interface ThreadState { | { readonly name: string; readonly directory: string; readonly credential: string } | undefined; instructions: string | undefined; + latestProposedPlan: OrchestrationV2PlanArtifact | null; } type TurnTerminal = @@ -755,6 +764,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr strandedSteers: new Set(), mcp: undefined, instructions: undefined, + latestProposedPlan: null, }); const rootOf = (state: ThreadState): ThreadState => { @@ -893,6 +903,70 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, }); + const emitProposedPlan = Effect.fnUntraced(function* ( + state: ThreadState, + turn: ActiveTurn, + nativeId: string, + markdown: string, + outcome: "streaming" | "completed" | "abandoned", + ) { + const nativeItemId = `${nativeId}:proposed_plan`; + const updatedAt = yield* DateTime.now; + let entry = turn.proposedPlans.get(nativeId); + if (entry === undefined) { + entry = { + id: yield* idAllocator.allocate + .plan({ + threadId: turn.input.threadId, + ...(turn.input.runId === null ? {} : { runId: turn.input.runId }), + driver, + }) + .pipe(Effect.orDie), + markdown, + startedAt: updatedAt, + closed: false, + }; + turn.proposedPlans.set(nativeId, entry); + } + entry.markdown = markdown; + entry.closed = outcome !== "streaming"; + const plan: OrchestrationV2PlanArtifact = { + id: entry.id, + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }), + kind: "proposed_plan", + status: outcome === "abandoned" ? "superseded" : "active", + markdown, + }; + const previous = state.latestProposedPlan; + if (outcome !== "abandoned") { + if (previous !== null && previous.id !== plan.id) { + yield* emit({ + type: "plan.updated", + driver, + plan: { ...previous, status: "superseded" }, + }); + } + state.latestProposedPlan = plan; + } + const status = outcome === "streaming" ? "running" : "completed"; + const completedAt = status === "completed" ? updatedAt : null; + yield* emitNode(state, turn, nativeItemId, "plan", status, entry.startedAt, completedAt); + yield* emit({ type: "plan.updated", driver, plan }); + yield* emit({ + type: "turn_item.updated", + driver, + turnItem: { + ...itemBase(state, turn, nativeItemId, status, entry.startedAt, completedAt, updatedAt), + type: "proposed_plan", + planId: entry.id, + markdown, + streaming: status !== "completed", + }, + }); + }); + const emitText = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -904,6 +978,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr kind: "text" | "reasoning", update: (current: string) => string, completed = "text" in data, + terminal?: TurnTerminal["status"], ) { const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`; const updatedAt = yield* DateTime.now; @@ -917,6 +992,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (completed) turn.texts.delete(nativeId); else turn.texts.set(nativeId, entry); if (entry.text.length === 0) return; + const split = kind === "text" && turn.planMode ? splitProposedPlanBlock(entry.text) : null; + if (split?.plan != null) { + yield* emitProposedPlan( + state, + turn, + nativeId, + split.plan, + split.planComplete || terminal === "completed" + ? "completed" + : terminal === undefined + ? "streaming" + : "abandoned", + ); + } + if (split !== null && split.prose.length === 0) return; + const text = split?.prose ?? entry.text; const status = completed ? "completed" : "running"; const completedAt = completed ? updatedAt : null; const nodeKind = kind === "text" ? "assistant_message" : "reasoning"; @@ -942,7 +1033,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr runId: turn.input.runId, nodeId: base.nodeId, role: "assistant", - text: entry.text, + text, attachments: [], streaming: !completed, createdAt: entry.startedAt, @@ -956,7 +1047,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ...base, type: "assistant_message", messageId, - text: entry.text, + text, streaming: !completed, }, }); @@ -1100,7 +1191,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const makeTurn = ( owner: TurnOwner, providerTurn: OrchestrationV2ProviderTurn, - options: { readonly scope: string; readonly awaitingStart: boolean }, + options: { + readonly scope: string; + readonly awaitingStart: boolean; + readonly planMode?: boolean; + }, ): ActiveTurn => ({ input: owner, providerTurn, @@ -1120,6 +1215,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr unsent: false, steers: new Set(), settledInbox: new Set(), + planMode: options.planMode ?? false, + proposedPlans: new Map(), heldEnd: undefined, awaitingStart: options.awaitingStart, scope: options.scope, @@ -1527,7 +1624,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const completedAt = yield* DateTime.now; for (const open of turn.texts.values()) { - yield* emitText(state, turn, open.block, open.kind, (text) => text, true); + yield* emitText(state, turn, open.block, open.kind, (text) => text, true, terminal.status); + } + for (const [nativeId, plan] of Array.from(turn.proposedPlans)) { + if (plan.closed) continue; + yield* emitProposedPlan( + state, + turn, + nativeId, + plan.markdown, + terminal.status === "completed" ? "completed" : "abandoned", + ); } for (const id of turn.tools.keys()) { yield* emitTool( @@ -3052,6 +3159,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), t3OrchestrationSystemPrompt(mcpAvailable), + agentFor(turnInput) === "plan" ? PROPOSED_PLAN_BLOCK_INSTRUCTIONS : undefined, ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n"); @@ -3094,7 +3202,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr startedAt, completedAt: null, }; - const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); + const turn = makeTurn(turnInput, providerTurn, { + scope: "", + awaitingStart, + planMode: agentFor(turnInput) === "plan", + }); if (streamFailure !== undefined) { return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ driver, diff --git a/packages/provider-opencode/src/testing.ts b/packages/provider-opencode/src/testing.ts index d9d079c708d86..16ec2fc6f69b1 100644 --- a/packages/provider-opencode/src/testing.ts +++ b/packages/provider-opencode/src/testing.ts @@ -4,3 +4,4 @@ export { OPENCODE_SDK_PROTOCOL, } from "./server/adapter.ts"; export { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./server/v2/adapter.ts"; +export { PROPOSED_PLAN_BLOCK_INSTRUCTIONS } from "./server/ProposedPlanBlock.ts"; From 265f8ba4aec0f9027312a7f4d3b7ebc030dee5b6 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 26 Oct 2026 12:47:11 -0500 Subject: [PATCH 06/10] chore: limit proposed plan recognition to the OpenCode V2 runtime --- docs/user/providers-opencode.md | 8 +- .../src/server/adapter.test.ts | 241 +----------------- .../provider-opencode/src/server/adapter.ts | 151 +---------- 3 files changed, 16 insertions(+), 384 deletions(-) diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index bd3c80406680c..2aa3087bb7e18 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -22,10 +22,10 @@ started on 1.x continue on 2.x. ## Plan mode -Switch a thread to plan mode to use OpenCode's `plan` agent. Its final plan appears as -a plan card in the thread. A plan -that was cut off by a failure or interruption is not offered for implementation. To -implement a plan, switch the thread out of plan mode. +Switch a thread to plan mode to use OpenCode's `plan` agent. On OpenCode 2, its final +plan appears as a plan card in the thread. A plan that was cut off by a failure or +interruption is not offered for implementation. To implement a plan, switch the thread +out of plan mode. ## Local or external server diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 09236e640d25f..cec5c7847c885 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -145,7 +145,6 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct nativeSessionId: string, client: object, host: Partial = {}, - interactionMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["interactionMode"] = "default", ) { const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); @@ -154,7 +153,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct model: "anthropic/claude-sonnet", options: [], }; - const policy = runtimePolicy("full-access", { cwd: "/workspace", interactionMode }); + const policy = runtimePolicy("full-access", { cwd: "/workspace" }); const adapter = yield* makeOpenCodeAdapterV2({ instanceId, settings: OPEN_CODE_TEST_SETTINGS, @@ -615,244 +614,6 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("recognizes proposed plan blocks and supersedes earlier ones", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-proposed-plan"; - const harness = yield* makeOpenCodeRuntimeHarness( - "proposed-plan", - nativeSessionId, - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async () => ({ data: true }), - }, - }, - {}, - "plan", - ); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); - const part = (id: string, text: string, end?: number) => ({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id, - sessionID: nativeSessionId, - messageID: "plan-assistant", - type: "text", - text, - time: { start: 1, ...(end === undefined ? {} : { end }) }, - }, - }, - }); - const delta = (partID: string, value: string) => ({ - type: "message.part.delta", - properties: { - sessionID: nativeSessionId, - messageID: "plan-assistant", - partID, - field: "text", - delta: value, - }, - }); - yield* push(part("p1", "Looking good.")); - yield* push(delta("p1", "\n\n# Plan A")); - yield* push(delta("p1", "\n")); - yield* push(part("p2", "\n# Plan B", 3)); - yield* push({ - type: "message.updated", - properties: { - sessionID: nativeSessionId, - info: { - id: "plan-assistant", - sessionID: nativeSessionId, - role: "assistant", - time: { created: 1, completed: 4 }, - }, - }, - }); - yield* push({ type: "session.compacted", properties: { sessionID: nativeSessionId } }); - const events = Array.from(yield* Fiber.join(received)); - const items = events.flatMap((event) => - event.type === "turn_item.updated" ? [event.turnItem] : [], - ); - const assistant = items.filter((item) => item.type === "assistant_message"); - assert.isTrue(assistant.every((item) => !item.text.includes("proposed_plan"))); - assert.isTrue(assistant.every((item) => !item.text.includes(" item.type === "proposed_plan"); - assert.isTrue(planItems.some((item) => item.streaming)); - const planA = planItems.filter((item) => item.markdown === "# Plan A"); - assert.equal(planA.at(-1)?.streaming, false); - assert.equal(planItems.at(-1)?.markdown, "# Plan B"); - const plans = events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], - ); - const planAId = planA[0]!.planId; - assert.deepEqual( - plans - .filter((plan) => plan.id === planAId) - .map((plan) => plan.status) - .at(-1), - "superseded", - ); - assert.equal(plans.at(-1)?.status, "active"); - assert.equal(plans.at(-1)?.markdown, "# Plan B"); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), - ); - - it.effect("does not offer an unfinished plan from a failed turn", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-failed-plan"; - const harness = yield* makeOpenCodeRuntimeHarness( - "failed-plan", - nativeSessionId, - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async () => ({ data: true }), - }, - }, - {}, - "plan", - ); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "failed", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); - yield* push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "p1", - sessionID: nativeSessionId, - messageID: "plan-assistant", - type: "text", - text: "\n# Half a pl", - time: { start: 1 }, - }, - }, - }); - yield* push({ - type: "session.error", - properties: { - sessionID: nativeSessionId, - error: { name: "UnknownError", data: { message: "failed" } }, - }, - }); - const events = Array.from(yield* Fiber.join(received)); - const plans = events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], - ); - assert.equal(plans.at(-1)?.status, "superseded"); - const planItems = events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" - ? [event.turnItem] - : [], - ); - assert.equal(planItems.at(-1)?.streaming, false); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), - ); - - it.effect("leaves plan tags alone outside plan mode", () => - Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-plain-tags"; - const harness = yield* makeOpenCodeRuntimeHarness("plain-tags", nativeSessionId, { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; - }, - }, - session: { - create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } } }), - promptAsync: async () => ({ data: true }), - }, - }); - yield* harness.startTurn(); - const received = yield* harness.runtime.events.pipe( - Stream.takeUntil( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const text = "Example:\n\n# x\n"; - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "p1", - sessionID: nativeSessionId, - messageID: "assistant", - type: "text", - text, - time: { start: 1, end: 2 }, - }, - }, - }), - ); - const events = Array.from(yield* Fiber.join(received)); - assert.isFalse( - events.some((e) => e.type === "turn_item.updated" && e.turnItem.type === "proposed_plan"), - ); - const last = events.at(-1); - assert.equal( - last?.type === "turn_item.updated" && last.turnItem.type === "assistant_message" - ? last.turnItem.text - : null, - text, - ); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), - ); - // OpenCode 1.18.32 background tasks complete at launch; children stay busy after root settlement. it.effect("reports a background task child as pending work after the root turn settles", () => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 081fa9166d815..efe90e2e77191 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -18,7 +18,6 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, - type OrchestrationV2PlanArtifact, type OrchestrationV2PlanStep, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, @@ -63,7 +62,6 @@ import { summarizeNativeProtocolPayload, } from "@t3tools/provider-core/server/nativeProtocolLogging"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { PROPOSED_PLAN_BLOCK_INSTRUCTIONS, splitProposedPlanBlock } from "./ProposedPlanBlock.ts"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; @@ -141,7 +139,7 @@ const OpenCodeProviderCapabilitiesV2 = { streamsAssistantText: true, streamsReasoning: true, streamsToolOutput: true, - streamsPlanText: true, + streamsPlanText: false, emitsMessageCompleted: true, }, tools: { @@ -164,7 +162,7 @@ const OpenCodeProviderCapabilitiesV2 = { planning: { emitsPlanUpdated: true, emitsTodoList: true, - emitsProposedPlan: true, + emitsProposedPlan: false, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, @@ -284,8 +282,6 @@ interface ActiveOpenCodeTurn { interrupted: boolean; finalized: boolean; planId: PlanId | null; - readonly planMode: boolean; - readonly proposedPlanIdsByPart: Map; admissionGeneration: number; admissionReconciliationGeneration: number | null; admissionPending: boolean; @@ -383,7 +379,6 @@ interface OpenCodeThreadState { parentSubagent: OpenCodeSubagentContext | null; nextChildTurnOrdinal: number; nextAdmissionGeneration: number; - latestProposedPlan: OrchestrationV2PlanArtifact | null; } interface OpenCodeRequestOwner { @@ -1162,122 +1157,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); }; - const emitProposedPlan = Effect.fnUntraced(function* ( - state: OpenCodeThreadState, - turn: ActiveOpenCodeTurn, - input: { - readonly part: { readonly id: string }; - readonly markdown: string; - readonly completed: boolean; - readonly abandoned?: boolean; - readonly startedAt: DateTime.Utc; - readonly emittedAt: DateTime.Utc; - }, - ) { - const nativeItemId = `${input.part.id}:proposed_plan`; - let planId = turn.proposedPlanIdsByPart.get(nativeItemId); - if (planId === undefined) { - planId = yield* idAllocator.allocate.plan({ - threadId: turn.threadId, - ...(turn.runId === null ? {} : { runId: turn.runId }), - driver: OPENCODE_PROVIDER, - }); - turn.proposedPlanIdsByPart.set(nativeItemId, planId); - } - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: OPENCODE_PROVIDER, - nativeItemId, - }); - const status = input.completed || input.abandoned ? "completed" : "running"; - const completedAt = status === "completed" ? input.emittedAt : null; - const nativeItemRef = providerRef(nativeItemId, "weak"); - const plan: OrchestrationV2PlanArtifact = { - id: planId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - kind: "proposed_plan", - status: input.abandoned ? "superseded" : "active", - markdown: input.markdown, - }; - const previous = state.latestProposedPlan; - if ( - !input.abandoned && - previous !== null && - previous.id !== plan.id && - previous.status !== "completed" - ) { - yield* emitProviderEvent({ - type: "plan.updated", - driver: OPENCODE_PROVIDER, - plan: { ...previous, status: "superseded" }, - }); - } - if (!input.abandoned) state.latestProposedPlan = plan; - yield* emitProviderEvent({ - type: "node.updated", - driver: OPENCODE_PROVIDER, - node: { - id: nodeId, - threadId: turn.threadId, - runId: turn.runId, - parentNodeId: turn.rootNodeId, - rootNodeId: turn.rootNodeId, - kind: "plan", - status, - countsForRun: false, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: input.startedAt, - completedAt, - }, - }); - yield* emitProviderEvent({ type: "plan.updated", driver: OPENCODE_PROVIDER, plan }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: OPENCODE_PROVIDER, - turnItem: { - id: turnItemId, - threadId: turn.threadId, - runId: turn.runId, - nodeId, - providerThreadId: state.providerThread.id, - providerTurnId: turn.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: itemOrdinal(turn, nativeItemId), - status, - title: null, - startedAt: input.startedAt, - completedAt, - updatedAt: input.emittedAt, - type: "proposed_plan", - planId, - markdown: input.markdown, - streaming: status !== "completed", - }, - }); - }); - const emitTextPart = Effect.fnUntraced(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, part: Extract, - terminal: TerminalTurnStatus | null = null, + forceCompleted = false, ) { if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; if (part.text.length === 0) return; - const split = - part.type === "text" && turn.planMode ? splitProposedPlanBlock(part.text) : null; const emittedAt = yield* DateTime.now; - const isCompleted = terminal !== null || part.time?.end !== undefined; + const isCompleted = forceCompleted || part.time?.end !== undefined; const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null; const nativeItemRef = providerRef(part.id); @@ -1289,17 +1178,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function driver: OPENCODE_PROVIDER, nativeItemId: part.id, }); - if (split !== null && split.plan !== null) { - yield* emitProposedPlan(state, turn, { - part, - markdown: split.plan, - completed: split.planComplete || terminal === "completed", - abandoned: !split.planComplete && terminal !== null && terminal !== "completed", - startedAt, - emittedAt, - }); - } - if (split !== null && split.prose.length === 0) return; const ordinal = itemOrdinal(turn, part.id); yield* emitProviderEvent({ type: "node.updated", @@ -1335,7 +1213,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function runId: turn.runId, nodeId, role: "assistant", - text: split?.prose ?? part.text, + text: part.text, attachments: [], streaming: !isCompleted, createdAt: startedAt, @@ -1366,7 +1244,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function updatedAt: emittedAt, type: "assistant_message", messageId, - text: split?.prose ?? part.text, + text: part.text, streaming: !isCompleted, }, }); @@ -1517,7 +1395,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function parentSubagent: context, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, - latestProposedPlan: null, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2138,7 +2015,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const completedAt = yield* DateTime.now; for (const part of turn.parts.values()) { if (part.type === "text" || part.type === "reasoning") { - yield* emitTextPart(state, turn, part, status); + yield* emitTextPart(state, turn, part, true); } } for (const pending of Array.from(pendingRequests.values())) { @@ -2350,8 +2227,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function interrupted: false, finalized: false, planId: null, - planMode: false, - proposedPlanIdsByPart: new Map(), admissionGeneration: 0, admissionReconciliationGeneration: null, admissionPending: false, @@ -2620,7 +2495,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { const part = turn.parts.get(partId); if (part?.type === "text" || part?.type === "reasoning") { - yield* emitTextPart(state, turn, part, "completed"); + yield* emitTextPart(state, turn, part, true); } } }); @@ -2914,7 +2789,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, - latestProposedPlan: null, }; threads.set(nativeSession.id, state); return state; @@ -3257,9 +3131,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function reason: "previous_turn_active", }); } - const agent = - getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? - (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const turn: ActiveOpenCodeTurn = { isRoot: true, threadId: turnInput.threadId, @@ -3286,8 +3157,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function interrupted: false, finalized: false, planId: null, - planMode: agent === "plan", - proposedPlanIdsByPart: new Map(), admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, admissionPending: !isCompaction, @@ -3345,10 +3214,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function harness: "OpenCode", model: turnInput.modelSelection.model, }), - agent === "plan" ? PROPOSED_PLAN_BLOCK_INSTRUCTIONS : "", ] .filter(Boolean) .join("\n\n"); + const agent = + getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? + (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const variant = getModelSelectionStringOptionValue( turnInput.modelSelection, "variant", From 47f7189f527aecac0ec8aab8521a1d6607fd9c01 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 26 Oct 2026 12:59:58 -0500 Subject: [PATCH 07/10] fix: skip whitespace streamed before an OpenCode proposed plan --- .../Adapters/OpenCode2AdapterV2.test.ts | 20 +++++++++++++++++++ .../src/server/v2/adapter.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 3eb5480b605b8..2d385b062883d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2542,6 +2542,26 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); + it.effect("leaves no assistant message for whitespace streamed before a plan", () => + Effect.gen(function* () { + const delta = (text: string) => + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_plan_assistant", + ordinal: 0, + delta: text, + }); + const { plans, messages } = yield* planTurn([ + delta("\n"), + delta("\n# Plan\n"), + textEnded("\n\n# Plan\n"), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + assert.deepEqual(messages, []); + assert.equal(plans.at(-1)?.status, "active"); + }), + ); + it.effect("supersedes a plan left unclosed by a failed turn", () => Effect.gen(function* () { const { plans, planItems } = yield* planTurn([ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 7b8722103cfcb..bb5ce72f63b9a 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -1006,7 +1006,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr : "abandoned", ); } - if (split !== null && split.prose.length === 0) return; + if (split !== null && split.prose.trim().length === 0) return; const text = split?.prose ?? entry.text; const status = completed ? "completed" : "running"; const completedAt = completed ? updatedAt : null; From 6a3689814eba34356d6b02f7c5bea1f17455c351 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Wed, 28 Oct 2026 15:01:28 -0500 Subject: [PATCH 08/10] chore: explain why proposed plan emission is untraced --- packages/provider-opencode/src/server/v2/adapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 2a675e382b953..34334f9b82697 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -903,6 +903,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, }); + // Untraced: runs on every streamed text delta of a plan turn, inside the event's span. const emitProposedPlan = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, From 8a275f67319d14af055e0f6008862c4d7a8dc868 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Fri, 30 Oct 2026 16:36:50 -0600 Subject: [PATCH 09/10] fix: stop resending an unchanged OpenCode proposed plan --- .../Adapters/OpenCode2AdapterV2.test.ts | 21 +++++++++++++++++++ .../src/server/v2/adapter.ts | 17 ++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index cc6c404cf6cd6..1fc67ac46b01f 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2605,6 +2605,27 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); + it.effect("sends a closed plan once while prose keeps streaming after it", () => + Effect.gen(function* () { + const delta = (text: string) => + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_plan_assistant", + ordinal: 0, + delta: text, + }); + const { plans, messages } = yield* planTurn([ + delta("\n# Plan\n\n"), + delta("Ready"), + delta(" when you are."), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + assert.equal(plans.length, 1); + assert.equal(plans[0]?.status, "active"); + assert.equal(messages.at(-1), "Ready when you are."); + }), + ); + it.effect("supersedes a plan left unclosed by a failed turn", () => Effect.gen(function* () { const { plans, planItems } = yield* planTurn([ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index eb446e384a091..57a9609c1a617 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -230,7 +230,12 @@ interface ActiveTurn { readonly planMode: boolean; readonly proposedPlans: Map< string, - { readonly id: PlanId; markdown: string; readonly startedAt: DateTime.Utc; closed: boolean } + { + readonly id: PlanId; + markdown: string; + readonly startedAt: DateTime.Utc; + outcome: "streaming" | "completed" | "abandoned"; + } >; heldEnd: TurnTerminal | undefined; awaitingStart: boolean; @@ -912,8 +917,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr outcome: "streaming" | "completed" | "abandoned", ) { const nativeItemId = `${nativeId}:proposed_plan`; - const updatedAt = yield* DateTime.now; let entry = turn.proposedPlans.get(nativeId); + // Prose after a closed plan re-splits the same block; resending the full plan adds nothing. + if (entry?.markdown === markdown && entry.outcome === outcome) return; + const updatedAt = yield* DateTime.now; if (entry === undefined) { entry = { id: yield* idAllocator.allocate @@ -925,12 +932,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr .pipe(Effect.orDie), markdown, startedAt: updatedAt, - closed: false, + outcome, }; turn.proposedPlans.set(nativeId, entry); } entry.markdown = markdown; - entry.closed = outcome !== "streaming"; + entry.outcome = outcome; const plan: OrchestrationV2PlanArtifact = { id: entry.id, threadId: turn.input.threadId, @@ -1628,7 +1635,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* emitText(state, turn, open.block, open.kind, (text) => text, true, terminal.status); } for (const [nativeId, plan] of Array.from(turn.proposedPlans)) { - if (plan.closed) continue; + if (plan.outcome !== "streaming") continue; yield* emitProposedPlan( state, turn, From a9a070112b9c66655fdea761eeaf29ccdfa2703b Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Fri, 30 Oct 2026 16:50:07 -0600 Subject: [PATCH 10/10] fix: expect the instructions rewrite when an OpenCode thread leaves plan mode --- .../OpenCode2OrchestratorV2.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index eea36ac3b769d..be91b3a740f92 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -421,6 +421,8 @@ describe("OpenCode 2 through the orchestrator", () => { reply("session.update", null), out("session.switchAgent", { sessionID: SESSION, agent: "build" }), reply("session.switchAgent", null), + // Leaving plan mode drops the proposed plan instructions. + ...instructionsWritten, ...answeredPrompt("BUILT"), ], commands: [