diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e5fedad35270c..1969b150e3119 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -48,6 +48,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"; @@ -339,6 +340,7 @@ const resumed = ( readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; readonly fileSystem?: FileSystem.FileSystem; readonly onPrompt?: (input: unknown) => void; + readonly ownInstructions?: boolean; }, ) => Effect.gen(function* () { @@ -359,7 +361,9 @@ const resumed = ( ? [out("job.list", { kind: "shell" }), reply("job.list", { data: [] })] : emptyShellInventory), ])), - ...withInstructions([ + ...(options?.ownInstructions === true + ? (all: ReadonlyArray) => all + : withInstructions)([ ...(options?.supervised === true ? [ out("agent.list", ""), @@ -4955,6 +4959,196 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); + 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("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("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([ + 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/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 8681ce4333cb4..2b77e607bab68 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: [ 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..7bc3490bc6e5f --- /dev/null +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.test.ts @@ -0,0 +1,74 @@ +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("hides a trailing partial closing tag", () => { + expect(splitProposedPlanBlock("\n# Plan\n\n { + expect(splitProposedPlanBlock("Intro\n\n# Pl")).toEqual({ + prose: "Intro", + plan: "# Pl", + planComplete: false, + }); + }); + + 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", () => { + 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"); + }); + + 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 new file mode 100644 index 0000000000000..ce5eea53a0c81 --- /dev/null +++ b/packages/provider-opencode/src/server/ProposedPlanBlock.ts @@ -0,0 +1,59 @@ +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).trim(); + 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) { + 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); + const plan = rest.slice(0, closeIndex).trim(); + return { + prose: [before.trim(), after.trim()].filter(Boolean).join("\n\n"), + plan: plan === "" ? null : plan, + planComplete: plan !== "", + }; +} diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 15f30b7fa0497..924bb8d296bfe 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -25,6 +25,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, type OrchestrationV2ProviderRetry, @@ -97,6 +98,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 { openCodeCommandFailed, openCodeToolTurnItem } from "../toolItems.ts"; const NativeGoal = Schema.NullOr( @@ -173,7 +175,7 @@ const OpenCode2ProviderCapabilities = { planning: { emitsPlanUpdated: true, emitsTodoList: true, - emitsProposedPlan: false, + emitsProposedPlan: true, supportsStructuredQuestions: true, planDeltasHaveItemIds: false, }, @@ -263,6 +265,16 @@ 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; + outcome: "streaming" | "completed" | "abandoned"; + } + >; heldEnd: TurnTerminal | undefined; awaitingStart: boolean; /** Scope child tool IDs because model-generated IDs can repeat parent IDs. */ @@ -374,6 +386,7 @@ interface ThreadState { | { readonly name: string; readonly directory: string; readonly credential: string } | undefined; instructions: string | undefined; + latestProposedPlan: OrchestrationV2PlanArtifact | null; } type TurnTerminal = @@ -902,6 +915,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr externalPrompts: new Map(), mcp: undefined, instructions: undefined, + latestProposedPlan: null, }); const rootOf = (state: ThreadState): ThreadState => { @@ -1114,6 +1128,73 @@ 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, + nativeId: string, + markdown: string, + outcome: "streaming" | "completed" | "abandoned", + ) { + const nativeItemId = `${nativeId}:proposed_plan`; + 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 + .plan({ + threadId: turn.input.threadId, + ...(turn.input.runId === null ? {} : { runId: turn.input.runId }), + driver, + }) + .pipe(Effect.orDie), + markdown, + startedAt: updatedAt, + outcome, + }; + turn.proposedPlans.set(nativeId, entry); + } + entry.markdown = markdown; + entry.outcome = outcome; + 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, @@ -1125,6 +1206,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; @@ -1139,6 +1221,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr else turn.texts.set(nativeId, entry); if (entry.text.length === 0) return; if (completed && kind === "text") turn.answer = entry.text; + 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.trim().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"; @@ -1164,7 +1262,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, @@ -1178,7 +1276,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ...base, type: "assistant_message", messageId, - text: entry.text, + text, streaming: !completed, }, }); @@ -1490,7 +1588,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, @@ -1515,6 +1617,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, @@ -2187,7 +2291,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.outcome !== "streaming") continue; + yield* emitProposedPlan( + state, + turn, + nativeId, + plan.markdown, + terminal.status === "completed" ? "completed" : "abandoned", + ); } for (const id of turn.tools.keys()) { if ([...state.imageJobs.values()].some((job) => job.turn === turn && job.toolId === id)) @@ -4236,6 +4350,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr mcpAvailable ? "To wait for a background shell you started (tests, a build), call `t3_background_wait` with its shell ID before you end your turn. Do not mark work you leave running, or PRs." : undefined, + agentFor(turnInput) === "plan" ? PROPOSED_PLAN_BLOCK_INSTRUCTIONS : undefined, ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n"); @@ -4278,7 +4393,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";