summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/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/transport-http/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/transport-http/src')
-rw-r--r--packages/transport-http/src/app.test.ts147
-rw-r--r--packages/transport-http/src/app.ts56
-rw-r--r--packages/transport-http/src/extension.ts7
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/seam.ts2
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";