summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
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 /packages/session-orchestrator/src
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.
Diffstat (limited to 'packages/session-orchestrator/src')
-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
3 files changed, 833 insertions, 30 deletions
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;