summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
commit7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch)
tree247d03408ecccd633290ea56b1b08811ebe460ec /packages/transport-http/src
parent4283d1f8a0bc3953e65962a2364c903d0015f047 (diff)
downloaddispatch-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.ts152
-rw-r--r--packages/transport-http/src/app.ts34
-rw-r--r--packages/transport-http/src/extension.ts9
-rw-r--r--packages/transport-http/src/index.ts4
-rw-r--r--packages/transport-http/src/logic.test.ts57
-rw-r--r--packages/transport-http/src/logic.ts20
-rw-r--r--packages/transport-http/src/seam.ts2
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";