diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 21:30:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 21:30:13 +0900 |
| commit | b3fc161c846f6495815e69b013ea5e4f4b73d3f5 (patch) | |
| tree | bfb1ac850ac98d4bae1525e3663acb93cc8dc828 | |
| parent | 12955cdf1d822ff395fd62d30916fbdb02d10e12 (diff) | |
| download | dispatch-b3fc161c846f6495815e69b013ea5e4f4b73d3f5.tar.gz dispatch-b3fc161c846f6495815e69b013ea5e4f4b73d3f5.zip | |
feat(heartbeat): resolve empty systemPrompt to global default (CR-HB-2)
| -rw-r--r-- | packages/heartbeat/src/extension.ts | 9 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.test.ts | 119 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.ts | 52 |
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 } : {}), |
