diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 00:08:47 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 00:08:47 +0900 |
| commit | d225ea4bd5f95d39a910704fe45acdf847c953fa (patch) | |
| tree | ec0a33665087bb34844b19955ab6fa74c39e3656 /packages/transport-http/src | |
| parent | 674853d87d54dba1cd83c4e51fce5411602f4d5d (diff) | |
| download | dispatch-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/transport-http/src')
| -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 |
5 files changed, 214 insertions, 0 deletions
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"; |
