summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 21:30:13 +0900
committerAdam Malczewski <[email protected]>2026-06-26 21:30:13 +0900
commitb3fc161c846f6495815e69b013ea5e4f4b73d3f5 (patch)
treebfb1ac850ac98d4bae1525e3663acb93cc8dc828
parent12955cdf1d822ff395fd62d30916fbdb02d10e12 (diff)
downloaddispatch-b3fc161c846f6495815e69b013ea5e4f4b73d3f5.tar.gz
dispatch-b3fc161c846f6495815e69b013ea5e4f4b73d3f5.zip
feat(heartbeat): resolve empty systemPrompt to global default (CR-HB-2)
-rw-r--r--packages/heartbeat/src/extension.ts9
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts119
-rw-r--r--packages/heartbeat/src/heartbeat.ts52
3 files changed, 167 insertions, 13 deletions
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
index e79b01f..0b2d0d3 100644
--- a/packages/heartbeat/src/extension.ts
+++ b/packages/heartbeat/src/extension.ts
@@ -6,7 +6,10 @@
* workspace's scheduler on boot. Prompt templates (`systemPrompt` /
* `taskPrompt`) are resolved against the SAME variable catalog the global
* system-prompt template uses (via the system-prompt service's `resolveText`),
- * so `[type:name]` placeholders reach the model substituted — not raw.
+ * so `[type:name]` placeholders reach the model substituted — not raw. An empty
+ * heartbeat `systemPrompt` inherits the global system prompt template (via
+ * `getTemplate`) before variable resolution runs — empty = inherit, not "no
+ * system prompt".
*/
import type { ConversationStore } from "@dispatch/conversation-store";
@@ -77,6 +80,10 @@ export const extension: Extension = {
orchestrator,
logger,
resolvePrompt,
+ // CR-HB-2: an empty heartbeat systemPrompt inherits the global
+ // system prompt template (GET /system-prompt / regular
+ // conversations resolve) before variable resolution runs.
+ getGlobalSystemPrompt: () => systemPromptService.getTemplate(),
});
// Reconcile stale runs + arm enabled workspaces on boot.
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
index 4c01904..770d2ad 100644
--- a/packages/heartbeat/src/heartbeat.test.ts
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -153,6 +153,7 @@ function createService(opts: {
readonly model: string;
},
) => Promise<string>;
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
}) {
const fake = createFakeTimers();
let id = 0;
@@ -163,6 +164,9 @@ function createService(opts: {
timers: fake.timers,
generateId: () => `id-${++id}`,
...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}),
+ ...(opts.getGlobalSystemPrompt !== undefined
+ ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
+ : {}),
});
return { svc, advance: fake.advance, storage };
}
@@ -295,6 +299,121 @@ describe("createHeartbeatService", () => {
await flush();
});
+ it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => {
+ const orch = createFakeOrchestrator();
+ // The global getter returns the workspace's regular system prompt
+ // template (the same one GET /system-prompt returns). No resolver → the
+ // inherited template reaches the orchestrator verbatim (isolates CR-HB-2
+ // from CR-HB-1).
+ const { svc, advance } = createService({
+ orch,
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty systemPrompt = inherit the global default, NOT "no prompt".
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE");
+ turn.resolve();
+ await flush();
+ });
+
+ it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ return template
+ .replaceAll("[system:os]", "Linux (WSL)")
+ .replaceAll("[prompt:cwd]", "/repo")
+ .replaceAll("[prompt:workspace_id]", ctx.workspaceId);
+ };
+ // The global template carries [type:name] placeholders (like the real
+ // default template embeds [prompt:cwd] / [file:AGENTS.md]).
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ getGlobalSystemPrompt: () =>
+ Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty → inherit the global template, THEN resolve its placeholders.
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // The global template was inherited (not empty), then its [type:name]
+ // placeholders were substituted — NOT left literal, NOT empty.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ turn.resolve();
+ await flush();
+ });
+
+ it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (template: string): Promise<string> =>
+ template.replaceAll("[system:os]", "Linux (WSL)");
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ // A DISTINCT global template — must NOT be used when overriding.
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "custom override on [system:os]",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // The override is used (and resolved), not the global template.
+ expect(turn.systemPrompt).toBe("custom override on Linux (WSL)");
+ turn.resolve();
+ await flush();
+ });
+
+ it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No getGlobalSystemPrompt → empty stays empty (no system prompt).
+ // Mirrors the no-resolver pass-through default: both deps are optional.
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("");
+ turn.resolve();
+ await flush();
+ });
+
it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => {
const orch = createFakeOrchestrator();
const { svc, advance } = createService({ orch });
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
index 7311479..118e244 100644
--- a/packages/heartbeat/src/heartbeat.ts
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -71,6 +71,18 @@ export interface HeartbeatServiceDeps {
readonly model: string;
},
) => Promise<string>;
+ /**
+ * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt
+ * template — the same one `GET /system-prompt` returns / that regular
+ * conversations resolve. Applied ONLY when the heartbeat's persisted
+ * `systemPrompt` is `""` (inherit), and BEFORE variable resolution
+ * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order:
+ * empty ⇒ global template, then `[type:name]` placeholders resolved. A
+ * non-empty `systemPrompt` is an explicit override and bypasses this.
+ * When omitted, empty stays empty (no system prompt) — the extension
+ * wires the real getter; tests inject a fake.
+ */
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
}
interface ActiveRun {
@@ -90,6 +102,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// real resolver so [type:name] placeholders are substituted like the global
// system-prompt template; tests inject a fake.
const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template));
+ // Default: an empty systemPrompt stays empty (no system prompt). The
+ // extension wires the real getter so empty INHERITS the global system
+ // prompt template (GET /system-prompt); tests inject a fake.
+ const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve(""));
// runId → active-run tracking (in-memory; the durable record lives in the
// run store). Used to (a) map a stop request to its conversation, and
@@ -121,17 +137,27 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
logger?.info("heartbeat: run started", { workspaceId, runId, conversationId });
- // Resolve [type:name] variables in the system/task prompts ONCE, when
- // the turn is constructed — the same resolver + variable catalog the
- // global system-prompt template uses. The orchestrator sends an
- // explicit systemPrompt override AS-IS (bypassing its own templated
- // prompt), so resolution must happen HERE, before handleMessage. For
- // prompts using stable variables (os/cwd/git) this yields a stable,
- // cache-warm prompt across runs; time-bearing variables refresh per
- // run (mirroring the global template's per-conversation resolution).
+ // Resolve the heartbeat's prompts ONCE, when the turn is constructed.
+ //
+ // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt
+ // template (the same one `GET /system-prompt` returns / that regular
+ // conversations resolve) — empty is an override-means-inherit flag, not
+ // "no system prompt". A non-empty `systemPrompt` is an explicit override.
+ // This step runs FIRST.
+ //
+ // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is
+ // in effect are resolved via the same resolver + variable catalog the
+ // global system-prompt template uses. The orchestrator sends an explicit
+ // systemPrompt override AS-IS (bypassing its own templated prompt), so
+ // resolution must happen HERE, before handleMessage. For prompts using
+ // stable variables (os/cwd/git) this yields a stable, cache-warm prompt
+ // across runs; time-bearing variables refresh per run (mirroring the
+ // global template's per-conversation resolution).
+ const baseSystemPrompt =
+ config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt;
const resolveCtx = { workspaceId, conversationId, model: config.model };
const [systemPrompt, taskPrompt] = await Promise.all([
- resolvePrompt(config.systemPrompt, resolveCtx),
+ resolvePrompt(baseSystemPrompt, resolveCtx),
resolvePrompt(config.taskPrompt, resolveCtx),
]);
@@ -143,9 +169,11 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// streamed events (it only awaits turn completion to mark the
// run done). A no-op onEvent satisfies the required callback.
onEvent: () => {},
- // Always an explicit override (incl. the empty string = no system
- // prompt) — bypasses the templated workspace prompt. Resolved
- // above so [type:name] placeholders are substituted, not raw.
+ // Always passed explicitly — bypasses the orchestrator's templated
+ // workspace prompt. Resolved above: an empty config systemPrompt
+ // inherited the global template (CR-HB-2), then [type:name]
+ // placeholders were substituted (CR-HB-1). Still "" when the global
+ // template itself is empty (no system prompt).
systemPrompt,
...(config.model !== "" ? { modelName: config.model } : {}),
...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),