summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/app.test.ts
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/src/app.test.ts
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/src/app.test.ts')
-rw-r--r--packages/transport-http/src/app.test.ts180
1 files changed, 180 insertions, 0 deletions
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");
+ });
+});