From 9bba7ac0079d8dfbcfab60293abed0dce08747ff Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 25 Jul 2026 01:58:12 +0000 Subject: [PATCH] fix(plan-mode): compared canonical local urls for scan membership The scan-membership test used a raw string includes, so a resumed single-slash local:/ state path failed to match the scanner's local:// entry and wrongly gained precedence over a newer draft. Normalize both sides via normalizeLocalScheme before comparing. Fixes #6569 --- .../src/plan-mode/approved-plan.ts | 6 ++++- .../test/plan-mode/approved-plan.test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/plan-mode/approved-plan.ts b/packages/coding-agent/src/plan-mode/approved-plan.ts index 2cc11380c..f4f36c226 100644 --- a/packages/coding-agent/src/plan-mode/approved-plan.ts +++ b/packages/coding-agent/src/plan-mode/approved-plan.ts @@ -1,3 +1,4 @@ +import { normalizeLocalScheme } from "../tools/path-utils"; import { ToolError } from "../tools/tool-errors"; /** Shape forwarded from the plan-proposal handler to InteractiveMode's @@ -167,7 +168,10 @@ export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Prom // it keeps precedence over scanned artifacts — otherwise a stale older draft // could shadow the deliberately-set current plan. A state plan already inside // the scan competes purely on the newest-first ordering below (issue #6569). - if (input.statePlanFilePath && !listed.includes(input.statePlanFilePath)) { + // Compare canonical `local://` spellings so a resumed `local:/…` state path + // still matches the scanner's `local://…` entry (normalizeLocalScheme). + const canonicalListed = new Set(listed.map(normalizeLocalScheme)); + if (input.statePlanFilePath && !canonicalListed.has(normalizeLocalScheme(input.statePlanFilePath))) { consider(input.statePlanFilePath); } for (const url of listed) consider(url); diff --git a/packages/coding-agent/test/plan-mode/approved-plan.test.ts b/packages/coding-agent/test/plan-mode/approved-plan.test.ts index e4ec3e3b4..bc5f9ab41 100644 --- a/packages/coding-agent/test/plan-mode/approved-plan.test.ts +++ b/packages/coding-agent/test/plan-mode/approved-plan.test.ts @@ -6,6 +6,7 @@ import { resolveApprovedPlan, resolvePlanTitle, } from "@oh-my-pi/pi-coding-agent/plan-mode/approved-plan"; +import { normalizeLocalScheme } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; describe("planFileUrlForSlug", () => { it("maps a slug to its local plan URL", () => { @@ -62,6 +63,28 @@ describe("resolveApprovedPlan", () => { expect(result.planContent).toContain("New plan"); }); + it("treats a single-slash state URL as in-scan and prefers the newer draft", async () => { + // Mirror the real reader: canonicalize the local scheme before lookup, so a + // `local:/…` state path resolves the same file as the scanner's `local://…`. + const canonical = (files: Record) => { + const map: Record = {}; + for (const url in files) map[normalizeLocalScheme(url)] = files[url]; + return async (url: string) => map[normalizeLocalScheme(url)] ?? null; + }; + const result = await resolveApprovedPlan({ + suppliedTitle: undefined, + // Resumed sessions can persist the accepted single-slash spelling. + statePlanFilePath: "local:/completed-plan.md", + readPlan: canonical({ + "local://completed-plan.md": "# Completed\n\nOld plan", + "local://new-draft-plan.md": "# New\n\nNew plan", + }), + listPlanFiles: async () => ["local://new-draft-plan.md", "local://completed-plan.md"], + }); + expect(result.planFilePath).toBe("local://new-draft-plan.md"); + expect(result.planContent).toContain("New plan"); + }); + it("keeps a state plan the scan can't see ahead of older scanned artifacts", async () => { const result = await resolveApprovedPlan({ suppliedTitle: undefined,