summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
commit7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch)
tree247d03408ecccd633290ea56b1b08811ebe460ec /packages/session-orchestrator/src
parent4283d1f8a0bc3953e65962a2364c903d0015f047 (diff)
downloaddispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz
dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/session-orchestrator/src')
-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
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);