diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
| commit | e7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch) | |
| tree | 447095fd60b43980358d1565506f3ae2430e5f29 /packages/transport-http | |
| parent | 35937cee7f838e414eb8147c67205e01d85a4da0 (diff) | |
| download | dispatch-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.json | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 180 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 90 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 13 | ||||
| -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/src/server.bun.test.ts | 20 | ||||
| -rw-r--r-- | packages/transport-http/tsconfig.json | 1 |
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" } |
