Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d667905
feat: recognize proposed plans from OpenCode plan mode
casassg Oct 8, 2026
5b689d9
fix: hide a CRLF plan opening tag while streaming
casassg Oct 8, 2026
3136e78
fix: ignore empty proposed plan blocks
casassg Oct 8, 2026
8ed4119
fix: hide a partial plan closing tag while streaming
casassg Oct 10, 2026
54deafb
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Oct 26, 2026
672e49a
fix: recognize proposed plans on the OpenCode V2 runtime
casassg Oct 26, 2026
265f8ba
chore: limit proposed plan recognition to the OpenCode V2 runtime
casassg Oct 26, 2026
47f7189
fix: skip whitespace streamed before an OpenCode proposed plan
casassg Oct 26, 2026
fa15beb
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Oct 28, 2026
6a36898
chore: explain why proposed plan emission is untraced
casassg Oct 28, 2026
ba96a2b
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Oct 30, 2026
8a275f6
fix: stop resending an unchanged OpenCode proposed plan
casassg Oct 30, 2026
a9a0701
fix: expect the instructions rewrite when an OpenCode thread leaves p…
casassg Oct 30, 2026
1b8122c
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 4, 2026
2faac15
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 4, 2026
3fb2419
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 7, 2026
d2d62ad
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 9, 2026
3947d4d
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 10, 2026
a3b7a3a
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 10, 2026
f286d8d
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 11, 2026
b8671ba
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 11, 2026
7b08d03
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 11, 2026
db7b649
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 11, 2026
b8fc1c9
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 12, 2026
723ead7
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 13, 2026
cd811ba
Merge remote-tracking branch 'upstream/main' into gerardc/opencode-pr…
casassg Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 195 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -339,6 +340,7 @@ const resumed = (
readonly resolveExtractedTextPath?: (path: string) => Effect.Effect<string | undefined>;
readonly fileSystem?: FileSystem.FileSystem;
readonly onPrompt?: (input: unknown) => void;
readonly ownInstructions?: boolean;
},
) =>
Effect.gen(function* () {
Expand All @@ -359,7 +361,9 @@ const resumed = (
? [out("job.list", { kind: "shell" }), reply("job.list", { data: [] })]
: emptyShellInventory),
])),
...withInstructions([
...(options?.ownInstructions === true
? (all: ReadonlyArray<ProviderReplayEntry>) => all
: withInstructions)([
...(options?.supervised === true
? [
out("agent.list", "<any>"),
Expand Down Expand Up @@ -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<ProviderReplayEntry> => [
out("session.switchAgent", { sessionID: SESSION, agent: "plan" }),
reply("session.switchAgent", null),
out("agent.list", "<any>"),
reply("agent.list", agentList),
out("session.update", { sessionID: SESSION, permissions: "<any>" }),
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: "<any>" }),
promptAccepted,
];
const textEnded = (text: string) =>
event("session.text.ended", {
sessionID: SESSION,
assistantMessageID: "msg_plan_assistant",
ordinal: 0,
text,
});
const planTurn = (entries: ReadonlyArray<ProviderReplayEntry>) =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([...planTurnSetup("<any>"), ...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: "<any>" }),
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: "<any>" }),
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<proposed_plan>\n# Plan\n- step\n</proposed_plan>"),
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("<proposed_plan>\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("<proposed_plan>\n# Plan\n</proposed_plan>"),
textEnded("\n<proposed_plan>\n# Plan\n</proposed_plan>"),
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("<proposed_plan>\n# Plan\n</proposed_plan>\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: "<proposed_plan>\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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
74 changes: 74 additions & 0 deletions packages/provider-opencode/src/server/ProposedPlanBlock.test.ts
Original file line number Diff line number Diff line change
@@ -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<proposed_plan>\n# Plan\n- a\n</proposed_plan>\nOutro"),
).toEqual({ prose: "Intro\n\nOutro", plan: "# Plan\n- a", planComplete: true });
});

it("hides a trailing partial closing tag", () => {
expect(splitProposedPlanBlock("<proposed_plan>\n# Plan\n</proposed_pla")).toEqual({
prose: "",
plan: "# Plan\n",
planComplete: false,
});
expect(splitProposedPlanBlock("<proposed_plan>\n</propo").plan).toBeNull();
});

it("treats an unclosed block as a streaming plan", () => {
expect(splitProposedPlanBlock("Intro\n<proposed_plan>\n# Pl")).toEqual({
prose: "Intro",
plan: "# Pl",
planComplete: false,
});
});

it("hides a trailing partial opening tag", () => {
expect(splitProposedPlanBlock("Intro\n<propo").prose).toBe("Intro");
expect(splitProposedPlanBlock("<proposed_plan").prose).toBe("");
expect(splitProposedPlanBlock("Intro\n<proposed_plan>\r").prose).toBe("Intro");
});

it("ignores a tag that is not on its own line", () => {
const result = splitProposedPlanBlock("Use the <proposed_plan> tag like so");
expect(result.plan).toBeNull();
expect(result.prose).toBe("Use the <proposed_plan> tag like so");
});

it("only closes on a standalone closing tag", () => {
const result = splitProposedPlanBlock(
"<proposed_plan>\nUse `</proposed_plan>` to end it.\n# Rest\n</proposed_plan>\nDone",
);
expect(result.plan).toBe("Use `</proposed_plan>` 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<proposed_plan>")).toEqual({
prose: "Intro",
plan: null,
planComplete: false,
});
expect(splitProposedPlanBlock("<proposed_plan>\n\n").plan).toBeNull();
expect(splitProposedPlanBlock("<proposed_plan>\n</proposed_plan>\nDone")).toEqual({
prose: "Done",
plan: null,
planComplete: false,
});
});
});
59 changes: 59 additions & 0 deletions packages/provider-opencode/src/server/ProposedPlanBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const OPEN_TAG = "<proposed_plan>";
const CLOSE_TAG = "</proposed_plan>";

export const PROPOSED_PLAN_BLOCK_INSTRUCTIONS = `When you present the final plan, wrap it in a \`<proposed_plan>\` 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 \`<proposed_plan>\` and \`</proposed_plan>\` (do not translate or rename them), even if the plan content is in another language.

Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. If the user asks for revisions after a prior \`<proposed_plan>\`, any new \`<proposed_plan>\` 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 `<proposed_plan>` 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]*<proposed_plan>[ \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 !== "",
};
}
Loading
Loading