From 70c10fc99221b21066a03e2f309bb3a2911aaaed Mon Sep 17 00:00:00 2001 From: Tehan Date: Mon, 10 Aug 2026 00:06:15 +0200 Subject: [PATCH 1/2] fix(prompt-surface): let the generator own the A1 golden's hash baseline Closes #290. prompt-surface-a1-golden.md says it is generated by export-agent-surface.ts, but the script emitted only sections 1 and 2. Section 3 (the system-prompt hash baseline) was hand-maintained, so running the script the way its own usage line documents deleted it -- exit 0, no warning -- and system-prompt-hash.test.ts then threw "Malformed A1 primary guidance golden". The generator now emits section 3. The table is DERIVED, not transcribed: the hash handler persists the MD5 of output.system.join("\n"), and in the no-host-prefix baseline each guidance section IS the whole system array, so the hash is just the MD5 of the guidance bytes already emitted in section 1. Verified by regenerating over the committed golden -- the output is byte-identical apart from the date stamp, including all four hash rows. Also hardens the two consumers that slice on that heading (tool-registry.test.ts and pi-plugin/src/tools/index.test.ts). Both did document.slice(indexOf("## 2. Tool surface"), indexOf("## 3. ...")) which yields -1 for a missing heading, so the slice silently returned "" and the golden parsed as ZERO tools -- and the tests still PASSED, comparing empty to empty. That was the worse half of the trap: one loud error while every other reader quietly degraded. Both now resolve offsets through a sectionOffset() helper that throws a named error. Red-checked: with section 3 stripped from the golden, both call sites now fail with `A1 golden is missing the "## 3. System-prompt hash baseline" section heading`. On master the same mutation leaves them green. Gates: plugin 3609/3, pi 740/0, cli 296 (2 skip), typecheck 0 x3, lint clean. The 3 plugin failures are pre-existing @opentui TDZ errors in tui-compiled-runtime-imports.test.ts, unrelated to this change (it touches no TUI file) and reproducible on a clean upstream/master worktree. --- packages/pi-plugin/src/tools/index.test.ts | 21 +++++++++++++-- .../plugin/scripts/export-agent-surface.ts | 27 +++++++++++++++++++ .../plugin/src/plugin/tool-registry.test.ts | 21 +++++++++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/pi-plugin/src/tools/index.test.ts b/packages/pi-plugin/src/tools/index.test.ts index 46ea1a327..0e9aa9ff5 100644 --- a/packages/pi-plugin/src/tools/index.test.ts +++ b/packages/pi-plugin/src/tools/index.test.ts @@ -236,6 +236,23 @@ type RegisteredPromptTool = { parameters: { properties?: Record }; }; +/** + * Locate a golden section heading, refusing to continue when it is absent. + * + * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)` + * silently yields an empty (or truncated) section rather than failing: the + * golden then parses as ZERO tools and this test passes by comparing empty to + * empty. Failing loudly here keeps a malformed golden from reading as a clean + * run. + */ +function sectionOffset(document: string, heading: string): number { + const offset = document.indexOf(heading); + if (offset === -1) { + throw new Error(`A1 golden is missing the "${heading}" section heading`); + } + return offset; +} + function readA1GoldenTools(): Record< string, { description: string; parameters: Record } @@ -248,8 +265,8 @@ function readA1GoldenTools(): Record< "utf8", ); const toolSection = document.slice( - document.indexOf("## 2. Tool surface"), - document.indexOf("## 3. System-prompt hash baseline"), + sectionOffset(document, "## 2. Tool surface"), + sectionOffset(document, "## 3. System-prompt hash baseline"), ); const headings = [...toolSection.matchAll(/^### (ctx_[a-z_]+) —.*$/gm)]; return Object.fromEntries( diff --git a/packages/plugin/scripts/export-agent-surface.ts b/packages/plugin/scripts/export-agent-surface.ts index 547d07326..e0e8db6b9 100644 --- a/packages/plugin/scripts/export-agent-surface.ts +++ b/packages/plugin/scripts/export-agent-surface.ts @@ -8,6 +8,7 @@ * Usage: bun packages/plugin/scripts/export-agent-surface.ts [outPath] * Default out: .alfonso/agent-surface-export.md (repo root) */ +import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import Tokenizer from "ai-tokenizer"; @@ -112,6 +113,32 @@ for (const [name, definition] of Object.entries(definitions)) { out.push(""); } +// ── Section 3: system-prompt hash baseline ─────────────────────────────────── +// Derived, not transcribed: the hash handler persists the MD5 of +// `output.system.join("\n")`, and in the no-host-prefix baseline each guidance +// section IS the whole system array — so the hash is just the MD5 of the +// guidance bytes emitted in section 1. +out.push("## 3. System-prompt hash baseline"); +out.push(""); +out.push( + 'The hash handler persists the MD5 of `output.system.join("\\\\n")`. The values below use each captured guidance section as the complete system array, which is the deterministic no-host-prefix baseline for later compatibility tests. The guidance bytes above are the source bytes; this table records the hash bytes that must remain unchanged when the default prompt surface is selected.', +); +out.push(""); +out.push("| Variant | Guidance bytes | MD5 system-prompt hash |"); +out.push("|---|---:|---|"); +for (const [label, text] of variants) { + // Table labels are the short form ("PRIMARY full"), not the full heading. + const shortLabel = label.split(" (")[0]; + const bytes = Buffer.byteLength(text, "utf8"); + const md5 = createHash("md5").update(text).digest("hex"); + out.push(`| ${shortLabel} | ${bytes} | \`${md5}\` |`); +} +out.push(""); +out.push( + 'The OpenCode and Pi runtime compatibility tests consume this snapshot for omitted `prompt_surface` and explicit `{ default: "full" }`: both assert guidance, registered tool descriptions, tool IDs, and hashes; OpenCode also asserts these parameter schemas directly, while Pi asserts its TypeBox-owned schemas stay byte-identical across both config forms.', +); +out.push(""); + const outPath = resolve(process.argv[2] ?? "../../.alfonso/agent-surface-export.md"); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, out.join("\n")); diff --git a/packages/plugin/src/plugin/tool-registry.test.ts b/packages/plugin/src/plugin/tool-registry.test.ts index 4a3fa0927..29f04e8f8 100644 --- a/packages/plugin/src/plugin/tool-registry.test.ts +++ b/packages/plugin/src/plugin/tool-registry.test.ts @@ -203,14 +203,31 @@ describe("createToolRegistry — compaction-off mode (#266 S4)", () => { type GoldenTool = { description: string; parameters: Record }; +/** + * Locate a golden section heading, refusing to continue when it is absent. + * + * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)` + * silently yields an empty (or truncated) section rather than failing: the + * golden then parses as ZERO tools and this test passes by comparing empty to + * empty. Failing loudly here keeps a malformed golden from reading as a clean + * run. + */ +function sectionOffset(document: string, heading: string): number { + const offset = document.indexOf(heading); + if (offset === -1) { + throw new Error(`A1 golden is missing the "${heading}" section heading`); + } + return offset; +} + function readA1GoldenTools(): Record { const document = readFileSync( join(import.meta.dir, "../shared/prompt-surface-a1-golden.md"), "utf8", ); const toolSection = document.slice( - document.indexOf("## 2. Tool surface"), - document.indexOf("## 3. System-prompt hash baseline"), + sectionOffset(document, "## 2. Tool surface"), + sectionOffset(document, "## 3. System-prompt hash baseline"), ); const headings = [...toolSection.matchAll(/^### (ctx_[a-z_]+) —.*$/gm)]; return Object.fromEntries( From ceb4cb01ecacc1faa8062d32e9b29eb3770ef740 Mon Sep 17 00:00:00 2001 From: Tehan Date: Mon, 10 Aug 2026 00:27:05 +0200 Subject: [PATCH 2/2] refactor(prompt-surface): extract the A1 golden accessors into a shared module Addresses cubic's P3 on #292: the sectionOffset() guard was duplicated verbatim across tool-registry.test.ts and pi-plugin/src/tools/index.test.ts, both parsing the same golden. New src/shared/prompt-surface-a1-golden.ts owns three things that were previously copy-pasted per package: the two section-heading strings, the document read, and the offset guard. Pi already consumes plugin/src through the @magic-context/core/* tsconfig alias, so this is the established seam rather than a new dependency. The module resolves the golden relative to ITSELF, which removes the per-package `../..` arithmetic (`../shared/...` on OpenCode, `../../../plugin/src/shared/...` on Pi). That arithmetic was the more dangerous half of the duplication: it is invisible to the type checker and rots silently when files move. Scope note: the two readA1PrimaryGuidance() readers are deliberately left alone. They regex the whole document rather than slicing between headings, so they were never exposed to the -1 bug this PR fixes, and folding them in would widen an unrelated PR. The heading constants are exported and ready if a later change wants them. Re-verified the red-check through the shared helper -- with section 3 stripped, both consumers still fail with the named error: A1 golden is missing the "## 3. System-prompt hash baseline" section heading Gates: plugin 3609/3, pi 740/0, cli 296 (2 skip), typecheck 0 x3, lint clean. Same 3 pre-existing @opentui TDZ failures as the parent commit; this touches no TUI file. --- packages/pi-plugin/src/tools/index.test.ts | 37 ++++------------- .../plugin/src/plugin/tool-registry.test.ts | 34 +++++---------- .../src/shared/prompt-surface-a1-golden.ts | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 52 deletions(-) create mode 100644 packages/plugin/src/shared/prompt-surface-a1-golden.ts diff --git a/packages/pi-plugin/src/tools/index.test.ts b/packages/pi-plugin/src/tools/index.test.ts index 0e9aa9ff5..025277800 100644 --- a/packages/pi-plugin/src/tools/index.test.ts +++ b/packages/pi-plugin/src/tools/index.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { + A1_HASH_BASELINE_HEADING, + A1_TOOL_SECTION_HEADING, + a1GoldenSectionOffset, + readA1GoldenDocument, +} from "@magic-context/core/shared/prompt-surface-a1-golden"; import { createPromptSurfaceRuntime, LIGHT_TOOL_DESCRIPTIONS, @@ -236,37 +240,14 @@ type RegisteredPromptTool = { parameters: { properties?: Record }; }; -/** - * Locate a golden section heading, refusing to continue when it is absent. - * - * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)` - * silently yields an empty (or truncated) section rather than failing: the - * golden then parses as ZERO tools and this test passes by comparing empty to - * empty. Failing loudly here keeps a malformed golden from reading as a clean - * run. - */ -function sectionOffset(document: string, heading: string): number { - const offset = document.indexOf(heading); - if (offset === -1) { - throw new Error(`A1 golden is missing the "${heading}" section heading`); - } - return offset; -} - function readA1GoldenTools(): Record< string, { description: string; parameters: Record } > { - const document = readFileSync( - join( - import.meta.dir, - "../../../plugin/src/shared/prompt-surface-a1-golden.md", - ), - "utf8", - ); + const document = readA1GoldenDocument(); const toolSection = document.slice( - sectionOffset(document, "## 2. Tool surface"), - sectionOffset(document, "## 3. System-prompt hash baseline"), + a1GoldenSectionOffset(document, A1_TOOL_SECTION_HEADING), + a1GoldenSectionOffset(document, A1_HASH_BASELINE_HEADING), ); const headings = [...toolSection.matchAll(/^### (ctx_[a-z_]+) —.*$/gm)]; return Object.fromEntries( diff --git a/packages/plugin/src/plugin/tool-registry.test.ts b/packages/plugin/src/plugin/tool-registry.test.ts index 29f04e8f8..d845e0d61 100644 --- a/packages/plugin/src/plugin/tool-registry.test.ts +++ b/packages/plugin/src/plugin/tool-registry.test.ts @@ -1,13 +1,19 @@ /// import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type ToolDefinition, tool } from "@opencode-ai/plugin"; import type { MagicContextPluginConfig } from "../config"; import { closeDatabase, openDatabase } from "../features/magic-context/storage"; import { resetCtxReduceRegisteredGloballyForTest } from "../hooks/magic-context/ctx-reduce-availability"; +import { + A1_HASH_BASELINE_HEADING, + A1_TOOL_SECTION_HEADING, + a1GoldenSectionOffset, + readA1GoldenDocument, +} from "../shared/prompt-surface-a1-golden"; import type { PromptSurfaceRuntime } from "../shared/prompt-surface-runtime"; import { createPromptSurfaceRuntime, @@ -203,31 +209,11 @@ describe("createToolRegistry — compaction-off mode (#266 S4)", () => { type GoldenTool = { description: string; parameters: Record }; -/** - * Locate a golden section heading, refusing to continue when it is absent. - * - * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)` - * silently yields an empty (or truncated) section rather than failing: the - * golden then parses as ZERO tools and this test passes by comparing empty to - * empty. Failing loudly here keeps a malformed golden from reading as a clean - * run. - */ -function sectionOffset(document: string, heading: string): number { - const offset = document.indexOf(heading); - if (offset === -1) { - throw new Error(`A1 golden is missing the "${heading}" section heading`); - } - return offset; -} - function readA1GoldenTools(): Record { - const document = readFileSync( - join(import.meta.dir, "../shared/prompt-surface-a1-golden.md"), - "utf8", - ); + const document = readA1GoldenDocument(); const toolSection = document.slice( - sectionOffset(document, "## 2. Tool surface"), - sectionOffset(document, "## 3. System-prompt hash baseline"), + a1GoldenSectionOffset(document, A1_TOOL_SECTION_HEADING), + a1GoldenSectionOffset(document, A1_HASH_BASELINE_HEADING), ); const headings = [...toolSection.matchAll(/^### (ctx_[a-z_]+) —.*$/gm)]; return Object.fromEntries( diff --git a/packages/plugin/src/shared/prompt-surface-a1-golden.ts b/packages/plugin/src/shared/prompt-surface-a1-golden.ts new file mode 100644 index 000000000..1c743e435 --- /dev/null +++ b/packages/plugin/src/shared/prompt-surface-a1-golden.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Shared accessors for the A1 prompt-surface golden. + * + * The golden (`prompt-surface-a1-golden.md`, generated by + * `scripts/export-agent-surface.ts`) is read by tests in BOTH packages, and its + * section headings are the contract between the generator and those readers. + * Keeping the path, the heading strings, and the offset guard here means a + * change to the golden's section format is a one-file edit rather than a hunt + * across packages. + * + * This module resolves the golden relative to ITSELF, so consumers never carry + * their own `../..` path arithmetic — that arithmetic differs per package and is + * exactly the kind of thing that rots silently when files move. + */ + +export const A1_TOOL_SECTION_HEADING = "## 2. Tool surface"; +export const A1_HASH_BASELINE_HEADING = "## 3. System-prompt hash baseline"; + +export function readA1GoldenDocument(): string { + return readFileSync(join(import.meta.dir, "prompt-surface-a1-golden.md"), "utf8"); +} + +/** + * Locate a golden section heading, refusing to continue when it is absent. + * + * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)` + * silently yields an empty (or truncated) section rather than failing: the + * golden then parses as ZERO tools and the consuming test passes by comparing + * empty to empty. Failing loudly here keeps a malformed golden from reading as a + * clean run. + */ +export function a1GoldenSectionOffset(document: string, heading: string): number { + const offset = document.indexOf(heading); + if (offset === -1) { + throw new Error(`A1 golden is missing the "${heading}" section heading`); + } + return offset; +}