From 858c3a9ef035aedf5c118404dda46b21364631cf Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 07:54:45 +0200 Subject: [PATCH] feat(catalog): added supportsHashlineEdits helper and exclude step-3.7-flash models - Added supportsHashlineEdits catalog function and Step 3.7 Flash SKU recognition. - Updated coding agent edit resolution to utilize the centralized catalog function. - Updated write snapshot generation to record empty seen-line provenance. --- packages/catalog/src/identity/family.ts | 21 +++++++++++++++++++ packages/catalog/test/identity-family.test.ts | 15 +++++++++++++ packages/coding-agent/src/tools/write.ts | 8 +++++-- packages/coding-agent/src/utils/edit-mode.ts | 21 +++---------------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index 729ed1bc6..5d90ee383 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -119,6 +119,11 @@ export const isMimoModelIdOrName = memo((value: string): boolean => { return value.toLowerCase().includes("mimo"); }); +/** StepFun Step 3.7 Flash SKU in any namespace form (`kilo/stepfun/step-3.7-flash:free`). */ +export const isStep37FlashModelId = memo((modelId: string): boolean => { + return modelId.toLowerCase().includes("step-3.7-flash"); +}); + /** Gemini family ids in any namespace form (`gemini-*`, `google/gemini-*`, `openrouter/google/gemini-…`). */ export const isGeminiModelId = memo((modelId: string): boolean => { return /(^|\/)gemini[-.]?/i.test(modelId); @@ -399,6 +404,22 @@ export const supportsMidConversationSystemMessages = memo((modelId: string): boo return parsed !== null && isAnthropicAdaptiveGenAtLeast(parsed, "4.8"); }); +/** + * Models that reliably follow the hashline line-anchored edit dialect + * (`[path#TAG]` headers plus 1-indexed anchors). Kimi, MiMo, DeepSeek V4 + * Flash, and Step 3.7 Flash miscount anchors or drop the tag header often + * enough that hosts fall back to a literal search-replace edit format for + * them. + */ +export const supportsHashlineEdits = memo((modelId: string): boolean => { + return !( + isKimiModelId(modelId) || + isMimoModelIdOrName(modelId) || + isDeepseekV4FlashModelId(modelId) || + isStep37FlashModelId(modelId) + ); +}); + export const isAnthropicFableOrMythosModel = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); return parsed !== null && isFableOrMythos(parsed.kind); diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index 4de00f1c5..009c3ad22 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -19,6 +19,7 @@ import { modelFamilyToken, parseAnthropicModel, supportsAdaptiveThinkingDisplay, + supportsHashlineEdits, supportsMidConversationSystemMessages, } from "@oh-my-pi/pi-catalog/identity"; @@ -31,6 +32,20 @@ describe("isKimiModelId", () => { }); }); +describe("supportsHashlineEdits", () => { + test("declines the families that miscount line anchors", () => { + expect(supportsHashlineEdits("openrouter/moonshotai/Kimi-K2-Instruct")).toBe(false); + expect(supportsHashlineEdits("xiaomi/MiMo-V2.5-Pro")).toBe(false); + expect(supportsHashlineEdits("tensormesh/deepseek-ai/DeepSeek-V4-Flash")).toBe(false); + expect(supportsHashlineEdits("kilo/stepfun/step-3.7-flash:free")).toBe(false); + }); + test("vouches for structured-edit-capable models", () => { + expect(supportsHashlineEdits("google/gemini-3.5-flash")).toBe(true); + expect(supportsHashlineEdits("claude-fable-5")).toBe(true); + expect(supportsHashlineEdits("moonshot/moonshot-v1-128k")).toBe(true); + }); +}); + describe("isQwen38PlusTemplateEffortModelId", () => { test("matches Qwen 3.8+ open-weight ids across id shapes and versions", () => { expect(isQwen38PlusTemplateEffortModelId("qwen3.8-27b")).toBe(true); diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 6eeff4202..5697ea4d5 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -360,12 +360,16 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin * when the session is not in hashline mode so callers can no-op cheaply. * * Mirrors the post-commit snapshot recording the hashline patcher performs - * after a successful edit: the model gets a tag without an extra `read`. + * after a successful edit — the model gets a tag without an extra `read` — + * but with EMPTY seen-line provenance: a write displays no numbered lines, + * so anchored edits against this tag must first see the anchor content (the + * patcher rejects them with an inline reveal). Authoring content is not + * knowing its line numbers. */ function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined { if (!resolveFileDisplayMode(session).hashLines) return undefined; const normalized = normalizeToLF(content); - const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized); + const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized, []); return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag); } diff --git a/packages/coding-agent/src/utils/edit-mode.ts b/packages/coding-agent/src/utils/edit-mode.ts index a73dd7e34..ee25fc112 100644 --- a/packages/coding-agent/src/utils/edit-mode.ts +++ b/packages/coding-agent/src/utils/edit-mode.ts @@ -1,3 +1,4 @@ +import { supportsHashlineEdits } from "@oh-my-pi/pi-catalog/identity"; import { $env, $flag } from "@oh-my-pi/pi-utils"; export type EditMode = "replace" | "patch" | "hashline" | "apply_patch"; @@ -13,22 +14,6 @@ const EDIT_MODE_IDS = { export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[]; -const HASHLINE_EXCLUDED_MODEL_MODES: Array<{ pattern: string; mode: EditMode }> = [ - { pattern: "kimi", mode: "replace" }, - { pattern: "mimo", mode: "replace" }, - { pattern: "deepseek-v4-flash", mode: "replace" }, - { pattern: "step-3.7-flash", mode: "replace" }, -]; - -function resolveHashlineExcludedModelMode(model: string | undefined): EditMode | null { - if (!model) return null; - const modelLower = model.toLowerCase(); - for (const entry of HASHLINE_EXCLUDED_MODEL_MODES) { - if (modelLower.includes(entry.pattern)) return entry.mode; - } - return null; -} - export function normalizeEditMode(mode?: string | null): EditMode | undefined { if (!mode) return undefined; return EDIT_MODE_IDS[mode as keyof typeof EDIT_MODE_IDS]; @@ -54,8 +39,8 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode { const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? "")); const mode = settingsMode ?? DEFAULT_EDIT_MODE; - if (mode === "hashline" && !$flag("PI_STRICT_EDIT_MODE")) { - return resolveHashlineExcludedModelMode(activeModel) ?? mode; + if (mode === "hashline" && !$flag("PI_STRICT_EDIT_MODE") && activeModel && !supportsHashlineEdits(activeModel)) { + return "replace"; } return mode; }