diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
| commit | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch) | |
| tree | 247d03408ecccd633290ea56b1b08811ebe460ec /packages/transport-http/src | |
| parent | 4283d1f8a0bc3953e65962a2364c903d0015f047 (diff) | |
| download | dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip | |
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 152 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 34 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 9 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 57 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 20 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 |
7 files changed, 255 insertions, 23 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 9763605..38089e4 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,7 +1,7 @@ import type { AgentEvent } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; -import type { SessionOrchestrator } from "./seam.js"; +import type { CredentialStore, SessionOrchestrator } from "./seam.js"; function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { return { @@ -13,6 +13,22 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { }; } +function createCapturingOrchestrator(): SessionOrchestrator & { + received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; +} { + const state: { + received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; + } = { received: undefined }; + return { + get received() { + return state.received; + }, + async handleMessage(input) { + state.received = input; + }, + }; +} + function createThrowingOrchestrator(error: Error): SessionOrchestrator { return { async handleMessage() { @@ -21,9 +37,34 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator { }; } +function createFakeCredentialStore(models: string[]): CredentialStore { + return { + resolve() { + return undefined; + }, + async listCatalog() { + return models; + }, + }; +} + +function createThrowingCredentialStore(error: Error): CredentialStore { + return { + resolve() { + return undefined; + }, + async listCatalog() { + throw error; + }, + }; +} + describe("GET /health", () => { it("returns ok", async () => { - const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/health"); expect(res.status).toBe(200); const body = await res.json(); @@ -31,9 +72,47 @@ describe("GET /health", () => { }); }); +describe("GET /models", () => { + it("returns model catalog", async () => { + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]); + }); + + it("returns empty array when no models", async () => { + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); + + it("returns 502 when listCatalog throws", async () => { + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createThrowingCredentialStore(new Error("db down")), + }); + const res = await app.request("/models"); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to retrieve model catalog"); + }); +}); + describe("POST /chat", () => { it("returns 400 for invalid JSON", async () => { - const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -43,7 +122,10 @@ describe("POST /chat", () => { }); it("returns 400 for missing message", async () => { - const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -55,7 +137,10 @@ describe("POST /chat", () => { }); it("returns 400 for empty message", async () => { - const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -71,7 +156,10 @@ describe("POST /chat", () => { { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" }, { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]; - const app = createApp({ orchestrator: createFakeOrchestrator(events) }); + const app = createApp({ + orchestrator: createFakeOrchestrator(events), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/chat", { method: "POST", @@ -100,6 +188,7 @@ describe("POST /chat", () => { orchestrator: createFakeOrchestrator([ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, ]), + credentialStore: createFakeCredentialStore([]), generateId: () => "generated-uuid", }); @@ -116,6 +205,7 @@ describe("POST /chat", () => { it("emits error event when orchestrator throws", async () => { const app = createApp({ orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), + credentialStore: createFakeCredentialStore([]), }); const res = await app.request("/chat", { @@ -139,7 +229,10 @@ describe("POST /chat", () => { }); it("handles empty event list", async () => { - const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const app = createApp({ + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); const res = await app.request("/chat", { method: "POST", @@ -151,4 +244,49 @@ describe("POST /chat", () => { const text = await res.text(); expect(text).toBe(""); }); + + it("forwards modelName and cwd to orchestrator", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + model: "opencode/m1", + cwd: "/tmp", + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.text).toBe("hi"); + expect(cap.received?.modelName).toBe("opencode/m1"); + expect(cap.received?.cwd).toBe("/tmp"); + }); + + it("omits modelName and cwd when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.modelName).toBeUndefined(); + expect(cap.received?.cwd).toBeUndefined(); + }); }); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index e1e954c..d5492ce 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,10 +1,12 @@ import type { AgentEvent } from "@dispatch/kernel"; +import type { ModelsResponse } from "@dispatch/transport-contract"; import { Hono } from "hono"; import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -import type { SessionOrchestrator } from "./seam.js"; +import type { CredentialStore, SessionOrchestrator } from "./seam.js"; export interface CreateServerOptions { readonly orchestrator: SessionOrchestrator; + readonly credentialStore: CredentialStore; readonly generateId?: () => string; } @@ -14,6 +16,16 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/health", (c) => c.json({ ok: true })); + app.get("/models", async (c) => { + try { + const models = await opts.credentialStore.listCatalog(); + const body: ModelsResponse = { models }; + return c.json(body, 200); + } catch { + return c.json({ error: "Failed to retrieve model catalog" }, 502); + } + }); + app.post("/chat", async (c) => { let body: unknown; try { @@ -27,21 +39,25 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: result.error }, 400); } - const { conversationId, message } = result; + const { conversationId, message, model, cwd } = result; const events: AgentEvent[] = []; let resolveStream: () => void; const streamReady = new Promise<void>((resolve) => { resolveStream = resolve; }); + const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = { + conversationId, + text: message, + onEvent: (event) => { + events.push(event); + }, + ...(model !== undefined ? { modelName: model } : {}), + ...(cwd !== undefined ? { cwd } : {}), + }; + const orchestratorPromise = opts.orchestrator - .handleMessage({ - conversationId, - text: message, - onEvent: (event) => { - events.push(event); - }, - }) + .handleMessage(orchestratorInput) .then(() => { resolveStream(); }) diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 3ed98a8..e9613c5 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,7 +1,7 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import type { Hono } from "hono"; import { createApp } from "./app.js"; -import { sessionOrchestratorHandle } from "./seam.js"; +import { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; export const manifest: Manifest = { id: "transport-http", @@ -9,9 +9,9 @@ export const manifest: Manifest = { version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", - dependsOn: ["session-orchestrator"], + dependsOn: ["credential-store", "session-orchestrator"], capabilities: { network: true }, - contributes: { routes: ["/chat", "/health"] }, + contributes: { routes: ["/chat", "/health", "/models"] }, activation: "eager", }; @@ -21,7 +21,8 @@ export interface CreateServerOptions { export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { const orchestrator = host.getService(sessionOrchestratorHandle); - return createApp({ orchestrator }); + const credentialStore = host.getService(credentialStoreHandle); + return createApp({ orchestrator, credentialStore }); } export const extension: Extension = { diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index 39a80ac..d91f9ad 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -3,5 +3,5 @@ export { createApp } from "./app.js"; export { createServer, extension, manifest } from "./extension.js"; export type { ChatCommand, ParseError, ParseResult } from "./logic.js"; export { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; -export type { SessionOrchestrator } from "./seam.js"; -export { sessionOrchestratorHandle } from "./seam.js"; +export type { CredentialStore, SessionOrchestrator } from "./seam.js"; +export { credentialStoreHandle, sessionOrchestratorHandle } from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index a0f3fe6..141549f 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -73,6 +73,63 @@ describe("parseChatBody", () => { expect(result.message).toBe("hello world"); } }); + + it("extracts model when present", () => { + const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("opencode/m1"); + } + }); + + it("extracts cwd when present", () => { + const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBe("/tmp"); + } + }); + + it("extracts both model and cwd", () => { + const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("openai/gpt-4"); + expect(result.cwd).toBe("/home"); + } + }); + + it("omits model when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBeUndefined(); + } + }); + + it("omits cwd when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBeUndefined(); + } + }); + + it("returns error when model is not a string", () => { + const result = parseChatBody({ message: "hi", model: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("model"); + } + }); + + it("returns error when cwd is not a string", () => { + const result = parseChatBody({ message: "hi", cwd: true }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("cwd"); + } + }); }); describe("serializeEventLine", () => { diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index a1a1638..11133a5 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -3,6 +3,8 @@ import type { AgentEvent } from "@dispatch/kernel"; export interface ChatCommand { readonly conversationId: string; readonly message: string; + readonly model?: string; + readonly cwd?: string; } export interface ParseError { @@ -28,7 +30,23 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes ? obj.conversationId : generateId(); - return { conversationId, message: message.trim() }; + const result: ChatCommand = { conversationId, message: message.trim() }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + (result as { model?: string }).model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + (result as { cwd?: string }).cwd = obj.cwd; + } + + return result; } export function isParseError(result: ParseResult): result is ParseError { diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index c6ce04f..297fb22 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -1,2 +1,4 @@ +export type { CredentialStore } from "@dispatch/credential-store"; +export { credentialStoreHandle } from "@dispatch/credential-store"; export type { SessionOrchestrator } from "@dispatch/session-orchestrator"; export { sessionOrchestratorHandle } from "@dispatch/session-orchestrator"; |
