diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/system-prompt/src | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip | |
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/system-prompt/src')
| -rw-r--r-- | packages/system-prompt/src/catalog.test.ts | 46 | ||||
| -rw-r--r-- | packages/system-prompt/src/catalog.ts | 36 | ||||
| -rw-r--r-- | packages/system-prompt/src/extension.ts | 206 | ||||
| -rw-r--r-- | packages/system-prompt/src/index.ts | 12 | ||||
| -rw-r--r-- | packages/system-prompt/src/parser.test.ts | 356 | ||||
| -rw-r--r-- | packages/system-prompt/src/parser.ts | 346 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.test.ts | 542 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.ts | 236 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 414 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 126 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 74 |
11 files changed, 1197 insertions, 1197 deletions
diff --git a/packages/system-prompt/src/catalog.test.ts b/packages/system-prompt/src/catalog.test.ts index 406455b..12999b6 100644 --- a/packages/system-prompt/src/catalog.test.ts +++ b/packages/system-prompt/src/catalog.test.ts @@ -2,30 +2,30 @@ import { describe, expect, it } from "vitest"; import { getVariableCatalog } from "./catalog.js"; describe("catalog", () => { - it("lists all fixed variables", () => { - const catalog = getVariableCatalog(); - const keys = catalog.map((v) => `${v.type}:${v.name}`); + it("lists all fixed variables", () => { + const catalog = getVariableCatalog(); + const keys = catalog.map((v) => `${v.type}:${v.name}`); - expect(keys).toContain("system:time"); - expect(keys).toContain("system:date"); - expect(keys).toContain("system:os"); - expect(keys).toContain("system:hostname"); - expect(keys).toContain("prompt:cwd"); - expect(keys).toContain("prompt:model"); - expect(keys).toContain("prompt:conversation_id"); - expect(keys).toContain("git:branch"); - expect(keys).toContain("git:status"); - }); + expect(keys).toContain("system:time"); + expect(keys).toContain("system:date"); + expect(keys).toContain("system:os"); + expect(keys).toContain("system:hostname"); + expect(keys).toContain("prompt:cwd"); + expect(keys).toContain("prompt:model"); + expect(keys).toContain("prompt:conversation_id"); + expect(keys).toContain("git:branch"); + expect(keys).toContain("git:status"); + }); - it("marks the file type as dynamic", () => { - const fileVar = getVariableCatalog().find((v) => v.type === "file"); - expect(fileVar).toBeDefined(); - expect(fileVar?.dynamic).toBe(true); - }); + it("marks the file type as dynamic", () => { + const fileVar = getVariableCatalog().find((v) => v.type === "file"); + expect(fileVar).toBeDefined(); + expect(fileVar?.dynamic).toBe(true); + }); - it("every entry has a description", () => { - for (const v of getVariableCatalog()) { - expect(v.description.length).toBeGreaterThan(0); - } - }); + it("every entry has a description", () => { + for (const v of getVariableCatalog()) { + expect(v.description.length).toBeGreaterThan(0); + } + }); }); diff --git a/packages/system-prompt/src/catalog.ts b/packages/system-prompt/src/catalog.ts index 1c825ab..f4df390 100644 --- a/packages/system-prompt/src/catalog.ts +++ b/packages/system-prompt/src/catalog.ts @@ -8,22 +8,22 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract"; export function getVariableCatalog(): SystemPromptVariable[] { - return [ - { type: "system", name: "time", description: "Current time in ISO 8601 format" }, - { type: "system", name: "date", description: "Current date (YYYY-MM-DD)" }, - { type: "system", name: "os", description: "Operating system platform" }, - { type: "system", name: "hostname", description: "Machine hostname" }, - { type: "prompt", name: "cwd", description: "Conversation working directory" }, - { type: "prompt", name: "model", description: "Current model name" }, - { type: "prompt", name: "conversation_id", description: "Conversation identifier" }, - { type: "prompt", name: "workspace_id", description: "Workspace identifier" }, - { type: "git", name: "branch", description: "Current git branch" }, - { type: "git", name: "status", description: "Short git status" }, - { - type: "file", - name: "<path>", - description: "Contents of a file (relative to cwd, or absolute if it starts with /)", - dynamic: true, - }, - ]; + return [ + { type: "system", name: "time", description: "Current time in ISO 8601 format" }, + { type: "system", name: "date", description: "Current date (YYYY-MM-DD)" }, + { type: "system", name: "os", description: "Operating system platform" }, + { type: "system", name: "hostname", description: "Machine hostname" }, + { type: "prompt", name: "cwd", description: "Conversation working directory" }, + { type: "prompt", name: "model", description: "Current model name" }, + { type: "prompt", name: "conversation_id", description: "Conversation identifier" }, + { type: "prompt", name: "workspace_id", description: "Workspace identifier" }, + { type: "git", name: "branch", description: "Current git branch" }, + { type: "git", name: "status", description: "Short git status" }, + { + type: "file", + name: "<path>", + description: "Contents of a file (relative to cwd, or absolute if it starts with /)", + dynamic: true, + }, + ]; } diff --git a/packages/system-prompt/src/extension.ts b/packages/system-prompt/src/extension.ts index 5cb9125..d281bf8 100644 --- a/packages/system-prompt/src/extension.ts +++ b/packages/system-prompt/src/extension.ts @@ -17,43 +17,43 @@ import { createSystemPromptService } from "./service.js"; import { systemPromptHandle } from "./types.js"; export const manifest: Manifest = { - id: "system-prompt", - name: "System Prompt", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - // exec-backend provides the resolver used to obtain a remote ExecBackend - // when computerId is set. The lookup is lazy (at construct time, not - // activation), but declaring the dep keeps the DAG honest. - dependsOn: ["exec-backend"], - capabilities: { fs: true, spawn: true }, - contributes: { services: ["system-prompt"] }, + id: "system-prompt", + name: "System Prompt", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + // exec-backend provides the resolver used to obtain a remote ExecBackend + // when computerId is set. The lookup is lazy (at construct time, not + // activation), but declaring the dep keeps the DAG honest. + dependsOn: ["exec-backend"], + capabilities: { fs: true, spawn: true }, + contributes: { services: ["system-prompt"] }, }; /** Run a command and capture stdout/stderr (used for git). */ async function realSpawn( - command: readonly string[], - opts: { readonly cwd: string }, + command: readonly string[], + opts: { readonly cwd: string }, ): Promise<GitSpawnResult> { - const proc = Bun.spawn([...command], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - Bun.readableStreamToText(proc.stdout), - Bun.readableStreamToText(proc.stderr), - proc.exited, - ]); - return { stdout, stderr, exitCode }; + const proc = Bun.spawn([...command], { + cwd: opts.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + Bun.readableStreamToText(proc.stdout), + Bun.readableStreamToText(proc.stderr), + proc.exited, + ]); + return { stdout, stderr, exitCode }; } function realFs() { - return { - readText: async (path: string): Promise<string> => Bun.file(path).text(), - exists: async (path: string): Promise<boolean> => Bun.file(path).exists(), - }; + return { + readText: async (path: string): Promise<string> => Bun.file(path).text(), + exists: async (path: string): Promise<boolean> => Bun.file(path).exists(), + }; } const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() }; @@ -63,25 +63,25 @@ const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() }; * `null` on any error (the resolver treats null as "unavailable"). */ async function remoteCommand( - backend: ExecBackend, - command: string, - cwd: string, + backend: ExecBackend, + command: string, + cwd: string, ): Promise<string | null> { - let stdout = ""; - try { - const result = await backend.spawn({ - command, - cwd, - signal: new AbortController().signal, - timeout: 10_000, - onOutput: (data: string, stream: "stdout" | "stderr") => { - if (stream === "stdout") stdout += data; - }, - }); - return result.exitCode === 0 ? stdout.trim() : null; - } catch { - return null; - } + let stdout = ""; + try { + const result = await backend.spawn({ + command, + cwd, + signal: new AbortController().signal, + timeout: 10_000, + onOutput: (data: string, stream: "stdout" | "stderr") => { + if (stream === "stdout") stdout += data; + }, + }); + return result.exitCode === 0 ? stdout.trim() : null; + } catch { + return null; + } } /** @@ -90,68 +90,68 @@ async function remoteCommand( * prompt reflect the REMOTE machine's OS, hostname, and git state. */ function buildRemoteAdapters(backend: ExecBackend, cwd: string): ResolverAdapters { - return { - spawn: async (command, opts) => { - let stdout = ""; - let stderr = ""; - const result = await backend.spawn({ - command: command.join(" "), - cwd: opts.cwd, - signal: new AbortController().signal, - timeout: 10_000, - onOutput: (data: string, stream: "stdout" | "stderr") => { - if (stream === "stdout") stdout += data; - else stderr += data; - }, - }); - return { stdout, stderr, exitCode: result.exitCode }; - }, - fs: { - readText: async (path: string): Promise<string> => backend.readFile(path), - exists: async (path: string): Promise<boolean> => backend.exists(path), - }, - // Run hostname/uname on the REMOTE machine. These are resolved once per - // construct call (cached by the service's cwd+computerId cache). If the - // remote command fails, fall back to a generic value (the resolver will - // still read /etc/os-release via SFTP for the distro name). - hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote", - platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux", - }; + return { + spawn: async (command, opts) => { + let stdout = ""; + let stderr = ""; + const result = await backend.spawn({ + command: command.join(" "), + cwd: opts.cwd, + signal: new AbortController().signal, + timeout: 10_000, + onOutput: (data: string, stream: "stdout" | "stderr") => { + if (stream === "stdout") stdout += data; + else stderr += data; + }, + }); + return { stdout, stderr, exitCode: result.exitCode }; + }, + fs: { + readText: async (path: string): Promise<string> => backend.readFile(path), + exists: async (path: string): Promise<boolean> => backend.exists(path), + }, + // Run hostname/uname on the REMOTE machine. These are resolved once per + // construct call (cached by the service's cwd+computerId cache). If the + // remote command fails, fall back to a generic value (the resolver will + // still read /etc/os-release via SFTP for the distro name). + hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote", + platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux", + }; } export function activate(host: HostAPI): void { - const storage = host.storage("system-prompt"); + const storage = host.storage("system-prompt"); - /** - * Resolve remote-backed adapters for a given computerId. Looks up the - * ExecBackendResolver (provided by exec-backend, which delegates to ssh's - * remote factory when computerId is set) and wraps it in ResolverAdapters. - * Falls back to local adapters if the resolver or backend is unavailable. - */ - const resolveRemoteAdapters = async ( - computerId: string, - cwd: string, - ): Promise<ResolverAdapters> => { - try { - const resolver = host.getService(execBackendHandle); - const backend = resolver(computerId); - return buildRemoteAdapters(backend, cwd); - } catch { - // exec-backend not loaded or resolver unavailable → local. - return localAdapters; - } - }; + /** + * Resolve remote-backed adapters for a given computerId. Looks up the + * ExecBackendResolver (provided by exec-backend, which delegates to ssh's + * remote factory when computerId is set) and wraps it in ResolverAdapters. + * Falls back to local adapters if the resolver or backend is unavailable. + */ + const resolveRemoteAdapters = async ( + computerId: string, + cwd: string, + ): Promise<ResolverAdapters> => { + try { + const resolver = host.getService(execBackendHandle); + const backend = resolver(computerId); + return buildRemoteAdapters(backend, cwd); + } catch { + // exec-backend not loaded or resolver unavailable → local. + return localAdapters; + } + }; - const service = createSystemPromptService({ - storage, - adapters: localAdapters, - resolveRemoteAdapters, - }); - host.provideService(systemPromptHandle, service); - host.logger.info("system-prompt: activated"); + const service = createSystemPromptService({ + storage, + adapters: localAdapters, + resolveRemoteAdapters, + }); + host.provideService(systemPromptHandle, service); + host.logger.info("system-prompt: activated"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts index 71cc434..35fd091 100644 --- a/packages/system-prompt/src/index.ts +++ b/packages/system-prompt/src/index.ts @@ -2,12 +2,12 @@ export { getVariableCatalog } from "./catalog.js"; export { extension, manifest } from "./extension.js"; export { extractVariables, parseTemplate } from "./parser.js"; export type { - GitSpawn, - GitSpawnResult, - ResolveOptions, - ResolverAdapters, - ResolverContext, - ResolverFs, + GitSpawn, + GitSpawnResult, + ResolveOptions, + ResolverAdapters, + ResolverContext, + ResolverFs, } from "./resolver.js"; export { resolveVariables } from "./resolver.js"; export type { SystemPromptServiceDeps } from "./service.js"; diff --git a/packages/system-prompt/src/parser.test.ts b/packages/system-prompt/src/parser.test.ts index 636a50d..3e3cdb6 100644 --- a/packages/system-prompt/src/parser.test.ts +++ b/packages/system-prompt/src/parser.test.ts @@ -2,185 +2,185 @@ 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); + 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"]); - }); - }); + 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"]); + }); + }); }); diff --git a/packages/system-prompt/src/parser.ts b/packages/system-prompt/src/parser.ts index 5b39b7f..d01e6a8 100644 --- a/packages/system-prompt/src/parser.ts +++ b/packages/system-prompt/src/parser.ts @@ -19,48 +19,48 @@ // ─── Token model ───────────────────────────────────────────────────────────── interface TextToken { - readonly kind: "text"; - readonly value: string; + readonly kind: "text"; + readonly value: string; } interface VarToken { - readonly kind: "var"; - readonly key: string; - readonly raw: string; + readonly kind: "var"; + readonly key: string; + readonly raw: string; } interface IfToken { - readonly kind: "if"; - readonly key: string; - readonly negated: boolean; - readonly raw: string; + readonly kind: "if"; + readonly key: string; + readonly negated: boolean; + readonly raw: string; } interface ElseToken { - readonly kind: "else"; - readonly raw: string; + readonly kind: "else"; + readonly raw: string; } interface EndifToken { - readonly kind: "endif"; - readonly raw: string; + readonly kind: "endif"; + readonly raw: string; } type Token = TextToken | VarToken | IfToken | ElseToken | EndifToken; // ─── Node model (AST) ──────────────────────────────────────────────────────── interface TextNode { - readonly kind: "text"; - readonly value: string; + readonly kind: "text"; + readonly value: string; } interface VarNode { - readonly kind: "var"; - readonly key: string; + readonly kind: "var"; + readonly key: string; } interface IfNode { - kind: "if"; - key: string; - negated: boolean; - thenBranch: Node[]; - else: Node[] | null; - matched: boolean; - readonly raw: string; + kind: "if"; + key: string; + negated: boolean; + thenBranch: Node[]; + else: Node[] | null; + matched: boolean; + readonly raw: string; } type Node = TextNode | VarNode | IfNode; @@ -71,23 +71,23 @@ type Node = TextNode | VarNode | IfNode; * `null` when it is not a recognized tag (then it stays literal text). */ function classifyTag(content: string): Token | null { - const trimmed = content.trim(); - if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` }; - if (trimmed === "else") return { kind: "else", raw: `[${content}]` }; + const trimmed = content.trim(); + if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` }; + if (trimmed === "else") return { kind: "else", raw: `[${content}]` }; - // `[if type:name]` / `[if !type:name]` - const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed); - if (ifMatch) { - const negated = (ifMatch[1] ?? "") === "!"; - const key = ifMatch[2] ?? ""; - return { kind: "if", key, negated, raw: `[${content}]` }; - } + // `[if type:name]` / `[if !type:name]` + const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed); + if (ifMatch) { + const negated = (ifMatch[1] ?? "") === "!"; + const key = ifMatch[2] ?? ""; + return { kind: "if", key, negated, raw: `[${content}]` }; + } - // `[type:name]` — variable insertion (any `word:rest`) - const varMatch = /^(\w+:.*)$/.exec(trimmed); - if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` }; + // `[type:name]` — variable insertion (any `word:rest`) + const varMatch = /^(\w+:.*)$/.exec(trimmed); + if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` }; - return null; + return null; } /** @@ -96,45 +96,45 @@ function classifyTag(content: string): Token | null { * as literal text. */ function tokenize(template: string): Token[] { - const tokens: Token[] = []; - let buf = ""; - let i = 0; - const n = template.length; + const tokens: Token[] = []; + let buf = ""; + let i = 0; + const n = template.length; - const flush = (): void => { - if (buf.length > 0) { - tokens.push({ kind: "text", value: buf }); - buf = ""; - } - }; + const flush = (): void => { + if (buf.length > 0) { + tokens.push({ kind: "text", value: buf }); + buf = ""; + } + }; - while (i < n) { - const ch = template[i]; - if (ch === undefined) break; - if (ch === "[") { - const close = template.indexOf("]", i + 1); - if (close === -1) { - buf += "["; - i++; - continue; - } - const content = template.slice(i + 1, close); - const tag = classifyTag(content); - if (tag !== null) { - flush(); - tokens.push(tag); - i = close + 1; - continue; - } - buf += "["; - i++; - } else { - buf += ch; - i++; - } - } - flush(); - return tokens; + while (i < n) { + const ch = template[i]; + if (ch === undefined) break; + if (ch === "[") { + const close = template.indexOf("]", i + 1); + if (close === -1) { + buf += "["; + i++; + continue; + } + const content = template.slice(i + 1, close); + const tag = classifyTag(content); + if (tag !== null) { + flush(); + tokens.push(tag); + i = close + 1; + continue; + } + buf += "["; + i++; + } else { + buf += ch; + i++; + } + } + flush(); + return tokens; } // ─── Parser (token stream → AST) ───────────────────────────────────────────── @@ -147,99 +147,99 @@ const EMPTY: readonly Node[] = Object.freeze([]) as readonly Node[]; * stray `else`/`endif` (no open `if`) becomes a literal text node. */ function parse(tokens: readonly Token[]): Node[] { - const root: Node[] = []; - const stack: IfNode[] = []; - let current: Node[] = root; + const root: Node[] = []; + const stack: IfNode[] = []; + let current: Node[] = root; - for (const tok of tokens) { - switch (tok.kind) { - case "text": - current.push({ kind: "text", value: tok.value }); - break; - case "var": - current.push({ kind: "var", key: tok.key }); - break; - case "if": { - const node: IfNode = { - kind: "if", - key: tok.key, - negated: tok.negated, - thenBranch: [], - else: null, - matched: true, - raw: tok.raw, - }; - current.push(node); - stack.push(node); - current = node.thenBranch; - break; - } - case "else": { - const top = stack[stack.length - 1]; - if (top !== undefined && top.else === null) { - top.else = []; - current = top.else; - } else { - // stray else (no open if, or if already has an else) → literal - current.push({ kind: "text", value: tok.raw }); - } - break; - } - case "endif": { - const top = stack.pop(); - if (top === undefined) { - // stray endif → literal - current.push({ kind: "text", value: tok.raw }); - break; - } - const parent = stack[stack.length - 1]; - current = parent === undefined ? root : (parent.else ?? parent.thenBranch); - break; - } - } - } + for (const tok of tokens) { + switch (tok.kind) { + case "text": + current.push({ kind: "text", value: tok.value }); + break; + case "var": + current.push({ kind: "var", key: tok.key }); + break; + case "if": { + const node: IfNode = { + kind: "if", + key: tok.key, + negated: tok.negated, + thenBranch: [], + else: null, + matched: true, + raw: tok.raw, + }; + current.push(node); + stack.push(node); + current = node.thenBranch; + break; + } + case "else": { + const top = stack[stack.length - 1]; + if (top !== undefined && top.else === null) { + top.else = []; + current = top.else; + } else { + // stray else (no open if, or if already has an else) → literal + current.push({ kind: "text", value: tok.raw }); + } + break; + } + case "endif": { + const top = stack.pop(); + if (top === undefined) { + // stray endif → literal + current.push({ kind: "text", value: tok.raw }); + break; + } + const parent = stack[stack.length - 1]; + current = parent === undefined ? root : (parent.else ?? parent.thenBranch); + break; + } + } + } - // Any `if` still on the stack never found its `endif` → unmatched. - for (const node of stack) node.matched = false; - return root; + // Any `if` still on the stack never found its `endif` → unmatched. + for (const node of stack) node.matched = false; + return root; } // ─── Renderer (AST → string) ──────────────────────────────────────────────── function variableExists(key: string, vars: ReadonlyMap<string, string | null>): boolean { - return vars.has(key) && vars.get(key) !== null; + return vars.has(key) && vars.get(key) !== null; } function render(nodes: readonly Node[], vars: ReadonlyMap<string, string | null>): string { - let out = ""; - for (const node of nodes) { - switch (node.kind) { - case "text": - out += node.value; - break; - case "var": - out += vars.get(node.key) ?? ""; - break; - case "if": { - if (node.matched) { - const exists = variableExists(node.key, vars); - const takeThen = node.negated ? !exists : exists; - const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY); - out += render(branch, vars); - } else { - // Unmatched `if` → the tag is literal text; content still renders. - out += node.raw; - out += render(node.thenBranch, vars); - if (node.else !== null) { - out += "[else]"; - out += render(node.else, vars); - } - } - break; - } - } - } - return out; + let out = ""; + for (const node of nodes) { + switch (node.kind) { + case "text": + out += node.value; + break; + case "var": + out += vars.get(node.key) ?? ""; + break; + case "if": { + if (node.matched) { + const exists = variableExists(node.key, vars); + const takeThen = node.negated ? !exists : exists; + const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY); + out += render(branch, vars); + } else { + // Unmatched `if` → the tag is literal text; content still renders. + out += node.raw; + out += render(node.thenBranch, vars); + if (node.else !== null) { + out += "[else]"; + out += render(node.else, vars); + } + } + break; + } + } + } + return out; } // ─── Public API ────────────────────────────────────────────────────────────── @@ -254,9 +254,9 @@ function render(nodes: readonly Node[], vars: ReadonlyMap<string, string | null> * - Unmatched `[if]`/`[endif]` tags pass through as literal text. */ export function parseTemplate(template: string, vars: ReadonlyMap<string, string | null>): string { - const tokens = tokenize(template); - const ast = parse(tokens); - return render(ast, vars); + const tokens = tokenize(template); + const ast = parse(tokens); + return render(ast, vars); } /** @@ -266,16 +266,16 @@ export function parseTemplate(template: string, vars: ReadonlyMap<string, string * Returns unique keys in first-seen order. */ export function extractVariables(template: string): string[] { - const tokens = tokenize(template); - const seen = new Set<string>(); - const keys: string[] = []; - for (const tok of tokens) { - if (tok.kind === "var" || tok.kind === "if") { - if (!seen.has(tok.key)) { - seen.add(tok.key); - keys.push(tok.key); - } - } - } - return keys; + const tokens = tokenize(template); + const seen = new Set<string>(); + const keys: string[] = []; + for (const tok of tokens) { + if (tok.kind === "var" || tok.kind === "if") { + if (!seen.has(tok.key)) { + seen.add(tok.key); + keys.push(tok.key); + } + } + } + return keys; } diff --git a/packages/system-prompt/src/resolver.test.ts b/packages/system-prompt/src/resolver.test.ts index d55af07..a92e43f 100644 --- a/packages/system-prompt/src/resolver.test.ts +++ b/packages/system-prompt/src/resolver.test.ts @@ -4,287 +4,287 @@ import { resolveVariables } from "./resolver.js"; /** A spawn that returns canned output per command (joined argv → result). */ function fakeSpawn( - table: ReadonlyMap<string, GitSpawnResult> | GitSpawnResult, + table: ReadonlyMap<string, GitSpawnResult> | GitSpawnResult, ): ResolverAdapters["spawn"] { - return async (command) => { - if (table instanceof Map) { - return table.get(command.join(" ")) ?? { stdout: "", stderr: "", exitCode: 128 }; - } - return table; - }; + return async (command) => { + if (table instanceof Map) { + return table.get(command.join(" ")) ?? { stdout: "", stderr: "", exitCode: 128 }; + } + return table; + }; } function fakeFs(files: ReadonlyMap<string, string>): ResolverFs { - return { - readText: async (path: string) => files.get(path) ?? "", - exists: async (path: string) => files.has(path), - }; + return { + readText: async (path: string) => files.get(path) ?? "", + exists: async (path: string) => files.has(path), + }; } const failSpawn = (): ResolverAdapters["spawn"] => async () => ({ - stdout: "", - stderr: "not a git repo", - exitCode: 128, + stdout: "", + stderr: "not a git repo", + exitCode: 128, }); const fixedNow = new Date("2024-06-15T12:30:00.000Z"); describe("resolver", () => { - describe("system variables", () => { - it("resolves system:* to non-null strings", async () => { - // 11. system:time, system:date, system:os, system:hostname - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - platform: async () => "linux", - hostname: async () => "myhost", - }); - - expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z"); - expect(map.get("system:date")).toBe("2024-06-15"); - expect(map.get("system:os")).toBe("linux"); - expect(map.get("system:hostname")).toBe("myhost"); - }); - - it("prompt:cwd is the cwd, model/conversation_id follow context", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { context: { model: "gpt-4", conversationId: "conv-1" } }, - ); - - expect(map.get("prompt:cwd")).toBe("/proj"); - expect(map.get("prompt:model")).toBe("gpt-4"); - expect(map.get("prompt:conversation_id")).toBe("conv-1"); - }); - - it("prompt:model / prompt:conversation_id are null when absent", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("prompt:model")).toBeNull(); - expect(map.get("prompt:conversation_id")).toBeNull(); - }); - }); - - describe("system:os rich resolution", () => { - it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => { - const files = new Map<string, string>([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS"); - }); - - it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => { - const files = new Map<string, string>([ - ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Debian 12"); - }); - - it("appends (WSL) when WSLInterop exists", async () => { - const files = new Map<string, string>([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], - ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); - }); - - it("detects WSL via 'microsoft' in /proc/version", async () => { - const files = new Map<string, string>([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], - ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); - }); - - it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => { - const files = new Map<string, string>([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Linux (WSL)"); - }); - - it("returns plain 'linux' when no os-release and no WSL", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("linux"); - }); - - it("returns platform as-is for non-Linux (darwin)", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - platform: async () => "darwin", - }); - expect(map.get("system:os")).toBe("darwin"); - }); - }); - - describe("file variables", () => { - it("reads a file relative to cwd", async () => { - // 12. file variable reads relative path; missing → null - const files = new Map<string, string>([["/proj/AGENTS.md", "rules"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:AGENTS.md"] }, - ); - - expect(map.get("file:AGENTS.md")).toBe("rules"); - }); - - it("missing file → null", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { referencedKeys: ["file:missing.md"] }, - ); - - expect(map.get("file:missing.md")).toBeNull(); - }); - - it("absolute path reads from absolute location", async () => { - const files = new Map<string, string>([["/etc/config", "data"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:/etc/config"] }, - ); - - expect(map.get("file:/etc/config")).toBe("data"); - }); - - it("reads nested relative path", async () => { - const files = new Map<string, string>([["/proj/src/foo.ts", "export {}"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:src/foo.ts"] }, - ); - - expect(map.get("file:src/foo.ts")).toBe("export {}"); - }); - - it("non-file referenced keys are not added to the map", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { referencedKeys: ["unknown:foo"] }, - ); - - expect(map.has("unknown:foo")).toBe(false); - }); - }); - - describe("git variables", () => { - it("git:branch returns the branch name", async () => { - // 13. git:branch via injected spawn - const table = new Map<string, GitSpawnResult>([ - ["git rev-parse --abbrev-ref HEAD", { stdout: "feature/x\n", stderr: "", exitCode: 0 }], - ["git status --short", { stdout: " M a.ts\n", stderr: "", exitCode: 0 }], - ]); - const map = await resolveVariables("/proj", { - spawn: fakeSpawn(table), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBe("feature/x"); - expect(map.get("git:status")).toBe(" M a.ts"); - }); - - it("non-git cwd → null", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBeNull(); - expect(map.get("git:status")).toBeNull(); - }); - - it("throwing spawn → null", async () => { - const throwingSpawn = async (): Promise<GitSpawnResult> => { - throw new Error("git not installed"); - }; - const map = await resolveVariables("/proj", { - spawn: throwingSpawn, - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBeNull(); - expect(map.get("git:status")).toBeNull(); - }); - - it("clean repo → git:status is empty string (existing)", async () => { - const table = new Map<string, GitSpawnResult>([ - ["git rev-parse --abbrev-ref HEAD", { stdout: "main\n", stderr: "", exitCode: 0 }], - ["git status --short", { stdout: "", stderr: "", exitCode: 0 }], - ]); - const map = await resolveVariables("/proj", { - spawn: fakeSpawn(table), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:status")).toBe(""); - }); - }); + describe("system variables", () => { + it("resolves system:* to non-null strings", async () => { + // 11. system:time, system:date, system:os, system:hostname + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + platform: async () => "linux", + hostname: async () => "myhost", + }); + + expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z"); + expect(map.get("system:date")).toBe("2024-06-15"); + expect(map.get("system:os")).toBe("linux"); + expect(map.get("system:hostname")).toBe("myhost"); + }); + + it("prompt:cwd is the cwd, model/conversation_id follow context", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { context: { model: "gpt-4", conversationId: "conv-1" } }, + ); + + expect(map.get("prompt:cwd")).toBe("/proj"); + expect(map.get("prompt:model")).toBe("gpt-4"); + expect(map.get("prompt:conversation_id")).toBe("conv-1"); + }); + + it("prompt:model / prompt:conversation_id are null when absent", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("prompt:model")).toBeNull(); + expect(map.get("prompt:conversation_id")).toBeNull(); + }); + }); + + describe("system:os rich resolution", () => { + it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => { + const files = new Map<string, string>([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS"); + }); + + it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => { + const files = new Map<string, string>([ + ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Debian 12"); + }); + + it("appends (WSL) when WSLInterop exists", async () => { + const files = new Map<string, string>([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], + ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); + }); + + it("detects WSL via 'microsoft' in /proc/version", async () => { + const files = new Map<string, string>([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], + ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); + }); + + it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => { + const files = new Map<string, string>([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Linux (WSL)"); + }); + + it("returns plain 'linux' when no os-release and no WSL", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("linux"); + }); + + it("returns platform as-is for non-Linux (darwin)", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + platform: async () => "darwin", + }); + expect(map.get("system:os")).toBe("darwin"); + }); + }); + + describe("file variables", () => { + it("reads a file relative to cwd", async () => { + // 12. file variable reads relative path; missing → null + const files = new Map<string, string>([["/proj/AGENTS.md", "rules"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:AGENTS.md"] }, + ); + + expect(map.get("file:AGENTS.md")).toBe("rules"); + }); + + it("missing file → null", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { referencedKeys: ["file:missing.md"] }, + ); + + expect(map.get("file:missing.md")).toBeNull(); + }); + + it("absolute path reads from absolute location", async () => { + const files = new Map<string, string>([["/etc/config", "data"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:/etc/config"] }, + ); + + expect(map.get("file:/etc/config")).toBe("data"); + }); + + it("reads nested relative path", async () => { + const files = new Map<string, string>([["/proj/src/foo.ts", "export {}"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:src/foo.ts"] }, + ); + + expect(map.get("file:src/foo.ts")).toBe("export {}"); + }); + + it("non-file referenced keys are not added to the map", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { referencedKeys: ["unknown:foo"] }, + ); + + expect(map.has("unknown:foo")).toBe(false); + }); + }); + + describe("git variables", () => { + it("git:branch returns the branch name", async () => { + // 13. git:branch via injected spawn + const table = new Map<string, GitSpawnResult>([ + ["git rev-parse --abbrev-ref HEAD", { stdout: "feature/x\n", stderr: "", exitCode: 0 }], + ["git status --short", { stdout: " M a.ts\n", stderr: "", exitCode: 0 }], + ]); + const map = await resolveVariables("/proj", { + spawn: fakeSpawn(table), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBe("feature/x"); + expect(map.get("git:status")).toBe(" M a.ts"); + }); + + it("non-git cwd → null", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBeNull(); + expect(map.get("git:status")).toBeNull(); + }); + + it("throwing spawn → null", async () => { + const throwingSpawn = async (): Promise<GitSpawnResult> => { + throw new Error("git not installed"); + }; + const map = await resolveVariables("/proj", { + spawn: throwingSpawn, + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBeNull(); + expect(map.get("git:status")).toBeNull(); + }); + + it("clean repo → git:status is empty string (existing)", async () => { + const table = new Map<string, GitSpawnResult>([ + ["git rev-parse --abbrev-ref HEAD", { stdout: "main\n", stderr: "", exitCode: 0 }], + ["git status --short", { stdout: "", stderr: "", exitCode: 0 }], + ]); + const map = await resolveVariables("/proj", { + spawn: fakeSpawn(table), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:status")).toBe(""); + }); + }); }); diff --git a/packages/system-prompt/src/resolver.ts b/packages/system-prompt/src/resolver.ts index e864554..8b397d2 100644 --- a/packages/system-prompt/src/resolver.ts +++ b/packages/system-prompt/src/resolver.ts @@ -17,9 +17,9 @@ import { isAbsolute, resolve as resolvePath } from "node:path"; /** Result of a spawned command (used for git). */ export interface GitSpawnResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; } /** @@ -27,73 +27,73 @@ export interface GitSpawnResult { * resolver (e.g. git not installed, bad cwd). */ export type GitSpawn = ( - command: readonly string[], - opts: { readonly cwd: string }, + command: readonly string[], + opts: { readonly cwd: string }, ) => Promise<GitSpawnResult>; /** Filesystem adapter — the read effects the resolver needs. */ export interface ResolverFs { - readonly readText: (path: string) => Promise<string>; - readonly exists: (path: string) => Promise<boolean>; + readonly readText: (path: string) => Promise<string>; + readonly exists: (path: string) => Promise<boolean>; } /** Injected effects + optional overridable clocks for deterministic tests. */ export interface ResolverAdapters { - /** Run a command (git) and capture stdout. */ - readonly spawn: GitSpawn; - /** File read effects. */ - readonly fs: ResolverFs; - /** Override the current time (defaults to `new Date()`). */ - readonly now?: () => Date; - /** - * Override `process.platform` (defaults to the real platform). Async so a - * remote adapter can run `uname -s` over SSH. - */ - readonly platform?: () => Promise<string>; - /** - * Override the hostname (defaults to `os.hostname()`). Async so a remote - * adapter can run `hostname` over SSH. - */ - readonly hostname?: () => Promise<string>; + /** Run a command (git) and capture stdout. */ + readonly spawn: GitSpawn; + /** File read effects. */ + readonly fs: ResolverFs; + /** Override the current time (defaults to `new Date()`). */ + readonly now?: () => Date; + /** + * Override `process.platform` (defaults to the real platform). Async so a + * remote adapter can run `uname -s` over SSH. + */ + readonly platform?: () => Promise<string>; + /** + * Override the hostname (defaults to `os.hostname()`). Async so a remote + * adapter can run `hostname` over SSH. + */ + readonly hostname?: () => Promise<string>; } /** Per-construction context forwarded by the session-orchestrator. */ export interface ResolverContext { - readonly model?: string; - readonly conversationId?: string; - readonly workspaceId?: string; + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; } export interface ResolveOptions { - readonly context?: ResolverContext; - /** Variable keys referenced by the template (drives dynamic `file:` reads). */ - readonly referencedKeys?: readonly string[]; + readonly context?: ResolverContext; + /** Variable keys referenced by the template (drives dynamic `file:` reads). */ + readonly referencedKeys?: readonly string[]; } /** Run a git subcommand in `cwd`; return raw stdout on success, else null. */ async function runGit( - args: readonly string[], - cwd: string, - spawn: GitSpawn, + args: readonly string[], + cwd: string, + spawn: GitSpawn, ): Promise<string | null> { - try { - const res = await spawn(["git", ...args], { cwd }); - if (res.exitCode !== 0) return null; - return res.stdout; - } catch { - return null; - } + try { + const res = await spawn(["git", ...args], { cwd }); + if (res.exitCode !== 0) return null; + return res.stdout; + } catch { + return null; + } } /** Read a file (relative to cwd, or absolute). Missing/error → null. */ async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise<string | null> { - const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath); - try { - if (!(await fs.exists(abs))) return null; - return await fs.readText(abs); - } catch { - return null; - } + const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath); + try { + if (!(await fs.exists(abs))) return null; + return await fs.readText(abs); + } catch { + return null; + } } /** @@ -109,38 +109,38 @@ async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise< * to the base platform string). The `platform` override is honored for tests. */ async function resolveOs(platform: string, fs: ResolverFs): Promise<string> { - if (platform !== "linux") return platform; - - let distro: string | null = null; - const osRelease = await readFile("/etc/os-release", "/", fs); - if (osRelease !== null) { - const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m); - if (pretty?.[1] !== undefined) { - distro = pretty[1]; - } else { - const name = osRelease.match(/^NAME="(.+)"/m); - const version = osRelease.match(/^VERSION_ID="(.+)"/m); - if (name?.[1] !== undefined) { - distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1]; - } - } - } - - let isWsl = false; - const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs); - if (wslInterop !== null) { - isWsl = true; - } else { - const procVersion = await readFile("/proc/version", "/", fs); - if (procVersion !== null && /microsoft/i.test(procVersion)) { - isWsl = true; - } - } - - if (distro !== null) { - return isWsl ? `${distro} (WSL)` : distro; - } - return isWsl ? "Linux (WSL)" : "linux"; + if (platform !== "linux") return platform; + + let distro: string | null = null; + const osRelease = await readFile("/etc/os-release", "/", fs); + if (osRelease !== null) { + const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m); + if (pretty?.[1] !== undefined) { + distro = pretty[1]; + } else { + const name = osRelease.match(/^NAME="(.+)"/m); + const version = osRelease.match(/^VERSION_ID="(.+)"/m); + if (name?.[1] !== undefined) { + distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1]; + } + } + } + + let isWsl = false; + const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs); + if (wslInterop !== null) { + isWsl = true; + } else { + const procVersion = await readFile("/proc/version", "/", fs); + if (procVersion !== null && /microsoft/i.test(procVersion)) { + isWsl = true; + } + } + + if (distro !== null) { + return isWsl ? `${distro} (WSL)` : distro; + } + return isWsl ? "Linux (WSL)" : "linux"; } /** @@ -151,45 +151,45 @@ async function resolveOs(platform: string, fs: ResolverFs): Promise<string> { * the template). Unknown types are intentionally left out of the map. */ export async function resolveVariables( - cwd: string, - adapters: ResolverAdapters, - options?: ResolveOptions, + cwd: string, + adapters: ResolverAdapters, + options?: ResolveOptions, ): Promise<Map<string, string | null>> { - const ctx = options?.context; - const referencedKeys = options?.referencedKeys; - const now = adapters.now?.() ?? new Date(); - const vars = new Map<string, string | null>(); - - // ── system:* ──────────────────────────────────────────────────────────── - vars.set("system:time", now.toISOString()); - vars.set("system:date", now.toISOString().slice(0, 10)); - const platform = (await adapters.platform?.()) ?? process.platform; - vars.set("system:os", await resolveOs(platform, adapters.fs)); - vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname()); - - // ── prompt:* ──────────────────────────────────────────────────────────── - vars.set("prompt:cwd", cwd); - vars.set("prompt:model", ctx?.model ?? null); - vars.set("prompt:conversation_id", ctx?.conversationId ?? null); - vars.set("prompt:workspace_id", ctx?.workspaceId ?? null); - - // ── git:* ──────────────────────────────────────────────────────────────── - // branch is a single value — trim fully; status keeps its leading status - // indicators, dropping only the trailing newline (trimEnd). - const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, adapters.spawn); - vars.set("git:branch", branch === null ? null : branch.trim()); - const status = await runGit(["status", "--short"], cwd, adapters.spawn); - vars.set("git:status", status === null ? null : status.trimEnd()); - - // ── file:<path> (dynamic — only those referenced by the template) ──────── - if (referencedKeys !== undefined) { - for (const key of referencedKeys) { - if (key.startsWith("file:")) { - const filePath = key.slice("file:".length); - vars.set(key, await readFile(filePath, cwd, adapters.fs)); - } - } - } - - return vars; + const ctx = options?.context; + const referencedKeys = options?.referencedKeys; + const now = adapters.now?.() ?? new Date(); + const vars = new Map<string, string | null>(); + + // ── system:* ──────────────────────────────────────────────────────────── + vars.set("system:time", now.toISOString()); + vars.set("system:date", now.toISOString().slice(0, 10)); + const platform = (await adapters.platform?.()) ?? process.platform; + vars.set("system:os", await resolveOs(platform, adapters.fs)); + vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname()); + + // ── prompt:* ──────────────────────────────────────────────────────────── + vars.set("prompt:cwd", cwd); + vars.set("prompt:model", ctx?.model ?? null); + vars.set("prompt:conversation_id", ctx?.conversationId ?? null); + vars.set("prompt:workspace_id", ctx?.workspaceId ?? null); + + // ── git:* ──────────────────────────────────────────────────────────────── + // branch is a single value — trim fully; status keeps its leading status + // indicators, dropping only the trailing newline (trimEnd). + const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, adapters.spawn); + vars.set("git:branch", branch === null ? null : branch.trim()); + const status = await runGit(["status", "--short"], cwd, adapters.spawn); + vars.set("git:status", status === null ? null : status.trimEnd()); + + // ── file:<path> (dynamic — only those referenced by the template) ──────── + if (referencedKeys !== undefined) { + for (const key of referencedKeys) { + if (key.startsWith("file:")) { + const filePath = key.slice("file:".length); + vars.set(key, await readFile(filePath, cwd, adapters.fs)); + } + } + } + + return vars; } diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 37c1c0d..08e3d33 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -5,223 +5,223 @@ import { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js"; /** In-memory StorageNamespace for tests. */ function memoryStorage(): StorageNamespace { - const store = new Map<string, string>(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - has: async (key: string) => store.has(key), - keys: async (prefix?: string) => - [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const store = new Map<string, string>(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + has: async (key: string) => store.has(key), + keys: async (prefix?: string) => + [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } function fakeFs(files: ReadonlyMap<string, string>): ResolverFs { - return { - readText: async (path: string) => files.get(path) ?? "", - exists: async (path: string) => files.has(path), - }; + return { + readText: async (path: string) => files.get(path) ?? "", + exists: async (path: string) => files.has(path), + }; } const failSpawn = async (): Promise<GitSpawnResult> => ({ - stdout: "", - stderr: "", - exitCode: 128, + stdout: "", + stderr: "", + exitCode: 128, }); function adapters(files: ReadonlyMap<string, string>): ResolverAdapters { - return { - spawn: failSpawn, - fs: fakeFs(files), - now: () => new Date("2024-06-15T12:30:00.000Z"), - platform: () => "linux", - hostname: () => "myhost", - }; + return { + spawn: failSpawn, + fs: fakeFs(files), + now: () => new Date("2024-06-15T12:30:00.000Z"), + platform: () => "linux", + hostname: () => "myhost", + }; } describe("system-prompt service", () => { - it("construct persists and returns the resolved string", async () => { - // 14. construct writes to storage and returns the resolved string. - const storage = memoryStorage(); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); - - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("RULES"); - expect(result).toContain("/proj"); - // persisted under resolved:<conversationId> - expect(await storage.get("resolved:conv-1")).toBe(result); - }); - - it("get returns persisted value after construct", async () => { - // 15. after construct, get returns the same string. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-2")).toBeNull(); - - const result = await service.construct("conv-2", "/proj"); - expect(await service.get("conv-2")).toBe(result); - }); - - it("get returns null before construct", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - expect(await service.get("never-constructed")).toBeNull(); - }); - - it("empty/no template stored → default template → non-empty", async () => { - // 16. no template stored → default template used → resolves to non-empty. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), // no AGENTS.md - }); - - const result = await service.construct("conv-3", "/proj"); - - expect(result.length).toBeGreaterThan(0); - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("/proj"); - // no AGENTS.md file → the [if file:AGENTS.md] block is omitted - expect(result).not.toContain("AGENTS.md"); - }); - - it("stored template is used instead of default", async () => { - const storage = memoryStorage(); - await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-4", "/work"); - expect(result).toBe("cwd=/work os=linux"); - }); - - it("empty stored template → empty string", async () => { - const storage = memoryStorage(); - await storage.set("template", ""); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-5", "/proj"); - expect(result).toBe(""); - expect(await service.get("conv-5")).toBe(""); - }); - - it("construct is independent per conversation", async () => { - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const a = await service.construct("conv-a", "/dir-a"); - const b = await service.construct("conv-b", "/dir-b"); - - expect(a).toBe("/dir-a"); - expect(b).toBe("/dir-b"); - expect(await service.get("conv-a")).toBe("/dir-a"); - expect(await service.get("conv-b")).toBe("/dir-b"); - }); - - it("DEFAULT_TEMPLATE contains the expected structure", () => { - expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); - expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); - }); - - it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { - // 1. never constructed → both fields null. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - const meta = await service.getWithMeta("never-constructed"); - expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); - }); - - it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { - // 2. after construct → prompt + exact cwd passed to construct. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); - const meta = await service.getWithMeta("conv-meta"); - - expect(meta.prompt).toBe(result); - expect(meta.cwd).toBe("/proj"); - }); - - it("get still returns the same value as before (backward compat)", async () => { - // 3. get() behavior is unchanged by the additive getWithMeta. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-bc")).toBeNull(); - - const result = await service.construct("conv-bc", "/proj"); - expect(await service.get("conv-bc")).toBe(result); - }); - - it("construct called twice with different cwds stores the latest cwd", async () => { - // 4. second construct overwrites the cwd (not the first). - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - await service.construct("conv-twice", "/first"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); - - const second = await service.construct("conv-twice", "/second"); - expect(second).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); - }); - - it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { - // 5. getWithMeta reflects the latest construct, not the first. - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const first = await service.construct("conv-second", "/dir-a"); - const firstMeta = await service.getWithMeta("conv-second"); - expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); - - const second = await service.construct("conv-second", "/dir-b"); - const secondMeta = await service.getWithMeta("conv-second"); - expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); - expect(secondMeta.cwd).not.toBe("/dir-a"); - }); + it("construct persists and returns the resolved string", async () => { + // 14. construct writes to storage and returns the resolved string. + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); + + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("RULES"); + expect(result).toContain("/proj"); + // persisted under resolved:<conversationId> + expect(await storage.get("resolved:conv-1")).toBe(result); + }); + + it("get returns persisted value after construct", async () => { + // 15. after construct, get returns the same string. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-2")).toBeNull(); + + const result = await service.construct("conv-2", "/proj"); + expect(await service.get("conv-2")).toBe(result); + }); + + it("get returns null before construct", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + expect(await service.get("never-constructed")).toBeNull(); + }); + + it("empty/no template stored → default template → non-empty", async () => { + // 16. no template stored → default template used → resolves to non-empty. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), // no AGENTS.md + }); + + const result = await service.construct("conv-3", "/proj"); + + expect(result.length).toBeGreaterThan(0); + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("/proj"); + // no AGENTS.md file → the [if file:AGENTS.md] block is omitted + expect(result).not.toContain("AGENTS.md"); + }); + + it("stored template is used instead of default", async () => { + const storage = memoryStorage(); + await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-4", "/work"); + expect(result).toBe("cwd=/work os=linux"); + }); + + it("empty stored template → empty string", async () => { + const storage = memoryStorage(); + await storage.set("template", ""); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-5", "/proj"); + expect(result).toBe(""); + expect(await service.get("conv-5")).toBe(""); + }); + + it("construct is independent per conversation", async () => { + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const a = await service.construct("conv-a", "/dir-a"); + const b = await service.construct("conv-b", "/dir-b"); + + expect(a).toBe("/dir-a"); + expect(b).toBe("/dir-b"); + expect(await service.get("conv-a")).toBe("/dir-a"); + expect(await service.get("conv-b")).toBe("/dir-b"); + }); + + it("DEFAULT_TEMPLATE contains the expected structure", () => { + expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); + expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); + }); + + it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { + // 1. never constructed → both fields null. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const meta = await service.getWithMeta("never-constructed"); + expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); + }); + + it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { + // 2. after construct → prompt + exact cwd passed to construct. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); + const meta = await service.getWithMeta("conv-meta"); + + expect(meta.prompt).toBe(result); + expect(meta.cwd).toBe("/proj"); + }); + + it("get still returns the same value as before (backward compat)", async () => { + // 3. get() behavior is unchanged by the additive getWithMeta. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-bc")).toBeNull(); + + const result = await service.construct("conv-bc", "/proj"); + expect(await service.get("conv-bc")).toBe(result); + }); + + it("construct called twice with different cwds stores the latest cwd", async () => { + // 4. second construct overwrites the cwd (not the first). + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.construct("conv-twice", "/first"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); + + const second = await service.construct("conv-twice", "/second"); + expect(second).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); + }); + + it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { + // 5. getWithMeta reflects the latest construct, not the first. + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const first = await service.construct("conv-second", "/dir-a"); + const firstMeta = await service.getWithMeta("conv-second"); + expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); + + const second = await service.construct("conv-second", "/dir-b"); + const secondMeta = await service.getWithMeta("conv-second"); + expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); + expect(secondMeta.cwd).not.toBe("/dir-a"); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index 8d6ede5..34ecbe7 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -29,20 +29,20 @@ const TEMPLATE_KEY = "template"; const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`; const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`; const resolvedComputerIdKey = (conversationId: string): string => - `resolved-computer:${conversationId}`; + `resolved-computer:${conversationId}`; export interface SystemPromptServiceDeps { - /** Namespaced KV (`host.storage("system-prompt")`). */ - readonly storage: StorageNamespace; - /** Injected effects for variable resolution (local). */ - readonly adapters: ResolverAdapters; - /** - * Optional: build remote-backed adapters for a given computerId. When - * `construct` is called with a `computerId`, this is invoked to obtain - * adapters that read/run commands on the REMOTE machine (via the - * ExecBackend/SSH). Absent → falls back to the local `adapters`. - */ - readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>; + /** Namespaced KV (`host.storage("system-prompt")`). */ + readonly storage: StorageNamespace; + /** Injected effects for variable resolution (local). */ + readonly adapters: ResolverAdapters; + /** + * Optional: build remote-backed adapters for a given computerId. When + * `construct` is called with a `computerId`, this is invoked to obtain + * adapters that read/run commands on the REMOTE machine (via the + * ExecBackend/SSH). Absent → falls back to the local `adapters`. + */ + readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>; } /** @@ -50,63 +50,63 @@ export interface SystemPromptServiceDeps { * State is owned (not ambient): the storage reference lives in this closure. */ export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService { - return { - async construct(conversationId, cwd, context) { - let template = await deps.storage.get(TEMPLATE_KEY); - if (template === null) template = DEFAULT_TEMPLATE; + return { + async construct(conversationId, cwd, context) { + let template = await deps.storage.get(TEMPLATE_KEY); + if (template === null) template = DEFAULT_TEMPLATE; - const referencedKeys = extractVariables(template); - const resolverContext: ResolverContext = { - conversationId, - ...(context?.model !== undefined ? { model: context.model } : {}), - ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), - }; + const referencedKeys = extractVariables(template); + const resolverContext: ResolverContext = { + conversationId, + ...(context?.model !== undefined ? { model: context.model } : {}), + ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), + }; - // Select adapters: when computerId is set, use remote-backed adapters - // (read files / run commands on the REMOTE machine via SSH). Otherwise - // use the local adapters. - const computerId = context?.computerId; - const adapters = - computerId !== undefined && deps.resolveRemoteAdapters !== undefined - ? await deps.resolveRemoteAdapters(computerId, cwd) - : deps.adapters; + // Select adapters: when computerId is set, use remote-backed adapters + // (read files / run commands on the REMOTE machine via SSH). Otherwise + // use the local adapters. + const computerId = context?.computerId; + const adapters = + computerId !== undefined && deps.resolveRemoteAdapters !== undefined + ? await deps.resolveRemoteAdapters(computerId, cwd) + : deps.adapters; - const vars = await resolveVariables(cwd, adapters, { - context: resolverContext, - referencedKeys, - }); - const result = parseTemplate(template, vars); + const vars = await resolveVariables(cwd, adapters, { + context: resolverContext, + referencedKeys, + }); + const result = parseTemplate(template, vars); - await deps.storage.set(resolvedKey(conversationId), result); - await deps.storage.set(resolvedCwdKey(conversationId), cwd); - // Store the computerId (or empty string for local) so the cache can be - // invalidated when the computer changes. - await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? ""); - return result; - }, + await deps.storage.set(resolvedKey(conversationId), result); + await deps.storage.set(resolvedCwdKey(conversationId), cwd); + // Store the computerId (or empty string for local) so the cache can be + // invalidated when the computer changes. + await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? ""); + return result; + }, - async get(conversationId) { - return deps.storage.get(resolvedKey(conversationId)); - }, + async get(conversationId) { + return deps.storage.get(resolvedKey(conversationId)); + }, - async getWithMeta(conversationId) { - const [prompt, cwd, computerIdStored] = await Promise.all([ - deps.storage.get(resolvedKey(conversationId)), - deps.storage.get(resolvedCwdKey(conversationId)), - deps.storage.get(resolvedComputerIdKey(conversationId)), - ]); - // Empty string → null (local, no computerId). Non-empty → the alias. - const computerId = computerIdStored === null ? null : computerIdStored || null; - return { prompt, cwd, computerId }; - }, + async getWithMeta(conversationId) { + const [prompt, cwd, computerIdStored] = await Promise.all([ + deps.storage.get(resolvedKey(conversationId)), + deps.storage.get(resolvedCwdKey(conversationId)), + deps.storage.get(resolvedComputerIdKey(conversationId)), + ]); + // Empty string → null (local, no computerId). Non-empty → the alias. + const computerId = computerIdStored === null ? null : computerIdStored || null; + return { prompt, cwd, computerId }; + }, - async getTemplate() { - const stored = await deps.storage.get(TEMPLATE_KEY); - return stored ?? DEFAULT_TEMPLATE; - }, + async getTemplate() { + const stored = await deps.storage.get(TEMPLATE_KEY); + return stored ?? DEFAULT_TEMPLATE; + }, - async setTemplate(template) { - await deps.storage.set(TEMPLATE_KEY, template); - }, - }; + async setTemplate(template) { + await deps.storage.set(TEMPLATE_KEY, template); + }, + }; } diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index a9fe3ca..534814e 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -15,46 +15,46 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel"; * no per-turn reconstruction). */ export interface SystemPromptService { - /** - * Resolve the template against the current environment and persist the - * result under `resolved:<conversationId>`. Returns the resolved string. - * When no template is stored, the built-in default template is used. An - * empty template yields an empty string. - * - * When `context.computerId` is set, the resolver uses remote-backed adapters - * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via - * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. - */ - construct( - conversationId: string, - cwd: string, - context?: { - readonly model?: string; - readonly workspaceId?: string; - readonly computerId?: string; - }, - ): Promise<string>; + /** + * Resolve the template against the current environment and persist the + * result under `resolved:<conversationId>`. Returns the resolved string. + * When no template is stored, the built-in default template is used. An + * empty template yields an empty string. + * + * When `context.computerId` is set, the resolver uses remote-backed adapters + * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via + * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. + */ + construct( + conversationId: string, + cwd: string, + context?: { + readonly model?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, + ): Promise<string>; - /** Read the persisted resolved system prompt, or `null` if never constructed. */ - get(conversationId: string): Promise<string | null>; + /** Read the persisted resolved system prompt, or `null` if never constructed. */ + get(conversationId: string): Promise<string | null>; - /** - * Read the persisted resolved system prompt AND the cwd + computerId it was - * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if - * never constructed. Consumers use this to detect whether the cached prompt - * is stale relative to the current effective cwd or computerId. - */ - getWithMeta(conversationId: string): Promise<{ - readonly prompt: string | null; - readonly cwd: string | null; - readonly computerId: string | null; - }>; + /** + * Read the persisted resolved system prompt AND the cwd + computerId it was + * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if + * never constructed. Consumers use this to detect whether the cached prompt + * is stale relative to the current effective cwd or computerId. + */ + getWithMeta(conversationId: string): Promise<{ + readonly prompt: string | null; + readonly cwd: string | null; + readonly computerId: string | null; + }>; - /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ - getTemplate(): Promise<string>; + /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ + getTemplate(): Promise<string>; - /** Set (upsert) the global template. An empty string means "no system prompt". */ - setTemplate(template: string): Promise<void>; + /** Set (upsert) the global template. An empty string means "no system prompt". */ + setTemplate(template: string): Promise<void>; } /** @@ -62,4 +62,4 @@ export interface SystemPromptService { * session-orchestrator imports to reach the builder — no string-keyed lookup. */ export const systemPromptHandle: ServiceHandle<SystemPromptService> = - defineService<SystemPromptService>("system-prompt"); + defineService<SystemPromptService>("system-prompt"); |
