diff options
| -rw-r--r-- | bun.lock | 2 | ||||
| -rw-r--r-- | packages/session-orchestrator/package.json | 3 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 19 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 737 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 107 | ||||
| -rw-r--r-- | packages/session-orchestrator/tsconfig.json | 3 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 9 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 6 | ||||
| -rw-r--r-- | packages/transport-http/package.json | 3 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 147 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 56 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 7 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/tsconfig.json | 1 | ||||
| -rw-r--r-- | tsconfig.base.json | 1 |
16 files changed, 1072 insertions, 33 deletions
@@ -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", |
