summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/app.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 21:07:53 +0900
committerAdam Malczewski <[email protected]>2026-06-23 21:07:53 +0900
commit4158e699e3c8ff556684fe2fc7a39ffab040623e (patch)
tree1ca7b3069f6b1cfcb119c4b1fae614baf20f6e69 /packages/transport-http/src/app.test.ts
parent961f564f6de6c6c63970b0aeaa48f730cc028d92 (diff)
downloaddispatch-4158e699e3c8ff556684fe2fc7a39ffab040623e.tar.gz
dispatch-4158e699e3c8ff556684fe2fc7a39ffab040623e.zip
fix(lsp): gate LSP endpoint on persisted cwd; accept workspaceId on PUT cwd
GET /conversations/:id/lsp was calling getEffectiveCwd directly, which falls through to serverDefaultCwd (process.cwd()) when no conversation cwd is set. Now gates on getCwd first: returns {cwd:null, servers:[]} when no cwd persisted; only resolves via getEffectiveCwd + calls lspService.status when a persisted cwd exists. PUT /conversations/:id/cwd now accepts optional workspaceId — validates with isValidWorkspaceSlug, then ensureWorkspace → setWorkspaceId → setCwd (assigns the workspace before persisting cwd, so getEffectiveCwd resolves relative cwds against the workspace defaultCwd, not the server default). transport-contract 0.16.0→0.17.0 (additive SetCwdRequest.workspaceId; LspStatusResponse.cwd comment updated). 1332 vitest pass.
Diffstat (limited to 'packages/transport-http/src/app.test.ts')
-rw-r--r--packages/transport-http/src/app.test.ts278
1 files changed, 277 insertions, 1 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 1390199..0b840db 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -125,6 +125,9 @@ function createFakeConversationStore(
async setCwd(conversationId, cwd) {
cwdStore.set(conversationId, cwd);
},
+ async clearCwd(conversationId) {
+ cwdStore.delete(conversationId);
+ },
async getReasoningEffort(conversationId) {
return reasoningEffortStore.get(conversationId) ?? null;
},
@@ -177,6 +180,35 @@ function createFakeConversationStore(
};
}
+/**
+ * Wraps a ConversationStore to record the ORDER of mutating calls
+ * (ensureWorkspace, setWorkspaceId, setCwd) as a labeled list — so tests can
+ * assert that workspace assignment happens BEFORE setCwd.
+ */
+function createCallTrackingStore(
+ base: ConversationStore,
+): ConversationStore & { readonly calls: readonly string[] } {
+ const calls: string[] = [];
+ return {
+ ...base,
+ get calls() {
+ return calls;
+ },
+ async ensureWorkspace(id, opts) {
+ calls.push(`ensureWorkspace:${id}`);
+ return base.ensureWorkspace(id, opts);
+ },
+ async setWorkspaceId(conversationId, workspaceId) {
+ calls.push(`setWorkspaceId:${workspaceId}`);
+ await base.setWorkspaceId(conversationId, workspaceId);
+ },
+ async setCwd(conversationId, cwd) {
+ calls.push(`setCwd:${cwd}`);
+ await base.setCwd(conversationId, cwd);
+ },
+ };
+}
+
function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
return {
startTurn() {
@@ -327,6 +359,28 @@ function createFakeLspService(
};
}
+function createCapturingLspService(
+ 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 & { readonly statusCalls: readonly string[] } {
+ const calls: string[] = [];
+ return {
+ get statusCalls() {
+ return calls;
+ },
+ async status(cwd) {
+ calls.push(cwd);
+ return statuses;
+ },
+ };
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -895,6 +949,7 @@ describe("GET /conversations/:id", () => {
return null;
},
async setCwd() {},
+ async clearCwd() {},
async getReasoningEffort() {
return null;
},
@@ -1013,6 +1068,7 @@ describe("GET /conversations/:id", () => {
return null;
},
async setCwd() {},
+ async clearCwd() {},
async getReasoningEffort() {
return null;
},
@@ -1200,6 +1256,7 @@ describe("GET /conversations/:id/metrics", () => {
return null;
},
async setCwd() {},
+ async clearCwd() {},
async getReasoningEffort() {
return null;
},
@@ -1888,6 +1945,78 @@ describe("PUT then GET /conversations/:id/cwd", () => {
});
});
+describe("DELETE /conversations/:id/cwd", () => {
+ it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", 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 deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+
+ 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).toBeNull();
+ });
+
+ it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+ });
+
+ it("does NOT affect other conversations' cwds (isolation)", async () => {
+ const cwdStore = new Map<string, string>([
+ ["conv1", "/home/user/project"],
+ ["conv2", "/other/path"],
+ ]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1Res = await app.request("/conversations/conv1/cwd");
+ expect(get1Res.status).toBe(200);
+ const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get1Body.cwd).toBeNull();
+
+ const get2Res = await app.request("/conversations/conv2/cwd");
+ expect(get2Res.status).toBe(200);
+ const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get2Body.cwd).toBe("/other/path");
+ });
+});
+
describe("PUT /conversations/:id/cwd", () => {
it("with missing cwd returns 400", async () => {
const app = createApp({
@@ -1920,6 +2049,80 @@ describe("PUT /conversations/:id/cwd", () => {
});
expect(res.status).toBe(400);
});
+
+ it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ 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: "/home/user/project", workspaceId: "my-team" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string };
+ expect(body.cwd).toBe("/home/user/project");
+ // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
+ expect(store.calls).toEqual([
+ "ensureWorkspace:my-team",
+ "setWorkspaceId:my-team",
+ "setCwd:/home/user/project",
+ ]);
+ });
+
+ it("PUT cwd without workspaceId: only setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ 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: "/home/user/project" }),
+ });
+ expect(res.status).toBe(200);
+ // ensureWorkspace and setWorkspaceId NOT called.
+ expect(store.calls).toEqual(["setCwd:/home/user/project"]);
+ });
+
+ it("PUT cwd with invalid workspaceId: returns 400", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ // Uppercase is not a valid workspace slug.
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("Invalid workspaceId");
+ // No mutating calls should have been made.
+ expect(store.calls).toEqual([]);
+
+ // Empty string is also invalid.
+ const res2 = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
+ });
+ expect(res2.status).toBe(400);
+ });
});
describe("GET /conversations/:id/lsp", () => {
@@ -1994,6 +2197,79 @@ describe("GET /conversations/:id/lsp", () => {
expect(body.servers[1]?.state).toBe("error");
expect(body.servers[1]?.error).toBe("spawn failed");
});
+
+ it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
+ const cwdStore = new Map<string, string>(); // no persisted cwd
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/irrelevant",
+ extensions: [".ts"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ 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([]);
+ expect(lsp.statusCalls).toEqual([]); // status NOT called
+ });
+
+ it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
+ // Persisted (relative) cwd differs from the resolved effective cwd.
+ const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ // Override getEffectiveCwd to return the resolved (absolute) value.
+ const resolvedStore: ConversationStore = {
+ ...store,
+ async getEffectiveCwd() {
+ return "/workspace/subdir";
+ },
+ };
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/workspace/subdir",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: resolvedStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ 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 }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
+ expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
+ expect(body.servers).toHaveLength(1);
+ expect(body.servers[0]?.id).toBe("typescript");
+ });
});
describe("POST /chat reasoningEffort", () => {
@@ -2964,7 +3240,7 @@ it("GET /conversations/:id/lsp uses effective cwd", async () => {
const res = await app.request("/conversations/conv1/lsp");
expect(res.status).toBe(200);
expect(effectiveCwdCalled).toBe(true);
- expect(getCwdCalled).toBe(false);
+ expect(getCwdCalled).toBe(true); // gated on persisted cwd first
expect(lspCwd).toBe("/effective");
const body = (await res.json()) as {
conversationId: string;