diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
| commit | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch) | |
| tree | 247d03408ecccd633290ea56b1b08811ebe460ec /packages/session-orchestrator/src | |
| parent | 4283d1f8a0bc3953e65962a2364c903d0015f047 (diff) | |
| download | dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip | |
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 10 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 153 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 42 |
3 files changed, 198 insertions, 7 deletions
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); |
