summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src/parser.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
committerAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
commit674853d87d54dba1cd83c4e51fce5411602f4d5d (patch)
tree07455f9753a09a5ca66f8cb885a37ba3c0cb7787 /packages/system-prompt/src/parser.test.ts
parent4158e699e3c8ff556684fe2fc7a39ffab040623e (diff)
downloaddispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.tar.gz
dispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.zip
feat(system-prompt): template-based system prompt builder extension
New @dispatch/system-prompt extension (standard tier): - Pure parser: [type:name] variables, [if]/[else]/[endif] conditionals, negated [if !...], nested blocks, unmatched-tag pass-through. - Variable resolver (injected adapters): system:time/date/os/hostname, prompt:cwd/model/conversation_id, git:branch/status, file:<path> (dynamic). - Service handle: construct (resolve+persist) + get (cached, cache-safe). - Default template: persona + AGENTS.md if exists + cwd. - 52 tests (parser 29, resolver 12, catalog 3, service 8). transport-contract 0.17.0→0.18.0: SystemPromptTemplateResponse, SetSystemPromptTemplateRequest, SystemPromptVariable, SystemPromptVariablesResponse. Design: notes/system-prompt-design.md (caching constraint, compaction integration, wave plan). 1384 vitest pass.
Diffstat (limited to 'packages/system-prompt/src/parser.test.ts')
-rw-r--r--packages/system-prompt/src/parser.test.ts186
1 files changed, 186 insertions, 0 deletions
diff --git a/packages/system-prompt/src/parser.test.ts b/packages/system-prompt/src/parser.test.ts
new file mode 100644
index 0000000..636a50d
--- /dev/null
+++ b/packages/system-prompt/src/parser.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from "vitest";
+import { extractVariables, parseTemplate } from "./parser.js";
+
+function vars(entries: ReadonlyArray<[string, string | null]>): Map<string, string | null> {
+ return new Map(entries);
+}
+
+describe("parser", () => {
+ describe("variable insertion", () => {
+ it("simple variable insertion", () => {
+ // 1. [system:time] with value → inserts it
+ expect(parseTemplate("[system:time]", vars([["system:time", "12:00"]]))).toBe("12:00");
+ });
+
+ it("unknown variable → blank", () => {
+ // 2. [unknown:foo] not in map → ""
+ expect(parseTemplate("[unknown:foo]", vars([]))).toBe("");
+ });
+
+ it("null variable → blank", () => {
+ // 3. [file:missing.md] with value null → ""
+ expect(parseTemplate("[file:missing.md]", vars([["file:missing.md", null]]))).toBe("");
+ });
+
+ it("inserts value mid-text", () => {
+ expect(parseTemplate("cwd is [prompt:cwd]!", vars([["prompt:cwd", "/proj"]]))).toBe(
+ "cwd is /proj!",
+ );
+ });
+
+ it("non-tag brackets stay literal", () => {
+ expect(parseTemplate("[not a tag] done", vars([]))).toBe("[not a tag] done");
+ });
+
+ it("unclosed bracket stays literal", () => {
+ expect(parseTemplate("[file:x", vars([]))).toBe("[file:x");
+ });
+ });
+
+ describe("conditionals", () => {
+ it("if block renders when variable exists", () => {
+ // 4. [if file:AGENTS.md]YES[endif] with value → "YES"
+ expect(
+ parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", "content"]])),
+ ).toBe("YES");
+ });
+
+ it("if block skipped when variable is null", () => {
+ // 5. same with null → ""
+ expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", null]]))).toBe(
+ "",
+ );
+ });
+
+ it("if block skipped when variable absent", () => {
+ expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([]))).toBe("");
+ });
+
+ it("if/else renders fallback when null", () => {
+ // 6. [if file:X]A[else]B[endif] with null → "B"
+ expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", null]]))).toBe("B");
+ });
+
+ it("if/else renders then-branch when exists", () => {
+ expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", "v"]]))).toBe("A");
+ });
+
+ it("negated if renders when variable is null", () => {
+ // 7. [if !file:X]A[endif] with null → "A"
+ expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", null]]))).toBe("A");
+ });
+
+ it("negated if skipped when variable exists", () => {
+ expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", "v"]]))).toBe("");
+ });
+
+ it("negated if renders when variable absent", () => {
+ expect(parseTemplate("[if !file:X]A[endif]", vars([]))).toBe("A");
+ });
+
+ it("nested if — inner skipped when its var is null", () => {
+ // 8. [if system:os][if file:X]A[endif][endif] with os=set, file=null → ""
+ expect(
+ parseTemplate(
+ "[if system:os][if file:X]A[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", null],
+ ]),
+ ),
+ ).toBe("");
+ });
+
+ it("nested if — inner renders when both exist", () => {
+ expect(
+ parseTemplate(
+ "[if system:os][if file:X]A[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", "v"],
+ ]),
+ ),
+ ).toBe("A");
+ });
+
+ it("nested if/else", () => {
+ expect(
+ parseTemplate(
+ "[if system:os]os[if file:X]A[else]B[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", null],
+ ]),
+ ),
+ ).toBe("osB");
+ });
+
+ it("unmatched if → literal text", () => {
+ // 9. [if file:X]text (no endif) → "[if file:X]text"
+ expect(parseTemplate("[if file:X]text", vars([["file:X", "v"]]))).toBe("[if file:X]text");
+ });
+
+ it("unmatched if with null var still emits literal tag", () => {
+ expect(parseTemplate("[if file:X]text", vars([["file:X", null]]))).toBe("[if file:X]text");
+ });
+
+ it("stray endif → literal text", () => {
+ expect(parseTemplate("a[endif]b", vars([]))).toBe("a[endif]b");
+ });
+
+ it("stray else → literal text", () => {
+ expect(parseTemplate("a[else]b", vars([]))).toBe("a[else]b");
+ });
+
+ it("multi-line content renders correctly", () => {
+ // 10. if block spanning multiple lines
+ const template = "[if file:AGENTS.md]line1\nline2\nline3[endif]";
+ expect(parseTemplate(template, vars([["file:AGENTS.md", "c"]]))).toBe("line1\nline2\nline3");
+ });
+
+ it("multi-line if/else block", () => {
+ const template = "[if file:X]\nA\n[else]\nB\n[endif]";
+ expect(parseTemplate(template, vars([["file:X", null]]))).toBe("\nB\n");
+ });
+
+ it("default-template-like structure renders", () => {
+ const template =
+ "You are a helpful coding assistant.\n\n[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\n\nThe current working directory is [prompt:cwd].\n";
+ expect(
+ parseTemplate(
+ template,
+ vars([
+ ["file:AGENTS.md", "RULES"],
+ ["prompt:cwd", "/proj"],
+ ]),
+ ),
+ ).toBe(
+ "You are a helpful coding assistant.\n\n\nRULES\n\n\nThe current working directory is /proj.\n",
+ );
+ });
+
+ it("default-template-like structure without AGENTS.md", () => {
+ const template = "[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\nThe cwd is [prompt:cwd].";
+ expect(parseTemplate(template, vars([["prompt:cwd", "/proj"]]))).toBe("\nThe cwd is /proj.");
+ });
+ });
+
+ describe("extractVariables", () => {
+ it("collects insertion + condition keys", () => {
+ const template = "[system:time] [if file:AGENTS.md][file:AGENTS.md][endif] [if !git:branch]";
+ expect(extractVariables(template)).toEqual(["system:time", "file:AGENTS.md", "git:branch"]);
+ });
+
+ it("deduplicates keys", () => {
+ expect(extractVariables("[file:X][if file:X][file:X]")).toEqual(["file:X"]);
+ });
+
+ it("returns empty for plain text", () => {
+ expect(extractVariables("no variables here")).toEqual([]);
+ });
+
+ it("ignores unmatched tags", () => {
+ expect(extractVariables("[if file:X]no endif")).toEqual(["file:X"]);
+ });
+ });
+});