From bcc27633f164566b703727521ddaa892ddfaf5b6 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 5 Oct 2026 13:50:56 -0500 Subject: [PATCH 1/3] feat: recognize proposed plans from OpenCode plan mode --- .../Adapters/OpenCodeAdapterV2.test.ts | 96 +++++++++++++ .../Adapters/OpenCodeAdapterV2.ts | 135 +++++++++++++++++- .../src/provider/ProposedPlanBlock.test.ts | 38 +++++ apps/server/src/provider/ProposedPlanBlock.ts | 51 +++++++ docs/user/providers-opencode.md | 2 +- 5 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/provider/ProposedPlanBlock.test.ts create mode 100644 apps/server/src/provider/ProposedPlanBlock.ts diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 5ae9368a784e8..82a96a328c5d6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -712,6 +712,102 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); + 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 }), + }, + }); + 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(IdAllocator.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/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index fdae326f92385..dca46b2d4fcbf 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -19,6 +19,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, type OrchestrationV2PlanStep, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, @@ -65,6 +66,10 @@ import { summarizeNativeProtocolPayload, } from "../../provider/NativeProtocolLogging.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { + PROPOSED_PLAN_BLOCK_INSTRUCTIONS, + splitProposedPlanBlock, +} from "../../provider/ProposedPlanBlock.ts"; import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; @@ -152,7 +157,7 @@ const OpenCodeProviderCapabilitiesV2 = { streamsAssistantText: true, streamsReasoning: true, streamsToolOutput: true, - streamsPlanText: false, + streamsPlanText: true, emitsMessageCompleted: true, }, tools: { @@ -175,7 +180,7 @@ const OpenCodeProviderCapabilitiesV2 = { planning: { emitsPlanUpdated: true, emitsTodoList: true, - emitsProposedPlan: false, + emitsProposedPlan: true, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, @@ -295,6 +300,7 @@ interface ActiveOpenCodeTurn { interrupted: boolean; finalized: boolean; planId: PlanId | null; + readonly proposedPlanIdsByPart: Map; admissionGeneration: number; admissionReconciliationGeneration: number | null; admissionPending: boolean; @@ -393,6 +399,7 @@ interface OpenCodeThreadState { parentSubagent: OpenCodeSubagentContext | null; nextChildTurnOrdinal: number; nextAdmissionGeneration: number; + latestProposedPlan: OrchestrationV2PlanArtifact | null; } interface OpenCodeRequestOwner { @@ -1196,6 +1203,104 @@ export function makeOpenCodeAdapterV2( }); }; + const emitProposedPlan = Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + turn: ActiveOpenCodeTurn, + input: { + readonly part: { readonly id: string }; + readonly markdown: string; + readonly completed: 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 ? "completed" : "running"; + const completedAt = input.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: "active", + markdown: input.markdown, + }; + const previous = state.latestProposedPlan; + if (previous !== null && previous.id !== plan.id && previous.status !== "completed") { + yield* emitProviderEvent({ + type: "plan.updated", + driver: OPENCODE_PROVIDER, + plan: { ...previous, status: "superseded" }, + }); + } + 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: !input.completed, + }, + }); + }); + const emitTextPart = Effect.fnUntraced(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, @@ -1204,6 +1309,7 @@ export function makeOpenCodeAdapterV2( ) { if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return; if (part.text.length === 0) return; + const split = part.type === "text" ? splitProposedPlanBlock(part.text) : null; const emittedAt = yield* DateTime.now; const isCompleted = forceCompleted || part.time?.end !== undefined; const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt); @@ -1217,6 +1323,16 @@ export function makeOpenCodeAdapterV2( driver: OPENCODE_PROVIDER, nativeItemId: part.id, }); + if (split !== null && split.plan !== null) { + yield* emitProposedPlan(state, turn, { + part, + markdown: split.plan, + completed: split.planComplete || isCompleted, + startedAt, + emittedAt, + }); + } + if (split !== null && split.prose.length === 0) return; const ordinal = itemOrdinal(turn, part.id); yield* emitProviderEvent({ type: "node.updated", @@ -1252,7 +1368,7 @@ export function makeOpenCodeAdapterV2( runId: turn.runId, nodeId, role: "assistant", - text: part.text, + text: split?.prose ?? part.text, attachments: [], streaming: !isCompleted, createdAt: startedAt, @@ -1284,7 +1400,7 @@ export function makeOpenCodeAdapterV2( updatedAt: emittedAt, type: "assistant_message", messageId, - text: part.text, + text: split?.prose ?? part.text, streaming: !isCompleted, }, }); @@ -1436,6 +1552,7 @@ export function makeOpenCodeAdapterV2( parentSubagent: context, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2272,6 +2389,7 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + proposedPlanIdsByPart: new Map(), admissionGeneration: 0, admissionReconciliationGeneration: null, admissionPending: false, @@ -2841,6 +2959,7 @@ export function makeOpenCodeAdapterV2( parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }; threads.set(nativeSession.id, state); return state; @@ -3206,6 +3325,7 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + proposedPlanIdsByPart: new Map(), admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, admissionPending: !isCompaction, @@ -3256,18 +3376,19 @@ export function makeOpenCodeAdapterV2( ); return; } + const agent = + getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? + (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const systemPrompt = [ orchestrationSystemPrompt, buildRuntimeInstructions({ 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", diff --git a/apps/server/src/provider/ProposedPlanBlock.test.ts b/apps/server/src/provider/ProposedPlanBlock.test.ts new file mode 100644 index 0000000000000..99441df35d15a --- /dev/null +++ b/apps/server/src/provider/ProposedPlanBlock.test.ts @@ -0,0 +1,38 @@ +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"); + }); +}); diff --git a/apps/server/src/provider/ProposedPlanBlock.ts b/apps/server/src/provider/ProposedPlanBlock.ts new file mode 100644 index 0000000000000..6b3464a62f4fc --- /dev/null +++ b/apps/server/src/provider/ProposedPlanBlock.ts @@ -0,0 +1,51 @@ +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: + +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); + } + return { prose: prose.trim(), plan: null, planComplete: false }; + } + const before = text.slice(0, openMatch.index); + const bodyStart = openMatch.index + openMatch[0].length; + const rest = text.slice(bodyStart); + const closeIndex = rest.indexOf(CLOSE_TAG); + if (closeIndex === -1) { + return { prose: before.trim(), plan: rest, planComplete: false }; + } + const after = rest.slice(closeIndex + CLOSE_TAG.length); + return { + prose: [before.trim(), after.trim()].filter(Boolean).join("\n\n"), + plan: rest.slice(0, closeIndex).trim(), + planComplete: true, + }; +} diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9ecee2a0439c2..68466ef0c30d2 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -20,7 +20,7 @@ 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 uses OpenCode's `plan` agent. T3 Code asks it to present the final plan in a plan block, so you get the plan card and can implement it from there. ## Local or external server From 2bb554b8014041866261de2d8a9e3dedf7ccee66 Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 5 Oct 2026 13:50:58 -0500 Subject: [PATCH 2/3] feat: cite text from proposed plans --- .../chat/MessagesTimeline.logic.test.ts | 9 +++++++ .../components/chat/MessagesTimeline.logic.ts | 12 +++++++++ .../src/components/chat/MessagesTimeline.tsx | 25 +++++++++++++------ .../src/components/chat/ProposedPlanCard.tsx | 5 +++- .../chat/useAssistantCitationTarget.ts | 23 +++++++++-------- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 173a964d5efc8..419a7f4cf0958 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -19,6 +19,7 @@ import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; import { MessageId, RunId } from "@t3tools/contracts"; import { + citationSourceId, computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, @@ -4889,3 +4890,11 @@ describe("failed turn transcript", () => { }, ); }); + +describe("citationSourceId", () => { + it("identifies messages and proposed plans, not other rows", () => { + expect(citationSourceId({ kind: "message", message: { id: "m1" } })).toBe("m1"); + expect(citationSourceId({ kind: "proposed-plan", id: "plan-item" })).toBe("plan-item"); + expect(citationSourceId({ kind: "event" })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 81b4af6286307..46ff5754bc23b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -589,6 +589,18 @@ type MessagesTimelineRowContent = proposedPlan: ProposedPlan; }; +/** Id a citation uses to find its source: the message id, or the plan row id. */ +export function citationSourceId( + row: + | { readonly kind: "message"; readonly message: { readonly id: string } } + | { readonly kind: "proposed-plan"; readonly id: string } + | { readonly kind: string }, +): string | null { + if (row.kind === "message" && "message" in row) return row.message.id; + if (row.kind === "proposed-plan" && "id" in row) return row.id; + return null; +} + export interface StableMessagesTimelineRowsState { byId: Map; result: MessagesTimelineRow[]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 33f51752fbca4..894a7b7619ecd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -21,7 +21,7 @@ import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type AssistantCitation, type EnvironmentId, - type MessageId, + MessageId, type OrchestrationV2TurnItem, type RunAttemptId, type ScopedThreadRef, @@ -2689,13 +2689,22 @@ function ProposedPlanTimelineRow({ return (
- + + +
); } diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 80eda86f5e9c7..e03af58d7a098 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -39,14 +39,17 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ threadRef, cwd, workspaceRoot, + forceExpanded = false, }: { planMarkdown: string; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | undefined; cwd: string | undefined; workspaceRoot: string | undefined; + forceExpanded?: boolean; }) { - const [expanded, setExpanded] = useState(false); + const [expandedState, setExpanded] = useState(false); + const expanded = expandedState || forceExpanded; const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); diff --git a/apps/web/src/components/chat/useAssistantCitationTarget.ts b/apps/web/src/components/chat/useAssistantCitationTarget.ts index 958e2531eeee0..b17d5e12a756f 100644 --- a/apps/web/src/components/chat/useAssistantCitationTarget.ts +++ b/apps/web/src/components/chat/useAssistantCitationTarget.ts @@ -2,7 +2,7 @@ import type { LegendListRef } from "@legendapp/list/react"; import type { RunId } from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import type { TimelineEntry } from "../../session-logic"; -import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { citationSourceId, type MessagesTimelineRow } from "./MessagesTimeline.logic"; import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource"; import { toastManager } from "../ui/toast"; @@ -83,10 +83,8 @@ export function useAssistantCitationTarget({ setFinishedKey(navigation.target.key); toastManager.add({ type: "warning", title, description }); }; - const source = entries.find( - (entry) => - entry.kind === "message" && entry.message.id === navigation.target.citation.messageId, - ); + const targetId = navigation.target.citation.messageId; + const source = entries.find((entry) => citationSourceId(entry) === targetId); if (!source) { if (loadEarlier) { if (loadEarlier.loading) return; @@ -108,18 +106,21 @@ export function useAssistantCitationTarget({ ); return; } - if (source.kind !== "message" || source.message.role !== "assistant") { + if ( + source.kind !== "proposed-plan" && + (source.kind !== "message" || source.message.role !== "assistant") + ) { fail( "The citation does not refer to an assistant response", "The selected text is still saved in your citation.", ); return; } - const index = rows.findIndex( - (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId, - ); + const index = rows.findIndex((row) => citationSourceId(row) === targetId); if (index < 0) { - if (source.message.runId) onExpandTurn(source.message.runId); + const runId = + source.kind === "proposed-plan" ? source.proposedPlan.runId : source.message.runId; + if (runId) onExpandTurn(runId); return; } if (listLoaded && listRef.current) setReady(navigation.target); @@ -180,7 +181,7 @@ export function useAssistantCitationTarget({ const positioning = request !== null && finishedKey !== request.key; const sourceRow = target && positioning - ? rows.find((row) => row.kind === "message" && row.message.id === target.citation.messageId) + ? rows.find((row) => citationSourceId(row) === target.citation.messageId) : undefined; return { target, From 298ce5f89927b969f5054c106bac876565f1823a Mon Sep 17 00:00:00 2001 From: Gerard Casas Saez Date: Mon, 5 Oct 2026 20:54:54 -0500 Subject: [PATCH 3/3] fix: address plan block review feedback --- .../Adapters/OpenCodeAdapterV2.test.ts | 154 ++++++++++++++++-- .../Adapters/OpenCodeAdapterV2.ts | 41 +++-- .../src/provider/ProposedPlanBlock.test.ts | 12 ++ apps/server/src/provider/ProposedPlanBlock.ts | 12 +- .../src/components/chat/ProposedPlanCard.tsx | 8 +- docs/user/providers-opencode.md | 7 +- 6 files changed, 198 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 82a96a328c5d6..1e93e59b543da 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -146,6 +146,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct suffix: string, nativeSessionId: string, client: object, + interactionMode: ProviderAdapterV2RuntimePolicy["interactionMode"] = "default", ) { const idAllocator = yield* IdAllocator.IdAllocatorV2; const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); @@ -155,7 +156,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 = makeOpenCodeAdapterV2({ instanceId, settings: OPEN_CODE_TEST_SETTINGS, @@ -716,18 +717,25 @@ describe("OpenCodeAdapterV2", () => { 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 }; + 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 }), }, }, - 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( @@ -808,6 +816,130 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.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(IdAllocator.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(IdAllocator.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/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index dca46b2d4fcbf..03da88660fbb9 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -300,6 +300,7 @@ interface ActiveOpenCodeTurn { interrupted: boolean; finalized: boolean; planId: PlanId | null; + readonly planMode: boolean; readonly proposedPlanIdsByPart: Map; admissionGeneration: number; admissionReconciliationGeneration: number | null; @@ -1210,6 +1211,7 @@ export function makeOpenCodeAdapterV2( readonly part: { readonly id: string }; readonly markdown: string; readonly completed: boolean; + readonly abandoned?: boolean; readonly startedAt: DateTime.Utc; readonly emittedAt: DateTime.Utc; }, @@ -1232,8 +1234,8 @@ export function makeOpenCodeAdapterV2( driver: OPENCODE_PROVIDER, nativeItemId, }); - const status = input.completed ? "completed" : "running"; - const completedAt = input.completed ? input.emittedAt : null; + 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, @@ -1241,18 +1243,23 @@ export function makeOpenCodeAdapterV2( runId: turn.runId, nodeId, kind: "proposed_plan", - status: "active", + status: input.abandoned ? "superseded" : "active", markdown: input.markdown, }; const previous = state.latestProposedPlan; - if (previous !== null && previous.id !== plan.id && previous.status !== "completed") { + if ( + !input.abandoned && + previous !== null && + previous.id !== plan.id && + previous.status !== "completed" + ) { yield* emitProviderEvent({ type: "plan.updated", driver: OPENCODE_PROVIDER, plan: { ...previous, status: "superseded" }, }); } - state.latestProposedPlan = plan; + if (!input.abandoned) state.latestProposedPlan = plan; yield* emitProviderEvent({ type: "node.updated", driver: OPENCODE_PROVIDER, @@ -1296,7 +1303,7 @@ export function makeOpenCodeAdapterV2( type: "proposed_plan", planId, markdown: input.markdown, - streaming: !input.completed, + streaming: status !== "completed", }, }); }); @@ -1305,13 +1312,14 @@ export function makeOpenCodeAdapterV2( 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" ? splitProposedPlanBlock(part.text) : null; + 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); @@ -1327,7 +1335,8 @@ export function makeOpenCodeAdapterV2( yield* emitProposedPlan(state, turn, { part, markdown: split.plan, - completed: split.planComplete || isCompleted, + completed: split.planComplete || terminal === "completed", + abandoned: !split.planComplete && terminal !== null && terminal !== "completed", startedAt, emittedAt, }); @@ -2178,7 +2187,7 @@ export function makeOpenCodeAdapterV2( 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())) { @@ -2389,6 +2398,7 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + planMode: false, proposedPlanIdsByPart: new Map(), admissionGeneration: 0, admissionReconciliationGeneration: null, @@ -2663,7 +2673,7 @@ export function makeOpenCodeAdapterV2( 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"); } } }); @@ -3300,6 +3310,9 @@ export function makeOpenCodeAdapterV2( `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, @@ -3325,6 +3338,7 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + planMode: agent === "plan", proposedPlanIdsByPart: new Map(), admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, @@ -3376,9 +3390,6 @@ export function makeOpenCodeAdapterV2( ); return; } - const agent = - getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? - (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); const systemPrompt = [ orchestrationSystemPrompt, buildRuntimeInstructions({ diff --git a/apps/server/src/provider/ProposedPlanBlock.test.ts b/apps/server/src/provider/ProposedPlanBlock.test.ts index 99441df35d15a..bf8db15360e68 100644 --- a/apps/server/src/provider/ProposedPlanBlock.test.ts +++ b/apps/server/src/provider/ProposedPlanBlock.test.ts @@ -35,4 +35,16 @@ describe("splitProposedPlanBlock", () => { 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/apps/server/src/provider/ProposedPlanBlock.ts b/apps/server/src/provider/ProposedPlanBlock.ts index 6b3464a62f4fc..f9c45f6803810 100644 --- a/apps/server/src/provider/ProposedPlanBlock.ts +++ b/apps/server/src/provider/ProposedPlanBlock.ts @@ -1,5 +1,4 @@ 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: @@ -31,18 +30,19 @@ export function splitProposedPlanBlock(text: string): ProposedPlanSplit { 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); + prose = text.slice(0, lineStart).trimEnd(); } - return { prose: prose.trim(), plan: null, planComplete: false }; + 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 closeIndex = rest.indexOf(CLOSE_TAG); - if (closeIndex === -1) { + const closeMatch = /(^|\n)[ \t]*<\/proposed_plan>[ \t]*(\r?\n|$)/.exec(rest); + if (closeMatch === null) { return { prose: before.trim(), plan: rest, planComplete: false }; } - const after = rest.slice(closeIndex + CLOSE_TAG.length); + 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(), diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e03af58d7a098..b62d08a030b4e 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useId } from "react"; +import { memo, useEffect, useState, useId } from "react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -48,8 +48,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ workspaceRoot: string | undefined; forceExpanded?: boolean; }) { - const [expandedState, setExpanded] = useState(false); - const expanded = expandedState || forceExpanded; + const [expanded, setExpanded] = useState(false); + useEffect(() => { + if (forceExpanded) setExpanded(true); + }, [forceExpanded]); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 68466ef0c30d2..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. T3 Code asks it to present the final plan in a plan block, so you get the plan card and can implement it from there. +## 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