summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 00:08:47 +0900
committerAdam Malczewski <[email protected]>2026-06-24 00:08:47 +0900
commitd225ea4bd5f95d39a910704fe45acdf847c953fa (patch)
treeec0a33665087bb34844b19955ab6fa74c39e3656
parent674853d87d54dba1cd83c4e51fce5411602f4d5d (diff)
downloaddispatch-d225ea4bd5f95d39a910704fe45acdf847c953fa.tar.gz
dispatch-d225ea4bd5f95d39a910704fe45acdf847c953fa.zip
feat(system-prompt): wire into turn flow + compaction + API routes
session-orchestrator: - Wire systemPromptService as optional dep (lazy via host.getService) - Regular turn: construct on first turn (new conversation), get on subsequent turns, set on providerOpts.systemPrompt (cache-safe) - Compaction: construct (fresh resolve) + append COMPACTION_SYSTEM_PROMPT - 12 new tests (construct/get/service-unavailable/compaction) transport-http: - GET /system-prompt (returns template or DEFAULT_TEMPLATE) - PUT /system-prompt (validate + setTemplate, 503 when unavailable) - GET /system-prompt/variables (static catalog, always available) - 6 new tests system-prompt service: added getTemplate/setTemplate to interface + impl. 1396 vitest pass. typecheck + biome clean.
-rw-r--r--bun.lock2
-rw-r--r--packages/session-orchestrator/package.json3
-rw-r--r--packages/session-orchestrator/src/extension.ts19
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts737
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts107
-rw-r--r--packages/session-orchestrator/tsconfig.json3
-rw-r--r--packages/system-prompt/src/service.ts9
-rw-r--r--packages/system-prompt/src/types.ts6
-rw-r--r--packages/transport-http/package.json3
-rw-r--r--packages/transport-http/src/app.test.ts147
-rw-r--r--packages/transport-http/src/app.ts56
-rw-r--r--packages/transport-http/src/extension.ts7
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/tsconfig.json1
-rw-r--r--tsconfig.base.json1
16 files changed, 1072 insertions, 33 deletions
diff --git a/bun.lock b/bun.lock
index e6da398..ce128e4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -155,6 +155,7 @@
"@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/message-queue": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
},
},
"packages/skills": {
@@ -283,6 +284,7 @@
"@dispatch/kernel": "workspace:*",
"@dispatch/lsp": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
"hono": "^4.0.0",
diff --git a/packages/session-orchestrator/package.json b/packages/session-orchestrator/package.json
index 8aa6c0d..40cc0fe 100644
--- a/packages/session-orchestrator/package.json
+++ b/packages/session-orchestrator/package.json
@@ -9,6 +9,7 @@
"@dispatch/kernel": "workspace:*",
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
- "@dispatch/message-queue": "workspace:*"
+ "@dispatch/message-queue": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*"
}
}
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index fca8ddb..4144827 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -3,6 +3,7 @@ import { credentialStoreHandle } from "@dispatch/credential-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { runTurn } from "@dispatch/kernel";
import { messageQueueHandle } from "@dispatch/message-queue";
+import { systemPromptHandle } from "@dispatch/system-prompt";
import {
cacheWarmHandle,
compactionHandle,
@@ -81,6 +82,17 @@ export function activate(host: HostAPI): void {
return undefined;
}
},
+ resolveSystemPrompt: () => {
+ // Lazily resolve the system-prompt service. Returns undefined when
+ // the system-prompt extension isn't loaded (no system prompt sent —
+ // current behavior). Lazy so activation order with system-prompt
+ // doesn't matter; called per-turn / per-compaction, not at activate.
+ try {
+ return host.getService(systemPromptHandle);
+ } catch {
+ return undefined;
+ }
+ },
});
host.provideService(sessionOrchestratorHandle, orchestrator);
@@ -124,6 +136,13 @@ export function activate(host: HostAPI): void {
const store = host.getService(credentialStoreHandle);
return store.getModelInfo(modelName);
},
+ resolveSystemPrompt: () => {
+ try {
+ return host.getService(systemPromptHandle);
+ } catch {
+ return undefined;
+ }
+ },
applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly),
runTurn,
logger: host.logger,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index e778705..18a4a62 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -1,3 +1,4 @@
+import { resolve as pathResolve } from "node:path";
import type { ConversationStore } from "@dispatch/conversation-store";
import type {
AgentEvent,
@@ -15,8 +16,10 @@ import type {
TurnMetrics,
} from "@dispatch/kernel";
import { runTurn } from "@dispatch/kernel";
+import type { SystemPromptService } from "@dispatch/system-prompt";
import { describe, expect, it } from "vitest";
import {
+ createCompactionService,
createSessionOrchestrator,
createWarmService,
type TurnLifecyclePayload,
@@ -29,17 +32,27 @@ function createInMemoryStore(): ConversationStore & {
readonly metricsData: Map<string, TurnMetrics[]>;
readonly cwdData: Map<string, string>;
readonly effortData: Map<string, ReasoningEffort>;
+ readonly workspaceIdData: Map<string, string>;
} {
const data = new Map<string, ChatMessage[]>();
const metricsData = new Map<string, TurnMetrics[]>();
const cwdData = new Map<string, string>();
const effortData = new Map<string, ReasoningEffort>();
+ const workspaceIdData = new Map<string, string>();
+ // Track conversations that have a meta row. In the real store, append,
+ // setWorkspaceId, setConversationStatus, setConversationTitle, and
+ // setCompactedFrom all create a minimal meta row on first contact.
+ // getConversationMeta returns non-null for known conversations so the
+ // orchestrator's newness detection (meta === null) matches reality.
+ const knownConversations = new Set<string>();
return {
data,
metricsData,
cwdData,
effortData,
+ workspaceIdData,
async append(conversationId, messages) {
+ knownConversations.add(conversationId);
const existing = data.get(conversationId) ?? [];
data.set(conversationId, [...existing, ...messages]);
},
@@ -82,21 +95,40 @@ function createInMemoryStore(): ConversationStore & {
async listConversations() {
return [];
},
- async getConversationMeta() {
- return null;
+ async getConversationMeta(conversationId) {
+ if (!knownConversations.has(conversationId)) return null;
+ return {
+ id: conversationId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ title: "Untitled",
+ status: "idle",
+ workspaceId: workspaceIdData.get(conversationId) ?? "default",
+ };
+ },
+ async setConversationTitle(conversationId) {
+ knownConversations.add(conversationId);
},
- async setConversationTitle() {},
async getConversationStatus() {
return null;
},
- async setConversationStatus() {},
- async replaceHistory() {},
+ async setConversationStatus(conversationId) {
+ knownConversations.add(conversationId);
+ },
+ async replaceHistory(conversationId, messages) {
+ knownConversations.add(conversationId);
+ data.set(conversationId, [...messages]);
+ },
async getCompactPercent() {
return null;
},
async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
+ async forkHistory(_sourceId, targetId) {
+ knownConversations.add(targetId);
+ },
+ async setCompactedFrom(conversationId) {
+ knownConversations.add(conversationId);
+ },
async getWorkspace() {
return null;
},
@@ -115,12 +147,15 @@ function createInMemoryStore(): ConversationStore & {
async listWorkspaces() {
return [];
},
- async getWorkspaceId() {
- return "default";
+ async getWorkspaceId(conversationId) {
+ return workspaceIdData.get(conversationId) ?? "default";
},
- async setWorkspaceId() {},
- async getEffectiveCwd(conversationId) {
- return cwdData.get(conversationId) ?? null;
+ async setWorkspaceId(conversationId, workspaceId) {
+ workspaceIdData.set(conversationId, workspaceId);
+ knownConversations.add(conversationId);
+ },
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ return overrideCwd ?? cwdData.get(conversationId) ?? null;
},
};
}
@@ -2664,8 +2699,8 @@ describe("workspace integration", () => {
const base = createInMemoryStore();
const store: ConversationStore = {
...base,
- async getEffectiveCwd() {
- return "/workspace/default/cwd";
+ async getEffectiveCwd(_conversationId, overrideCwd) {
+ return overrideCwd ?? "/workspace/default/cwd";
},
};
@@ -2785,4 +2820,678 @@ describe("workspace integration", () => {
workspaceId: "enqueued-ws",
});
});
+
+ // --- cwd-timing invariant: workspace assigned BEFORE getEffectiveCwd ---
+
+ it("new conversation: workspace assigned before getEffectiveCwd resolves (relative per-turn cwd)", async () => {
+ // A fake store that implements the REAL getEffectiveCwd algorithm:
+ // a relative overrideCwd is resolved against the workspace's
+ // defaultCwd via path.resolve. Different workspaces have different
+ // defaultCwds so we can assert which workspace was active when
+ // getEffectiveCwd ran.
+ const workspaceDefaultCwds = new Map<string, string | null>([
+ ["default", null],
+ ["my-workspace", "/projects/my-workspace"],
+ ]);
+ const assignedWorkspaceIds = new Map<string, string>();
+ const callOrder: string[] = [];
+
+ const store: ConversationStore = {
+ ...createInMemoryStore(),
+ async getConversationMeta(conversationId) {
+ // A conversation is "known" once setWorkspaceId has been called
+ // (matching the real store, where setWorkspaceId creates a meta
+ // row). This lets us assert the ordering: getConversationMeta
+ // sees null first (new), then setWorkspaceId is called, then
+ // getEffectiveCwd runs and sees the assigned workspace.
+ const wsId = assignedWorkspaceIds.get(conversationId);
+ return wsId !== undefined
+ ? {
+ id: conversationId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ title: "Untitled",
+ status: "idle",
+ workspaceId: wsId,
+ }
+ : null;
+ },
+ async ensureWorkspace(id) {
+ callOrder.push(`ensureWorkspace:${id}`);
+ return {
+ id,
+ title: id,
+ defaultCwd: workspaceDefaultCwds.get(id) ?? null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceId(conversationId, workspaceId) {
+ callOrder.push(`setWorkspaceId:${workspaceId}`);
+ assignedWorkspaceIds.set(conversationId, workspaceId);
+ },
+ async getWorkspaceId(conversationId) {
+ return assignedWorkspaceIds.get(conversationId) ?? "default";
+ },
+ async getWorkspace(id) {
+ const defaultCwd = workspaceDefaultCwds.get(id) ?? null;
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ // Real algorithm: relative cwd resolved against workspace defaultCwd.
+ const wsId = assignedWorkspaceIds.get(conversationId) ?? "default";
+ callOrder.push(`getEffectiveCwd(workspace=${wsId})`);
+ const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null;
+ const conversationCwd = overrideCwd ?? null;
+ if (conversationCwd === null) {
+ return workspaceCwd;
+ }
+ if (conversationCwd.startsWith("/")) {
+ return conversationCwd;
+ }
+ return pathResolve(workspaceCwd ?? "/server-default", conversationCwd);
+ },
+ };
+
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-cwd-timing",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "arch-rewrite",
+ workspaceId: "my-workspace",
+ });
+
+ // The workspace was assigned before getEffectiveCwd ran.
+ const ensureIdx = callOrder.indexOf("ensureWorkspace:my-workspace");
+ const setWsIdx = callOrder.indexOf("setWorkspaceId:my-workspace");
+ const effCwdIdx = callOrder.indexOf("getEffectiveCwd(workspace=my-workspace)");
+ expect(ensureIdx).toBeGreaterThanOrEqual(0);
+ expect(setWsIdx).toBeGreaterThan(ensureIdx);
+ expect(effCwdIdx).toBeGreaterThan(setWsIdx);
+
+ // The relative cwd "arch-rewrite" resolved against my-workspace's
+ // defaultCwd "/projects/my-workspace", NOT against the default
+ // workspace's null (→ server default / process.cwd()).
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/projects/my-workspace/arch-rewrite");
+ });
+
+ it("new conversation with no per-turn cwd: workspace assigned, effective cwd = workspace defaultCwd", async () => {
+ const workspaceDefaultCwds = new Map<string, string | null>([
+ ["default", null],
+ ["my-workspace", "/projects/my-workspace"],
+ ]);
+ const assignedWorkspaceIds = new Map<string, string>();
+
+ const store: ConversationStore = {
+ ...createInMemoryStore(),
+ async getConversationMeta(conversationId) {
+ const wsId = assignedWorkspaceIds.get(conversationId);
+ return wsId !== undefined
+ ? {
+ id: conversationId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ title: "Untitled",
+ status: "idle",
+ workspaceId: wsId,
+ }
+ : null;
+ },
+ async ensureWorkspace(id) {
+ return {
+ id,
+ title: id,
+ defaultCwd: workspaceDefaultCwds.get(id) ?? null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceId(conversationId, workspaceId) {
+ assignedWorkspaceIds.set(conversationId, workspaceId);
+ },
+ async getWorkspaceId(conversationId) {
+ return assignedWorkspaceIds.get(conversationId) ?? "default";
+ },
+ async getWorkspace(id) {
+ const defaultCwd = workspaceDefaultCwds.get(id) ?? null;
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ const wsId = assignedWorkspaceIds.get(conversationId) ?? "default";
+ const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null;
+ const conversationCwd = overrideCwd ?? null;
+ if (conversationCwd === null) {
+ return workspaceCwd;
+ }
+ if (conversationCwd.startsWith("/")) {
+ return conversationCwd;
+ }
+ return pathResolve(workspaceCwd ?? "/server-default", conversationCwd);
+ },
+ };
+
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-cwd-timing-no-cwd",
+ text: "hi",
+ onEvent: () => {},
+ workspaceId: "my-workspace",
+ });
+
+ // No per-turn cwd → effective cwd = workspace defaultCwd.
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/projects/my-workspace");
+ });
+
+ it("existing conversation: workspace NOT re-assigned, effective cwd resolves as before", async () => {
+ const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = [];
+ const base = createInMemoryStore();
+ // Pre-populate the conversation so getConversationMeta returns non-null
+ // (existing conversation with history + workspace already assigned).
+ await base.append("conv-existing", [
+ { role: "user", chunks: [{ type: "text", text: "previous turn" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
+ ]);
+
+ const store: ConversationStore = {
+ ...base,
+ async setWorkspaceId(conversationId, workspaceId) {
+ setWorkspaceIdCalls.push({ conversationId, workspaceId });
+ },
+ async getEffectiveCwd(_conversationId, overrideCwd) {
+ return overrideCwd ?? "/existing/workspace/cwd";
+ },
+ };
+
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-existing",
+ text: "follow up",
+ onEvent: () => {},
+ cwd: "arch-rewrite",
+ workspaceId: "should-not-be-stamped",
+ });
+
+ // setWorkspaceId was NOT called (existing conversation keeps its workspace).
+ expect(setWorkspaceIdCalls).toHaveLength(0);
+
+ // Effective cwd still resolves (here via the fake store's override).
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("arch-rewrite");
+ });
+});
+
+describe("getEffectiveCwd override (per-turn cwd resolution)", () => {
+ it("turn start with a per-turn cwd → getEffectiveCwd called with that cwd as overrideCwd", async () => {
+ const base = createInMemoryStore();
+ const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> =
+ [];
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ effectiveCwdCalls.push({ conversationId, overrideCwd });
+ return overrideCwd ?? (await base.getEffectiveCwd(conversationId));
+ },
+ };
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-turn-override",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "arch-rewrite",
+ });
+
+ expect(effectiveCwdCalls).toHaveLength(1);
+ expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite");
+ });
+
+ it("turn start with no per-turn cwd → getEffectiveCwd called with undefined override", async () => {
+ const base = createInMemoryStore();
+ const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> =
+ [];
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ effectiveCwdCalls.push({ conversationId, overrideCwd });
+ return overrideCwd ?? (await base.getEffectiveCwd(conversationId));
+ },
+ };
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-turn-no-override",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(effectiveCwdCalls).toHaveLength(1);
+ expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined();
+ });
+
+ it("warm with opts.cwd → getEffectiveCwd called with opts.cwd as override", async () => {
+ const base = createInMemoryStore();
+ await base.append("conv-warm-override", [
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ ]);
+ const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> =
+ [];
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ effectiveCwdCalls.push({ conversationId, overrideCwd });
+ return overrideCwd ?? (await base.getEffectiveCwd(conversationId));
+ },
+ };
+
+ const provider: ProviderContract = {
+ id: "p",
+ stream: async function* () {
+ yield {
+ type: "usage",
+ usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ },
+ };
+
+ const deps = {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: () => {},
+ };
+
+ const { activeConversations } = createSessionOrchestrator(deps);
+ const warmService = createWarmService(deps, activeConversations);
+
+ await warmService.warm("conv-warm-override", { cwd: "arch-rewrite" });
+
+ expect(effectiveCwdCalls).toHaveLength(1);
+ expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite");
+ });
+
+ it("warm without opts.cwd → getEffectiveCwd called with undefined override", async () => {
+ const base = createInMemoryStore();
+ await base.append("conv-warm-no-override", [
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ ]);
+ const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> =
+ [];
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd(conversationId, overrideCwd) {
+ effectiveCwdCalls.push({ conversationId, overrideCwd });
+ return overrideCwd ?? (await base.getEffectiveCwd(conversationId));
+ },
+ };
+
+ const provider: ProviderContract = {
+ id: "p",
+ stream: async function* () {
+ yield {
+ type: "usage",
+ usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ },
+ };
+
+ const deps = {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: () => {},
+ };
+
+ const { activeConversations } = createSessionOrchestrator(deps);
+ const warmService = createWarmService(deps, activeConversations);
+
+ await warmService.warm("conv-warm-no-override");
+
+ expect(effectiveCwdCalls).toHaveLength(1);
+ expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined();
+ });
+});
+
+// --- System prompt integration ---
+
+function createFakeSystemPromptService(
+ constructImpl: (
+ conversationId: string,
+ cwd: string,
+ context?: { readonly model?: string },
+ ) => Promise<string>,
+ getImpl: (conversationId: string) => Promise<string | null> = () => Promise.resolve(null),
+): SystemPromptService {
+ return {
+ construct: constructImpl,
+ get: getImpl,
+ async getTemplate() {
+ return "";
+ },
+ async setTemplate() {},
+ };
+}
+
+describe("system prompt: regular turn flow", () => {
+ it("First turn: construct called — new conversation (meta null) → construct called with conversationId + cwd + model → result set on providerOpts.systemPrompt", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const constructCalls: Array<{
+ conversationId: string;
+ cwd: string;
+ model: string | undefined;
+ }> = [];
+ const getCalls: string[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(
+ async (conversationId, cwd, context) => {
+ constructCalls.push({
+ conversationId,
+ cwd,
+ model: context?.model,
+ });
+ return "CONSTRUCTED_PROMPT";
+ },
+ async (conversationId) => {
+ getCalls.push(conversationId);
+ return null;
+ },
+ ),
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-sp-first",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "/work/dir",
+ modelName: "my-model",
+ });
+
+ expect(constructCalls).toHaveLength(1);
+ expect(constructCalls[0]?.conversationId).toBe("conv-sp-first");
+ expect(constructCalls[0]?.cwd).toBe("/work/dir");
+ expect(constructCalls[0]?.model).toBe("my-model");
+ expect(getCalls).toHaveLength(0);
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.providerOpts?.systemPrompt).toBe("CONSTRUCTED_PROMPT");
+ });
+
+ it("Subsequent turn: get called — existing conversation (meta non-null) → get called → result set on providerOpts.systemPrompt", async () => {
+ const store = createInMemoryStore();
+ // Seed an existing conversation so getConversationMeta returns non-null.
+ await store.append("conv-sp-sub", [
+ { role: "user", chunks: [{ type: "text", text: "first" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
+ ]);
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const constructCalls: string[] = [];
+ const getCalls: string[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(
+ async (conversationId) => {
+ constructCalls.push(conversationId);
+ return "SHOULD_NOT_BE_USED";
+ },
+ async (conversationId) => {
+ getCalls.push(conversationId);
+ return "PERSISTED_PROMPT";
+ },
+ ),
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-sp-sub",
+ text: "second",
+ onEvent: () => {},
+ });
+
+ expect(getCalls).toHaveLength(1);
+ expect(getCalls[0]).toBe("conv-sp-sub");
+ expect(constructCalls).toHaveLength(0);
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.providerOpts?.systemPrompt).toBe("PERSISTED_PROMPT");
+ });
+
+ it("Subsequent turn: get returns null → systemPrompt omitted from providerOpts", async () => {
+ const store = createInMemoryStore();
+ await store.append("conv-sp-null", [
+ { role: "user", chunks: [{ type: "text", text: "first" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
+ ]);
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(
+ async () => "SHOULD_NOT_BE_USED",
+ async () => null,
+ ),
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-sp-null",
+ text: "second",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.providerOpts?.systemPrompt).toBeUndefined();
+ });
+
+ it("Service unavailable: no system prompt — resolveSystemPrompt is undefined → providerOpts.systemPrompt is NOT set", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ // resolveSystemPrompt omitted entirely
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-sp-none",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "/work",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.providerOpts?.systemPrompt).toBeUndefined();
+ });
+});
+
+describe("system prompt: compaction flow", () => {
+ function seedHistory(
+ store: ReturnType<typeof createInMemoryStore>,
+ conversationId: string,
+ count: number,
+ ): void {
+ const messages: ChatMessage[] = [];
+ for (let i = 0; i < count; i++) {
+ messages.push({
+ role: i % 2 === 0 ? "user" : "assistant",
+ chunks: [{ type: "text", text: `message ${i}` }],
+ });
+ }
+ store.data.set(conversationId, messages);
+ }
+
+ it("Compaction: construct + append — compaction flow calls construct → result appended with COMPACTION_SYSTEM_PROMPT → combined string set as systemPrompt", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-compact-sp", 15);
+
+ const constructCalls: Array<{
+ conversationId: string;
+ cwd: string;
+ model: string | undefined;
+ }> = [];
+
+ let capturedSystemPrompt: string | undefined;
+ const provider: ProviderContract = {
+ id: "compaction-provider",
+ stream(_messages, _tools, opts) {
+ capturedSystemPrompt = opts?.systemPrompt;
+ return (async function* () {
+ yield { type: "text-delta", delta: "Summary text" } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const compactionService = createCompactionService(
+ {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(async (conversationId, cwd, context) => {
+ constructCalls.push({ conversationId, cwd, model: context?.model });
+ return "RECONSTRUCTED_PROMPT";
+ }),
+ emit: () => {},
+ },
+ new Set(),
+ );
+
+ const result = await compactionService.compact("conv-compact-sp", {
+ modelName: "compaction-model",
+ });
+
+ expect("summary" in result).toBe(true);
+ expect(constructCalls).toHaveLength(1);
+ expect(constructCalls[0]?.conversationId).toBe("conv-compact-sp");
+ expect(constructCalls[0]?.model).toBe("compaction-model");
+
+ // The system prompt sent to the provider must be the constructed prompt
+ // appended with the COMPACTION_SYSTEM_PROMPT.
+ expect(capturedSystemPrompt).toBeDefined();
+ expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED_PROMPT\n\n")).toBe(true);
+ expect(capturedSystemPrompt).toContain("conversation summarizer");
+ });
+
+ it("Compaction: fallback when service unavailable — compaction flow with no service → COMPACTION_SYSTEM_PROMPT alone", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-compact-nosp", 15);
+
+ let capturedSystemPrompt: string | undefined;
+ const provider: ProviderContract = {
+ id: "compaction-provider",
+ stream(_messages, _tools, opts) {
+ capturedSystemPrompt = opts?.systemPrompt;
+ return (async function* () {
+ yield { type: "text-delta", delta: "Summary text" } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const compactionService = createCompactionService(
+ {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ // resolveSystemPrompt omitted — service unavailable
+ emit: () => {},
+ },
+ new Set(),
+ );
+
+ const result = await compactionService.compact("conv-compact-nosp", {
+ modelName: "compaction-model",
+ });
+
+ expect("summary" in result).toBe(true);
+ expect(capturedSystemPrompt).toBeDefined();
+ // Must be the COMPACTION_SYSTEM_PROMPT alone — no constructed prefix.
+ expect(capturedSystemPrompt).toContain("conversation summarizer");
+ expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED")).toBe(false);
+ });
});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 54b1b40..5398867 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -19,6 +19,7 @@ import type {
} from "@dispatch/kernel";
import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel";
import type { MessageQueueService, QueuedMessage } from "@dispatch/message-queue";
+import type { SystemPromptService } from "@dispatch/system-prompt";
import { createMetricsAccumulator } from "./metrics.js";
import {
buildUserMessage,
@@ -282,6 +283,14 @@ export interface SessionOrchestratorDeps {
* threshold is exceeded). Lazy so activation order doesn't matter.
*/
readonly resolveCompaction?: () => CompactionService | undefined;
+ /**
+ * Lazily resolves the system-prompt service, or `undefined` when the
+ * system-prompt extension isn't loaded. Used to construct the per-
+ * conversation system prompt once (first turn) and reuse it (cache-safe) on
+ * subsequent turns, and to reconstruct it on compaction. Lazy so activation
+ * order doesn't matter.
+ */
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
/** Apply the per-turn tools filter chain. Injected for testability. */
readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>;
/** Base logger (auto-scoped to this extension); childed per turn for span capture. */
@@ -357,10 +366,37 @@ export function createSessionOrchestrator(
emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
- const effectiveCwdPromise =
- cwd !== undefined
- ? Promise.resolve(cwd)
- : deps.conversationStore.getEffectiveCwd(conversationId).then((c) => c ?? undefined);
+ // For a NEW conversation the workspace MUST be assigned (persisted)
+ // BEFORE getEffectiveCwd runs, so the effective cwd resolves against
+ // the intended workspace's defaultCwd rather than the stale "default"
+ // workspace returned by getWorkspaceId for a not-yet-persisted
+ // conversation. Detect newness via getConversationMeta === null
+ // (equivalent to history.length === 0 in practice). Existing
+ // conversations keep their assigned workspace — never overwritten.
+ // The newness flag is also reused to decide whether to construct
+ // (first turn) or get (subsequent turn) the system prompt — see the
+ // providerOpts assembly below.
+ const workspaceSetupPromise = (async (): Promise<boolean> => {
+ const meta = await deps.conversationStore.getConversationMeta(conversationId);
+ if (meta === null) {
+ await deps.conversationStore.ensureWorkspace(workspaceId);
+ await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
+ return true;
+ }
+ return false;
+ })();
+
+ // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
+ // per-turn cwd as the overrideCwd when present. A relative per-turn cwd
+ // (e.g. "arch-rewrite") must be resolved against the workspace's
+ // defaultCwd via the same workspace-relative algorithm the persisted cwd
+ // uses — NOT used raw (which would resolve against process.cwd() and
+ // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd.
+ // Chained after workspaceSetupPromise so the workspace is assigned
+ // first for new conversations (the timing invariant this enforces).
+ const effectiveCwdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
+ );
const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
@@ -381,9 +417,10 @@ export function createSessionOrchestrator(
void (async () => {
let sealed = false;
try {
- const [effectiveCwd, storedEffort] = await Promise.all([
+ const [effectiveCwd, storedEffort, isNewConversation] = await Promise.all([
effectiveCwdPromise,
storedEffortPromise,
+ workspaceSetupPromise,
]);
if (cwd !== undefined) {
@@ -395,14 +432,11 @@ export function createSessionOrchestrator(
const history = await deps.conversationStore.load(conversationId);
const userMsg = buildUserMessage(text);
- // New conversation: stamp the workspaceId so subsequent turns resolve
- // the effective cwd from the workspace's defaultCwd. Auto-create the
- // workspace if missing (idempotent). Only for new conversations (no
- // history) — existing conversations keep their assigned workspace.
- if (history.length === 0) {
- await deps.conversationStore.ensureWorkspace(workspaceId);
- await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
- }
+ // Workspace assignment for new conversations happens BEFORE
+ // effective-cwd resolution (see workspaceSetupPromise above) so
+ // getEffectiveCwd resolves against the intended workspace, not
+ // the stale "default". The history-load + append flow below is
+ // otherwise unchanged.
let provider: ProviderContract;
let modelOverride: string | undefined;
@@ -439,9 +473,33 @@ export function createSessionOrchestrator(
emitToHub(conversationId, event);
};
+ // Resolve the system prompt for this turn (cache-safe). On the
+ // FIRST turn of a new conversation, construct it once (resolves all
+ // template variables + persists the result). On subsequent turns,
+ // reuse the persisted prompt via `get` (no reconstruction — the
+ // system prompt is part of the cacheable prefix). When the
+ // system-prompt service isn't loaded, no system prompt is sent
+ // (current behavior preserved).
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ let systemPrompt: string | undefined;
+ if (systemPromptService !== undefined) {
+ if (isNewConversation) {
+ systemPrompt = await systemPromptService.construct(
+ conversationId,
+ effectiveCwd ?? process.cwd(),
+ {
+ ...(modelName !== undefined ? { model: modelName } : {}),
+ },
+ );
+ } else {
+ systemPrompt = (await systemPromptService.get(conversationId)) ?? undefined;
+ }
+ }
+
const providerOpts: ProviderStreamOptions = {
reasoningEffort: resolvedEffort,
...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
};
// Resolve the steering queue once for this turn. When present, wire
@@ -720,7 +778,7 @@ export function createWarmService(
}
const baseTools = deps.resolveTools();
- // Resolve cwd the SAME way handleMessage does (caller value → stored cwd).
+ // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd
// The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
// `load_skill` description per-cwd). If the warm assembles tools under a
// different cwd than the real turn, the tools block — the FIRST bytes of
@@ -728,7 +786,7 @@ export function createWarmService(
// A manual reheat sends no cwd, so without this fallback it would warm the
// wrong prefix. See notes/observability-design.md §3.1.
const cwd =
- opts?.cwd ?? (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? undefined;
+ (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined;
const assembled = await deps.applyToolsFilter({
tools: baseTools,
conversationId,
@@ -885,11 +943,28 @@ export function createCompactionService(
: {}),
};
+ // Reconstruct the system prompt on compaction (fresh variable
+ // resolution — files/cwd/time may have changed since construction).
+ // The construct call also persists the result for future turns. When
+ // the system-prompt service is unavailable, fall back to the
+ // compaction-only system prompt (current behavior, no regression).
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ let compactionSystemPrompt: string;
+ if (systemPromptService !== undefined) {
+ const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
+ const constructed = await systemPromptService.construct(conversationId, cwd, {
+ ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
+ });
+ compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
+ } else {
+ compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
+ }
+
// Call the provider and accumulate the summary
let summary = "";
for await (const event of provider.stream([summaryRequest], [], {
...providerOpts,
- systemPrompt: COMPACTION_SYSTEM_PROMPT,
+ systemPrompt: compactionSystemPrompt,
})) {
if ((event as ProviderEvent).type === "text-delta") {
summary += (event as { delta: string }).delta;
diff --git a/packages/session-orchestrator/tsconfig.json b/packages/session-orchestrator/tsconfig.json
index 782c1c8..2ca3bd2 100644
--- a/packages/session-orchestrator/tsconfig.json
+++ b/packages/session-orchestrator/tsconfig.json
@@ -6,6 +6,7 @@
{ "path": "../kernel" },
{ "path": "../conversation-store" },
{ "path": "../credential-store" },
- { "path": "../message-queue" }
+ { "path": "../message-queue" },
+ { "path": "../system-prompt" }
]
}
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
index 45645b9..4b4436f 100644
--- a/packages/system-prompt/src/service.ts
+++ b/packages/system-prompt/src/service.ts
@@ -63,5 +63,14 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
async get(conversationId) {
return deps.storage.get(resolvedKey(conversationId));
},
+
+ async getTemplate() {
+ const stored = await deps.storage.get(TEMPLATE_KEY);
+ return stored ?? DEFAULT_TEMPLATE;
+ },
+
+ async setTemplate(template) {
+ await deps.storage.set(TEMPLATE_KEY, template);
+ },
};
}
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
index 9e87512..dd6aa69 100644
--- a/packages/system-prompt/src/types.ts
+++ b/packages/system-prompt/src/types.ts
@@ -29,6 +29,12 @@ export interface SystemPromptService {
/** Read the persisted resolved system prompt, or `null` if never constructed. */
get(conversationId: string): Promise<string | null>;
+
+ /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
+ getTemplate(): Promise<string>;
+
+ /** Set (upsert) the global template. An empty string means "no system prompt". */
+ setTemplate(template: string): Promise<void>;
}
/**
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index 2a3acdd..e39a992 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -13,6 +13,7 @@
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
- "hono": "^4.0.0"
+ "hono": "^4.0.0",
+ "@dispatch/system-prompt": "workspace:*"
}
}
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 0b840db..2a4b451 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -10,11 +10,13 @@ import type {
StoredChunk,
TurnMetrics,
} from "@dispatch/kernel";
+import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
DeleteWorkspaceResponse,
QueuedMessage,
QueueResponse,
+ SystemPromptVariable,
ThroughputResponse,
WorkspaceListResponse,
WorkspaceResponse,
@@ -28,6 +30,7 @@ import type {
CredentialStore,
LspService,
SessionOrchestrator,
+ SystemPromptService,
WarmService,
} from "./seam.js";
import { conversationOpened } from "./seam.js";
@@ -381,6 +384,39 @@ function createCapturingLspService(
};
}
+function createFakeSystemPromptService(
+ template: string = "custom template",
+): SystemPromptService & {
+ readonly setTemplateCalls: readonly string[];
+ readonly getTemplateCalls: number;
+} {
+ const setCalls: string[] = [];
+ let getTemplateCount = 0;
+ let currentTemplate = template;
+ return {
+ get setTemplateCalls() {
+ return setCalls;
+ },
+ get getTemplateCalls() {
+ return getTemplateCount;
+ },
+ async construct() {
+ return currentTemplate;
+ },
+ async get() {
+ return currentTemplate;
+ },
+ async getTemplate() {
+ getTemplateCount++;
+ return currentTemplate;
+ },
+ async setTemplate(t) {
+ setCalls.push(t);
+ currentTemplate = t;
+ },
+ };
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -3249,3 +3285,114 @@ it("GET /conversations/:id/lsp uses effective cwd", async () => {
};
expect(body.cwd).toBe("/effective");
});
+
+describe("GET /system-prompt", () => {
+ it("returns stored template", async () => {
+ const service = createFakeSystemPromptService("custom template");
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("custom template");
+ expect(service.getTemplateCalls).toBe(1);
+ });
+
+ it("returns default when service unavailable", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe(DEFAULT_TEMPLATE);
+ });
+});
+
+describe("PUT /system-prompt", () => {
+ it("sets template", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("new");
+ expect(service.setTemplateCalls).toEqual(["new"]);
+ });
+
+ it("missing template → 400", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(service.setTemplateCalls).toEqual([]);
+ });
+
+ it("service unavailable → 503", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("System prompt service not available");
+ });
+});
+
+describe("GET /system-prompt/variables", () => {
+ it("returns catalog", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt/variables");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
+ expect(Array.isArray(body.variables)).toBe(true);
+ // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
+ const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
+ const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
+ const fileEntry = body.variables.find((v) => v.type === "file");
+ expect(hasSystemTime).toBe(true);
+ expect(hasPromptCwd).toBe(true);
+ expect(fileEntry).toBeDefined();
+ expect(fileEntry?.dynamic).toBe(true);
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index eba5c1a..41b583c 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,4 +1,5 @@
import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
+import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt";
import type {
CloseConversationResponse,
CompactPercentResponse,
@@ -17,6 +18,9 @@ import type {
QueueResponse,
ReasoningEffortResponse,
SetCompactPercentRequest,
+ SetSystemPromptTemplateRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
ThroughputResponse,
TitleResponse,
WarmResponse,
@@ -51,6 +55,7 @@ import {
type LspServerStatus,
type LspService,
type SessionOrchestrator,
+ type SystemPromptService,
ThroughputQueryError,
type ThroughputStore,
type WarmService,
@@ -63,6 +68,8 @@ export interface CreateServerOptions {
readonly warmService?: WarmService;
readonly compactionService?: CompactionService;
readonly lspService?: LspService;
+ /** Optional — system prompt builder service (GET/PUT template). */
+ readonly systemPromptService?: SystemPromptService;
/** Optional — defaults to a no-op store (recording disabled, empty reports). */
readonly throughputStore?: ThroughputStore;
readonly logger?: Logger;
@@ -1018,6 +1025,55 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // ─── System prompt template ───────────────────────────────────────────────
+
+ app.get("/system-prompt/variables", (c) => {
+ // Static catalog — no service call needed. Always available.
+ const variables = getVariableCatalog();
+ const body: SystemPromptVariablesResponse = { variables };
+ return c.json(body, 200);
+ });
+
+ app.get("/system-prompt", async (c) => {
+ if (opts.systemPromptService === undefined) {
+ // FE always gets something useful — the built-in default template.
+ const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE };
+ return c.json(body, 200);
+ }
+ const template = await opts.systemPromptService.getTemplate();
+ const body: SystemPromptTemplateResponse = { template };
+ return c.json(body, 200);
+ });
+
+ app.put("/system-prompt", async (c) => {
+ if (opts.systemPromptService === undefined) {
+ return c.json({ error: "System prompt service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("system-prompt: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ // `template` must be a string; empty string is valid ("no system prompt").
+ if (typeof obj.template !== "string") {
+ return c.json({ error: "Field 'template' is required and must be a string" }, 400);
+ }
+
+ const { template } = obj as unknown as SetSystemPromptTemplateRequest;
+ await opts.systemPromptService.setTemplate(template);
+ log.info("system-prompt: template set");
+ const response: SystemPromptTemplateResponse = { template };
+ return c.json(response, 200);
+ });
+
// ─── Static frontend serving (catch-all, API routes take precedence) ──────
if (opts.webDir !== undefined) {
const webDir = opts.webDir;
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 77738f8..ac6553c 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -7,6 +7,7 @@ import {
credentialStoreHandle,
lspServiceHandle,
sessionOrchestratorHandle,
+ systemPromptHandle,
throughputStoreHandle,
} from "./seam.js";
@@ -39,11 +40,14 @@ export const manifest: Manifest = {
"/conversations/:id/open",
"/conversations/:id/queue",
"/conversations/:id/reasoning-effort",
+ "/conversations/:id/status",
"/conversations/:id/stop",
"/conversations/:id/title",
"/health",
"/models",
"/metrics/throughput",
+ "/system-prompt",
+ "/system-prompt/variables",
"/workspaces",
"/workspaces/:id",
"/workspaces/:id/title",
@@ -71,6 +75,7 @@ export function createTransportHttpExtension(): Extension & {
const warmService = host.getService(cacheWarmHandle);
const compactionService = host.getService(compactionHandle);
const lspService = host.getService(lspServiceHandle);
+ const systemPromptService = host.getService(systemPromptHandle);
const logger = host.logger;
const app = createApp({
@@ -81,6 +86,7 @@ export function createTransportHttpExtension(): Extension & {
warmService,
compactionService,
lspService,
+ systemPromptService,
logger,
emit: host.emit.bind(host),
...(process.env.DISPATCH_WEB_DIR !== undefined
@@ -93,6 +99,7 @@ export function createTransportHttpExtension(): Extension & {
server = Bun.serve({
port,
fetch: app.fetch,
+ idleTimeout: 0,
});
logger.info("transport-http: listening", { port });
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index 244bce6..7165c55 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -30,6 +30,7 @@ export type {
CredentialStore,
LspService,
SessionOrchestrator,
+ SystemPromptService,
WarmService,
} from "./seam.js";
export {
@@ -39,4 +40,5 @@ export {
isValidWorkspaceSlug,
lspServiceHandle,
sessionOrchestratorHandle,
+ systemPromptHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index 7ce4518..1b359e4 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -15,5 +15,7 @@ export {
conversationOpened,
sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
+export type { SystemPromptService } from "@dispatch/system-prompt";
+export { systemPromptHandle } from "@dispatch/system-prompt";
export type { ThroughputStore } from "@dispatch/throughput-store";
export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughput-store";
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index fd3f3ea..8dd2439 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -8,6 +8,7 @@
{ "path": "../kernel" },
{ "path": "../lsp" },
{ "path": "../session-orchestrator" },
+ { "path": "../system-prompt" },
{ "path": "../throughput-store" },
{ "path": "../transport-contract" }
]
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 591a9e9..c6fc144 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -1,4 +1,5 @@
{
+ "exclude": ["**/*.test.ts", "**/*.test.tsx"],
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",