mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
98113182b5
* 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.
91 lines
3.6 KiB
TypeScript
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");
|
|
});
|
|
});
|