summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 20:24:18 +0900
committerAdam Malczewski <[email protected]>2026-06-26 20:24:18 +0900
commit12955cdf1d822ff395fd62d30916fbdb02d10e12 (patch)
treec23a4b0700a08926eaf3636af7e5ca92a39fbe2f /packages/system-prompt/src
parentc5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (diff)
downloaddispatch-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.ts100
-rw-r--r--packages/system-prompt/src/service.ts71
-rw-r--r--packages/system-prompt/src/types.ts22
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>;