feat(apiproxy): make the default model a user setting the picker writes
The route a new session starts from was frozen into the gateway's composition entry, so switching models in a conversation reached only that conversation and every later session went back to the shipped default. The gateway now owns an `api-gateway` settings section: the entry is the base layer and the user document layers over it, so `session.selectModel` records an accepted switch as the default for the next session. The write is wholesale rather than a merge — switching to a model with no reasoning effort has to clear a stored one — and a storage failure is reported without undoing the switch, which already applies to its own session. `targetFor` now resolves its tiers on every read instead of seeding once: an explicit selection, else the session's own logged request header, else the live default. That is what keeps a session that has run a turn deriving its route from its log forever after, while a session still blank — New Session reuses one rather than minting another — starts from a default saved after it was created.
This commit is contained in:
@@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
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 { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||||
import type { MessageSource } 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. */
|
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
|
||||||
export interface ApiProxyDefaults {
|
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<void>
|
||||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||||
cwd: string
|
cwd: string
|
||||||
/** Parent directory for name-created workspaces. */
|
/** Parent directory for name-created workspaces. */
|
||||||
@@ -720,7 +731,11 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
|||||||
* @returns the ApiProxy implementation.
|
* @returns the ApiProxy implementation.
|
||||||
*/
|
*/
|
||||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
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 }
|
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
|
||||||
const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
||||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
/** 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.
|
* 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
|
* Precedence, resolved on EVERY read rather than seeded once: a selection
|
||||||
* returns (a create-options contribution), it must fold in between the two.
|
* 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 {
|
function targetFor(agent: Agent): WebLlmTargetRef {
|
||||||
const installed = targets.get(agent)
|
const installed = targets.get(agent)
|
||||||
if (installed !== undefined) return installed
|
if (installed !== undefined) return installed
|
||||||
const logged = agent.session.requestHeader()?.config
|
let picked: AgentLlmTarget | undefined
|
||||||
const target: WebLlmTargetRef = {
|
const target: WebLlmTargetRef = {
|
||||||
current: logged === undefined
|
get current(): AgentLlmTarget {
|
||||||
? { provider: defaults.provider, model: defaults.model }
|
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,
|
provider: logged.provider,
|
||||||
model: logged.model,
|
model: logged.model,
|
||||||
...logged.reasoningEffort === undefined
|
...logged.reasoningEffort === undefined
|
||||||
? {}
|
? {}
|
||||||
: { reasoningEffort: logged.reasoningEffort },
|
: { reasoningEffort: logged.reasoningEffort },
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
set current(next: AgentLlmTarget) {
|
||||||
|
picked = next
|
||||||
|
},
|
||||||
assembled: undefined,
|
assembled: undefined,
|
||||||
}
|
}
|
||||||
installAgentLlmTarget(agent.ctx, target)
|
installAgentLlmTarget(agent.ctx, target)
|
||||||
@@ -1023,7 +1053,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
const handle = await ctx.agents.resume({
|
const handle = await ctx.agents.resume({
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions,
|
agentOptions: agentOptions(),
|
||||||
setup: installTarget,
|
setup: installTarget,
|
||||||
})
|
})
|
||||||
return handle.agent
|
return handle.agent
|
||||||
@@ -1140,7 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
return (await ctx.agents.resume({
|
return (await ctx.agents.resume({
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions,
|
agentOptions: agentOptions(),
|
||||||
setup: installTarget,
|
setup: installTarget,
|
||||||
})).agent
|
})).agent
|
||||||
}
|
}
|
||||||
@@ -1152,7 +1182,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
return (await ctx.agents.create({
|
return (await ctx.agents.create({
|
||||||
sessionId,
|
sessionId,
|
||||||
agentOptions,
|
agentOptions: agentOptions(),
|
||||||
meta: { cwd },
|
meta: { cwd },
|
||||||
setup: installTarget,
|
setup: installTarget,
|
||||||
})).agent
|
})).agent
|
||||||
@@ -1692,6 +1722,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
: { reasoningEffort: resolved.reasoningEffort },
|
: { reasoningEffort: resolved.reasoningEffort },
|
||||||
}
|
}
|
||||||
targetFor(found.agent).current = selected
|
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 } })
|
return ok(request, { selected: { ...selected } })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return err(request, {
|
return err(request, {
|
||||||
@@ -1794,7 +1835,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
parentSession: source.id,
|
parentSession: source.id,
|
||||||
seedLength: cut,
|
seedLength: cut,
|
||||||
},
|
},
|
||||||
agentOptions,
|
agentOptions: agentOptions(),
|
||||||
setup: installTarget,
|
setup: installTarget,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -2179,13 +2220,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
host: {
|
host: {
|
||||||
describe(request) {
|
describe(request) {
|
||||||
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
||||||
|
const route = defaults.defaultTarget()
|
||||||
return Promise.resolve(ok(request, {
|
return Promise.resolve(ok(request, {
|
||||||
version: '0.0.1',
|
version: '0.0.1',
|
||||||
// Same source as session.create's fallback: the UI's default project
|
// Same source as session.create's fallback: the UI's default project
|
||||||
// must match where an unspecified-cwd session actually lands.
|
// must match where an unspecified-cwd session actually lands.
|
||||||
cwd: defaults.cwd,
|
cwd: defaults.cwd,
|
||||||
provider: defaults.provider,
|
// Read live for the same reason: this is what the NEXT session will
|
||||||
model: defaults.model,
|
// start from, so a saved default has to be what it reports.
|
||||||
|
provider: route.provider,
|
||||||
|
model: route.model,
|
||||||
attachedSessions: ctx.agents.list().length,
|
attachedSessions: ctx.agents.list().length,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,11 +6,20 @@
|
|||||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
* 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 { resolve } from 'node:path'
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import z from 'schemastery'
|
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 type { ApiProxy } from './api/index.ts'
|
||||||
import { createApiProxy } from './api-proxy.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 {
|
* The settings namespace carrying the user's default route. Named for the
|
||||||
/** Default provider route for created/resumed agents. */
|
* 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
|
provider: string
|
||||||
/** Default model id. */
|
/** Default model id. */
|
||||||
model: string
|
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. */
|
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||||
workspaceRoot?: string
|
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<DefaultRouteSettings>]: z<string> } {
|
||||||
|
return {
|
||||||
|
provider: z.string().required(),
|
||||||
|
model: z.string().required(),
|
||||||
|
reasoningEffort: z.string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema of the settings section. */
|
||||||
|
const DefaultRouteSchema: z<DefaultRouteSettings> = 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
|
* 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
|
* 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<Config> = z.object({
|
static Config: z<Config> = z.object({
|
||||||
provider: z.string().required(),
|
...defaultRouteFields(),
|
||||||
model: z.string().required(),
|
|
||||||
workspaceRoot: z.string(),
|
workspaceRoot: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,9 +126,32 @@ export class ApiProxyService extends Service implements ApiProxy {
|
|||||||
constructor(ctx: Context, config: Config) {
|
constructor(ctx: Context, config: Config) {
|
||||||
super(ctx, 'apiProxy')
|
super(ctx, 'apiProxy')
|
||||||
const cwd = process.cwd()
|
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,
|
provider: config.provider,
|
||||||
model: config.model,
|
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,
|
cwd,
|
||||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
|||||||
await ctx.plugin(UserInteractionService)
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(ApprovalService)
|
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 }
|
return { ctx, api }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
|
|||||||
await ctx.plugin(ApprovalService)
|
await ctx.plugin(ApprovalService)
|
||||||
let api!: ApiProxy
|
let api!: ApiProxy
|
||||||
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
|
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'] }))
|
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
|
|||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
return {
|
return {
|
||||||
ctx,
|
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) => {
|
attach: (session) => {
|
||||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => {
|
|||||||
return undefined
|
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({}))
|
const response = await api.sessions.list(request({}))
|
||||||
expect(response.result.ok).toBe(true)
|
expect(response.result.ok).toBe(true)
|
||||||
@@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(UserInteractionService)
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
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.
|
// Old work, resumed just now: the log tail would report the pickup.
|
||||||
const worked = 1_000_000
|
const worked = 1_000_000
|
||||||
@@ -148,7 +148,7 @@ describe('cold history recovery view', () => {
|
|||||||
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
|
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
|
||||||
locate: () => undefined,
|
locate: () => undefined,
|
||||||
} as never)
|
} 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 }))
|
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
|
||||||
if (!history.result.ok) throw new Error('history failed')
|
if (!history.result.ok) throw new Error('history failed')
|
||||||
@@ -216,7 +216,7 @@ describe('subagent ownership fence', () => {
|
|||||||
locate: () => undefined,
|
locate: () => undefined,
|
||||||
} as never)
|
} as never)
|
||||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
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 }))
|
const history = await api.sessions.history(request({ sessionId }))
|
||||||
expect(history.result.ok).toBe(true)
|
expect(history.result.ok).toBe(true)
|
||||||
@@ -275,7 +275,7 @@ describe('subagent ownership fence', () => {
|
|||||||
// instead of answering `agent-busy`.
|
// instead of answering `agent-busy`.
|
||||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||||
.mockRejectedValue(new Error('registry unavailable in this bench'))
|
.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({
|
const prompt = await api.sessions.prompt(request({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -316,7 +316,7 @@ describe('subagent ownership fence', () => {
|
|||||||
})
|
})
|
||||||
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
||||||
ctx.agents.enter(startingChild, parent)
|
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 }))
|
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
|
||||||
expect(stopped.result.ok).toBe(false)
|
expect(stopped.result.ok).toBe(false)
|
||||||
@@ -362,7 +362,7 @@ describe('subagent ownership fence', () => {
|
|||||||
const followup = vi.fn()
|
const followup = vi.fn()
|
||||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||||
ctx.agents.register(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({
|
const response = await api.sessions.prompt(request({
|
||||||
sessionId: agent.id,
|
sessionId: agent.id,
|
||||||
@@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(UserInteractionService)
|
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({}))
|
const listed = await api.sessions.list(request({}))
|
||||||
expect(listed.result.ok).toBe(true)
|
expect(listed.result.ok).toBe(true)
|
||||||
@@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
|||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
inspect,
|
inspect,
|
||||||
} as never)
|
} 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') }))
|
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
|
||||||
expect(response.result.ok).toBe(false)
|
expect(response.result.ok).toBe(false)
|
||||||
@@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
|||||||
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||||
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||||
} as unknown as Agent)
|
} 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) {
|
for (const mode of ['queue', 'steer'] as const) {
|
||||||
const response = await api.sessions.prompt(request({
|
const response = await api.sessions.prompt(request({
|
||||||
@@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
|||||||
ctx.agents.register(child)
|
ctx.agents.register(child)
|
||||||
throw new Error('session id already published')
|
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 }))
|
const models = await api.sessions.models(request({ sessionId }))
|
||||||
expect(models.result.ok).toBe(false)
|
expect(models.result.ok).toBe(false)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
|||||||
import { RpcId } from '../src/api/rpc.ts'
|
import { RpcId } from '../src/api/rpc.ts'
|
||||||
import { createApiProxy } from '../src/api-proxy.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<P>(payload: P): RpcRequest<P> {
|
function request<P>(payload: P): RpcRequest<P> {
|
||||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
|||||||
import { RpcId } from '../src/api/rpc.ts'
|
import { RpcId } from '../src/api/rpc.ts'
|
||||||
import { createApiProxy } from '../src/api-proxy.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
|
let nextRpc = 1
|
||||||
function request<P>(payload: P): RpcRequest<P> {
|
function request<P>(payload: P): RpcRequest<P> {
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ function liveAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = (ctx: Context) => createApiProxy(ctx, {
|
const api = (ctx: Context) => createApiProxy(ctx, {
|
||||||
provider: 'default-provider',
|
defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }),
|
||||||
model: 'default-model',
|
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
workspaceRoot: '/tmp',
|
workspaceRoot: '/tmp',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe('Web session model selection', () => {
|
|||||||
model: 'private-preview',
|
model: 'private-preview',
|
||||||
reasoningEffort: ReasoningEffortId('max'),
|
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 })))
|
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||||
expect(catalog.current).toEqual({
|
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 () => {
|
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 { 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 seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
@@ -225,4 +225,101 @@ describe('Web session model selection', () => {
|
|||||||
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
||||||
await ctx.fiber.dispose()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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', () => {
|
describe('session.history projections block', () => {
|
||||||
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
|
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
|||||||
await ctx.plugin(UserInteractionService)
|
await ctx.plugin(UserInteractionService)
|
||||||
return {
|
return {
|
||||||
ctx,
|
ctx,
|
||||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
|
api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
|
|||||||
return 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', () => {
|
describe('sessions.rename', () => {
|
||||||
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
|
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const sid = (value: string): SessionId => value as SessionId
|
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 }> {
|
function request(query: string): RpcRequest<{ query: string }> {
|
||||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function bench(options: {
|
|||||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||||
const api = createApiProxy(ctx, {
|
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 }
|
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
|
|||||||
describe('mux live view computation', () => {
|
describe('mux live view computation', () => {
|
||||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||||
const { ctx } = await harness()
|
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 abort = new AbortController()
|
||||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||||
const collected = collect(stream, 9, abort)
|
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 () => {
|
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||||
const { ctx } = await harness()
|
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()
|
const session = ctx.sessions.create()
|
||||||
// history resolves the agent first; a live structural stub is enough (only
|
// history resolves the agent first; a live structural stub is enough (only
|
||||||
// .session is read on this path).
|
// .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 () => {
|
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||||
const { ctx } = await harness()
|
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()
|
const session = ctx.sessions.create()
|
||||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||||
session.append('turn/start', { turn: 1 })
|
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 () => {
|
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||||
const { ctx } = await harness()
|
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 abort = new AbortController()
|
||||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
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 () => {
|
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||||
const { ctx } = await harness()
|
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 abort = new AbortController()
|
||||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||||
const collected = collect(stream, 4, abort)
|
const collected = collect(stream, 4, abort)
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ async function harness(
|
|||||||
// object per harness mirrors the seam's stability contract.
|
// object per harness mirrors the seam's stability contract.
|
||||||
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
||||||
const api = createApiProxy(ctx, {
|
const api = createApiProxy(ctx, {
|
||||||
provider: 'test',
|
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
|
||||||
model: 'test-model',
|
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ describe('sessions domain schemas', () => {
|
|||||||
describe('host domain schemas', () => {
|
describe('host domain schemas', () => {
|
||||||
it('validates describe request/value', () => {
|
it('validates describe request/value', () => {
|
||||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
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(value.attachedSessions).toBe(2)
|
||||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
|
|||||||
if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
||||||
const session = ctx.sessions.create()
|
const session = ctx.sessions.create()
|
||||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
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 {
|
return {
|
||||||
ctx,
|
ctx,
|
||||||
session,
|
session,
|
||||||
|
|||||||
Reference in New Issue
Block a user