diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 20:24:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 20:24:18 +0900 |
| commit | 12955cdf1d822ff395fd62d30916fbdb02d10e12 (patch) | |
| tree | c23a4b0700a08926eaf3636af7e5ca92a39fbe2f /packages/system-prompt/src | |
| parent | c5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (diff) | |
| download | dispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.tar.gz dispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.zip | |
feat(heartbeat): resolve [type:name] variables in heartbeat prompts (CR-HB-1)
Diffstat (limited to 'packages/system-prompt/src')
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 100 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 71 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 22 |
3 files changed, 175 insertions, 18 deletions
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 37c1c0d..31c55da 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -224,4 +224,104 @@ describe("system-prompt service", () => { expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); expect(secondMeta.cwd).not.toBe("/dir-a"); }); + + // ── resolveText: resolve an arbitrary template (no persistence) ──────────── + + it("resolveText substitutes [type:name] variables in an arbitrary template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const result = await service.resolveText( + "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]", + "/work", + { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" }, + ); + + expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4"); + }); + + it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => { + // A template resolved via resolveText must equal the same template + // resolved via construct (both go through the shared resolveTemplate). + const storage = memoryStorage(); + const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]"; + await storage.set("template", template); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const viaConstruct = await service.construct("conv-rt", "/work"); + const viaResolveText = await service.resolveText(template, "/work"); + expect(viaResolveText).toBe(viaConstruct); + }); + + it("resolveText does NOT persist (unlike construct)", async () => { + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" }); + + // No resolved:* / resolved-cwd:* / resolved-computer:* keys written. + expect(await storage.get("resolved:no-persist")).toBeNull(); + expect(await storage.get("resolved-cwd:no-persist")).toBeNull(); + expect(await storage.get("resolved-computer:no-persist")).toBeNull(); + }); + + it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])), + }); + + const result = await service.resolveText("[file:AGENTS.md]", "/work"); + expect(result).toBe("RULES"); + }); + + it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // [prompt:model] exists (string) → then-branch renders. + const withModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + { model: "gpt-4" }, + ); + expect(withModel).toBe("has model"); + + // model absent → else-branch renders. + const noModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + ); + expect(noModel).toBe("no model"); + }); + + it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // An unknown variable (not in the catalog) → empty string, matching + // parseTemplate's "key absent from the map → empty string" rule. + const result = await service.resolveText("x=[unknown:thing]", "/work"); + expect(result).toBe("x="); + }); + + it("resolveText on an empty template returns an empty string", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + expect(await service.resolveText("", "/work")).toBe(""); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index 8d6ede5..f695f49 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -46,6 +46,48 @@ export interface SystemPromptServiceDeps { } /** + * Resolve a template against the current environment (the shared resolution + * path used by both `construct` — which persists the result — and + * `resolveText`, which does not). Always resolves the fixed catalog + * (`system:*`, `prompt:*`, `git:*`) plus any `file:<path>` keys referenced by + * the template. Selects remote-backed adapters when `context.computerId` is + * set, mirroring `construct`. + */ +async function resolveTemplate( + deps: SystemPromptServiceDeps, + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, +): Promise<string> { + const referencedKeys = extractVariables(template); + const resolverContext: ResolverContext = { + ...(context?.conversationId !== undefined ? { conversationId: context.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; + + const vars = await resolveVariables(cwd, adapters, { + context: resolverContext, + referencedKeys, + }); + return parseTemplate(template, vars); +} + +/** * Create a `SystemPromptService` backed by a storage namespace + adapters. * State is owned (not ambient): the storage reference lives in this closure. */ @@ -55,36 +97,29 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System let template = await deps.storage.get(TEMPLATE_KEY); if (template === null) template = DEFAULT_TEMPLATE; - const referencedKeys = extractVariables(template); - const resolverContext: ResolverContext = { + const result = await resolveTemplate(deps, template, cwd, { 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; - - const vars = await resolveVariables(cwd, adapters, { - context: resolverContext, - referencedKeys, + ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}), }); - 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 ?? ""); + await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? ""); return result; }, + async resolveText(template, cwd, context) { + // An empty template has no variables to resolve; short-circuit to + // avoid spawning git / reading files for nothing (the heartbeat's + // default-empty prompts hit this every run). + if (template === "") return ""; + return resolveTemplate(deps, template, cwd, context); + }, + async get(conversationId) { return deps.storage.get(resolvedKey(conversationId)); }, diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index a9fe3ca..670430a 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -35,6 +35,28 @@ export interface SystemPromptService { }, ): Promise<string>; + /** + * Resolve an ARBITRARY template string against the current environment, + * using the SAME variable resolver + adapter set as `construct` (so a + * caller that owns its own prompt — e.g. the heartbeat extension — gets + * `[type:name]` placeholders substituted identically to the global + * template). Pure resolution: nothing is persisted (the result is the + * caller's to use). An empty template yields an empty string. + * + * Like `construct`, when `context.computerId` is set the resolver uses + * remote-backed adapters (reading the remote's OS/hostname/git via SSH). + */ + resolveText( + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: 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>; |
