summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
committerAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
commite7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch)
tree447095fd60b43980358d1565506f3ae2430e5f29 /packages/transport-http
parent35937cee7f838e414eb8147c67205e01d85a4da0 (diff)
downloaddispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz
dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block โ€” the first bytes of the prompt-cache prefix โ€” diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs ยง3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/transport-http')
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts180
-rw-r--r--packages/transport-http/src/app.ts90
-rw-r--r--packages/transport-http/src/extension.ts13
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts20
-rw-r--r--packages/transport-http/tsconfig.json1
8 files changed, 306 insertions, 3 deletions
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index d439fe4..2a3acdd 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -9,6 +9,7 @@
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 22b26fc..07f6777 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -13,6 +13,7 @@ import { createApp } from "./app.js";
import type {
ConversationStore,
CredentialStore,
+ LspService,
SessionOrchestrator,
WarmService,
} from "./seam.js";
@@ -78,6 +79,7 @@ function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[]
function createFakeConversationStore(
store: Map<string, StoredChunk[]> = new Map(),
metricsStore: Map<string, TurnMetrics[]> = new Map(),
+ cwdStore: Map<string, string> = new Map(),
): ConversationStore {
return {
async append() {},
@@ -93,6 +95,12 @@ function createFakeConversationStore(
async loadMetrics(conversationId) {
return metricsStore.get(conversationId) ?? [];
},
+ async getCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async setCwd(conversationId, cwd) {
+ cwdStore.set(conversationId, cwd);
+ },
};
}
@@ -169,6 +177,23 @@ function createFakeWarmService(
};
}
+function createFakeLspService(
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ }[] = [],
+): LspService {
+ return {
+ async status() {
+ return statuses;
+ },
+ };
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -752,6 +777,10 @@ describe("GET /conversations/:id/metrics", () => {
async loadMetrics() {
throw new Error("storage exploded");
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
const app = createApp({
conversationStore: brokenStore,
@@ -1031,3 +1060,154 @@ describe("throughput recording + GET /metrics/throughput", () => {
expect(res.status).toBe(400);
});
});
+
+describe("GET /conversations/:id/cwd", () => {
+ it("returns null when unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ });
+});
+
+describe("PUT then GET /conversations/:id/cwd", () => {
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.cwd).toBe("/home/user/project");
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBe("/home/user/project");
+ });
+});
+
+describe("PUT /conversations/:id/cwd", () => {
+ it("with missing cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("cwd");
+ });
+
+ it("with empty cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("GET /conversations/:id/lsp", () => {
+ it("returns empty servers when cwd is unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ });
+
+ it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lspStatuses = [
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ {
+ id: "lua-lsp",
+ name: "Lua LSP",
+ root: "/home/user/project",
+ extensions: [".luau"],
+ state: "error" as const,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(lspStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: string;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("typescript");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("lua-lsp");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 84c7d20..7778bad 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -2,6 +2,9 @@ import type { AgentEvent, Logger } from "@dispatch/kernel";
import type {
ConversationHistoryResponse,
ConversationMetricsResponse,
+ CwdResponse,
+ LspServerInfo,
+ LspStatusResponse,
ModelsResponse,
ThroughputResponse,
WarmResponse,
@@ -21,6 +24,8 @@ import {
import {
type ConversationStore,
type CredentialStore,
+ type LspServerStatus,
+ type LspService,
type SessionOrchestrator,
ThroughputQueryError,
type ThroughputStore,
@@ -32,6 +37,7 @@ export interface CreateServerOptions {
readonly orchestrator: SessionOrchestrator;
readonly credentialStore: CredentialStore;
readonly warmService?: WarmService;
+ readonly lspService?: LspService;
/** Optional โ€” defaults to a no-op store (recording disabled, empty reports). */
readonly throughputStore?: ThroughputStore;
readonly logger?: Logger;
@@ -105,7 +111,7 @@ export function createApp(opts: CreateServerOptions): Hono {
"*",
cors({
origin: "*",
- allowMethods: ["GET", "POST", "OPTIONS"],
+ allowMethods: ["GET", "POST", "PUT", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
);
@@ -313,5 +319,87 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.get("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const cwd = await opts.conversationStore.getCwd(conversationId);
+ log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null });
+ const body: CwdResponse = { conversationId, cwd };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: cwd read failure", { err });
+ return c.json({ error: "Failed to read conversation cwd" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/cwd: 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>;
+ if (typeof obj.cwd !== "string" || obj.cwd.length === 0) {
+ return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400);
+ }
+
+ try {
+ await opts.conversationStore.setCwd(conversationId, obj.cwd);
+ log.info("conversations: cwd set", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: obj.cwd };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: cwd set failure", { err });
+ return c.json({ error: "Failed to set conversation cwd" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/lsp", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const cwd = await opts.conversationStore.getCwd(conversationId);
+ if (cwd === null) {
+ log.info("conversations: lsp status read (no cwd)", { conversationId });
+ const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ if (opts.lspService === undefined) {
+ log.warn("conversations: lsp service not available", { conversationId });
+ return c.json({ error: "LSP service not available" }, 503);
+ }
+
+ const statuses = await opts.lspService.status(cwd);
+ const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => {
+ const info: LspServerInfo = {
+ id: s.id,
+ name: s.name,
+ root: s.root,
+ extensions: s.extensions,
+ state: s.state,
+ ...(s.error !== undefined ? { error: s.error } : {}),
+ };
+ return info;
+ });
+ log.info("conversations: lsp status read", {
+ conversationId,
+ cwd,
+ serverCount: servers.length,
+ });
+ const body: LspStatusResponse = { conversationId, cwd, servers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: lsp status failure", { err });
+ return c.json({ error: "Failed to read LSP status" }, 500);
+ }
+ });
+
return app;
}
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 5274033..6c988a5 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -4,6 +4,7 @@ import {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ lspServiceHandle,
sessionOrchestratorHandle,
throughputStoreHandle,
} from "./seam.js";
@@ -14,13 +15,21 @@ export const manifest: Manifest = {
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
- dependsOn: ["conversation-store", "credential-store", "session-orchestrator", "throughput-store"],
+ dependsOn: [
+ "conversation-store",
+ "credential-store",
+ "lsp",
+ "session-orchestrator",
+ "throughput-store",
+ ],
capabilities: { network: true },
contributes: {
routes: [
"/chat",
"/chat/warm",
"/conversations/:id",
+ "/conversations/:id/cwd",
+ "/conversations/:id/lsp",
"/health",
"/models",
"/metrics/throughput",
@@ -45,6 +54,7 @@ export function createTransportHttpExtension(): Extension & {
const credentialStore = host.getService(credentialStoreHandle);
const throughputStore = host.getService(throughputStoreHandle);
const warmService = host.getService(cacheWarmHandle);
+ const lspService = host.getService(lspServiceHandle);
const logger = host.logger;
const app = createApp({
@@ -53,6 +63,7 @@ export function createTransportHttpExtension(): Extension & {
credentialStore,
throughputStore,
warmService,
+ lspService,
logger,
});
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index 64929fc..718aaef 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -19,6 +19,7 @@ export {
export type {
ConversationStore,
CredentialStore,
+ LspService,
SessionOrchestrator,
WarmService,
} from "./seam.js";
@@ -26,5 +27,6 @@ export {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ lspServiceHandle,
sessionOrchestratorHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index e89370e..43c9d4d 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -2,6 +2,8 @@ export type { ConversationStore } from "@dispatch/conversation-store";
export { conversationStoreHandle } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
+export type { LspServerStatus, LspService } from "@dispatch/lsp";
+export { lspServiceHandle } from "@dispatch/lsp";
export type { SessionOrchestrator, WarmService } from "@dispatch/session-orchestrator";
export { cacheWarmHandle, sessionOrchestratorHandle } from "@dispatch/session-orchestrator";
export type { ThroughputStore } from "@dispatch/throughput-store";
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index 5b4c6aa..b43469f 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel";
import { createApp } from "./app.js";
import { createTransportHttpExtension } from "./index.js";
-import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js";
+import type {
+ ConversationStore,
+ CredentialStore,
+ LspService,
+ SessionOrchestrator,
+} from "./seam.js";
function fakeLogger(): Logger {
return {
@@ -41,6 +46,10 @@ function fakeConversationStore(): ConversationStore {
async loadMetrics() {
return [];
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
}
@@ -61,6 +70,14 @@ function fakeCredentialStore(): CredentialStore {
};
}
+function fakeLspService(): LspService {
+ return {
+ async status() {
+ return [];
+ },
+ };
+}
+
function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess {
return {
get<T>(key: string): T | undefined {
@@ -76,6 +93,7 @@ const SERVICES = new Map<string, unknown>([
["conversation-store/store", fakeConversationStore()],
["session-orchestrator/orchestrator", fakeOrchestrator()],
["credential-store/registry", fakeCredentialStore()],
+ ["lsp", fakeLspService()],
]);
function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI {
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index fc29c8c..fd3f3ea 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -6,6 +6,7 @@
{ "path": "../conversation-store" },
{ "path": "../credential-store" },
{ "path": "../kernel" },
+ { "path": "../lsp" },
{ "path": "../session-orchestrator" },
{ "path": "../throughput-store" },
{ "path": "../transport-contract" }