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 | |
| 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
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" }, |
