From 9419c7f231bef4d0b5d9d0333ddc8dbc118fbf95 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:38:24 -0700 Subject: [PATCH 1/2] feat(coding-agent): define native plan artifacts Define validated plan artifacts and the intrinsic submit_plan tool so every runtime uses one normalized, bounded representation for planning output. --- packages/coding-agent/src/core/plan-mode.ts | 110 ++++++++++++++++++ .../coding-agent/test/plan-mode-tools.test.ts | 60 ++++++++++ 2 files changed, 170 insertions(+) create mode 100644 packages/coding-agent/src/core/plan-mode.ts create mode 100644 packages/coding-agent/test/plan-mode-tools.test.ts diff --git a/packages/coding-agent/src/core/plan-mode.ts b/packages/coding-agent/src/core/plan-mode.ts new file mode 100644 index 000000000..d93ee7cd4 --- /dev/null +++ b/packages/coding-agent/src/core/plan-mode.ts @@ -0,0 +1,110 @@ +import { type Static, Type } from "typebox"; +import { defineTool, type ToolDefinition } from "./extensions/types.ts"; + +export const SUBMIT_PLAN_TOOL_NAME = "submit_plan"; + +export type CollaborationMode = "default" | "plan"; + +const MAX_PLAN_ID_LENGTH = 256; +const MAX_PLAN_TITLE_LENGTH = 120; +const MAX_PLAN_MARKDOWN_LENGTH = 65_536; + +const submitPlanSchema = Type.Object({ + title: Type.String({ + description: "Concise title for the implementation plan.", + minLength: 1, + maxLength: MAX_PLAN_TITLE_LENGTH, + }), + markdown: Type.String({ + description: "The complete decision-ready implementation plan in Markdown.", + minLength: 1, + maxLength: MAX_PLAN_MARKDOWN_LENGTH, + }), +}); + +export type SubmitPlanInput = Static; + +export interface PlanArtifact { + id: string; + title: string; + markdown: string; +} + +export type PlanResolution = "implement" | "discard"; + +export interface SubmitPlanToolDetails { + plan: PlanArtifact; +} + +interface SubmitPlanToolOptions { + submitPlan: (input: SubmitPlanInput) => PlanArtifact | Promise; +} + +const unsafeControlTextPattern = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u; + +function normalizePlanText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} cannot be empty`); + if (unsafeControlTextPattern.test(normalized)) { + throw new Error(`${label} contains unsafe control text`); + } + return normalized; +} + +/** Strictly parse and normalize an artifact before it reaches persisted or runtime plan state. */ +export function parsePlanArtifact(value: unknown): PlanArtifact | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const artifact = value as Record; + if (typeof artifact.id !== "string" || typeof artifact.title !== "string" || typeof artifact.markdown !== "string") { + return undefined; + } + if ( + artifact.id.length === 0 || + artifact.id.length > MAX_PLAN_ID_LENGTH || + /\s/u.test(artifact.id) || + unsafeControlTextPattern.test(artifact.id) + ) { + return undefined; + } + + try { + const title = normalizePlanText(artifact.title, "Plan title").replace(/\s+/gu, " "); + const markdown = normalizePlanText(artifact.markdown, "Plan Markdown"); + if (title.length > MAX_PLAN_TITLE_LENGTH || markdown.length > MAX_PLAN_MARKDOWN_LENGTH) return undefined; + return Object.freeze({ id: artifact.id, title, markdown }); + } catch { + return undefined; + } +} + +export function createSubmitPlanToolDefinition(options: SubmitPlanToolOptions): ToolDefinition { + return defineTool({ + name: SUBMIT_PLAN_TOOL_NAME, + label: "Submit plan", + description: + "Submit the complete implementation plan after repository exploration and all material decisions are resolved. This ends the planning run and waits for explicit user resolution.", + promptSnippet: "Submit a decision-ready Markdown plan", + parameters: submitPlanSchema, + executionMode: "sequential", + async execute(_toolCallId, input) { + const plan = await options.submitPlan({ + title: normalizePlanText(input.title, "Plan title").replace(/\s+/gu, " "), + markdown: normalizePlanText(input.markdown, "Plan Markdown"), + }); + return { + content: [{ type: "text", text: `Submitted plan: ${plan.title}` }], + details: { plan }, + }; + }, + }); +} + +export function createPlanImplementationPrompt(plan: PlanArtifact): string { + return `The user explicitly accepted the following plan and authorized implementation. + +# ${plan.title} + +${plan.markdown} + +Implement the accepted plan now. Verify the result in proportion to risk and report the completed outcome.`; +} diff --git a/packages/coding-agent/test/plan-mode-tools.test.ts b/packages/coding-agent/test/plan-mode-tools.test.ts new file mode 100644 index 000000000..61d2eef5b --- /dev/null +++ b/packages/coding-agent/test/plan-mode-tools.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createPlanImplementationPrompt, + createSubmitPlanToolDefinition, + type PlanArtifact, + SUBMIT_PLAN_TOOL_NAME, +} from "../src/core/plan-mode.ts"; + +describe("submit_plan", () => { + it("normalizes the title to one line and returns the persisted artifact", async () => { + const plan: PlanArtifact = { + id: "plan-1", + title: "Ship the change", + markdown: "1. Inspect\n2. Implement\n3. Test", + }; + const submitPlan = vi.fn(() => plan); + const tool = createSubmitPlanToolDefinition({ submitPlan }); + + const result = await tool.execute( + "call-1", + { title: " Ship\n\tthe change ", markdown: " 1. Inspect\n2. Implement\n3. Test " }, + undefined, + undefined, + undefined as never, + ); + + expect(tool.name).toBe(SUBMIT_PLAN_TOOL_NAME); + expect(tool.executionMode).toBe("sequential"); + expect(submitPlan).toHaveBeenCalledWith({ + title: "Ship the change", + markdown: "1. Inspect\n2. Implement\n3. Test", + }); + expect(result.details).toEqual({ plan }); + }); + + it.each([ + ["title", { title: "unsafe\u001b[31m", markdown: "Safe body" }], + ["Markdown", { title: "Safe title", markdown: "unsafe\u202e body" }], + ])("rejects unsafe control text in plan %s", async (_field, input) => { + const tool = createSubmitPlanToolDefinition({ + submitPlan: () => ({ id: "unused", title: input.title, markdown: input.markdown }), + }); + + await expect(tool.execute("call-1", input, undefined, undefined, undefined as never)).rejects.toThrow( + "unsafe control text", + ); + }); + + it("builds an explicit implementation authorization prompt", () => { + const prompt = createPlanImplementationPrompt({ + id: "plan-1", + title: "Ship the change", + markdown: "- Update the API\n- Add tests", + }); + + expect(prompt).toContain("explicitly accepted"); + expect(prompt).toContain("# Ship the change"); + expect(prompt).toContain("- Add tests"); + }); +}); From 6f036b156787fb8e4995f523c96aad44d30789e0 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:40:03 -0700 Subject: [PATCH 2/2] feat(coding-agent): persist native plan state Persist collaboration mode, submitted plans, resolutions, and accepted continuations through validated append-only session history and branch replay. --- .../core/compaction/branch-summarization.ts | 3 + .../coding-agent/src/core/session-manager.ts | 208 +++++++++++++++++- .../session-manager/file-operations.test.ts | 131 +++++++++++ 3 files changed, 336 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index e51380855..0257f2cbf 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -172,6 +172,9 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { // These don't contribute to conversation content case "thinking_level_change": case "model_change": + case "collaboration_mode_change": + case "plan": + case "plan_resolution": case "custom": case "label": case "session_info": diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f52392953..b8992deb2 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -26,6 +26,7 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.ts"; +import { type CollaborationMode, type PlanArtifact, type PlanResolution, parsePlanArtifact } from "./plan-mode.ts"; export const CURRENT_SESSION_VERSION = 3; @@ -66,6 +67,22 @@ export interface ModelChangeEntry extends SessionEntryBase { modelId: string; } +export interface CollaborationModeChangeEntry extends SessionEntryBase { + type: "collaboration_mode_change"; + mode: CollaborationMode; +} + +export interface PlanEntry extends SessionEntryBase { + type: "plan"; + plan: PlanArtifact; +} + +export interface PlanResolutionEntry extends SessionEntryBase { + type: "plan_resolution"; + planId: string; + resolution: PlanResolution; +} + export interface CompactionEntry extends SessionEntryBase { type: "compaction"; summary: string; @@ -145,6 +162,9 @@ export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry + | CollaborationModeChangeEntry + | PlanEntry + | PlanResolutionEntry | CompactionEntry | BranchSummaryEntry | CustomEntry @@ -169,6 +189,9 @@ export interface SessionContext { messages: AgentMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; + collaborationMode: CollaborationMode; + pendingPlan: PlanArtifact | undefined; + pendingPlanContinuation: PlanArtifact | undefined; } export interface SessionInfo { @@ -359,9 +382,98 @@ function buildSessionPath( return path; } -function getSessionContextSettings(path: SessionEntry[]): Pick { +interface PlanLifecycleState { + collaborationMode: CollaborationMode; + pendingPlan: PlanArtifact | undefined; + pendingPlanContinuation: PlanArtifact | undefined; +} + +function applyPlanLifecycleEntry(state: PlanLifecycleState, entry: SessionEntry): PlanLifecycleState { + if (entry.type === "collaboration_mode_change") { + if (entry.mode !== "default" && entry.mode !== "plan") { + throw new Error(`Invalid collaboration mode at session entry ${entry.id}.`); + } + if (entry.mode === "default" && state.pendingPlan) { + throw new Error(`Cannot leave plan mode with a pending plan at session entry ${entry.id}.`); + } + if (state.pendingPlanContinuation) { + throw new Error( + `Cannot change collaboration mode with a pending plan continuation at session entry ${entry.id}.`, + ); + } + return { ...state, collaborationMode: entry.mode }; + } + if (entry.type === "plan") { + const plan = parsePlanArtifact(entry.plan); + if (!plan) { + throw new Error(`Invalid plan artifact at session entry ${entry.id}.`); + } + if (state.collaborationMode !== "plan") { + throw new Error(`Plan entry ${entry.id} is outside plan mode.`); + } + return { collaborationMode: "plan", pendingPlan: plan, pendingPlanContinuation: undefined }; + } + if (entry.type === "plan_resolution") { + if (entry.resolution !== "implement" && entry.resolution !== "discard") { + throw new Error(`Invalid plan resolution at session entry ${entry.id}.`); + } + if (state.collaborationMode !== "plan" || !state.pendingPlan || state.pendingPlan.id !== entry.planId) { + throw new Error(`Plan resolution ${entry.id} does not match the pending plan.`); + } + return { + collaborationMode: "default", + pendingPlan: undefined, + pendingPlanContinuation: entry.resolution === "implement" ? state.pendingPlan : undefined, + }; + } + if (entry.type === "message" && entry.message.role === "user" && state.pendingPlanContinuation) { + return { ...state, pendingPlanContinuation: undefined }; + } + return state; +} + +function validateSessionPlanLifecycle(entries: SessionEntry[]): void { + const seenIds = new Set(); + for (const entry of entries) { + if (seenIds.has(entry.id)) { + throw new Error(`Session contains duplicate entry ID ${entry.id}.`); + } + seenIds.add(entry.id); + } + const hasPlanLifecycleEntries = entries.some( + (entry) => + entry.type === "collaboration_mode_change" || entry.type === "plan" || entry.type === "plan_resolution", + ); + const stateById = new Map(); + for (const entry of entries) { + const parentState = + entry.parentId === null + ? { + collaborationMode: "default" as const, + pendingPlan: undefined, + pendingPlanContinuation: undefined, + } + : stateById.get(entry.parentId); + if (!parentState) { + if (hasPlanLifecycleEntries) { + throw new Error(`Session entry ${entry.id} has a missing parent in a plan-aware session.`); + } + continue; + } + stateById.set(entry.id, applyPlanLifecycleEntry(parentState, entry)); + } +} + +function getSessionContextSettings( + path: SessionEntry[], +): Pick { let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; + let planState: PlanLifecycleState = { + collaborationMode: "default", + pendingPlan: undefined, + pendingPlanContinuation: undefined, + }; for (const entry of path) { if (entry.type === "thinking_level_change") { @@ -371,9 +483,10 @@ function getSessionContextSettings(path: SessionEntry[]): Pick, ): SessionContext { const path = buildSessionPath(entries, leafId, byId); - const { thinkingLevel, model } = getSessionContextSettings(path); + const { thinkingLevel, model, collaborationMode, pendingPlan, pendingPlanContinuation } = + getSessionContextSettings(path); const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages); - return { messages, thinkingLevel, model }; + return { messages, thinkingLevel, model, collaborationMode, pendingPlan, pendingPlanContinuation }; } /** @@ -502,12 +616,21 @@ class SessionHeaderScanLimitError extends Error { function parseSessionEntryLine(line: string): FileEntry | null { if (!line.trim()) return null; + let parsed: unknown; try { - return JSON.parse(line) as FileEntry; + parsed = JSON.parse(line); } catch { // Skip malformed lines return null; } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const entry = parsed as FileEntry; + if (entry.type === "plan") { + const plan = parsePlanArtifact(entry.plan); + if (!plan) throw new Error("Session contains an invalid persisted plan artifact."); + return { ...entry, plan }; + } + return entry.type === "session" ? normalizeSessionHeader(entry) : entry; } /** Exported for testing */ @@ -918,6 +1041,9 @@ export class SessionManager { this._rewriteFile(); } + validateSessionPlanLifecycle( + this.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"), + ); this._buildIndex(); this.flushed = true; } else { @@ -1042,10 +1168,18 @@ export class SessionManager { } private _appendEntry(entry: SessionEntry): void { + const previousLeafId = this.leafId; this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; - this._persist(entry); + try { + this._persist(entry); + } catch (error) { + this.fileEntries.pop(); + this.byId.delete(entry.id); + this.leafId = previousLeafId; + throw error; + } } /** Append a message as child of current leaf, then advance leaf. Returns entry id. @@ -1093,6 +1227,68 @@ export class SessionManager { return entry.id; } + /** Append a collaboration mode change as child of current leaf, then advance leaf. Returns entry id. */ + appendCollaborationModeChange(mode: CollaborationMode): string { + if (mode !== "default" && mode !== "plan") { + throw new Error(`Invalid collaboration mode: ${String(mode)}`); + } + const context = this.buildSessionContext(); + if (mode === "default" && context.pendingPlan) { + throw new Error("Resolve the pending plan before leaving plan mode."); + } + if (context.pendingPlanContinuation) { + throw new Error("Submit the pending plan continuation before changing collaboration mode."); + } + const entry: CollaborationModeChangeEntry = { + type: "collaboration_mode_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + mode, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a submitted plan as child of current leaf, then advance leaf. Returns entry id. */ + appendPlan(plan: PlanArtifact): string { + const normalizedPlan = parsePlanArtifact(plan); + if (!normalizedPlan) throw new Error("Invalid plan artifact."); + if (this.buildSessionContext().collaborationMode !== "plan") { + throw new Error("Plans can only be appended in plan mode."); + } + const entry: PlanEntry = { + type: "plan", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + plan: normalizedPlan, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a plan resolution as child of current leaf, then advance leaf. Returns entry id. */ + appendPlanResolution(planId: string, resolution: PlanResolution): string { + if (resolution !== "implement" && resolution !== "discard") { + throw new Error("Invalid plan resolution."); + } + const pendingPlan = this.buildSessionContext().pendingPlan; + if (!pendingPlan || pendingPlan.id !== planId) { + throw new Error("Plan resolution does not match the pending plan."); + } + const entry: PlanResolutionEntry = { + type: "plan_resolution", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + planId, + resolution, + }; + this._appendEntry(entry); + return entry.id; + } + /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ appendCompaction( summary: string, diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index a7ff6729d..99a4b61b9 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -74,6 +74,7 @@ describe("loadEntriesFromFile", () => { file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + "not valid json\n" + + "null\n[]\n" + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', ); const entries = loadEntriesFromFile(file); @@ -297,6 +298,69 @@ describe("SessionManager custom flat session directory", () => { }); }); +describe("SessionManager plan persistence", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-plan-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("round-trips collaboration mode, a pending plan, and its resolution through JSONL", () => { + const plan = { + id: "plan-1", + title: "Persist plan mode", + markdown: "## Steps\n\n1. Persist the plan.\n2. Resolve it.", + }; + const session = SessionManager.create(tempDir, tempDir); + session.appendCollaborationModeChange("plan"); + session.appendPlan(plan); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "Plan submitted." }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + const sessionFile = session.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file"); + + const pending = SessionManager.open(sessionFile, tempDir); + expect(pending.getEntries().map((entry) => entry.type)).toEqual(["collaboration_mode_change", "plan", "message"]); + expect(pending.buildSessionContext()).toMatchObject({ + collaborationMode: "plan", + pendingPlan: plan, + }); + + pending.appendPlanResolution(plan.id, "implement"); + const resolved = SessionManager.open(sessionFile, tempDir); + expect(resolved.getEntries().map((entry) => entry.type)).toEqual([ + "collaboration_mode_change", + "plan", + "message", + "plan_resolution", + ]); + expect(resolved.buildSessionContext()).toMatchObject({ + collaborationMode: "default", + pendingPlan: undefined, + }); + }); +}); + describe("SessionManager.setSessionFile with corrupted files", () => { let tempDir: string; @@ -352,6 +416,73 @@ describe("SessionManager.setSessionFile with corrupted files", () => { expect(readFileSync(nonSessionFile, "utf-8")).toBe(originalContent); }); + it("rejects duplicate entry IDs in an ordinary session before indexing", () => { + const duplicateFile = join(tempDir, "duplicate-entry.jsonl"); + const entries = [ + { + type: "session", + version: 3, + id: "duplicate-session", + timestamp: "2025-01-01T00:00:00Z", + cwd: tempDir, + }, + { + type: "custom", + id: "duplicate", + parentId: null, + timestamp: "2025-01-01T00:00:01Z", + customType: "first", + }, + { + type: "custom", + id: "duplicate", + parentId: null, + timestamp: "2025-01-01T00:00:02Z", + customType: "second", + }, + ]; + writeFileSync(duplicateFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + + expect(() => SessionManager.open(duplicateFile, tempDir)).toThrow("duplicate entry ID duplicate"); + }); + + it("rejects duplicate entry IDs before a plan-mode entry can be overwritten", () => { + const duplicateFile = join(tempDir, "duplicate-plan-entry.jsonl"); + const entries = [ + { + type: "session", + version: 3, + id: "duplicate-plan-session", + timestamp: "2025-01-01T00:00:00Z", + cwd: tempDir, + }, + { + type: "collaboration_mode_change", + id: "duplicate", + parentId: null, + timestamp: "2025-01-01T00:00:01Z", + mode: "plan", + }, + { + type: "custom", + id: "duplicate", + parentId: null, + timestamp: "2025-01-01T00:00:02Z", + customType: "overwrite-plan-mode", + }, + { + type: "custom", + id: "child", + parentId: "duplicate", + timestamp: "2025-01-01T00:00:03Z", + customType: "would-fail-open", + }, + ]; + writeFileSync(duplicateFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + + expect(() => SessionManager.open(duplicateFile, tempDir)).toThrow("duplicate entry ID duplicate"); + }); + it("preserves explicit session file path when recovering from corrupted file", () => { const explicitPath = join(tempDir, "my-session.jsonl"); writeFileSync(explicitPath, "");