diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..709c63e4d0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' @@ -329,8 +329,19 @@ function directoryError(error: unknown): RpcError { /** Resolved Host routing and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { - provider: string - model: string + /** + * The route a session starts from when its own log names none. Read on + * every access rather than captured, so a default saved during this process + * reaches the sessions that have not run a turn yet. + */ + defaultTarget: () => AgentLlmTarget + /** + * Record a selection as the new default. Absent when the deployment stores + * no user settings, in which case a switch stays process-local. A rejection + * is reported and swallowed: the switch already applies to its own session, + * and undoing it because storage failed would be the worse outcome. + */ + persistDefaultTarget?: (target: AgentLlmTarget) => Promise /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string /** Parent directory for name-created workspaces. */ @@ -720,7 +731,11 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { - const agentOptions = { provider: defaults.provider, model: defaults.model } + /** The seed route each create/resume declares; re-read so it never goes stale. */ + const agentOptions = (): AgentOptions => { + const { provider, model } = defaults.defaultTarget() + return { provider, model } + } type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ @@ -735,24 +750,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** * Install or return the session-local target that prompt assembly snapshots. - * Seed order: latest logged request/header, else the host default routing. - * There is no create-time per-session override tier on this wire — if one - * returns (a create-options contribution), it must fold in between the two. + * + * Precedence, resolved on EVERY read rather than seeded once: a selection + * made in this process, else the session's own latest logged request/header, + * else the live host default. Re-reading is what keeps the two tiers honest + * in both directions — a session that has run a turn derives its route from + * its log forever after, so changing the default never retargets it; and a + * session still blank (New Session reuses one rather than minting another) + * starts from a default saved after it was created. There is no create-time + * per-session override tier on this wire — if one returns (a create-options + * contribution), it must fold in between the selection and the log. */ function targetFor(agent: Agent): WebLlmTargetRef { const installed = targets.get(agent) if (installed !== undefined) return installed - const logged = agent.session.requestHeader()?.config + let picked: AgentLlmTarget | undefined const target: WebLlmTargetRef = { - current: logged === undefined - ? { provider: defaults.provider, model: defaults.model } - : { + get current(): AgentLlmTarget { + if (picked !== undefined) return picked + // Incrementally folded by the session, so a per-step read costs + // O(new events) rather than a rescan. + const logged = agent.session.requestHeader()?.config + if (logged === undefined) return defaults.defaultTarget() + return { provider: logged.provider, model: logged.model, ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }, - }, + } + }, + set current(next: AgentLlmTarget) { + picked = next + }, assembled: undefined, } installAgentLlmTarget(agent.ctx, target) @@ -1023,7 +1053,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, }) return handle.agent @@ -1140,7 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return (await ctx.agents.resume({ resumeSessionId: sessionId, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, })).agent } @@ -1152,7 +1182,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return (await ctx.agents.create({ sessionId, - agentOptions, + agentOptions: agentOptions(), meta: { cwd }, setup: installTarget, })).agent @@ -1692,6 +1722,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { reasoningEffort: resolved.reasoningEffort }, } targetFor(found.agent).current = selected + // A switch is also how this deployment's default is chosen: the next + // session created without one of its own starts here. Sessions that + // have already logged a route are unaffected — they derive from + // their own log (see targetFor). + try { + await defaults.persistDefaultTarget?.(selected) + } catch (error: unknown) { + ctx.logger.warn( + `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, + ) + } return ok(request, { selected: { ...selected } }) } catch (error: unknown) { return err(request, { @@ -1794,7 +1835,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentSession: source.id, seedLength: cut, }, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, }) } catch (error: unknown) { @@ -2179,13 +2220,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host: { describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. + const route = defaults.defaultTarget() return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, - provider: defaults.provider, - model: defaults.model, + // Read live for the same reason: this is what the NEXT session will + // start from, so a saved default has to be what it reports. + provider: route.provider, + model: route.model, attachedSessions: ctx.agents.list().length, })) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..34ce49fc77 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -6,11 +6,20 @@ * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing * `ctx.apiProxy`). Transport-agnostic by design: this package registers no * routes — physical carriers wrap `ctx.apiProxy` themselves. + * + * The gateway also owns the `api-gateway` settings section: the route a + * session starts from when its own log names none. The composition entry is + * the shipped default and the section layers the user's choice over it, so + * switching models in a conversation is what sets the default for the next + * one. Sessions that have already logged a route are never retargeted by it. */ import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' +import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' @@ -29,16 +38,62 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config { - /** Default provider route for created/resumed agents. */ +/** + * The settings namespace carrying the user's default route. Named for the + * gateway rather than for the package, because this key is what a person reads + * and writes in `settings.yaml`; the row id in a composition happens to match + * but does not determine it. + */ +export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway') + +/** + * The user-settable slice of the gateway config: the route a session starts + * from when its own log names none. `workspaceRoot` is deliberately not part + * of it — that is a launcher fact, not a preference. + */ +export interface DefaultRouteSettings { + /** Default provider route for created agents. */ provider: string /** Default model id. */ model: string + /** Default reasoning effort; absence preserves the adapter/provider default. */ + reasoningEffort?: string +} + +/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +export interface Config extends DefaultRouteSettings { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } +/** + * The default-route fields, as fresh schema instances. Both the plugin config + * and the settings section are built from this one call, so the section stays + * a subset of the config structurally rather than by a comment two people have + * to keep true. + */ +function defaultRouteFields(): { [K in keyof Required]: z } { + return { + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), + } +} + +/** Schema of the settings section. */ +const DefaultRouteSchema: z = z.object(defaultRouteFields()) + +/** Project the stored/composed section onto the agent-facing target shape. */ +function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { + return { + provider: settings.provider, + model: settings.model, + ...settings.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) }, + } +} + /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default @@ -51,8 +106,7 @@ export class ApiProxyService extends Service implements ApiProxy { ] static Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), + ...defaultRouteFields(), workspaceRoot: z.string(), }) @@ -72,9 +126,32 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') const cwd = process.cwd() - const api = createApiProxy(ctx, { + // The composition entry is the shipped default; the settings section + // layers the user's own choice over it, and a deployment without a + // settings provider simply keeps the entry. + const entry: DefaultRouteSettings = { provider: config.provider, model: config.model, + ...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort }, + } + let route: () => DefaultRouteSettings = () => entry + installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, { + setSource: (current) => { + route = current + }, + // Nothing registration-level derives from the default: every consumer + // reads it through the thunk at the moment it needs a route. + onChange: () => {}, + }) + const api = createApiProxy(ctx, { + defaultTarget: () => routeTarget(route()), + // Wholesale, never a merge: switching to a model with no reasoning + // effort must clear a stored one, and a merged patch would strand it + // for the next session to fail on. The section holds no secrets, so + // there is nothing a replace can collaterally drop. + persistDefaultTarget: async (target) => { + await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target) + }, cwd, workspaceRoot: resolve(config.workspaceRoot ?? cwd), }) diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..e6555898cd 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..4943c051bb 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..4b6337ede8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..55781a3e77 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..c13a66eaec 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..fb6f8cdfed 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -82,8 +82,7 @@ function liveAgent( } const api = (ctx: Context) => createApiProxy(ctx, { - provider: 'default-provider', - model: 'default-model', + defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', workspaceRoot: '/tmp', }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..7a9f2b2f86 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -225,4 +225,101 @@ describe('Web session model selection', () => { .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) + + it('reads the host default live for a session whose log names no route', async () => { + const { ctx, sessionId } = await harness() + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + const api = createApiProxy(ctx, { + defaultTarget: () => stored, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + // The default moving after the session exists still reaches it: New + // Session reuses a blank session rather than minting another, so a seed + // captured at creation would show the superseded model there. + stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + expect(expectValue(await api.host.describe(request({})))) + .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await ctx.fiber.dispose() + }) + + it('keeps a session that logged a route on it when the host default moves', async () => { + const { ctx, sessionId } = await harness({ + provider: 'deepseek-official', + model: 'deepseek-chat', + }) + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + const api = createApiProxy(ctx, { + defaultTarget: () => stored, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + stored = { provider: 'duplicate', model: 'same' } + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + await ctx.fiber.dispose() + }) + + it('saves an accepted selection as the default and survives a storage failure', async () => { + const { ctx, sessionId } = await harness() + const saved: unknown[] = [] + let reject = false + const api = createApiProxy(ctx, { + defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + persistDefaultTarget: (target) => { + saved.push(target) + return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve() + }, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max', + }))) + expect(saved).toEqual([ + { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }, + ]) + + // A refused selection never becomes anyone's default. + await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' })) + expect(saved).toHaveLength(1) + + // Storage failing is not the selection failing: the switch already applies + // to this session, so the call still succeeds. + reject = true + const stillAccepted = expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-chat', + }))) + expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + await ctx.fiber.dispose() + }) + + it('serves a session and its catalog when the stored default names a route that is gone', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { + // What a Models-page removal leaves behind: the settings document still + // names the route the user last picked, and nothing serves it. + defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + // Passed through rather than repaired: matching no group is precisely what + // makes the composer seat prompt for a selection instead of naming a model + // the deployment cannot reach. + expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`))) + .not.toContain('deleted-gateway/deleted-model') + await ctx.fiber.dispose() + }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c9cb212004 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..ee5747039f 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..2f93cdd9b3 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..15a4ae3bf3 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..feb9ecb073 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..4490c71bc2 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..aa560bdf58 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -100,8 +100,7 @@ async function harness( // object per harness mirrors the seam's stability contract. ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { - provider: 'test', - model: 'test-model', + defaultTarget: () => ({ provider: 'test', model: 'test-model' }), cwd: workspaceRoot, workspaceRoot, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..040fe56ff5 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -274,7 +274,7 @@ describe('sessions domain schemas', () => { describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 }) + const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..08cb7d3216 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, session,