From 6815339c2411a3aa74b8180d782778b5f0038f5a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 13 Nov 2026 21:50:35 +0200 Subject: [PATCH 1/6] feat(rpc): plan mode for RPC hosts Oh My Pi plans only in its terminal and for ACP clients, so Scient Desktop could not offer plan mode for Scient Agent. A new plan command (enter, exit, get) gives an RPC host the same flow. In plan mode the agent reads and drafts a plan file but changes nothing else. When it proposes the plan through xd://propose, a plan_proposed frame carries the plan (for the host's plan card), and a select request asks Approve or Refine while the tool call waits. Only an explicit approval leaves plan mode; the same run then carries out the plan. Refine, a typed answer (sent to the model as feedback) or a cancelled question keeps planning. An answer that arrives after plan mode was left never approves. Unlike ACP, there is no auto-approval for a host without a question surface: a host that sends plan answers it. Planning is refused while a goal is set. Hosts that do not send plan are unaffected. Scient-Divergence: SA-PLAN-MODE Co-Authored-By: Claude Opus 5.5 --- SCIENT.md | 1 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 15 ++ .../coding-agent/src/modes/rpc/rpc-types.ts | 12 ++ .../src/modes/rpc/wire/commands.ts | 8 + .../coding-agent/src/modes/rpc/wire/frames.ts | 6 + .../coding-agent/src/modes/rpc/wire/index.ts | 3 + .../coding-agent/src/modes/rpc/wire/state.ts | 7 + packages/coding-agent/src/scient/rpc-plan.ts | 194 ++++++++++++++++++ .../coding-agent/test/scient/rpc-plan.test.ts | 153 ++++++++++++++ scripts/scient/divergences.json | 14 ++ 10 files changed, 413 insertions(+) create mode 100644 packages/coding-agent/src/scient/rpc-plan.ts create mode 100644 packages/coding-agent/test/scient/rpc-plan.test.ts diff --git a/SCIENT.md b/SCIENT.md index b6dbf8055c..c70991f366 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -25,6 +25,7 @@ Everything else is upstream's code and behavior. | Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. | | Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. | | Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. | +| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves. Planning is refused while a goal is set. | | Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. | | macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. | | Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. | diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1a45c46654..5a94dfb77d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -90,6 +90,9 @@ import { RpcNextMessagePredictor } from "../../scient/next-message"; // scient(SA-PROMPT-CACHE): begin - A host compacts an idle conversation before its prompt cache expires. import { withPromptCache } from "../../scient/prompt-cache"; // scient(SA-PROMPT-CACHE): end +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +import { RpcPlanController } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages"; import { RpcBtwController } from "./rpc-btw"; import { RpcGoalController } from "./rpc-goal"; @@ -1754,6 +1757,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions // A single shared instance routes all responses received on stdin to the // correct waiting promise regardless of which code path created the request. const rpcUiContext = new RpcExtensionUIContext(pendingExtensionRequests, output); + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + const planController = new RpcPlanController(session, rpcUiContext, output); + // scient(SA-PLAN-MODE): end setToolUIContext?.(rpcUiContext, true); const setAskDialog = (command: RpcCommand & { type: "set_ask_dialog" }): RpcResponse => { rpcUiContext.askDialogEnabled = command.enabled === true; @@ -2976,6 +2982,15 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions case "predict_next_message": return success(id, "predict_next_message", { text: await nextMessagePredictor.predict() }); // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + + case "plan": + try { + return success(id, "plan", await planController.handle(command)); + } catch (planError) { + return error(id, "plan", planError instanceof Error ? planError.message : String(planError)); + } + // scient(SA-PLAN-MODE): end default: { const unknownCommand = command as { type: string }; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index d09bf3e738..51ebfb7388 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -33,6 +33,9 @@ import type { LivePhase } from "@oh-my-pi/pi-tui/apps/live-visualizer"; import type { RpcMessagesPage } from "./rpc-messages"; import type { GoalModeState } from "../../goals/state"; import type { RpcGoalOp, RpcGoalResult } from "./rpc-goal"; +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +import type { RpcPlanOp, RpcPlanResult } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end import type { BtwHistoryRecord } from "../../session/btw-history"; // scient(SA-AUTH): begin - A host sees and signs out stored sign-ins. import type { RpcLoginProvider } from "../../scient/sign-in-providers"; @@ -177,6 +180,9 @@ export type RpcCommand = // scient(SA-NEXT-MESSAGE): begin - A host shows the agent's prediction of the user's next message. | { id?: string; type: "predict_next_message" } // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + | { id?: string; type: "plan"; op: RpcPlanOp } + // scient(SA-PLAN-MODE): end | { id?: string; type: "predict_word_feedback"; @@ -352,6 +358,9 @@ export type RpcLiveFrame = | RpcLiveEndFrame | RpcLiveDelegationFrame; // scient(SA-LIVE-HOST-DELEGATION): end +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +export type { RpcPlanProposedFrame } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end /** Draft-only dictation frames bypass the session event filter. */ export interface RpcDictationStateFrame { @@ -719,6 +728,9 @@ export type RpcResponse = // scient(SA-NEXT-MESSAGE): begin - A host shows the agent's prediction of the user's next message. | { id?: string; type: "response"; command: "predict_next_message"; success: true; data: { text: string | null } } // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + | { id?: string; type: "response"; command: "plan"; success: true; data: RpcPlanResult } + // scient(SA-PLAN-MODE): end // Side questions (/btw) | { id?: string; type: "response"; command: "btw"; success: true; data: { record: RpcBtwHistoryRecord } } diff --git a/packages/coding-agent/src/modes/rpc/wire/commands.ts b/packages/coding-agent/src/modes/rpc/wire/commands.ts index e67e811d7f..d34288a564 100644 --- a/packages/coding-agent/src/modes/rpc/wire/commands.ts +++ b/packages/coding-agent/src/modes/rpc/wire/commands.ts @@ -452,4 +452,12 @@ export const rpcCommands: readonly RpcCommandSpec[] = [ timeoutMs: 30_000, }, // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + { + name: "plan", + doc: "Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).", + params: { op: "PlanOp" }, + result: "PlanResult", + }, + // scient(SA-PLAN-MODE): end ]; diff --git a/packages/coding-agent/src/modes/rpc/wire/frames.ts b/packages/coding-agent/src/modes/rpc/wire/frames.ts index a9e158517b..2d8393e71f 100644 --- a/packages/coding-agent/src/modes/rpc/wire/frames.ts +++ b/packages/coding-agent/src/modes/rpc/wire/frames.ts @@ -132,6 +132,12 @@ export const frameDefs = { "A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`.", ), // scient(SA-LIVE-HOST-DELEGATION): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + PlanProposedEvent: doc( + { type: "'plan_proposed'", title: "string", planFilePath: "string", markdown: "string" }, + "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.", + ), + // scient(SA-PLAN-MODE): end DictationStateEvent: doc( { type: "'dictation_state'", captureId: "string", state: "'starting' | 'recording' | 'transcribing'" }, "State of one draft-only microphone capture.", diff --git a/packages/coding-agent/src/modes/rpc/wire/index.ts b/packages/coding-agent/src/modes/rpc/wire/index.ts index 1dbf7d8955..31e7516e39 100644 --- a/packages/coding-agent/src/modes/rpc/wire/index.ts +++ b/packages/coding-agent/src/modes/rpc/wire/index.ts @@ -78,6 +78,9 @@ const notificationDefs = { // scient(SA-LIVE-HOST-DELEGATION): begin - A host that owns its turns sends spoken requests itself. "LiveDelegationEvent", // scient(SA-LIVE-HOST-DELEGATION): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + "PlanProposedEvent", + // scient(SA-PLAN-MODE): end "DictationStateEvent", "DictationTranscriptEvent", "DictationEndEvent", diff --git a/packages/coding-agent/src/modes/rpc/wire/state.ts b/packages/coding-agent/src/modes/rpc/wire/state.ts index d42fecc8b3..b4f934d64d 100644 --- a/packages/coding-agent/src/modes/rpc/wire/state.ts +++ b/packages/coding-agent/src/modes/rpc/wire/state.ts @@ -20,6 +20,13 @@ export const stateDefs = { "`set_event_filter` projection: `delta` replaces the accumulated snapshots with the block a start/end event touches.", ), TodoStatus: "'pending' | 'in_progress' | 'completed' | 'abandoned' | 'blocked'", + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + PlanOp: "'get' | 'enter' | 'exit'", + PlanResult: { + enabled: doc("boolean", "Plan mode is on: the agent plans and changes nothing until a plan is approved."), + "planFilePath?": doc("string", "The plan file the agent drafts, while plan mode is on."), + }, + // scient(SA-PLAN-MODE): end GoalOp: "'get' | 'create' | 'resume' | 'pause' | 'drop'", GoalStatus: "'active' | 'paused' | 'budget-limited' | 'complete' | 'dropped'", SlashCommandSource: "'builtin' | 'skill' | 'extension' | 'custom' | 'mcp_prompt' | 'file'", diff --git a/packages/coding-agent/src/scient/rpc-plan.ts b/packages/coding-agent/src/scient/rpc-plan.ts new file mode 100644 index 0000000000..023085ff2e --- /dev/null +++ b/packages/coding-agent/src/scient/rpc-plan.ts @@ -0,0 +1,194 @@ +/** + * Plan mode for RPC hosts. Oh My Pi offers plan mode only in its terminal and to + * ACP clients; this controller gives an RPC host the same flow: + * + * - `plan enter` turns plan mode on: the agent may read and draft a plan file, + * but changes nothing else, until it proposes the plan by writing its title to + * `xd://propose`. + * - The proposal sends the plan to the host (`plan_proposed`), then asks Approve + * or Refine on the host's select card while the tool call waits. Approve leaves + * plan mode and the same run goes on to carry out the plan; Refine (or any + * other answer, which is read as feedback) keeps planning. A cancelled or + * abandoned question never approves: only an explicit Approve does. + * - `plan exit` leaves plan mode without a plan. + */ +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; +import { logger } from "@oh-my-pi/pi-utils"; +import type { ExtensionUIContext } from "../extensibility/extensions"; +import type { LocalProtocolOptions } from "../internal-urls"; +import { normalizePlanTitle, type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan"; +import { autosaveApprovedPlan } from "../plan-mode/plan-autosave"; +import { listPlanFiles, readPlanFile } from "../plan-mode/plan-files"; +import { cfgPlanEnabled } from "../plan-mode/settings"; +import type { AgentSession } from "../session/agent-session"; + +export const PLAN_APPROVE_OPTION = "Approve and carry out"; +export const PLAN_REFINE_OPTION = "Refine plan"; +const DEFAULT_PLAN_FILE_URL = "local://PLAN.md"; + +export type RpcPlanOp = "get" | "enter" | "exit"; + +export interface RpcPlanCommand { + op: RpcPlanOp; +} + +export interface RpcPlanResult { + /** Plan mode is on: the agent plans and changes nothing until a plan is approved. */ + enabled: boolean; + /** The plan file the agent drafts, while plan mode is on. */ + planFilePath?: string; +} + +/** The plan the agent proposed, sent before the host is asked to approve it. */ +export interface RpcPlanProposedFrame { + type: "plan_proposed"; + title: string; + planFilePath: string; + markdown: string; +} + +export type RpcPlanSession = Pick< + AgentSession, + | "settings" + | "sessionManager" + | "getPlanModeState" + | "setPlanModeState" + | "setPlanProposalHandler" + | "setPlanReferencePath" + | "sendPlanModeContext" + | "getGoalModeState" + | "isStreaming" +>; + +export class RpcPlanController { + readonly #session: RpcPlanSession; + readonly #ui: Pick; + readonly #output: (frame: RpcPlanProposedFrame) => void; + /** Bumped when plan mode is left, so an approval answered afterwards changes nothing. */ + #generation = 0; + + constructor( + session: RpcPlanSession, + ui: Pick, + output: (frame: RpcPlanProposedFrame) => void, + ) { + this.#session = session; + this.#ui = ui; + this.#output = output; + } + + get #state(): RpcPlanResult { + const state = this.#session.getPlanModeState(); + return state?.enabled ? { enabled: true, planFilePath: state.planFilePath } : { enabled: false }; + } + + async handle(command: RpcPlanCommand): Promise { + switch (command.op) { + case "get": + return this.#state; + case "enter": + await this.#enter(); + return this.#state; + case "exit": + this.#exit(); + return this.#state; + default: { + const op: never = command.op; + throw new Error(`Unknown plan op: ${String(op)}`); + } + } + } + + async #enter(): Promise { + const session = this.#session; + if (session.getPlanModeState()?.enabled) return; + if (!cfgPlanEnabled.get(session.settings)) throw new Error("Plan mode is disabled (plan.enabled)."); + if (session.getGoalModeState()) throw new Error("Drop or finish the goal before planning."); + const previous = session.getPlanModeState(); + session.setPlanModeState({ + enabled: true, + planFilePath: previous?.planFilePath ?? DEFAULT_PLAN_FILE_URL, + workflow: previous?.workflow ?? "parallel", + reentry: previous !== undefined, + }); + const generation = this.#generation; + session.setPlanProposalHandler(title => this.#propose(title, generation)); + if (session.isStreaming) await session.sendPlanModeContext({ deliverAs: "steer" }); + } + + #exit(): void { + this.#generation++; + this.#session.setPlanProposalHandler(null); + this.#session.setPlanModeState(undefined); + } + + #localProtocolOptions(): LocalProtocolOptions { + const manager = this.#session.sessionManager; + return { getArtifactsDir: () => manager.getArtifactsDir(), getSessionId: () => manager.getSessionId() }; + } + + async #propose(title: string, generation: number): Promise> { + const session = this.#session; + const state = session.getPlanModeState(); + if (!state?.enabled || generation !== this.#generation) throw new ToolError("Plan mode is not active."); + const localProtocolOptions = this.#localProtocolOptions(); + const cwd = session.sessionManager.getCwd(); + const plan = await resolveApprovedPlan({ + suppliedTitle: title, + statePlanFilePath: state.planFilePath, + readPlan: url => readPlanFile(url, { localProtocolOptions, cwd }), + listPlanFiles: () => listPlanFiles({ localProtocolOptions }), + }); + const details: PlanApprovalDetails = { planFilePath: plan.planFilePath, title: plan.title, planExists: true }; + this.#output({ + type: "plan_proposed", + title: plan.title, + planFilePath: plan.planFilePath, + markdown: plan.planContent, + }); + const answer = ( + await this.#ui.select(`Carry out the plan "${plan.title}"?`, [PLAN_APPROVE_OPTION, PLAN_REFINE_OPTION]) + )?.trim(); + if (generation !== this.#generation) throw new ToolError("Plan mode ended before the plan was approved."); + if (answer !== PLAN_APPROVE_OPTION) { + // The next planning turn targets the plan just reviewed. + if (state.planFilePath !== plan.planFilePath) + session.setPlanModeState({ ...state, planFilePath: plan.planFilePath }); + const title = normalizePlanTitle(plan.title).title; + // Without feedback, the user says what to change in their next message. + const text = + answer && answer !== PLAN_REFINE_OPTION + ? `The plan was not approved. The user asked: ${answer} Update the plan file, then write ${title} to xd://propose again when it is ready.` + : `The plan was not approved. Stop here and ask the user what to change; plan mode stays on. When they answer, update the plan file and write ${title} to xd://propose again.`; + return { + content: [{ type: "text", text }], + details, + }; + } + session.setPlanReferencePath(plan.planFilePath); + this.#exit(); + session.sessionManager.appendModeChange("none", { approvedPlanFilePath: plan.planFilePath }); + const autosaved = await autosaveApprovedPlan({ + settings: session.settings, + cwd, + title: plan.title, + planContent: plan.planContent, + }).then( + () => true, + error => { + logger.warn("Failed to autosave approved plan", { error: String(error) }); + return false; + }, + ); + return { + content: [ + { + type: "text", + text: `Plan approved at ${plan.planFilePath}. Plan mode exited; proceed with the implementation.${autosaved ? "" : " (Plan autosave failed; continuing.)"}`, + }, + ], + details, + }; + } +} diff --git a/packages/coding-agent/test/scient/rpc-plan.test.ts b/packages/coding-agent/test/scient/rpc-plan.test.ts new file mode 100644 index 0000000000..4e35b9295d --- /dev/null +++ b/packages/coding-agent/test/scient/rpc-plan.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resolvePlanFilePath } from "@oh-my-pi/pi-coding-agent/plan-mode/plan-files"; +import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; +import { + PLAN_APPROVE_OPTION, + PLAN_REFINE_OPTION, + RpcPlanController, + type RpcPlanProposedFrame, + type RpcPlanSession, +} from "@oh-my-pi/pi-coding-agent/scient/rpc-plan"; +import type { PlanProposalHandler } from "@oh-my-pi/pi-coding-agent/tools/resolve"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +describe("RPC plan mode", () => { + let tempDir: TempDir; + let planState: PlanModeState | undefined; + let handler: PlanProposalHandler | undefined; + let referencePath: string | undefined; + let modeChanges: Array<{ mode: string; data?: Record }>; + let goalActive: boolean; + let frames: RpcPlanProposedFrame[]; + let answers: Array>; + let sessionId: string; + + async function controller(): Promise { + const settings = await Settings.loadIsolated({ agentDir: tempDir.path(), cwd: tempDir.path() }); + const session = { + settings, + sessionManager: { + getCwd: () => tempDir.path(), + getArtifactsDir: () => path.join(tempDir.path(), "artifacts"), + getSessionId: () => sessionId, + appendModeChange: (mode: string, data?: Record) => { + modeChanges.push({ mode, data }); + }, + }, + getPlanModeState: () => planState, + setPlanModeState: (state: PlanModeState | undefined) => { + planState = state; + }, + setPlanProposalHandler: (next: PlanProposalHandler | null) => { + handler = next ?? undefined; + }, + setPlanReferencePath: (next: string) => { + referencePath = next; + }, + sendPlanModeContext: async () => {}, + getGoalModeState: () => (goalActive ? { enabled: true } : undefined), + isStreaming: false, + } as unknown as RpcPlanSession; + return new RpcPlanController( + session, + { + select: () => { + const answer = Promise.withResolvers(); + answers.push(answer); + return answer.promise; + }, + }, + frame => frames.push(frame), + ); + } + + /** Enters plan mode and drafts the plan file the agent would write. */ + async function draft(plans: RpcPlanController, markdown = "# Fit the model\n\n1. Load data\n"): Promise { + expect(await plans.handle({ op: "enter" })).toEqual({ enabled: true, planFilePath: "local://PLAN.md" }); + const localProtocolOptions = { + getArtifactsDir: () => path.join(tempDir.path(), "artifacts"), + getSessionId: () => sessionId, + }; + await Bun.write(resolvePlanFilePath("local://PLAN.md", { localProtocolOptions, cwd: tempDir.path() }), markdown); + } + + /** The agent proposes; resolves once the host has been asked, with the pending tool result. */ + async function propose(): Promise<{ result: Promise }> { + if (!handler) throw new Error("plan mode installed no proposal handler"); + const asked = answers.length; + const result = handler("PLAN"); + while (answers.length === asked) await Promise.race([result, Bun.sleep(1)]); + return { result }; + } + + const resultText = (result: unknown) => (result as { content: Array<{ text: string }> }).content[0]!.text; + + beforeEach(() => { + tempDir = TempDir.createSync("@scient-rpc-plan-"); + planState = undefined; + handler = undefined; + referencePath = undefined; + modeChanges = []; + goalActive = false; + frames = []; + answers = []; + sessionId = `scient-rpc-plan-${crypto.randomUUID()}`; + }); + + afterEach(async () => { + try { + await tempDir.remove(); + } catch {} + }); + + it("shows the proposed plan, and an approval leaves plan mode so the run carries it out", async () => { + const plans = await controller(); + await draft(plans); + const { result } = await propose(); + expect(frames).toMatchObject([{ type: "plan_proposed", markdown: "# Fit the model\n\n1. Load data\n" }]); + answers[0]!.resolve(PLAN_APPROVE_OPTION); + + expect(resultText(await result)).toStartWith(`Plan approved at ${frames[0]!.planFilePath}.`); + expect(await plans.handle({ op: "get" })).toEqual({ enabled: false }); + expect(handler).toBeUndefined(); + expect(referencePath).toBe(frames[0]!.planFilePath); + expect(modeChanges).toEqual([{ mode: "none", data: { approvedPlanFilePath: frames[0]!.planFilePath } }]); + }); + + it("keeps planning on Refine, a typed answer, or a cancelled question", async () => { + const plans = await controller(); + await draft(plans); + for (const [answer, expected] of [ + [PLAN_REFINE_OPTION, "Stop here and ask the user what to change"], + ["Use a mixed model instead", "The user asked: Use a mixed model instead"], + [undefined, "Stop here and ask the user what to change"], + ] as const) { + const { result } = await propose(); + answers.at(-1)!.resolve(answer); + expect(resultText(await result)).toContain(expected); + expect((await plans.handle({ op: "get" })).enabled).toBe(true); + } + expect(referencePath).toBeUndefined(); + }); + + it("never approves an answer that arrives after plan mode was left", async () => { + const plans = await controller(); + await draft(plans); + const { result } = await propose(); + await plans.handle({ op: "exit" }); + answers[0]!.resolve(PLAN_APPROVE_OPTION); + + await expect(result).rejects.toThrow("Plan mode ended before the plan was approved."); + expect(referencePath).toBeUndefined(); + expect(modeChanges).toEqual([]); + }); + + it("refuses to plan while a goal is set", async () => { + goalActive = true; + const plans = await controller(); + await expect(plans.handle({ op: "enter" })).rejects.toThrow("Drop or finish the goal before planning."); + expect(planState).toBeUndefined(); + }); +}); diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index c265c3691f..bb8567b70f 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -389,6 +389,20 @@ "packages/coding-agent/src/modes/rpc/wire/events.ts" ], "verification": ["bun --cwd=packages/coding-agent test test/scient/prompt-cache.test.ts"] + }, + "SA-PLAN-MODE": { + "status": "active", + "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", + "owned": ["packages/coding-agent/src/scient/rpc-plan.ts", "packages/coding-agent/test/scient/rpc-plan.test.ts"], + "upstream": [ + "packages/coding-agent/src/modes/rpc/rpc-mode.ts", + "packages/coding-agent/src/modes/rpc/rpc-types.ts", + "packages/coding-agent/src/modes/rpc/wire/commands.ts", + "packages/coding-agent/src/modes/rpc/wire/frames.ts", + "packages/coding-agent/src/modes/rpc/wire/index.ts", + "packages/coding-agent/src/modes/rpc/wire/state.ts" + ], + "verification": ["bun --cwd=packages/coding-agent test test/scient/rpc-plan.test.ts"] } } } From 63413475c8d5192d38d09d497fba5fdbaa39a309 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 13 Nov 2026 21:54:59 +0200 Subject: [PATCH 2/6] chore(rpc): regenerate clients for plan mode (generated) Scient-Generator: rpc Co-Authored-By: Claude Opus 5.5 --- .../src/modes/rpc/wire/rpc-wire.generated.ts | 24 ++++- .../src/modes/rpc/wire/rpc-wire.schema.json | 73 +++++++++++++++ sdk/go/omp-rpc/wire.go | 92 +++++++++++++++++++ sdk/python/omp-rpc/src/omp_rpc/_wire.py | 58 +++++++++++- sdk/rust/omp-rpc/src/wire.rs | 66 ++++++++++++- 5 files changed, 310 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts index 3b91dd8665..e05eb6710c 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts @@ -392,6 +392,15 @@ export type MessageUpdates = "full" | "delta"; export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked"; +export type PlanOp = "get" | "enter" | "exit"; + +export interface PlanResult { + /** Plan mode is on: the agent plans and changes nothing until a plan is approved. */ + enabled: boolean; + /** The plan file the agent drafts, while plan mode is on. */ + planFilePath?: string; +} + export type GoalOp = "get" | "create" | "resume" | "pause" | "drop"; export type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped"; @@ -1223,6 +1232,14 @@ export interface LiveDelegationEvent { text: string; } +/** The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. */ +export interface PlanProposedEvent { + type: "plan_proposed"; + title: string; + planFilePath: string; + markdown: string; +} + /** State of one draft-only microphone capture. */ export interface DictationStateEvent { type: "dictation_state"; @@ -1580,7 +1597,7 @@ export interface HostUriSchemeDefinition { } /** Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`. */ -export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; +export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; /** Any frame the server writes to stdout (after reassembling `rpc_chunk` sequences), discriminated by `type`. */ export type RpcServerFrame = RpcResponse | RpcHostRequest | RpcNotification; @@ -1980,6 +1997,10 @@ export interface PredictNextMessageResult { text: string | null; } +export interface PlanParams { + op: PlanOp; +} + /** Every RPC command's parameters and successful response `data`. */ export interface RpcWireCommands { negotiate_protocol: { params: NegotiateProtocolParams; result: NegotiateProtocolResult }; @@ -2059,4 +2080,5 @@ export interface RpcWireCommands { btw_cancel: { params: BtwCancelParams; result: BtwCancelResult }; get_btw_history: { params: undefined; result: GetBtwHistoryResult }; predict_next_message: { params: undefined; result: PredictNextMessageResult }; + plan: { params: PlanParams; result: PlanResult }; } diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json index 8a5b353de9..feb5fd7b0c 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json @@ -857,6 +857,17 @@ "timeoutMs": 30000, "completion": null, "clientOmit": [] + }, + { + "name": "plan", + "doc": "Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).", + "params": "PlanParams", + "result": "PlanResult", + "nullable": false, + "unwrap": null, + "timeoutMs": null, + "completion": null, + "clientOmit": [] } ] }, @@ -2334,6 +2345,30 @@ ], "type": "string" }, + "PlanOp": { + "enum": [ + "get", + "enter", + "exit" + ], + "type": "string" + }, + "PlanResult": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Plan mode is on: the agent plans and changes nothing until a plan is approved." + }, + "planFilePath": { + "type": "string", + "description": "The plan file the agent drafts, while plan mode is on." + } + }, + "required": [ + "enabled" + ] + }, "GoalOp": { "enum": [ "get", @@ -5047,6 +5082,30 @@ ], "description": "A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`." }, + "PlanProposedEvent": { + "type": "object", + "properties": { + "type": { + "const": "plan_proposed" + }, + "title": { + "type": "string" + }, + "planFilePath": { + "type": "string" + }, + "markdown": { + "type": "string" + } + }, + "required": [ + "type", + "title", + "planFilePath", + "markdown" + ], + "description": "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it." + }, "DictationStateEvent": { "type": "object", "properties": { @@ -6258,6 +6317,9 @@ { "$ref": "#/$defs/LiveDelegationEvent" }, + { + "$ref": "#/$defs/PlanProposedEvent" + }, { "$ref": "#/$defs/DictationStateEvent" }, @@ -7469,6 +7531,17 @@ "required": [ "text" ] + }, + "PlanParams": { + "type": "object", + "properties": { + "op": { + "$ref": "#/$defs/PlanOp" + } + }, + "required": [ + "op" + ] } } } diff --git a/sdk/go/omp-rpc/wire.go b/sdk/go/omp-rpc/wire.go index bbfe90d937..b5b8d784b1 100644 --- a/sdk/go/omp-rpc/wire.go +++ b/sdk/go/omp-rpc/wire.go @@ -2031,6 +2031,50 @@ func (v *TodoStatus) UnmarshalJSON(data []byte) error { return unknownValue("TodoStatus", s) } +type PlanOp string + +const ( + PlanOpGet PlanOp = "get" + PlanOpEnter PlanOp = "enter" + PlanOpExit PlanOp = "exit" +) + +func (v *PlanOp) UnmarshalJSON(data []byte) error { + s, err := decodeString(data, "PlanOp") + if err != nil { + return err + } + switch value := PlanOp(s); value { + case PlanOpGet, PlanOpEnter, PlanOpExit: + *v = value + return nil + } + return unknownValue("PlanOp", s) +} + +type PlanResult struct { + // Plan mode is on: the agent plans and changes nothing until a plan is approved. + Enabled bool `json:"enabled"` + // The plan file the agent drafts, while plan mode is on. + PlanFilePath *string `json:"planFilePath,omitempty"` +} + +func (v *PlanResult) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanResult", v.decodeFrom) +} + +func (v *PlanResult) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanResult + d := fieldDecoder{raw: raw, owner: "PlanResult"} + d.required("enabled", &out.Enabled) + d.optional("planFilePath", &out.PlanFilePath) + if d.err != nil { + return d.err + } + *v = out + return nil +} + type GoalOp string const ( @@ -5404,6 +5448,36 @@ func (v LiveDelegationEvent) MarshalJSON() ([]byte, error) { return encodeObject(plain(v), `"type":"live_delegation"`, nil) } +// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. +type PlanProposedEvent struct { + Title string `json:"title"` + PlanFilePath string `json:"planFilePath"` + Markdown string `json:"markdown"` +} + +func (v *PlanProposedEvent) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanProposedEvent", v.decodeFrom) +} + +func (v *PlanProposedEvent) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanProposedEvent + d := fieldDecoder{raw: raw, owner: "PlanProposedEvent"} + d.constant("type", "plan_proposed") + d.required("title", &out.Title) + d.required("planFilePath", &out.PlanFilePath) + d.required("markdown", &out.Markdown) + if d.err != nil { + return d.err + } + *v = out + return nil +} + +func (v PlanProposedEvent) MarshalJSON() ([]byte, error) { + type plain PlanProposedEvent + return encodeObject(plain(v), `"type":"plan_proposed"`, nil) +} + // State of one draft-only microphone capture. type DictationStateEvent struct { CaptureID string `json:"captureId"` @@ -6980,6 +7054,7 @@ func (LiveLevelsEvent) isRpcNotification() {} func (LiveTranscriptEvent) isRpcNotification() {} func (LiveEndEvent) isRpcNotification() {} func (LiveDelegationEvent) isRpcNotification() {} +func (PlanProposedEvent) isRpcNotification() {} func (DictationStateEvent) isRpcNotification() {} func (DictationTranscriptEvent) isRpcNotification() {} func (DictationEndEvent) isRpcNotification() {} @@ -7064,6 +7139,8 @@ func (v *RpcNotification) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveEndEvent](raw) case "live_delegation": value, err = decodeVariant[LiveDelegationEvent](raw) + case "plan_proposed": + value, err = decodeVariant[PlanProposedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": @@ -7188,6 +7265,7 @@ func (LiveLevelsEvent) isRpcServerFrame() {} func (LiveTranscriptEvent) isRpcServerFrame() {} func (LiveEndEvent) isRpcServerFrame() {} func (LiveDelegationEvent) isRpcServerFrame() {} +func (PlanProposedEvent) isRpcServerFrame() {} func (DictationStateEvent) isRpcServerFrame() {} func (DictationTranscriptEvent) isRpcServerFrame() {} func (DictationEndEvent) isRpcServerFrame() {} @@ -7282,6 +7360,8 @@ func (v *RpcServerFrame) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveEndEvent](raw) case "live_delegation": value, err = decodeVariant[LiveDelegationEvent](raw) + case "plan_proposed": + value, err = decodeVariant[PlanProposedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": @@ -8824,3 +8904,15 @@ func (c Commands) PredictNextMessage(ctx context.Context) (*string, error) { err := c.call(ctx, "predict_next_message", nil, 30*time.Second, &out) return out.Text, err } + +// PlanCommand holds the parameters of "plan". +type PlanCommand struct { + Op PlanOp `json:"op"` +} + +// Plan sends "plan": Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request). +func (c Commands) Plan(ctx context.Context, p PlanCommand) (PlanResult, error) { + var out PlanResult + err := c.call(ctx, "plan", p, 0, &out) + return out, err +} diff --git a/sdk/python/omp-rpc/src/omp_rpc/_wire.py b/sdk/python/omp-rpc/src/omp_rpc/_wire.py index f671cfa940..0dfbffec9e 100644 --- a/sdk/python/omp-rpc/src/omp_rpc/_wire.py +++ b/sdk/python/omp-rpc/src/omp_rpc/_wire.py @@ -98,6 +98,11 @@ _decode_todo_status = cast("Decoder[TodoStatus]", literal(_TODO_STATUS_VALUES)) +PlanOp: TypeAlias = Literal["get", "enter", "exit"] +_PLAN_OP_VALUES: Final[frozenset[str]] = frozenset({"get", "enter", "exit"}) +_decode_plan_op = cast("Decoder[PlanOp]", literal(_PLAN_OP_VALUES)) + + GoalOp: TypeAlias = Literal["get", "create", "resume", "pause", "drop"] _GOAL_OP_VALUES: Final[frozenset[str]] = frozenset({"get", "create", "resume", "pause", "drop"}) _decode_goal_op = cast("Decoder[GoalOp]", literal(_GOAL_OP_VALUES)) @@ -553,6 +558,14 @@ class ModelInfo: compat: JsonObject | None = None +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanResult: + enabled: bool + """Plan mode is on: the agent plans and changes nothing until a plan is approved.""" + plan_file_path: str | None = None + """The plan file the agent drafts, while plan mode is on.""" + + @dataclass(slots=True, frozen=True, kw_only=True) class TodoItem: content: str @@ -1435,6 +1448,15 @@ class LiveDelegationEvent: text: str +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanProposedEvent: + """The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.""" + type: Literal["plan_proposed"] = "plan_proposed" + title: str + plan_file_path: str + markdown: str + + @dataclass(slots=True, frozen=True, kw_only=True) class DictationStateEvent: """State of one draft-only microphone capture.""" @@ -1754,7 +1776,7 @@ class LogoutResult: """A session event, discriminated by `type`; `set_event_filter` selects which are sent.""" -RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification +RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification """Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`.""" @@ -1971,6 +1993,14 @@ def parse_model_info(value: object, path: str = "ModelInfo") -> ModelInfo: ) +def parse_plan_result(value: object, path: str = "PlanResult") -> PlanResult: + payload = expect_object(value, path) + return PlanResult( + enabled=required(payload, "enabled", decode_bool, path), + plan_file_path=optional(payload, "planFilePath", decode_str, path), + ) + + def parse_todo_item(value: object, path: str = "TodoItem") -> TodoItem: payload = expect_object(value, path) return TodoItem( @@ -2939,6 +2969,16 @@ def parse_live_delegation_event(value: object, path: str = "LiveDelegationEvent" ) +def parse_plan_proposed_event(value: object, path: str = "PlanProposedEvent") -> PlanProposedEvent: + payload = expect_object(value, path) + required(payload, "type", cast('Decoder[Literal["plan_proposed"]]', literal(frozenset({"plan_proposed"}))), path) + return PlanProposedEvent( + title=required(payload, "title", decode_str, path), + plan_file_path=required(payload, "planFilePath", decode_str, path), + markdown=required(payload, "markdown", decode_str, path), + ) + + def parse_dictation_state_event(value: object, path: str = "DictationStateEvent") -> DictationStateEvent: payload = expect_object(value, path) required(payload, "type", cast('Decoder[Literal["dictation_state"]]', literal(frozenset({"dictation_state"}))), path) @@ -3357,6 +3397,7 @@ def parse_notification(value: object, path: str = "notification") -> RpcNotifica "live_transcript": parse_live_transcript_event, "live_end": parse_live_end_event, "live_delegation": parse_live_delegation_event, + "plan_proposed": parse_plan_proposed_event, "dictation_state": parse_dictation_state_event, "dictation_transcript": parse_dictation_transcript_event, "dictation_end": parse_dictation_end_event, @@ -3877,6 +3918,12 @@ def predict_next_message(self) -> str | None: params: dict[str, object] = {} return required(expect_object(self._command("predict_next_message", params, timeout=30), "predict_next_message"), "text", nullable(decode_str), "predict_next_message") + def plan(self, op: PlanOp) -> PlanResult: + """Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).""" + params: dict[str, object] = {} + params["op"] = op + return parse_plan_result(self._command("plan", params), "plan") + def on_ready(self, listener: Callable[[ReadyEvent], None]) -> Callable[[], None]: """Subscribe to `ready`: First frame after startup; transport fields are absent on servers without protocol v2.""" return self._listen("ready", listener) @@ -3933,6 +3980,10 @@ def on_live_delegation(self, listener: Callable[[LiveDelegationEvent], None]) -> """Subscribe to `live_delegation`: A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`.""" return self._listen("live_delegation", listener) + def on_plan_proposed(self, listener: Callable[[PlanProposedEvent], None]) -> Callable[[], None]: + """Subscribe to `plan_proposed`: The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.""" + return self._listen("plan_proposed", listener) + def on_dictation_state(self, listener: Callable[[DictationStateEvent], None]) -> Callable[[], None]: """Subscribe to `dictation_state`: State of one draft-only microphone capture.""" return self._listen("dictation_state", listener) @@ -4227,6 +4278,9 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "OpenSessionResult", "OpenUrlUiRequest", "ParentIrc", + "PlanOp", + "PlanProposedEvent", + "PlanResult", "PromoteQueuedMessageResult", "PromptAck", "PromptCacheEntry", @@ -4414,6 +4468,8 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "parse_open_session_result", "parse_open_url_ui_request", "parse_parent_irc", + "parse_plan_proposed_event", + "parse_plan_result", "parse_promote_queued_message_result", "parse_prompt_ack", "parse_prompt_cache_entry", diff --git a/sdk/rust/omp-rpc/src/wire.rs b/sdk/rust/omp-rpc/src/wire.rs index 14df3f071f..8ec4c6f9f5 100644 --- a/sdk/rust/omp-rpc/src/wire.rs +++ b/sdk/rust/omp-rpc/src/wire.rs @@ -2778,6 +2778,36 @@ impl TodoStatus { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PlanOp { + #[serde(rename = "get")] + Get, + #[serde(rename = "enter")] + Enter, + #[serde(rename = "exit")] + Exit, +} + +impl PlanOp { + /// Wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + Self::Enter => "enter", + Self::Exit => "exit", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanResult { + /// Plan mode is on: the agent plans and changes nothing until a plan is approved. + pub enabled: bool, + /// The plan file the agent drafts, while plan mode is on. + #[serde(rename = "planFilePath", default, skip_serializing_if = "Option::is_none")] + pub plan_file_path: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum GoalOp { #[serde(rename = "get")] @@ -4410,6 +4440,15 @@ pub struct LiveDelegationEvent { pub text: String, } +/// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanProposedEvent { + pub title: String, + #[serde(rename = "planFilePath")] + pub plan_file_path: String, + pub markdown: String, +} + /// State of one draft-only microphone capture. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DictationStateEvent { @@ -5137,6 +5176,8 @@ pub enum RpcNotification { LiveEnd(LiveEndEvent), /// A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`. LiveDelegation(LiveDelegationEvent), + /// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. + PlanProposed(PlanProposedEvent), /// State of one draft-only microphone capture. DictationState(DictationStateEvent), /// Full accumulated draft preview; replaces the previous preview for this capture. @@ -5179,6 +5220,7 @@ impl RpcNotification { Some("live_transcript") => |value| serde_json::from_value(value).map(Self::LiveTranscript), Some("live_end") => |value| serde_json::from_value(value).map(Self::LiveEnd), Some("live_delegation") => |value| serde_json::from_value(value).map(Self::LiveDelegation), + Some("plan_proposed") => |value| serde_json::from_value(value).map(Self::PlanProposed), Some("dictation_state") => |value| serde_json::from_value(value).map(Self::DictationState), Some("dictation_transcript") => |value| serde_json::from_value(value).map(Self::DictationTranscript), Some("dictation_end") => |value| serde_json::from_value(value).map(Self::DictationEnd), @@ -5212,6 +5254,7 @@ impl Serialize for RpcNotification { Self::LiveTranscript(member) => serialize_tagged(member, &[("type", "live_transcript")], serializer), Self::LiveEnd(member) => serialize_tagged(member, &[("type", "live_end")], serializer), Self::LiveDelegation(member) => serialize_tagged(member, &[("type", "live_delegation")], serializer), + Self::PlanProposed(member) => serialize_tagged(member, &[("type", "plan_proposed")], serializer), Self::DictationState(member) => serialize_tagged(member, &[("type", "dictation_state")], serializer), Self::DictationTranscript(member) => serialize_tagged(member, &[("type", "dictation_transcript")], serializer), Self::DictationEnd(member) => serialize_tagged(member, &[("type", "dictation_end")], serializer), @@ -5252,7 +5295,7 @@ impl RpcServerFrame { let decode: fn(Value) -> Result = match value.get("type").and_then(Value::as_str) { Some("response") => |value| serde_json::from_value(value).map(Self::Response), Some("host_tool_call" | "host_tool_cancel" | "host_uri_request" | "host_uri_cancel") => |value| serde_json::from_value(value).map(Self::RpcHostRequest), - Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), + Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "plan_proposed" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), _ => |value| Ok(Self::Unknown(value)), }; decode(value) @@ -5824,6 +5867,11 @@ pub struct PredictNextMessageResult { pub text: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanParams { + pub op: PlanOp, +} + /// The constant `"image"`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct LitImage; @@ -7608,3 +7656,19 @@ impl Command for PredictNextMessageCommand { serde_json::from_value::(data.unwrap_or_else(|| Value::Object(Map::new()))).map(|result| result.text) } } + +/// Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanCommand { + pub op: PlanOp, +} + +impl Command for PlanCommand { + const NAME: &'static str = "plan"; + const TIMEOUT_MS: Option = None; + type Output = PlanResult; + + fn decode(data: Option) -> Result { + serde_json::from_value::(data.unwrap_or_else(|| Value::Object(Map::new()))) + } +} From 20f8534da75f95d4cdf643eb1ed4cdccf2c826d9 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sat, 14 Nov 2026 19:24:39 +0200 Subject: [PATCH 3/6] fix(rpc): leave plan mode when the host changes conversation A proposal still waiting for approval belonged to the old conversation. After new_session or switch_session, approving it cleared plan mode in the new conversation and recorded the old plan's approval there. A new, switched or forked conversation now leaves plan mode, so the old answer is refused like any answer after plan mode ended. Scient-Divergence: SA-PLAN-MODE Co-Authored-By: Claude Opus 5.5 --- SCIENT.md | 2 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 6 +++ .../coding-agent/test/scient/rpc-plan.test.ts | 43 ++++++++++++++++++- scripts/scient/divergences.json | 2 +- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/SCIENT.md b/SCIENT.md index c70991f366..b0a666acf3 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -25,7 +25,7 @@ Everything else is upstream's code and behavior. | Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. | | Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. | | Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. | -| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves. Planning is refused while a goal is set. | +| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Planning is refused while a goal is set. | | Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. | | macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. | | Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. | diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 5a94dfb77d..457e76f221 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -1826,6 +1826,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions // close the prompts it was answering. Branch and navigation leave a live // run streaming to its normal yield. if (detachesRun) promptResults.abortOpen(); + // scient(SA-PLAN-MODE): begin - A plan never carries into another conversation. + if (detachesRun) await planController.handle({ op: "exit" }); + // scient(SA-PLAN-MODE): end void settleWatcher.check(); } } @@ -2206,6 +2209,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions inputGate.commitSessionChange(command); // `branch` leaves a live run streaming to its normal yield; new/switch detach it. if (command.type !== "branch" && command.type !== "fork") promptResults.abortOpen(); + // scient(SA-PLAN-MODE): begin - A plan never carries into another conversation. + if (command.type !== "branch") await planController.handle({ op: "exit" }); + // scient(SA-PLAN-MODE): end // The detached run publishes no terminal agent_end to settle on. void settleWatcher.check(); await emitAvailableCommandsUpdate(); diff --git a/packages/coding-agent/test/scient/rpc-plan.test.ts b/packages/coding-agent/test/scient/rpc-plan.test.ts index 4e35b9295d..cdcade57a7 100644 --- a/packages/coding-agent/test/scient/rpc-plan.test.ts +++ b/packages/coding-agent/test/scient/rpc-plan.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolvePlanFilePath } from "@oh-my-pi/pi-coding-agent/plan-mode/plan-files"; @@ -11,7 +13,7 @@ import { type RpcPlanSession, } from "@oh-my-pi/pi-coding-agent/scient/rpc-plan"; import type { PlanProposalHandler } from "@oh-my-pi/pi-coding-agent/tools/resolve"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { readLines, removeWithRetries, TempDir } from "@oh-my-pi/pi-utils"; describe("RPC plan mode", () => { let tempDir: TempDir; @@ -151,3 +153,42 @@ describe("RPC plan mode", () => { expect(planState).toBeUndefined(); }); }); + +describe("RPC plan mode across sessions", () => { + it("leaves plan mode when the host starts another conversation, so an old approval cannot touch it", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "scient-rpc-plan-")); + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "..", "fixtures", "live-rpc-agent.ts")], { + cwd: dir, + env: { ...process.env, SCIENT_AGENT_DIR: dir, PI_NO_TITLE: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new Response(child.stderr).text(); + const lines = readLines(child.stdout, AbortSignal.timeout(30_000)); + const decoder = new TextDecoder(); + const receive = async () => { + const line = await lines.next(); + if (line.done) throw new Error(await stderr); + return JSON.parse(decoder.decode(line.value)) as Record; + }; + const command = async (id: string, frame: object) => { + child.stdin.write(`${JSON.stringify({ ...frame, id })}\n`); + await child.stdin.flush(); + for (;;) { + const reply = await receive(); + if (reply.type === "response" && reply.id === id) return reply; + } + }; + try { + while ((await receive()).type !== "ready") {} + expect(await command("enter", { type: "plan", op: "enter" })).toMatchObject({ data: { enabled: true } }); + expect(await command("new", { type: "new_session" })).toMatchObject({ success: true }); + expect(await command("get", { type: "plan", op: "get" })).toMatchObject({ data: { enabled: false } }); + } finally { + child.kill(); + await child.exited; + await removeWithRetries(dir); + } + }, 60_000); +}); diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index bb8567b70f..750dfe9c07 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -392,7 +392,7 @@ }, "SA-PLAN-MODE": { "status": "active", - "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", + "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", "owned": ["packages/coding-agent/src/scient/rpc-plan.ts", "packages/coding-agent/test/scient/rpc-plan.test.ts"], "upstream": [ "packages/coding-agent/src/modes/rpc/rpc-mode.ts", From 1d09dd60b02a4d654564997867985cb64106b32a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sat, 14 Nov 2026 20:14:45 +0200 Subject: [PATCH 4/6] feat(rpc): tell hosts when plan mode turns on or off Approving a plan, or changing conversation, leaves plan mode without the host asking. A plan_updated frame ({ enabled, planFilePath? }) now reports every change, so Scient Desktop can show whether the agent is planning. Scient-Divergence: SA-PLAN-MODE Co-Authored-By: Claude Opus 5.5 --- SCIENT.md | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 2 +- .../coding-agent/src/modes/rpc/wire/frames.ts | 4 ++++ .../coding-agent/src/modes/rpc/wire/index.ts | 1 + packages/coding-agent/src/scient/rpc-plan.ts | 13 +++++++++++-- .../coding-agent/test/scient/rpc-plan.test.ts | 17 ++++++++++++----- scripts/scient/divergences.json | 2 +- 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/SCIENT.md b/SCIENT.md index b0a666acf3..22b5818ecf 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -25,7 +25,7 @@ Everything else is upstream's code and behavior. | Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. | | Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. | | Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. | -| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Planning is refused while a goal is set. | +| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Each time plan mode turns on or off, a `plan_updated` frame tells the host. Planning is refused while a goal is set. | | Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. | | macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. | | Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. | diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 51ebfb7388..659e38cc5d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -359,7 +359,7 @@ export type RpcLiveFrame = | RpcLiveDelegationFrame; // scient(SA-LIVE-HOST-DELEGATION): end // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. -export type { RpcPlanProposedFrame } from "../../scient/rpc-plan"; +export type { RpcPlanProposedFrame, RpcPlanUpdatedFrame } from "../../scient/rpc-plan"; // scient(SA-PLAN-MODE): end /** Draft-only dictation frames bypass the session event filter. */ diff --git a/packages/coding-agent/src/modes/rpc/wire/frames.ts b/packages/coding-agent/src/modes/rpc/wire/frames.ts index 2d8393e71f..67484451d5 100644 --- a/packages/coding-agent/src/modes/rpc/wire/frames.ts +++ b/packages/coding-agent/src/modes/rpc/wire/frames.ts @@ -137,6 +137,10 @@ export const frameDefs = { { type: "'plan_proposed'", title: "string", planFilePath: "string", markdown: "string" }, "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.", ), + PlanUpdatedEvent: doc( + { type: "'plan_updated'", enabled: "boolean", "planFilePath?": "string" }, + "Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.", + ), // scient(SA-PLAN-MODE): end DictationStateEvent: doc( { type: "'dictation_state'", captureId: "string", state: "'starting' | 'recording' | 'transcribing'" }, diff --git a/packages/coding-agent/src/modes/rpc/wire/index.ts b/packages/coding-agent/src/modes/rpc/wire/index.ts index 31e7516e39..b2457e7d6a 100644 --- a/packages/coding-agent/src/modes/rpc/wire/index.ts +++ b/packages/coding-agent/src/modes/rpc/wire/index.ts @@ -80,6 +80,7 @@ const notificationDefs = { // scient(SA-LIVE-HOST-DELEGATION): end // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. "PlanProposedEvent", + "PlanUpdatedEvent", // scient(SA-PLAN-MODE): end "DictationStateEvent", "DictationTranscriptEvent", diff --git a/packages/coding-agent/src/scient/rpc-plan.ts b/packages/coding-agent/src/scient/rpc-plan.ts index 023085ff2e..f37924d942 100644 --- a/packages/coding-agent/src/scient/rpc-plan.ts +++ b/packages/coding-agent/src/scient/rpc-plan.ts @@ -11,6 +11,7 @@ * other answer, which is read as feedback) keeps planning. A cancelled or * abandoned question never approves: only an explicit Approve does. * - `plan exit` leaves plan mode without a plan. + * - Every time plan mode turns on or off, for any reason, the host gets `plan_updated`. */ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; @@ -40,6 +41,11 @@ export interface RpcPlanResult { planFilePath?: string; } +/** Plan mode turned on or off: by the host, by an approval, or by a change of conversation. */ +export interface RpcPlanUpdatedFrame extends RpcPlanResult { + type: "plan_updated"; +} + /** The plan the agent proposed, sent before the host is asked to approve it. */ export interface RpcPlanProposedFrame { type: "plan_proposed"; @@ -64,14 +70,14 @@ export type RpcPlanSession = Pick< export class RpcPlanController { readonly #session: RpcPlanSession; readonly #ui: Pick; - readonly #output: (frame: RpcPlanProposedFrame) => void; + readonly #output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void; /** Bumped when plan mode is left, so an approval answered afterwards changes nothing. */ #generation = 0; constructor( session: RpcPlanSession, ui: Pick, - output: (frame: RpcPlanProposedFrame) => void, + output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void, ) { this.#session = session; this.#ui = ui; @@ -114,13 +120,16 @@ export class RpcPlanController { }); const generation = this.#generation; session.setPlanProposalHandler(title => this.#propose(title, generation)); + this.#output({ type: "plan_updated", ...this.#state }); if (session.isStreaming) await session.sendPlanModeContext({ deliverAs: "steer" }); } #exit(): void { this.#generation++; + const wasEnabled = this.#session.getPlanModeState()?.enabled === true; this.#session.setPlanProposalHandler(null); this.#session.setPlanModeState(undefined); + if (wasEnabled) this.#output({ type: "plan_updated", enabled: false }); } #localProtocolOptions(): LocalProtocolOptions { diff --git a/packages/coding-agent/test/scient/rpc-plan.test.ts b/packages/coding-agent/test/scient/rpc-plan.test.ts index cdcade57a7..22e4c25ae4 100644 --- a/packages/coding-agent/test/scient/rpc-plan.test.ts +++ b/packages/coding-agent/test/scient/rpc-plan.test.ts @@ -10,6 +10,7 @@ import { PLAN_REFINE_OPTION, RpcPlanController, type RpcPlanProposedFrame, + type RpcPlanUpdatedFrame, type RpcPlanSession, } from "@oh-my-pi/pi-coding-agent/scient/rpc-plan"; import type { PlanProposalHandler } from "@oh-my-pi/pi-coding-agent/tools/resolve"; @@ -22,7 +23,7 @@ describe("RPC plan mode", () => { let referencePath: string | undefined; let modeChanges: Array<{ mode: string; data?: Record }>; let goalActive: boolean; - let frames: RpcPlanProposedFrame[]; + let frames: Array; let answers: Array>; let sessionId: string; @@ -108,14 +109,20 @@ describe("RPC plan mode", () => { const plans = await controller(); await draft(plans); const { result } = await propose(); - expect(frames).toMatchObject([{ type: "plan_proposed", markdown: "# Fit the model\n\n1. Load data\n" }]); + expect(frames).toMatchObject([ + { type: "plan_updated", enabled: true, planFilePath: "local://PLAN.md" }, + { type: "plan_proposed", markdown: "# Fit the model\n\n1. Load data\n" }, + ]); + const planFilePath = frames[1]!.planFilePath; answers[0]!.resolve(PLAN_APPROVE_OPTION); - expect(resultText(await result)).toStartWith(`Plan approved at ${frames[0]!.planFilePath}.`); + expect(resultText(await result)).toStartWith(`Plan approved at ${planFilePath}.`); + // The host learns plan mode ended without asking. + expect(frames.at(-1)).toEqual({ type: "plan_updated", enabled: false }); expect(await plans.handle({ op: "get" })).toEqual({ enabled: false }); expect(handler).toBeUndefined(); - expect(referencePath).toBe(frames[0]!.planFilePath); - expect(modeChanges).toEqual([{ mode: "none", data: { approvedPlanFilePath: frames[0]!.planFilePath } }]); + expect(referencePath).toBe(planFilePath); + expect(modeChanges).toEqual([{ mode: "none", data: { approvedPlanFilePath: planFilePath } }]); }); it("keeps planning on Refine, a typed answer, or a cancelled question", async () => { diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index 750dfe9c07..7682f17c15 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -392,7 +392,7 @@ }, "SA-PLAN-MODE": { "status": "active", - "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", + "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Each time plan mode turns on or off, a plan_updated frame ({ enabled, planFilePath? }) tells the host. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", "owned": ["packages/coding-agent/src/scient/rpc-plan.ts", "packages/coding-agent/test/scient/rpc-plan.test.ts"], "upstream": [ "packages/coding-agent/src/modes/rpc/rpc-mode.ts", From 86165dc70612f8ded03b5ad1c08bac932d13eb07 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sat, 14 Nov 2026 20:14:46 +0200 Subject: [PATCH 5/6] chore(rpc): regenerate clients for plan_updated (generated) Scient-Generator: rpc Co-Authored-By: Claude Opus 5.5 --- .../src/modes/rpc/wire/rpc-wire.generated.ts | 9 ++++- .../src/modes/rpc/wire/rpc-wire.schema.json | 22 ++++++++++++ sdk/go/omp-rpc/wire.go | 34 +++++++++++++++++++ sdk/python/omp-rpc/src/omp_rpc/_wire.py | 26 +++++++++++++- sdk/rust/omp-rpc/src/wire.rs | 14 +++++++- 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts index e05eb6710c..7fc9b822d8 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts @@ -1240,6 +1240,13 @@ export interface PlanProposedEvent { markdown: string; } +/** Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. */ +export interface PlanUpdatedEvent { + type: "plan_updated"; + enabled: boolean; + planFilePath?: string; +} + /** State of one draft-only microphone capture. */ export interface DictationStateEvent { type: "dictation_state"; @@ -1597,7 +1604,7 @@ export interface HostUriSchemeDefinition { } /** Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`. */ -export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; +export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | PlanUpdatedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; /** Any frame the server writes to stdout (after reassembling `rpc_chunk` sequences), discriminated by `type`. */ export type RpcServerFrame = RpcResponse | RpcHostRequest | RpcNotification; diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json index feb5fd7b0c..cd07cfd8ba 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json @@ -5106,6 +5106,25 @@ ], "description": "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it." }, + "PlanUpdatedEvent": { + "type": "object", + "properties": { + "type": { + "const": "plan_updated" + }, + "enabled": { + "type": "boolean" + }, + "planFilePath": { + "type": "string" + } + }, + "required": [ + "type", + "enabled" + ], + "description": "Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation." + }, "DictationStateEvent": { "type": "object", "properties": { @@ -6320,6 +6339,9 @@ { "$ref": "#/$defs/PlanProposedEvent" }, + { + "$ref": "#/$defs/PlanUpdatedEvent" + }, { "$ref": "#/$defs/DictationStateEvent" }, diff --git a/sdk/go/omp-rpc/wire.go b/sdk/go/omp-rpc/wire.go index b5b8d784b1..c3c70484ea 100644 --- a/sdk/go/omp-rpc/wire.go +++ b/sdk/go/omp-rpc/wire.go @@ -5478,6 +5478,34 @@ func (v PlanProposedEvent) MarshalJSON() ([]byte, error) { return encodeObject(plain(v), `"type":"plan_proposed"`, nil) } +// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. +type PlanUpdatedEvent struct { + Enabled bool `json:"enabled"` + PlanFilePath *string `json:"planFilePath,omitempty"` +} + +func (v *PlanUpdatedEvent) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanUpdatedEvent", v.decodeFrom) +} + +func (v *PlanUpdatedEvent) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanUpdatedEvent + d := fieldDecoder{raw: raw, owner: "PlanUpdatedEvent"} + d.constant("type", "plan_updated") + d.required("enabled", &out.Enabled) + d.optional("planFilePath", &out.PlanFilePath) + if d.err != nil { + return d.err + } + *v = out + return nil +} + +func (v PlanUpdatedEvent) MarshalJSON() ([]byte, error) { + type plain PlanUpdatedEvent + return encodeObject(plain(v), `"type":"plan_updated"`, nil) +} + // State of one draft-only microphone capture. type DictationStateEvent struct { CaptureID string `json:"captureId"` @@ -7055,6 +7083,7 @@ func (LiveTranscriptEvent) isRpcNotification() {} func (LiveEndEvent) isRpcNotification() {} func (LiveDelegationEvent) isRpcNotification() {} func (PlanProposedEvent) isRpcNotification() {} +func (PlanUpdatedEvent) isRpcNotification() {} func (DictationStateEvent) isRpcNotification() {} func (DictationTranscriptEvent) isRpcNotification() {} func (DictationEndEvent) isRpcNotification() {} @@ -7141,6 +7170,8 @@ func (v *RpcNotification) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveDelegationEvent](raw) case "plan_proposed": value, err = decodeVariant[PlanProposedEvent](raw) + case "plan_updated": + value, err = decodeVariant[PlanUpdatedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": @@ -7266,6 +7297,7 @@ func (LiveTranscriptEvent) isRpcServerFrame() {} func (LiveEndEvent) isRpcServerFrame() {} func (LiveDelegationEvent) isRpcServerFrame() {} func (PlanProposedEvent) isRpcServerFrame() {} +func (PlanUpdatedEvent) isRpcServerFrame() {} func (DictationStateEvent) isRpcServerFrame() {} func (DictationTranscriptEvent) isRpcServerFrame() {} func (DictationEndEvent) isRpcServerFrame() {} @@ -7362,6 +7394,8 @@ func (v *RpcServerFrame) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveDelegationEvent](raw) case "plan_proposed": value, err = decodeVariant[PlanProposedEvent](raw) + case "plan_updated": + value, err = decodeVariant[PlanUpdatedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": diff --git a/sdk/python/omp-rpc/src/omp_rpc/_wire.py b/sdk/python/omp-rpc/src/omp_rpc/_wire.py index 0dfbffec9e..377d918f0f 100644 --- a/sdk/python/omp-rpc/src/omp_rpc/_wire.py +++ b/sdk/python/omp-rpc/src/omp_rpc/_wire.py @@ -1457,6 +1457,14 @@ class PlanProposedEvent: markdown: str +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanUpdatedEvent: + """Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.""" + type: Literal["plan_updated"] = "plan_updated" + enabled: bool + plan_file_path: str | None = None + + @dataclass(slots=True, frozen=True, kw_only=True) class DictationStateEvent: """State of one draft-only microphone capture.""" @@ -1776,7 +1784,7 @@ class LogoutResult: """A session event, discriminated by `type`; `set_event_filter` selects which are sent.""" -RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification +RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | PlanUpdatedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification """Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`.""" @@ -2979,6 +2987,15 @@ def parse_plan_proposed_event(value: object, path: str = "PlanProposedEvent") -> ) +def parse_plan_updated_event(value: object, path: str = "PlanUpdatedEvent") -> PlanUpdatedEvent: + payload = expect_object(value, path) + required(payload, "type", cast('Decoder[Literal["plan_updated"]]', literal(frozenset({"plan_updated"}))), path) + return PlanUpdatedEvent( + enabled=required(payload, "enabled", decode_bool, path), + plan_file_path=optional(payload, "planFilePath", decode_str, path), + ) + + def parse_dictation_state_event(value: object, path: str = "DictationStateEvent") -> DictationStateEvent: payload = expect_object(value, path) required(payload, "type", cast('Decoder[Literal["dictation_state"]]', literal(frozenset({"dictation_state"}))), path) @@ -3398,6 +3415,7 @@ def parse_notification(value: object, path: str = "notification") -> RpcNotifica "live_end": parse_live_end_event, "live_delegation": parse_live_delegation_event, "plan_proposed": parse_plan_proposed_event, + "plan_updated": parse_plan_updated_event, "dictation_state": parse_dictation_state_event, "dictation_transcript": parse_dictation_transcript_event, "dictation_end": parse_dictation_end_event, @@ -3984,6 +4002,10 @@ def on_plan_proposed(self, listener: Callable[[PlanProposedEvent], None]) -> Cal """Subscribe to `plan_proposed`: The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.""" return self._listen("plan_proposed", listener) + def on_plan_updated(self, listener: Callable[[PlanUpdatedEvent], None]) -> Callable[[], None]: + """Subscribe to `plan_updated`: Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.""" + return self._listen("plan_updated", listener) + def on_dictation_state(self, listener: Callable[[DictationStateEvent], None]) -> Callable[[], None]: """Subscribe to `dictation_state`: State of one draft-only microphone capture.""" return self._listen("dictation_state", listener) @@ -4281,6 +4303,7 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "PlanOp", "PlanProposedEvent", "PlanResult", + "PlanUpdatedEvent", "PromoteQueuedMessageResult", "PromptAck", "PromptCacheEntry", @@ -4470,6 +4493,7 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "parse_parent_irc", "parse_plan_proposed_event", "parse_plan_result", + "parse_plan_updated_event", "parse_promote_queued_message_result", "parse_prompt_ack", "parse_prompt_cache_entry", diff --git a/sdk/rust/omp-rpc/src/wire.rs b/sdk/rust/omp-rpc/src/wire.rs index 8ec4c6f9f5..c53e23c541 100644 --- a/sdk/rust/omp-rpc/src/wire.rs +++ b/sdk/rust/omp-rpc/src/wire.rs @@ -4449,6 +4449,14 @@ pub struct PlanProposedEvent { pub markdown: String, } +/// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanUpdatedEvent { + pub enabled: bool, + #[serde(rename = "planFilePath", default, skip_serializing_if = "Option::is_none")] + pub plan_file_path: Option, +} + /// State of one draft-only microphone capture. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DictationStateEvent { @@ -5178,6 +5186,8 @@ pub enum RpcNotification { LiveDelegation(LiveDelegationEvent), /// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. PlanProposed(PlanProposedEvent), + /// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. + PlanUpdated(PlanUpdatedEvent), /// State of one draft-only microphone capture. DictationState(DictationStateEvent), /// Full accumulated draft preview; replaces the previous preview for this capture. @@ -5221,6 +5231,7 @@ impl RpcNotification { Some("live_end") => |value| serde_json::from_value(value).map(Self::LiveEnd), Some("live_delegation") => |value| serde_json::from_value(value).map(Self::LiveDelegation), Some("plan_proposed") => |value| serde_json::from_value(value).map(Self::PlanProposed), + Some("plan_updated") => |value| serde_json::from_value(value).map(Self::PlanUpdated), Some("dictation_state") => |value| serde_json::from_value(value).map(Self::DictationState), Some("dictation_transcript") => |value| serde_json::from_value(value).map(Self::DictationTranscript), Some("dictation_end") => |value| serde_json::from_value(value).map(Self::DictationEnd), @@ -5255,6 +5266,7 @@ impl Serialize for RpcNotification { Self::LiveEnd(member) => serialize_tagged(member, &[("type", "live_end")], serializer), Self::LiveDelegation(member) => serialize_tagged(member, &[("type", "live_delegation")], serializer), Self::PlanProposed(member) => serialize_tagged(member, &[("type", "plan_proposed")], serializer), + Self::PlanUpdated(member) => serialize_tagged(member, &[("type", "plan_updated")], serializer), Self::DictationState(member) => serialize_tagged(member, &[("type", "dictation_state")], serializer), Self::DictationTranscript(member) => serialize_tagged(member, &[("type", "dictation_transcript")], serializer), Self::DictationEnd(member) => serialize_tagged(member, &[("type", "dictation_end")], serializer), @@ -5295,7 +5307,7 @@ impl RpcServerFrame { let decode: fn(Value) -> Result = match value.get("type").and_then(Value::as_str) { Some("response") => |value| serde_json::from_value(value).map(Self::Response), Some("host_tool_call" | "host_tool_cancel" | "host_uri_request" | "host_uri_cancel") => |value| serde_json::from_value(value).map(Self::RpcHostRequest), - Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "plan_proposed" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), + Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "plan_proposed" | "plan_updated" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), _ => |value| Ok(Self::Unknown(value)), }; decode(value) From 908038f8fe381aee9a02756ce4ac0841f97ca6d5 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sat, 14 Nov 2026 20:23:30 +0200 Subject: [PATCH 6/6] fix(rpc): ask what to change when the host refines a plan Refine returned a tool result telling the model to stop and ask, but the model often proposed the same plan again in the same run. Refine now asks the host what should change (an input request) and gives the answer to the model as feedback. A cancelled question falls back to asking in the conversation. Scient-Divergence: SA-PLAN-MODE Co-Authored-By: Claude Opus 5.5 --- SCIENT.md | 2 +- packages/coding-agent/src/scient/rpc-plan.ts | 16 +++++++++---- .../coding-agent/test/scient/rpc-plan.test.ts | 23 ++++++++++++++++++- scripts/scient/divergences.json | 2 +- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/SCIENT.md b/SCIENT.md index 22b5818ecf..b1566eef6e 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -25,7 +25,7 @@ Everything else is upstream's code and behavior. | Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. | | Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. | | Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. | -| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Any other answer keeps planning, and a typed answer reaches the model as feedback. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Each time plan mode turns on or off, a `plan_updated` frame tells the host. Planning is refused while a goal is set. | +| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Refine asks the host what to change, and that answer, like any typed answer, reaches the model as feedback; planning goes on. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Each time plan mode turns on or off, a `plan_updated` frame tells the host. Planning is refused while a goal is set. | | Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. | | macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. | | Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. | diff --git a/packages/coding-agent/src/scient/rpc-plan.ts b/packages/coding-agent/src/scient/rpc-plan.ts index f37924d942..98de1406af 100644 --- a/packages/coding-agent/src/scient/rpc-plan.ts +++ b/packages/coding-agent/src/scient/rpc-plan.ts @@ -7,8 +7,9 @@ * `xd://propose`. * - The proposal sends the plan to the host (`plan_proposed`), then asks Approve * or Refine on the host's select card while the tool call waits. Approve leaves - * plan mode and the same run goes on to carry out the plan; Refine (or any - * other answer, which is read as feedback) keeps planning. A cancelled or + * plan mode and the same run goes on to carry out the plan; Refine asks the + * host what to change and gives the answer to the model as feedback, and any + * other answer is read as feedback too. Both keep planning. A cancelled or * abandoned question never approves: only an explicit Approve does. * - `plan exit` leaves plan mode without a plan. * - Every time plan mode turns on or off, for any reason, the host gets `plan_updated`. @@ -69,14 +70,14 @@ export type RpcPlanSession = Pick< export class RpcPlanController { readonly #session: RpcPlanSession; - readonly #ui: Pick; + readonly #ui: Pick; readonly #output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void; /** Bumped when plan mode is left, so an approval answered afterwards changes nothing. */ #generation = 0; constructor( session: RpcPlanSession, - ui: Pick, + ui: Pick, output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void, ) { this.#session = session; @@ -156,10 +157,15 @@ export class RpcPlanController { planFilePath: plan.planFilePath, markdown: plan.planContent, }); - const answer = ( + let answer = ( await this.#ui.select(`Carry out the plan "${plan.title}"?`, [PLAN_APPROVE_OPTION, PLAN_REFINE_OPTION]) )?.trim(); if (generation !== this.#generation) throw new ToolError("Plan mode ended before the plan was approved."); + // Refine asks what to change, so the model revises in the same run instead of proposing again. + if (answer === PLAN_REFINE_OPTION) { + answer = (await this.#ui.input("What should change in the plan?", "Describe the change"))?.trim() || answer; + if (generation !== this.#generation) throw new ToolError("Plan mode ended before the plan was approved."); + } if (answer !== PLAN_APPROVE_OPTION) { // The next planning turn targets the plan just reviewed. if (state.planFilePath !== plan.planFilePath) diff --git a/packages/coding-agent/test/scient/rpc-plan.test.ts b/packages/coding-agent/test/scient/rpc-plan.test.ts index 22e4c25ae4..276fb0f751 100644 --- a/packages/coding-agent/test/scient/rpc-plan.test.ts +++ b/packages/coding-agent/test/scient/rpc-plan.test.ts @@ -61,6 +61,11 @@ describe("RPC plan mode", () => { answers.push(answer); return answer.promise; }, + input: () => { + const answer = Promise.withResolvers(); + answers.push(answer); + return answer.promise; + }, }, frame => frames.push(frame), ); @@ -129,7 +134,6 @@ describe("RPC plan mode", () => { const plans = await controller(); await draft(plans); for (const [answer, expected] of [ - [PLAN_REFINE_OPTION, "Stop here and ask the user what to change"], ["Use a mixed model instead", "The user asked: Use a mixed model instead"], [undefined, "Stop here and ask the user what to change"], ] as const) { @@ -141,6 +145,23 @@ describe("RPC plan mode", () => { expect(referencePath).toBeUndefined(); }); + it("asks what to change on Refine and gives the answer to the model", async () => { + const plans = await controller(); + await draft(plans); + for (const [change, expected] of [ + ["Add a sensitivity analysis", "The user asked: Add a sensitivity analysis"], + [undefined, "Stop here and ask the user what to change"], + ] as const) { + const { result } = await propose(); + const asked = answers.length; + answers.at(-1)!.resolve(PLAN_REFINE_OPTION); + while (answers.length === asked) await Promise.race([result, Bun.sleep(1)]); + answers.at(-1)!.resolve(change); + expect(resultText(await result)).toContain(expected); + expect((await plans.handle({ op: "get" })).enabled).toBe(true); + } + }); + it("never approves an answer that arrives after plan mode was left", async () => { const plans = await controller(); await draft(plans); diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index 7682f17c15..b8c72a980e 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -392,7 +392,7 @@ }, "SA-PLAN-MODE": { "status": "active", - "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; any other answer keeps planning, a typed answer reaching the model as feedback. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Each time plan mode turns on or off, a plan_updated frame ({ enabled, planFilePath? }) tells the host. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", + "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; Refine asks the host what to change (an input request), and that answer, like any typed answer, reaches the model as feedback; planning goes on. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Each time plan mode turns on or off, a plan_updated frame ({ enabled, planFilePath? }) tells the host. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", "owned": ["packages/coding-agent/src/scient/rpc-plan.ts", "packages/coding-agent/test/scient/rpc-plan.test.ts"], "upstream": [ "packages/coding-agent/src/modes/rpc/rpc-mode.ts",