Files
backnotprop__plannotator/packages/shared/guide-instructions-store.test.ts
Michael Ramos 98113182b5 feat(guide): reviewer-supplied extra instructions for Guided Review (#1267)
* feat(guide): reviewer-supplied extra instructions for Guided Review (#1265)

Adds a quiet, collapsed-by-default Custom instructions affordance to the
guide launch page. The text is APPENDED to the built-in organizer
methodology as a clearly delimited section (composeGuideMethodology) and
never replaces it; absent or blank instructions produce byte-identical
prompts to before. Persisted in a dedicated cookie
(plannotator-guide-instructions) so a standing team preference survives
sessions without bloating the plannotator.agents blob past the browser's
per-cookie limit.

Server side, the launch body gains an optional guide-only instructions
field (both the Bun and Pi node:http agent-jobs handlers accept and
thread it); prompt composition lives in the shared guide-review.ts that
vendor.sh already vendors to Pi, so both runtimes compose identically.
Text is capped at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS (2000) server-side
and mirrored by the textarea maxLength. Repair launches deliberately
ignore instructions: a repair is a mechanical JSON fix, not a rewrite.

Tests pin the regression contract (empty input keeps prior prompt bytes),
appended-not-replacing composition, the length cap, repair isolation, and
the cookie round-trip via the storage backend seam.

* refactor(guide): store standing instructions server-side, not in a cookie

Review findings on the cookie approach (silent write failure past the
encoded 4KB per-cookie limit for multi-byte text) pointed at the real
design problem: the instructions are consumed by the SERVER at launch
time, so they belong in the data dir like review-skills.json, where no
size ceiling or encoding inflation exists and the preference follows
the machine instead of one browser profile.

New GET/PUT /api/agents/guide-instructions in both runtimes backed by
shared guide-instructions-store (vendored to Pi). Guide launches apply
the stored text when the body carries none; the launch page still sends
its live textarea value (explicit wins), so a just-typed preference can
never race the debounced save. The sidebar surface sends nothing and
inherits the stored text server-side. All cookie machinery removed.

Also folds in the review fixes: marker-tag-shaped strings in
instructions are defanged so first-match nonce recovery cannot be
hijacked by pasted examples.
2026-08-11 10:24:39 -07:00

91 lines
3.6 KiB
TypeScript

/**
* Server-side persistence for Guided Review standing instructions (#1265).
*
* Contract: read/write round-trip through `${dataDir}/guide-instructions.md`
* (trimmed, bounded at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS); blank input
* deletes the file rather than persisting whitespace; and launch resolution
* prefers explicit launch-body text over the stored value, yielding
* undefined when neither has text so instruction-less launches stay
* byte-identical to pre-feature prompts.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
readGuideInstructions,
resolveGuideLaunchInstructions,
writeGuideInstructions,
} from "./guide-instructions-store";
import { GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS } from "./guide";
let dir: string;
let priorDataDir: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pn-guide-instructions-"));
priorDataDir = process.env.PLANNOTATOR_DATA_DIR;
process.env.PLANNOTATOR_DATA_DIR = dir;
});
afterEach(() => {
if (priorDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = priorDataDir;
rmSync(dir, { recursive: true, force: true });
});
describe("read/write round trip", () => {
it("stores and returns the trimmed text", () => {
expect(writeGuideInstructions(" Never invent ticket IDs. ")).toBe("Never invent ticket IDs.");
expect(readGuideInstructions()).toBe("Never invent ticket IDs.");
});
it("returns empty when nothing was ever stored", () => {
expect(readGuideInstructions()).toBe("");
});
it("blank input deletes the stored file rather than persisting whitespace", () => {
writeGuideInstructions("keep me");
expect(existsSync(join(dir, "guide-instructions.md"))).toBe(true);
expect(writeGuideInstructions(" \n ")).toBe("");
expect(existsSync(join(dir, "guide-instructions.md"))).toBe(false);
expect(readGuideInstructions()).toBe("");
});
it("bounds stored text at the shared cap", () => {
const long = "a".repeat(GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS + 500);
expect(writeGuideInstructions(long).length).toBe(GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS);
expect(readGuideInstructions().length).toBe(GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS);
});
it("multi-byte text round-trips exactly (the failure mode cookies had)", () => {
const emoji = "\u{1F525}".repeat(400);
expect(writeGuideInstructions(emoji)).toBe(emoji);
expect(readGuideInstructions()).toBe(emoji);
});
});
describe("resolveGuideLaunchInstructions (launch precedence)", () => {
it("explicit launch-body text wins over the stored value", () => {
writeGuideInstructions("stored standing text");
expect(resolveGuideLaunchInstructions("live textarea text")).toBe("live textarea text");
});
it("falls back to the stored value when the body carries none", () => {
writeGuideInstructions("stored standing text");
expect(resolveGuideLaunchInstructions(undefined)).toBe("stored standing text");
expect(resolveGuideLaunchInstructions(" ")).toBe("stored standing text");
});
it("yields undefined when neither has text (byte-identical launches)", () => {
expect(resolveGuideLaunchInstructions(undefined)).toBeUndefined();
expect(resolveGuideLaunchInstructions("")).toBeUndefined();
});
it("non-string body values never reach the prompt", () => {
writeGuideInstructions("stored standing text");
expect(resolveGuideLaunchInstructions(42)).toBe("stored standing text");
expect(resolveGuideLaunchInstructions({ evil: true })).toBe("stored standing text");
});
});