diff options
| author | Adam Malczewski <[email protected]> | 2026-06-23 23:04:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-23 23:04:30 +0900 |
| commit | 674853d87d54dba1cd83c4e51fce5411602f4d5d (patch) | |
| tree | 07455f9753a09a5ca66f8cb885a37ba3c0cb7787 /packages/system-prompt/src/parser.test.ts | |
| parent | 4158e699e3c8ff556684fe2fc7a39ffab040623e (diff) | |
| download | dispatch-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.ts | 186 |
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"]); + }); + }); +}); |
