diff --git a/packages/pi-plugin/src/tools/index.test.ts b/packages/pi-plugin/src/tools/index.test.ts index 46ea1a32..02527780 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, @@ -240,16 +244,10 @@ 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( - document.indexOf("## 2. Tool surface"), - document.indexOf("## 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/scripts/export-agent-surface.ts b/packages/plugin/scripts/export-agent-surface.ts index 547d0732..e0e8db6b 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 4a3fa092..d845e0d6 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, @@ -204,13 +210,10 @@ describe("createToolRegistry — compaction-off mode (#266 S4)", () => { type GoldenTool = { description: string; parameters: Record }; function readA1GoldenTools(): Record { - const document = readFileSync( - join(import.meta.dir, "../shared/prompt-surface-a1-golden.md"), - "utf8", - ); + const document = readA1GoldenDocument(); const toolSection = document.slice( - document.indexOf("## 2. Tool surface"), - document.indexOf("## 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 00000000..1c743e43 --- /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; +}