summaryrefslogtreecommitdiffhomepage
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
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
-rw-r--r--packages/credential-store/package.json11
-rw-r--r--packages/credential-store/src/extension.ts29
-rw-r--r--packages/credential-store/src/index.ts9
-rw-r--r--packages/credential-store/src/registry.test.ts81
-rw-r--r--packages/credential-store/src/registry.ts80
-rw-r--r--packages/credential-store/src/service.ts4
-rw-r--r--packages/credential-store/tsconfig.json6
-rw-r--r--packages/host-bin/package.json1
-rw-r--r--packages/host-bin/src/main.ts5
-rw-r--r--packages/host-bin/tsconfig.json1
-rw-r--r--packages/provider-openai-compat/src/index.ts1
-rw-r--r--packages/provider-openai-compat/src/listModels.test.ts101
-rw-r--r--packages/provider-openai-compat/src/listModels.ts67
-rw-r--r--packages/provider-openai-compat/src/provider.ts37
-rw-r--r--packages/session-orchestrator/package.json3
-rw-r--r--packages/session-orchestrator/src/extension.ts10
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts153
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts42
-rw-r--r--packages/session-orchestrator/tsconfig.json6
-rw-r--r--packages/tool-read-file/src/read-file.test.ts68
-rw-r--r--packages/tool-read-file/src/read-file.ts21
-rw-r--r--packages/transport-http/package.json2
-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
-rw-r--r--packages/transport-http/tsconfig.json7
-rw-r--r--tsconfig.json1
31 files changed, 977 insertions, 47 deletions
diff --git a/packages/credential-store/package.json b/packages/credential-store/package.json
new file mode 100644
index 0000000..19f6b07
--- /dev/null
+++ b/packages/credential-store/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/credential-store",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/credential-store/src/extension.ts b/packages/credential-store/src/extension.ts
new file mode 100644
index 0000000..8f2a826
--- /dev/null
+++ b/packages/credential-store/src/extension.ts
@@ -0,0 +1,29 @@
+import type { Extension, Manifest } from "@dispatch/kernel";
+import type { Credential } from "./registry.js";
+import { createCredentialStore } from "./registry.js";
+import { credentialStoreHandle } from "./service.js";
+
+export const manifest: Manifest = {
+ id: "credential-store",
+ name: "Credential Store",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ contributes: { services: ["credential-store/registry"] },
+};
+
+export function createCredentialStoreExtension(deps: {
+ credentials: readonly Credential[];
+}): Extension {
+ return {
+ manifest,
+ activate(host) {
+ const store = createCredentialStore({
+ credentials: deps.credentials,
+ getProvider: (id) => host.getProviders().get(id),
+ });
+ host.provideService(credentialStoreHandle, store);
+ },
+ };
+}
diff --git a/packages/credential-store/src/index.ts b/packages/credential-store/src/index.ts
new file mode 100644
index 0000000..749abed
--- /dev/null
+++ b/packages/credential-store/src/index.ts
@@ -0,0 +1,9 @@
+export { createCredentialStoreExtension, manifest } from "./extension.js";
+export type {
+ Credential,
+ CredentialStore,
+ CredentialStoreDeps,
+ ResolvedModel,
+} from "./registry.js";
+export { createCredentialStore } from "./registry.js";
+export { credentialStoreHandle } from "./service.js";
diff --git a/packages/credential-store/src/registry.test.ts b/packages/credential-store/src/registry.test.ts
new file mode 100644
index 0000000..e67b5d4
--- /dev/null
+++ b/packages/credential-store/src/registry.test.ts
@@ -0,0 +1,81 @@
+import type { ProviderContract } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import { createCredentialStore } from "./registry.js";
+
+function fakeProvider(
+ id: string,
+ listModels?: () => Promise<readonly { id: string }[]>,
+): ProviderContract {
+ return {
+ id,
+ stream: async function* () {},
+ ...(listModels ? { listModels } : {}),
+ };
+}
+
+describe("createCredentialStore", () => {
+ const credentials = [{ name: "opencode", providerId: "openai-compat" }];
+ const providers = new Map<string, ProviderContract>();
+
+ const store = createCredentialStore({
+ credentials,
+ getProvider: (id) => providers.get(id),
+ });
+
+ describe("resolve", () => {
+ it("resolves a simple model name", () => {
+ providers.set("openai-compat", fakeProvider("openai-compat"));
+ const result = store.resolve("opencode/deepseek-v4-flash");
+ expect(result).toEqual({ providerId: "openai-compat", model: "deepseek-v4-flash" });
+ });
+
+ it("resolves a model name with slashes", () => {
+ const result = store.resolve("opencode/a/b");
+ expect(result).toEqual({ providerId: "openai-compat", model: "a/b" });
+ });
+
+ it("returns undefined for unknown credential name", () => {
+ const result = store.resolve("unknown/x");
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined for no slash", () => {
+ const result = store.resolve("noslash");
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined for empty model segment", () => {
+ const result = store.resolve("opencode/");
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe("listCatalog", () => {
+ it("lists models from providers with listModels", async () => {
+ providers.set(
+ "openai-compat",
+ fakeProvider("openai-compat", async () => [{ id: "m1" }, { id: "m2" }]),
+ );
+
+ const catalog = await store.listCatalog();
+ expect(catalog).toEqual(["opencode/m1", "opencode/m2"]);
+ });
+
+ it("skips credentials whose provider has no listModels", async () => {
+ providers.set("openai-compat", fakeProvider("openai-compat"));
+
+ const catalog = await store.listCatalog();
+ expect(catalog).toEqual([]);
+ });
+
+ it("skips credentials whose provider is missing", async () => {
+ const emptyStore = createCredentialStore({
+ credentials: [{ name: "missing", providerId: "nonexistent" }],
+ getProvider: () => undefined,
+ });
+
+ const catalog = await emptyStore.listCatalog();
+ expect(catalog).toEqual([]);
+ });
+ });
+});
diff --git a/packages/credential-store/src/registry.ts b/packages/credential-store/src/registry.ts
new file mode 100644
index 0000000..c1ba771
--- /dev/null
+++ b/packages/credential-store/src/registry.ts
@@ -0,0 +1,80 @@
+import type { ProviderContract } from "@dispatch/kernel";
+
+export interface Credential {
+ readonly name: string;
+ readonly providerId: string;
+}
+
+export interface ResolvedModel {
+ readonly providerId: string;
+ readonly model: string;
+}
+
+export interface CredentialStore {
+ /**
+ * Split a model name on the FIRST "/": name=before, model=after (model may contain "/").
+ * Look up the credential by name; return its providerId + the model id, or undefined if
+ * the name is unknown or there is no model segment.
+ */
+ resolve(modelName: string): ResolvedModel | undefined;
+
+ /**
+ * The model catalog: for each credential, look up its provider and call listModels(),
+ * emitting `${credential.name}/${modelInfo.id}`. Skip credentials whose provider is
+ * missing or has no listModels.
+ */
+ listCatalog(): Promise<readonly string[]>;
+}
+
+export interface CredentialStoreDeps {
+ readonly credentials: readonly Credential[];
+ readonly getProvider: (id: string) => ProviderContract | undefined;
+}
+
+export function createCredentialStore(deps: CredentialStoreDeps): CredentialStore {
+ const credentialMap = new Map<string, string>();
+ for (const credential of deps.credentials) {
+ credentialMap.set(credential.name, credential.providerId);
+ }
+
+ return {
+ resolve(modelName: string): ResolvedModel | undefined {
+ const slashIndex = modelName.indexOf("/");
+ if (slashIndex === -1) {
+ return undefined;
+ }
+
+ const credentialName = modelName.slice(0, slashIndex);
+ const model = modelName.slice(slashIndex + 1);
+
+ if (!model) {
+ return undefined;
+ }
+
+ const providerId = credentialMap.get(credentialName);
+ if (!providerId) {
+ return undefined;
+ }
+
+ return { providerId, model };
+ },
+
+ async listCatalog(): Promise<readonly string[]> {
+ const results: string[] = [];
+
+ for (const credential of deps.credentials) {
+ const provider = deps.getProvider(credential.providerId);
+ if (!provider?.listModels) {
+ continue;
+ }
+
+ const models = await provider.listModels();
+ for (const model of models) {
+ results.push(`${credential.name}/${model.id}`);
+ }
+ }
+
+ return results;
+ },
+ };
+}
diff --git a/packages/credential-store/src/service.ts b/packages/credential-store/src/service.ts
new file mode 100644
index 0000000..9f06064
--- /dev/null
+++ b/packages/credential-store/src/service.ts
@@ -0,0 +1,4 @@
+import { defineService } from "@dispatch/kernel";
+import type { CredentialStore } from "./registry.js";
+
+export const credentialStoreHandle = defineService<CredentialStore>("credential-store/registry");
diff --git a/packages/credential-store/tsconfig.json b/packages/credential-store/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/credential-store/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index a3e24c8..4ce63a9 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -8,6 +8,7 @@
"@dispatch/storage-sqlite": "workspace:*",
"@dispatch/conversation-store": "workspace:*",
"@dispatch/auth-apikey": "workspace:*",
+ "@dispatch/credential-store": "workspace:*",
"@dispatch/provider-openai-compat": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/transport-http": "workspace:*",
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 4e4b3e4..d766e46 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { extension as authApikeyExt } from "@dispatch/auth-apikey";
import { extension as conversationStoreExt } from "@dispatch/conversation-store";
+import { createCredentialStoreExtension } from "@dispatch/credential-store";
import { createJournalSink } from "@dispatch/journal-sink";
import {
type ConfigAccess,
@@ -54,6 +55,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [
authApikeyExt,
providerOpenaiCompatExt,
toolReadFileExt,
+ // MVP single hardcoded credential; future work makes it config/TOML-driven.
+ createCredentialStoreExtension({
+ credentials: [{ name: "opencode", providerId: "openai-compat" }],
+ }),
sessionOrchestratorExt,
transportHttpExt,
];
diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json
index a811c47..70ff95c 100644
--- a/packages/host-bin/tsconfig.json
+++ b/packages/host-bin/tsconfig.json
@@ -7,6 +7,7 @@
{ "path": "../storage-sqlite" },
{ "path": "../conversation-store" },
{ "path": "../auth-apikey" },
+ { "path": "../credential-store" },
{ "path": "../provider-openai-compat" },
{ "path": "../session-orchestrator" },
{ "path": "../transport-http" }
diff --git a/packages/provider-openai-compat/src/index.ts b/packages/provider-openai-compat/src/index.ts
index f35f2e9..3498a9d 100644
--- a/packages/provider-openai-compat/src/index.ts
+++ b/packages/provider-openai-compat/src/index.ts
@@ -3,6 +3,7 @@ export { convertMessages } from "./convert-messages.js";
export type { OpenAITool } from "./convert-tools.js";
export { convertTools } from "./convert-tools.js";
export { activate, extension, manifest } from "./extension.js";
+export { parseModelList } from "./listModels.js";
export { parseSSELines } from "./parse-sse.js";
export type { CreateOpenAICompatProviderOpts } from "./provider.js";
export { createOpenAICompatProvider } from "./provider.js";
diff --git a/packages/provider-openai-compat/src/listModels.test.ts b/packages/provider-openai-compat/src/listModels.test.ts
new file mode 100644
index 0000000..97badaa
--- /dev/null
+++ b/packages/provider-openai-compat/src/listModels.test.ts
@@ -0,0 +1,101 @@
+import type { ApiKeyCredentials, ModelInfo, ProviderContract } from "@dispatch/kernel";
+import type { FetchLike } from "@dispatch/trace-replay";
+import { describe, expect, it, vi } from "vitest";
+import { parseModelList } from "./listModels.js";
+import { createOpenAICompatProvider } from "./provider.js";
+
+function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract {
+ const creds: ApiKeyCredentials = {
+ type: "api-key",
+ apiKey,
+ baseURL: "https://api.example.com/v1",
+ };
+ return createOpenAICompatProvider({
+ credentials: creds,
+ model: "test-model",
+ fetchFn,
+ });
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("listModels — pure mapping (parseModelList)", () => {
+ it("maps OpenAI model entries to ModelInfo", () => {
+ const result = parseModelList([{ id: "a" }, { id: "b" }]);
+ expect(result).toEqual([{ id: "a" }, { id: "b" }]);
+ });
+
+ it("returns empty array for empty input", () => {
+ const result = parseModelList([]);
+ expect(result).toEqual([]);
+ });
+});
+
+describe("listModels — provider contract", () => {
+ it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => {
+ const fetchFn = vi.fn(
+ () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType<FetchLike>,
+ );
+ const provider = makeProvider(fetchFn);
+ const listModels = provider.listModels;
+ if (!listModels) throw new Error("listModels not defined");
+
+ const models = await listModels();
+
+ expect(fetchFn).toHaveBeenCalledOnce();
+ const callArgs = fetchFn.mock.calls[0];
+ if (!callArgs) throw new Error("no call args");
+ const [url, init] = callArgs as unknown as [string, RequestInit];
+ expect(url).toBe("https://api.example.com/v1/models");
+ expect(init.method).toBe("GET");
+ expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" });
+
+ expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]);
+ });
+
+ it("throws on non-OK HTTP status with a clear message", async () => {
+ const fetchFn = vi.fn(
+ () =>
+ new Response("Unauthorized", {
+ status: 401,
+ headers: { "Content-Type": "text/plain" },
+ }) as unknown as ReturnType<FetchLike>,
+ );
+ const provider = makeProvider(fetchFn);
+ const listModels = provider.listModels;
+ if (!listModels) throw new Error("listModels not defined");
+
+ await expect(listModels()).rejects.toThrow(
+ "listModels[openai-compat]: HTTP 401 — Unauthorized",
+ );
+ });
+
+ it("throws on network error with a clear message", async () => {
+ const fetchFn = vi.fn(() => {
+ throw new Error("connection refused");
+ }) as unknown as FetchLike;
+ const provider = makeProvider(fetchFn);
+ const listModels = provider.listModels;
+ if (!listModels) throw new Error("listModels not defined");
+
+ await expect(listModels()).rejects.toThrow(
+ "listModels[openai-compat]: network error — connection refused",
+ );
+ });
+
+ it("throws when response shape is missing data array", async () => {
+ const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType<FetchLike>);
+ const provider = makeProvider(fetchFn);
+ const listModels = provider.listModels;
+ if (!listModels) throw new Error("listModels not defined");
+
+ await expect(listModels()).rejects.toThrow(
+ 'listModels[openai-compat]: unexpected response shape — missing "data" array',
+ );
+ });
+});
diff --git a/packages/provider-openai-compat/src/listModels.ts b/packages/provider-openai-compat/src/listModels.ts
new file mode 100644
index 0000000..d253ebe
--- /dev/null
+++ b/packages/provider-openai-compat/src/listModels.ts
@@ -0,0 +1,67 @@
+import type { ModelInfo } from "@dispatch/kernel";
+import type { FetchLike } from "@dispatch/trace-replay";
+
+/**
+ * opencode-go specifics (model-list URL, usage/cache-token mapping, headers)
+ * live in this generic `provider-openai-compat` for now. When a SECOND
+ * OpenAI-compatible backend lands, split this into a generic OpenAI-stream
+ * capability exposed as a typed SERVICE handle and a `provider-opencode-go`
+ * extension that `dependsOn` it and layers the specifics — coupling via the
+ * typed handle only (isolation-over-DRY: no cross-extension code import).
+ */
+
+interface OpenAIModelEntry {
+ readonly id: string;
+}
+
+interface OpenAIModelListResponse {
+ readonly data: readonly OpenAIModelEntry[];
+}
+
+/**
+ * Pure mapping: raw OpenAI-compatible model list → ModelInfo[].
+ * Extracted for direct unit testing with no I/O.
+ */
+export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] {
+ return data.map((entry) => ({ id: entry.id }));
+}
+
+export interface ListModelsConfig {
+ readonly baseURL: string;
+ readonly apiKey: string;
+ readonly fetchFn?: FetchLike;
+ readonly providerId: string;
+}
+
+export async function listModels(config: ListModelsConfig): Promise<readonly ModelInfo[]> {
+ const effectiveFetch: FetchLike = config.fetchFn ?? fetch;
+ const url = `${config.baseURL}/models`;
+
+ let response: Response;
+ try {
+ response = await effectiveFetch(url, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`,
+ },
+ });
+ } catch (err) {
+ throw new Error(
+ `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "unknown");
+ throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`);
+ }
+
+ const body = (await response.json()) as OpenAIModelListResponse;
+ if (!Array.isArray(body.data)) {
+ throw new Error(
+ `listModels[${config.providerId}]: unexpected response shape — missing "data" array`,
+ );
+ }
+
+ return parseModelList(body.data);
+}
diff --git a/packages/provider-openai-compat/src/provider.ts b/packages/provider-openai-compat/src/provider.ts
index 8f0ddda..19c29a1 100644
--- a/packages/provider-openai-compat/src/provider.ts
+++ b/packages/provider-openai-compat/src/provider.ts
@@ -1,22 +1,44 @@
import type {
ApiKeyCredentials,
ChatMessage,
+ ModelInfo,
ProviderContract,
ProviderStreamOptions,
ToolContract,
} from "@dispatch/kernel";
+import type { FetchLike } from "@dispatch/trace-replay";
+import { listModels as fetchModels } from "./listModels.js";
import { streamChat } from "./stream.js";
+/**
+ * opencode-go specifics (model-list URL, usage/cache-token mapping, headers)
+ * live in this generic `provider-openai-compat` for now. When a SECOND
+ * OpenAI-compatible backend lands, split this into a generic OpenAI-stream
+ * capability exposed as a typed SERVICE handle and a `provider-opencode-go`
+ * extension that `dependsOn` it and layers the specifics — coupling via the
+ * typed handle only (isolation-over-DRY: no cross-extension code import).
+ */
+
export interface CreateOpenAICompatProviderOpts {
readonly credentials: ApiKeyCredentials;
readonly model: string;
+ /**
+ * Internal injectable fetch — used by tests and replay mode.
+ * When absent, falls back to globalThis.fetch (production default).
+ */
+ readonly fetchFn?: FetchLike;
}
export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract {
- const config = {
- baseURL: opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1",
- apiKey: opts.credentials.apiKey,
+ const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1";
+ const apiKey = opts.credentials.apiKey;
+ const fetchFn = opts.fetchFn;
+
+ const streamConfig = {
+ baseURL,
+ apiKey,
model: opts.model,
+ ...(fetchFn !== undefined ? { fetchFn } : {}),
};
return {
@@ -25,6 +47,13 @@ export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts)
messages: readonly ChatMessage[],
tools: readonly ToolContract[],
streamOpts?: ProviderStreamOptions,
- ) => streamChat(config, messages, tools, streamOpts),
+ ) => streamChat(streamConfig, messages, tools, streamOpts),
+ listModels: (): Promise<readonly ModelInfo[]> =>
+ fetchModels({
+ baseURL,
+ apiKey,
+ providerId: "openai-compat",
+ ...(fetchFn !== undefined ? { fetchFn } : {}),
+ }),
};
}
diff --git a/packages/session-orchestrator/package.json b/packages/session-orchestrator/package.json
index 2556ead..47ab1a2 100644
--- a/packages/session-orchestrator/package.json
+++ b/packages/session-orchestrator/package.json
@@ -7,6 +7,7 @@
"types": "dist/index.d.ts",
"dependencies": {
"@dispatch/kernel": "workspace:*",
- "@dispatch/conversation-store": "workspace:*"
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/credential-store": "workspace:*"
}
}
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index bfbc7ca..af7d6e0 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -1,4 +1,5 @@
import { conversationStoreHandle } from "@dispatch/conversation-store";
+import { credentialStoreHandle } from "@dispatch/credential-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { runTurn } from "@dispatch/kernel";
import {
@@ -14,7 +15,7 @@ export const manifest: Manifest = {
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
- dependsOn: ["conversation-store"],
+ dependsOn: ["conversation-store", "credential-store"],
activation: "eager",
contributes: {
services: ["session-orchestrator/orchestrator"],
@@ -28,6 +29,13 @@ export function activate(host: HostAPI): void {
conversationStore,
resolveProvider: () => selectFirstProvider(host.getProviders()),
resolveTools: () => [...host.getTools().values()],
+ resolveModel: (modelName: string) => {
+ const store = host.getService(credentialStoreHandle);
+ const r = store.resolve(modelName);
+ if (r === undefined) return undefined;
+ const provider = host.getProviders().get(r.providerId);
+ return provider ? { provider, model: r.model } : undefined;
+ },
runTurn,
logger: host.logger,
});
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 0d908b2..d381d6c 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -1,5 +1,12 @@
import type { ConversationStore } from "@dispatch/conversation-store";
-import type { AgentEvent, ChatMessage, ProviderContract, ProviderEvent } from "@dispatch/kernel";
+import type {
+ AgentEvent,
+ ChatMessage,
+ ProviderContract,
+ ProviderEvent,
+ RunTurnInput,
+ RunTurnResult,
+} from "@dispatch/kernel";
import { runTurn } from "@dispatch/kernel";
import { describe, expect, it } from "vitest";
import { createSessionOrchestrator } from "./orchestrator.js";
@@ -198,3 +205,147 @@ describe("handleMessage integration", () => {
expect(stored?.length).toBeGreaterThanOrEqual(1);
});
});
+
+function createCapturingRunTurn(): {
+ result: RunTurnResult;
+ captured: RunTurnInput[];
+ captureRunTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
+} {
+ const result: RunTurnResult = {
+ messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }],
+ usage: { inputTokens: 1, outputTokens: 1 },
+ finishReason: "stop",
+ };
+ const captured: RunTurnInput[] = [];
+ return {
+ result,
+ captured,
+ captureRunTurn: async (input) => {
+ captured.push(input);
+ return result;
+ },
+ };
+}
+
+describe("handleMessage model resolution", () => {
+ it("modelName resolves → runTurn receives resolved provider, providerOpts.model, and cwd", async () => {
+ const store = createInMemoryStore();
+ const resolvedProvider: ProviderContract = { id: "resolved", stream: async function* () {} };
+ const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => fallbackProvider,
+ resolveTools: () => [],
+ resolveModel: (name) => {
+ if (name === "cred/gpt-4") return { provider: resolvedProvider, model: "gpt-4" };
+ return undefined;
+ },
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-model",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "cred/gpt-4",
+ cwd: "/work/dir",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.provider).toBe(resolvedProvider);
+ expect(captured[0]?.providerOpts).toEqual({ model: "gpt-4" });
+ expect(captured[0]?.cwd).toBe("/work/dir");
+ });
+
+ it("modelName given but resolveModel returns undefined → error event emitted, runTurn NOT called", async () => {
+ const store = createInMemoryStore();
+ const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const events: AgentEvent[] = [];
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => fallbackProvider,
+ resolveTools: () => [],
+ resolveModel: () => undefined,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-unknown",
+ text: "hi",
+ onEvent: (e) => events.push(e),
+ modelName: "cred/nonexistent",
+ });
+
+ expect(captured).toHaveLength(0);
+ const errorEvents = events.filter((e) => e.type === "error");
+ expect(errorEvents).toHaveLength(1);
+ expect((errorEvents[0] as AgentEvent & { type: "error" }).message).toBe(
+ "unknown model: cred/nonexistent",
+ );
+ expect((errorEvents[0] as AgentEvent & { type: "error" }).conversationId).toBe("conv-unknown");
+ expect((errorEvents[0] as AgentEvent & { type: "error" }).turnId).toMatch(/^turn-/);
+ });
+
+ it("no modelName → falls back to resolveProvider(), no model override", async () => {
+ const store = createInMemoryStore();
+ const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => fallbackProvider,
+ resolveTools: () => [],
+ resolveModel: () => ({
+ provider: { id: "should-not-use", stream: async function* () {} },
+ model: "x",
+ }),
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-fallback",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.provider).toBe(fallbackProvider);
+ expect(captured[0]?.providerOpts).toBeUndefined();
+ });
+
+ it("cwd is forwarded to RunTurnInput.cwd and absent when not provided", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-cwd",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "/custom/path",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/custom/path");
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-cwd",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(2);
+ expect(captured[1]?.cwd).toBeUndefined();
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 37fc512..a9ff4ff 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -4,6 +4,7 @@ import type {
ChatMessage,
Logger,
ProviderContract,
+ ProviderStreamOptions,
RunTurnInput,
RunTurnResult,
ToolContract,
@@ -18,6 +19,8 @@ export interface SessionOrchestrator {
text: string;
onEvent: (event: AgentEvent) => void;
signal?: AbortSignal;
+ modelName?: string;
+ cwd?: string;
}): Promise<void>;
}
@@ -30,6 +33,9 @@ export interface SessionOrchestratorDeps {
readonly resolveProvider: () => ProviderContract;
readonly resolveTools: () => readonly ToolContract[];
readonly resolveDispatch?: () => ToolDispatchPolicy;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
/** Base logger (auto-scoped to this extension); childed per turn for span capture. */
readonly logger?: Logger;
@@ -37,16 +43,36 @@ export interface SessionOrchestratorDeps {
export function createSessionOrchestrator(deps: SessionOrchestratorDeps): SessionOrchestrator {
return {
- async handleMessage({ conversationId, text, onEvent, signal }) {
+ async handleMessage({ conversationId, text, onEvent, signal, modelName, cwd }) {
const history = await deps.conversationStore.load(conversationId);
const userMsg = buildUserMessage(text);
- const provider = deps.resolveProvider();
+ const turnId = generateTurnId();
+
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+
+ if (modelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(modelName);
+ if (resolved === undefined) {
+ onEvent({
+ type: "error",
+ conversationId,
+ turnId,
+ message: `unknown model: ${modelName}`,
+ });
+ return;
+ }
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ } else {
+ provider = deps.resolveProvider();
+ }
+
const tools = deps.resolveTools();
const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
- const turnId = generateTurnId();
const turnLogger = deps.logger?.child({ conversationId, turnId });
- const result = await deps.runTurn({
+ const opts: RunTurnInput = {
provider,
messages: [...history, userMsg],
tools,
@@ -54,9 +80,15 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio
emit: onEvent,
conversationId,
turnId,
+ ...(modelOverride !== undefined
+ ? { providerOpts: { model: modelOverride } satisfies ProviderStreamOptions }
+ : {}),
...(turnLogger !== undefined ? { logger: turnLogger } : {}),
...(signal !== undefined ? { signal } : {}),
- });
+ ...(cwd !== undefined ? { cwd } : {}),
+ };
+
+ const result = await deps.runTurn(opts);
const toPersist: ChatMessage[] = [userMsg, ...result.messages];
await deps.conversationStore.append(conversationId, toPersist);
diff --git a/packages/session-orchestrator/tsconfig.json b/packages/session-orchestrator/tsconfig.json
index 6b137f4..4401407 100644
--- a/packages/session-orchestrator/tsconfig.json
+++ b/packages/session-orchestrator/tsconfig.json
@@ -2,5 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
"include": ["src/**/*.ts"],
- "references": [{ "path": "../kernel" }, { "path": "../conversation-store" }]
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../conversation-store" },
+ { "path": "../credential-store" }
+ ]
}
diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts
index f995b09..2725a05 100644
--- a/packages/tool-read-file/src/read-file.test.ts
+++ b/packages/tool-read-file/src/read-file.test.ts
@@ -11,7 +11,7 @@ import {
validateArgs,
} from "./read-file.js";
-function stubCtx(): ToolExecuteContext {
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
return {
toolCallId: "test-call-1",
onOutput: () => {},
@@ -21,6 +21,7 @@ function stubCtx(): ToolExecuteContext {
{ emit: () => {} },
{ now: () => 0, newId: () => "id" },
),
+ ...overrides,
};
}
@@ -250,4 +251,69 @@ describe("createReadFileTool", () => {
expect(tool.parameters.required).toEqual(["path"]);
expect(tool.parameters.properties?.path?.type).toBe("string");
});
+
+ it("reads file under ctx.cwd when set (not baked workdir)", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const filePath = join(ctxDir, "ctx-file.txt");
+ await writeFile(filePath, "from ctx cwd", "utf8");
+
+ const tool = createReadFileTool(workdir); // baked workdir is different
+ const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("1: from ctx cwd");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects path escaping ctx.cwd via ..", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-escape-test-"));
+ try {
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "../escape.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects symlink escaping ctx.cwd", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-symlink-test-"));
+ const outsideDir = await mkdtemp(join(tmpdir(), "ctx-outside-"));
+ try {
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "secret data", "utf8");
+
+ const symlinkPath = join(ctxDir, "link.txt");
+ const { symlink } = await import("node:fs/promises");
+ await symlink(outsideFile, symlinkPath);
+
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "link.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ await rm(outsideDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ const filePath = join(workdir, "baked-file.txt");
+ await writeFile(filePath, "from baked workdir", "utf8");
+
+ const tool = createReadFileTool(workdir);
+ const ctx = stubCtx();
+ // Ensure cwd is undefined
+ expect(ctx.cwd).toBeUndefined();
+ const result = await tool.execute({ path: "baked-file.txt" }, ctx);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("1: from baked workdir");
+ });
});
diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts
index b5bb0f1..d4a4de8 100644
--- a/packages/tool-read-file/src/read-file.ts
+++ b/packages/tool-read-file/src/read-file.ts
@@ -103,7 +103,7 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
required: ["path"],
},
concurrencySafe: true,
- async execute(args: unknown, _ctx): Promise<ToolResult> {
+ async execute(args: unknown, ctx): Promise<ToolResult> {
const validated = validateArgs(args);
if ("error" in validated) {
return { content: validated.error, isError: true };
@@ -111,11 +111,14 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
const { path: relPath, offset, limit } = validated;
- // Resolve the requested path against the working directory.
- const resolvedPath = resolve(workdir, relPath);
+ // Effective base: per-turn ctx.cwd overrides the baked workdir.
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
- // Basic prefix check (catches ".." and absolute paths outside workdir).
- if (!isPathWithinWorkdir(resolvedPath, workdir)) {
+ // Resolve the requested path against the effective base.
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ // Basic prefix check (catches ".." and absolute paths outside effectiveBase).
+ if (!isPathWithinWorkdir(resolvedPath, effectiveBase)) {
return {
content: `Error: Path "${relPath}" is outside the working directory.`,
isError: true,
@@ -124,11 +127,11 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
// Symlink hardening: realpath both and re-check containment.
let realResolved: string;
- let realWorkdir: string;
+ let realBase: string;
try {
- [realResolved, realWorkdir] = await Promise.all([
+ [realResolved, realBase] = await Promise.all([
realpath(resolvedPath),
- realpath(workdir),
+ realpath(effectiveBase),
]);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
@@ -141,7 +144,7 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
};
}
- if (!isPathWithinWorkdir(realResolved, realWorkdir)) {
+ if (!isPathWithinWorkdir(realResolved, realBase)) {
return {
content: `Error: Path "${relPath}" is outside the working directory.`,
isError: true,
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index c9273f4..eacf39a 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -6,8 +6,10 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
+ "@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
"hono": "^4.0.0"
}
}
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";
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index 2ae3233..a6d1ca8 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -2,5 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
"include": ["src/**/*.ts"],
- "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }]
+ "references": [
+ { "path": "../credential-store" },
+ { "path": "../kernel" },
+ { "path": "../session-orchestrator" },
+ { "path": "../transport-contract" }
+ ]
}
diff --git a/tsconfig.json b/tsconfig.json
index fdea52f..9a64819 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,6 +6,7 @@
{ "path": "./packages/storage-sqlite" },
{ "path": "./packages/auth-apikey" },
{ "path": "./packages/provider-openai-compat" },
+ { "path": "./packages/credential-store" },
{ "path": "./packages/conversation-store" },
{ "path": "./packages/session-orchestrator" },
{ "path": "./packages/transport-http" },