diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 44632e02c66d5..f231200719fa6 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, @@ -712,6 +713,233 @@ 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 }), + }, + }, + "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(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 aad31d6363258..336c5c3135a92 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,8 @@ interface ActiveOpenCodeTurn { interrupted: boolean; finalized: boolean; planId: PlanId | null; + readonly planMode: boolean; + readonly proposedPlanIdsByPart: Map; admissionGeneration: number; admissionReconciliationGeneration: number | null; admissionPending: boolean; @@ -393,6 +400,7 @@ interface OpenCodeThreadState { parentSubagent: OpenCodeSubagentContext | null; nextChildTurnOrdinal: number; nextAdmissionGeneration: number; + latestProposedPlan: OrchestrationV2PlanArtifact | null; } interface OpenCodeRequestOwner { @@ -1196,16 +1204,122 @@ 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 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); @@ -1217,6 +1331,17 @@ 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 || 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", @@ -1252,7 +1377,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 +1409,7 @@ export function makeOpenCodeAdapterV2( updatedAt: emittedAt, type: "assistant_message", messageId, - text: part.text, + text: split?.prose ?? part.text, streaming: !isCompleted, }, }); @@ -1436,6 +1561,7 @@ export function makeOpenCodeAdapterV2( parentSubagent: context, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2061,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())) { @@ -2272,6 +2398,8 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + planMode: false, + proposedPlanIdsByPart: new Map(), admissionGeneration: 0, admissionReconciliationGeneration: null, admissionPending: false, @@ -2545,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"); } } }); @@ -2841,6 +2969,7 @@ export function makeOpenCodeAdapterV2( parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + latestProposedPlan: null, }; threads.set(nativeSession.id, state); return state; @@ -3183,6 +3312,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, @@ -3208,6 +3340,8 @@ export function makeOpenCodeAdapterV2( interrupted: false, finalized: false, planId: null, + planMode: agent === "plan", + proposedPlanIdsByPart: new Map(), admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, admissionPending: !isCompaction, @@ -3264,12 +3398,10 @@ export function makeOpenCodeAdapterV2( 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..bf8db15360e68 --- /dev/null +++ b/apps/server/src/provider/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/apps/server/src/provider/ProposedPlanBlock.ts b/apps/server/src/provider/ProposedPlanBlock.ts new file mode 100644 index 0000000000000..f9c45f6803810 --- /dev/null +++ b/apps/server/src/provider/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/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 7360650a3a998..cb372a7f9f983 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -597,6 +597,18 @@ type MessagesTimelineRowContent = htmlRender: HtmlRenderReference; }; +/** 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 89d44b6abf76d..c36d4fc160e90 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, @@ -2693,13 +2693,22 @@ function ProposedPlanTimelineRow({ return (
- + + +
); } diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 80eda86f5e9c7..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, @@ -39,14 +39,19 @@ 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); + useEffect(() => { + if (forceExpanded) setExpanded(true); + }, [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, 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