summaryrefslogtreecommitdiffhomepage
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
parentc5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (diff)
downloaddispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.tar.gz
dispatch-12955cdf1d822ff395fd62d30916fbdb02d10e12.zip
feat(heartbeat): resolve [type:name] variables in heartbeat prompts (CR-HB-1)
-rw-r--r--bun.lock2
-rw-r--r--packages/heartbeat/package.json2
-rw-r--r--packages/heartbeat/src/config-store.test.ts12
-rw-r--r--packages/heartbeat/src/config-store.ts9
-rw-r--r--packages/heartbeat/src/extension.ts49
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts87
-rw-r--r--packages/heartbeat/src/heartbeat.ts56
-rw-r--r--packages/heartbeat/src/index.ts18
-rw-r--r--packages/heartbeat/src/run-store.ts14
-rw-r--r--packages/heartbeat/src/scheduler.ts18
-rw-r--r--packages/heartbeat/tsconfig.json4
-rw-r--r--packages/host-bin/src/main.ts2
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts11
-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
-rw-r--r--packages/transport-http/src/app.ts5
-rw-r--r--packages/transport-http/src/extension.ts2
18 files changed, 400 insertions, 84 deletions
diff --git a/bun.lock b/bun.lock
index 202ead7..2261ba8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -62,8 +62,10 @@
"name": "@dispatch/heartbeat",
"version": "0.0.0",
"dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
},
},
diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json
index 40212b9..dc3d9a2 100644
--- a/packages/heartbeat/package.json
+++ b/packages/heartbeat/package.json
@@ -6,8 +6,10 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
"@dispatch/transport-contract": "workspace:*"
}
}
diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts
index 0cb6afa..17d3ee9 100644
--- a/packages/heartbeat/src/config-store.test.ts
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -48,15 +48,15 @@ describe("applyConfigUpdate (pure)", () => {
});
it("clamps intervalMinutes to a minimum of 1", () => {
- expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes).toBe(
- 1,
- );
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes,
+ ).toBe(1);
expect(
applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes,
).toBe(1);
- expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes).toBe(
- 7,
- );
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes,
+ ).toBe(7);
});
it("truncates a non-integer interval to an integer", () => {
diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts
index 74c01e2..5791601 100644
--- a/packages/heartbeat/src/config-store.ts
+++ b/packages/heartbeat/src/config-store.ts
@@ -63,9 +63,7 @@ export interface HeartbeatConfigStore {
readonly listWorkspaceIds: () => Promise<readonly string[]>;
}
-export function createHeartbeatConfigStore(
- storage: StorageNamespace,
-): HeartbeatConfigStore {
+export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore {
return {
async get(workspaceId: string): Promise<HeartbeatConfig> {
const raw = await storage.get(configKey(workspaceId));
@@ -88,10 +86,7 @@ export function createHeartbeatConfigStore(
}
},
- async update(
- workspaceId: string,
- update: UpdateHeartbeatRequest,
- ): Promise<HeartbeatConfig> {
+ async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> {
const current = await this.get(workspaceId);
const next = applyConfigUpdate(current, update);
await storage.set(configKey(workspaceId), JSON.stringify(next));
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
index 66580e6..e79b01f 100644
--- a/packages/heartbeat/src/extension.ts
+++ b/packages/heartbeat/src/extension.ts
@@ -3,14 +3,21 @@
*
* Wires the heartbeat service against the session-orchestrator + a storage
* namespace, registers the typed service handle, and arms every enabled
- * workspace's scheduler on boot.
+ * 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.
*/
+import type { ConversationStore } from "@dispatch/conversation-store";
+import { conversationStoreHandle } from "@dispatch/conversation-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import {
type SessionOrchestrator,
sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
+import type { SystemPromptService } from "@dispatch/system-prompt";
+import { systemPromptHandle } from "@dispatch/system-prompt";
import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js";
export const manifest: Manifest = {
@@ -19,7 +26,12 @@ export const manifest: Manifest = {
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
- dependsOn: ["session-orchestrator"],
+ // system-prompt provides `resolveText` (the variable resolver used to
+ // substitute [type:name] placeholders in heartbeat prompts); conversation-
+ // store resolves the workspace's default cwd (the resolver runs git / reads
+ // files against it, mirroring the global template). Both lookups are lazy
+ // (at fire time, not activation), but declaring them keeps the DAG honest.
+ dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"],
activation: "eager",
contributes: { services: ["heartbeat"] },
};
@@ -34,7 +46,38 @@ export const extension: Extension = {
const storage = host.storage("heartbeat");
const logger = host.logger;
- const service = createHeartbeatService({ storage, orchestrator, logger });
+ // Resolve [type:name] placeholders in heartbeat prompts via the
+ // system-prompt service (same resolver + variables as the global
+ // template). The cwd is the workspace's defaultCwd (resolved the same
+ // way the orchestrator resolves a new conversation's effective cwd);
+ // falling back to process.cwd() when the workspace has none. Both
+ // services are declared `dependsOn` (always activated before heartbeat).
+ const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle);
+ const conversationStore = host.getService<ConversationStore>(conversationStoreHandle);
+
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ const workspace = await conversationStore.getWorkspace(ctx.workspaceId);
+ const cwd = workspace?.defaultCwd ?? process.cwd();
+ return systemPromptService.resolveText(template, cwd, {
+ ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}),
+ ...(ctx.model !== "" ? { model: ctx.model } : {}),
+ workspaceId: ctx.workspaceId,
+ });
+ };
+
+ const service = createHeartbeatService({
+ storage,
+ orchestrator,
+ logger,
+ resolvePrompt,
+ });
// Reconcile stale runs + arm enabled workspaces on boot.
await service.startAll();
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
index c185c92..4c01904 100644
--- a/packages/heartbeat/src/heartbeat.test.ts
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -145,6 +145,14 @@ const flush = async (): Promise<void> => {
function createService(opts: {
readonly orch: ReturnType<typeof createFakeOrchestrator>;
readonly storage?: StorageNamespace;
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
}) {
const fake = createFakeTimers();
let id = 0;
@@ -154,6 +162,7 @@ function createService(opts: {
orchestrator: opts.orch,
timers: fake.timers,
generateId: () => `id-${++id}`,
+ ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}),
});
return { svc, advance: fake.advance, storage };
}
@@ -225,14 +234,75 @@ describe("createHeartbeatService", () => {
await flush();
});
+ it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => {
+ const orch = createFakeOrchestrator();
+ // A fake resolver that mirrors the real resolver's contract: substitute
+ // known [type:name] placeholders, leave unknown text verbatim.
+ 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)
+ .replaceAll("[prompt:conversation_id]", ctx.conversationId)
+ .replaceAll("[prompt:model]", ctx.model);
+ };
+ const { svc, advance } = createService({ orch, resolvePrompt });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).",
+ taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].",
+ model: "opencode/gpt-4o",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // Variables substituted — NOT left as literal [type:name] text.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`);
+ expect(turn.modelName).toBe("opencode/gpt-4o");
+ turn.resolve();
+ await flush();
+ });
+
+ it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No resolvePrompt → raw pass-through (the default).
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "raw [system:os] prompt",
+ taskPrompt: "raw [system:date] task",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // Unresolved — literals reach the orchestrator verbatim.
+ expect(turn.systemPrompt).toBe("raw [system:os] prompt");
+ expect(turn.text).toBe("raw [system:date] task");
+ 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 });
await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
advance(60_000);
await flush();
- const conversationId = orch.pending[0]!.conversationId;
- const runId = (await svc.listRuns("ws-1"))[0]!.id;
+ const conversationId = orch.pending[0]?.conversationId;
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
const res = await svc.stopRun("ws-1", runId);
expect(res).toEqual({ ok: true });
@@ -248,10 +318,10 @@ describe("createHeartbeatService", () => {
await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
advance(60_000);
await flush();
- orch.pending[0]!.resolve();
+ orch.pending[0]?.resolve();
await flush();
- const runId = (await svc.listRuns("ws-1"))[0]!.id;
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
const res = await svc.stopRun("ws-1", runId);
expect(res).toEqual({ ok: true });
expect(orch.stopped).toEqual([]); // no abort on a completed run
@@ -284,7 +354,14 @@ describe("createHeartbeatService", () => {
);
await storage.set(
"config:ws-1",
- JSON.stringify({ enabled: false, systemPrompt: "", taskPrompt: "", intervalMinutes: 30, model: "", reasoningEffort: null }),
+ JSON.stringify({
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+ }),
);
const { svc } = createService({ orch: createFakeOrchestrator(), storage });
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
index d1efe47..7311479 100644
--- a/packages/heartbeat/src/heartbeat.ts
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -7,12 +7,9 @@ import type {
StopHeartbeatRunResponse,
UpdateHeartbeatRequest,
} from "@dispatch/transport-contract";
-import {
- type HeartbeatConfigStore,
- createHeartbeatConfigStore,
-} from "./config-store.js";
-import { type HeartbeatRunStore, createHeartbeatRunStore } from "./run-store.js";
-import { HeartbeatScheduler, type Timers, realTimers } from "./scheduler.js";
+import { createHeartbeatConfigStore, type HeartbeatConfigStore } from "./config-store.js";
+import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
+import { HeartbeatScheduler, realTimers, type Timers } from "./scheduler.js";
/**
* The heartbeat service surface — what transport-http consumes and what the
@@ -36,10 +33,7 @@ export interface HeartbeatService {
* Stop an in-flight run (abort its turn). Idempotent for an already-finished
* run. Throws when the run id is unknown (→ HTTP 404).
*/
- readonly stopRun: (
- workspaceId: string,
- runId: string,
- ) => Promise<StopHeartbeatRunResponse>;
+ readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>;
/** Boot: sweep stale runs + arm every enabled workspace's scheduler. */
readonly startAll: () => Promise<void>;
/** Shutdown: stop every scheduler. */
@@ -60,6 +54,23 @@ export interface HeartbeatServiceDeps {
readonly timers?: Timers;
/** Injectable id generator (default: crypto.randomUUID). */
readonly generateId?: () => string;
+ /**
+ * Resolve `[type:name]` variable placeholders in a prompt template against
+ * the current environment — the SAME resolver + variable catalog the global
+ * system-prompt template uses (system/file/prompt/git groups). Applied once
+ * per run, when the turn is constructed (mirrors the global template's
+ * construct-once-per-conversation resolution). When omitted, templates pass
+ * through UNRESOLVED (raw) — the extension wires the real resolver; tests
+ * inject a fake.
+ */
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
}
interface ActiveRun {
@@ -75,6 +86,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage);
const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage);
const orchestrator = deps.orchestrator;
+ // Default: pass templates through UNRESOLVED (raw). The extension wires the
+ // 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));
// 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
@@ -106,17 +121,32 @@ 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).
+ const resolveCtx = { workspaceId, conversationId, model: config.model };
+ const [systemPrompt, taskPrompt] = await Promise.all([
+ resolvePrompt(config.systemPrompt, resolveCtx),
+ resolvePrompt(config.taskPrompt, resolveCtx),
+ ]);
+
try {
await orchestrator.handleMessage({
conversationId,
- text: config.taskPrompt,
+ text: taskPrompt,
// Fire-and-forget: the heartbeat loop does not consume the
// 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.
- systemPrompt: config.systemPrompt,
+ // prompt) — bypasses the templated workspace prompt. Resolved
+ // above so [type:name] placeholders are substituted, not raw.
+ systemPrompt,
...(config.model !== "" ? { modelName: config.model } : {}),
...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),
workspaceId,
diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts
index 3886be5..985ad68 100644
--- a/packages/heartbeat/src/index.ts
+++ b/packages/heartbeat/src/index.ts
@@ -1,16 +1,16 @@
+export {
+ applyConfigUpdate,
+ createHeartbeatConfigStore,
+ DEFAULT_HEARTBEAT_CONFIG,
+ type HeartbeatConfigStore,
+ MIN_INTERVAL_MINUTES,
+} from "./config-store.js";
export { extension, manifest } from "./extension.js";
export {
- heartbeatServiceHandle,
createHeartbeatService,
type HeartbeatService,
type HeartbeatServiceDeps,
+ heartbeatServiceHandle,
} from "./heartbeat.js";
-export {
- createHeartbeatConfigStore,
- applyConfigUpdate,
- DEFAULT_HEARTBEAT_CONFIG,
- MIN_INTERVAL_MINUTES,
- type HeartbeatConfigStore,
-} from "./config-store.js";
export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
-export { HeartbeatScheduler, realTimers, type Timers, type TimerHandle } from "./scheduler.js";
+export { HeartbeatScheduler, realTimers, type TimerHandle, type Timers } from "./scheduler.js";
diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts
index 7522aab..819ae00 100644
--- a/packages/heartbeat/src/run-store.ts
+++ b/packages/heartbeat/src/run-store.ts
@@ -19,10 +19,7 @@ function parseRunId(key: string, workspaceId: string): string {
export interface HeartbeatRunStore {
/** Create a new run record (status `"running"`) and persist it. */
- readonly create: (
- workspaceId: string,
- run: HeartbeatRun,
- ) => Promise<HeartbeatRun>;
+ readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>;
/** Update the status of an existing run. No-op if the run is unknown. */
readonly setStatus: (
workspaceId: string,
@@ -35,13 +32,8 @@ export interface HeartbeatRunStore {
readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
}
-export function createHeartbeatRunStore(
- storage: StorageNamespace,
-): HeartbeatRunStore {
- async function readRun(
- workspaceId: string,
- runId: string,
- ): Promise<HeartbeatRun | null> {
+export function createHeartbeatRunStore(storage: StorageNamespace): HeartbeatRunStore {
+ async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
const raw = await storage.get(runKey(workspaceId, runId));
if (raw === null) return null;
try {
diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts
index 53ffb96..e366bbc 100644
--- a/packages/heartbeat/src/scheduler.ts
+++ b/packages/heartbeat/src/scheduler.ts
@@ -72,17 +72,25 @@ export class HeartbeatScheduler {
* run is in progress, the new `intervalMinutes` takes effect on the next
* re-arm (the in-progress run is never cancelled here).
*/
- arm(workspaceId: string, config: {
- readonly enabled: boolean;
- readonly intervalMinutes: number;
- }): void {
+ arm(
+ workspaceId: string,
+ config: {
+ readonly enabled: boolean;
+ readonly intervalMinutes: number;
+ },
+ ): void {
if (!config.enabled) {
this.disarm(workspaceId);
return;
}
let schedule = this.schedules.get(workspaceId);
if (schedule === undefined) {
- schedule = { intervalMinutes: config.intervalMinutes, timer: undefined, running: false, armed: true };
+ schedule = {
+ intervalMinutes: config.intervalMinutes,
+ timer: undefined,
+ running: false,
+ armed: true,
+ };
this.schedules.set(workspaceId, schedule);
} else {
schedule.intervalMinutes = config.intervalMinutes;
diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json
index 088ee0f..0c18985 100644
--- a/packages/heartbeat/tsconfig.json
+++ b/packages/heartbeat/tsconfig.json
@@ -5,6 +5,8 @@
"references": [
{ "path": "../kernel" },
{ "path": "../transport-contract" },
- { "path": "../session-orchestrator" }
+ { "path": "../session-orchestrator" },
+ { "path": "../system-prompt" },
+ { "path": "../conversation-store" }
]
}
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 26015ed..9919432 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { extension as authApikeyExt } from "@dispatch/auth-apikey";
import { extension as cacheWarmingExt } from "@dispatch/cache-warming";
-import { extension as heartbeatExt } from "@dispatch/heartbeat";
import { extension as conversationStoreExt } from "@dispatch/conversation-store";
import { createCredentialStoreExtension } from "@dispatch/credential-store";
import { createExecBackendExtension } from "@dispatch/exec-backend";
+import { extension as heartbeatExt } from "@dispatch/heartbeat";
import { createJournalSink } from "@dispatch/journal-sink";
import {
type ConfigAccess,
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 9778d1f..ca9ce93 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -796,7 +796,16 @@ export function createSessionOrchestrator(
}
const orchestrator: SessionOrchestrator = {
- startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId, systemPrompt }) {
+ startTurn({
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ systemPrompt,
+ }) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
}
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>;
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 36b27a4..b9327b3 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,5 +1,5 @@
-import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
+import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt";
import type {
CloseConversationResponse,
@@ -1445,8 +1445,7 @@ export function createApp(opts: CreateServerOptions): Hono {
if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) {
return c.json(
{
- error:
- "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null",
+ error: "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null",
},
400,
);
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index e3f9ffc..eb14c55 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -93,7 +93,7 @@ export function createTransportHttpExtension(): Extension & {
const lspService = host.getService(lspServiceHandle);
const mcpService = host.getService(mcpServiceHandle);
const systemPromptService = host.getService(systemPromptHandle);
- const heartbeatService = host.getService(heartbeatServiceHandle);
+ const heartbeatService = host.getService(heartbeatServiceHandle);
// Optional: the `ssh` extension provides ComputerService. It is NOT in
// dependsOn (ssh may be absent), so resolve defensively — when no
// provider registered the handle, the computer routes degrade to