Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire
master extracted this layer's inline cold-resume resolver into @deepseek-ai/dsh-api-remotes, whose `setup` was a fixed AgentSetup. A resumed session composes the preset ITS header recorded, so the option becomes a function of that header; the resolver builds the setup before the published re-checks so those stay adjacent to `resume`. Conflicts: docs/cordis-catalog/services.md docs/module-graph.md packages/host/apiproxy/package.json packages/host/apiproxy/src/api-proxy.ts pnpm-lock.yaml
This commit is contained in:
@@ -88,7 +88,11 @@ async function harness(presets?: readonly string[]) {
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd, workspaceRoot: cwd })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
})
|
||||
return { api, ctx, cwd }
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -62,7 +64,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 +92,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 +150,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')
|
||||
@@ -180,6 +182,100 @@ describe('cold history recovery view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Agent and Session lookup policy', () => {
|
||||
it('deduplicates a cold resume across Agent and Session parameters', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-remote-cold')
|
||||
const meta = header(sessionId, 1000)
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
|
||||
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
await release.promise
|
||||
return { agent: resumedAgent, dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
|
||||
const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId))
|
||||
const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId))
|
||||
await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() })
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(resolvedAgent).resolves.toBe(resumedAgent)
|
||||
await expect(resolvedSession).resolves.toBe(resumedSession)
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves the subagent ownership fence for cold and live Remote lookups', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const coldId = sid('session-remote-cold-child')
|
||||
const coldMeta = header(coldId, 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
origin: 'subagent',
|
||||
})
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([coldMeta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
|
||||
meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
|
||||
})
|
||||
const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(liveAgent)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
const ownershipFailure = {
|
||||
failure: {
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
},
|
||||
}
|
||||
|
||||
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
|
||||
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent ownership fence', () => {
|
||||
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -216,7 +312,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 +371,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 +412,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 +458,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 +476,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 +501,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 +527,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 +571,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)
|
||||
|
||||
@@ -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<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
|
||||
@@ -22,9 +22,9 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, 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<P>(payload: P): RpcRequest<P> {
|
||||
@@ -398,6 +398,25 @@ describe('settings domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the gateway default route changes', async () => {
|
||||
const ctx = await harness()
|
||||
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// The gateway's own section names the route every session with no logged
|
||||
// one resolves to, so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'api-gateway' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
|
||||
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The `api-gateway` settings section over a REAL settings provider: the
|
||||
* composition entry as the base layer, the wholesale replace the gateway
|
||||
* persists with, and the fallback when the provider detaches. The other model
|
||||
* specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
|
||||
* this is the only place the layering itself is exercised.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
|
||||
import type { DefaultRouteSettings } from '../src/index.ts'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the gateway's own section wiring over a live provider. */
|
||||
async function boot(entry: DefaultRouteSettings) {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(MemorySettings)
|
||||
await fiber.await()
|
||||
let route: () => DefaultRouteSettings = () => entry
|
||||
const consumer = ctx.plugin(function section(child: Context) {
|
||||
installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
|
||||
setSource: (current) => { route = current },
|
||||
onChange: () => {},
|
||||
})
|
||||
})
|
||||
await consumer.await()
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) throw new Error('settings provider did not mount')
|
||||
return { ctx, fiber, consumer, settings, read: () => route() }
|
||||
}
|
||||
|
||||
describe('the api-gateway default-route section', () => {
|
||||
it('resolves the composition entry until the user layer overrides it', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read()).toEqual({
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('clears a stored effort when the next switch has none', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read().reasoningEffort).toBe('high')
|
||||
|
||||
// The whole reason the gateway persists with `replace` rather than a merge
|
||||
// patch — and the reason `Config` carries no effort for the base layer to
|
||||
// re-inherit here. A stranded effort would fail the next session's first
|
||||
// request against a model that does not support it.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-plain',
|
||||
})
|
||||
expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('layers a hand-written partial section over the entry', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
// Someone editing settings.yaml by hand may name only the model. The
|
||||
// entry supplies the provider, which is what makes this legal — and is
|
||||
// exactly why an effort in the entry could never be cleared, so there
|
||||
// is none to inherit.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the provider detaches', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large',
|
||||
})
|
||||
expect(bench.read().provider).toBe('acme-gateway')
|
||||
|
||||
// A deployment that loses its settings provider keeps serving the route it
|
||||
// was composed with rather than the one it can no longer read.
|
||||
await bench.fiber.dispose()
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,132 @@ 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('refuses a prompt no adapter can route, and reports it on the directory', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
// The client disabling its input is an affordance; this method stays
|
||||
// callable, so the refusal has to live here.
|
||||
const refused = await api.sessions.prompt(request({
|
||||
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
|
||||
}))
|
||||
expect(refused.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
|
||||
|
||||
// An advisory-unlisted model on a live route is NOT this: the route
|
||||
// serves it, so the prompt goes through and nothing blocks.
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
|
||||
})))
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.routable).toBe(true)
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
|
||||
.not.toContain('unlisted-but-served')
|
||||
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', () => {
|
||||
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)
|
||||
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' }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, 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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -45,6 +45,7 @@ function scriptedApi(overrides: {
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
|
||||
@@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
|
||||
@@ -167,3 +167,123 @@ describe('native path opener', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-renderable documents', () => {
|
||||
const LS_PLIST = `{
|
||||
LSHandlers = (
|
||||
{
|
||||
LSHandlerPreferredVersions = {
|
||||
LSHandlerRoleAll = "-";
|
||||
};
|
||||
LSHandlerRoleAll = "com.google.chrome";
|
||||
LSHandlerURLScheme = https;
|
||||
}
|
||||
);
|
||||
}`
|
||||
|
||||
it('opens a page with the default browser rather than the .html handler on darwin', async () => {
|
||||
const calls: { command: string; args: readonly string[] }[] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push({ command, args })
|
||||
return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
// A developer who bound .html to an editor still gets a rendered page.
|
||||
expect(calls.map(c => [c.command, ...c.args])).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '-b', 'com.google.chrome', '/w/page.html'],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves every other document to the default application', async () => {
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run })
|
||||
// No LaunchServices read at all: markdown is not a browser document.
|
||||
expect(calls).toEqual([['open', '/w/report.md']])
|
||||
})
|
||||
|
||||
it('falls back to the default application when no browser can be named', async () => {
|
||||
// LaunchServices has no https record (a fresh account), so the system's
|
||||
// own content-type choice is the best answer available.
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === 'defaults') throw new Error('domain not found')
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
expect(calls).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '/w/page.html'],
|
||||
])
|
||||
|
||||
// A record without an https handler is the same answer.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'darwin',
|
||||
run: async (command, args) => {
|
||||
bare.push([command, ...args])
|
||||
return { stdout: '{ LSHandlers = ( ); }', stderr: '' }
|
||||
},
|
||||
})
|
||||
expect(bare[1]).toEqual(['open', '/w/page.html'])
|
||||
})
|
||||
|
||||
it('honors $BROWSER on linux and leaves windows to its association', async () => {
|
||||
const linux: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(linux).toEqual([['firefox', '/w/page.html']])
|
||||
|
||||
// Unset $BROWSER: xdg-open's association is the fallback.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: {},
|
||||
run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(bare).toEqual([['xdg-open', '/w/page.html']])
|
||||
|
||||
// Windows names no browser without the UserChoice registry.
|
||||
const win: string[][] = []
|
||||
await openNativePath('C:\\w\\page.html', new AbortController().signal, {
|
||||
platform: 'win32',
|
||||
run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(win[0]?.[0]).toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('hands browser-renderable WSL paths to the Windows desktop', async () => {
|
||||
const calls: string[][] = []
|
||||
await openNativePath('/home/test/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '5.15.153.1-microsoft-standard-WSL2',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return {
|
||||
stdout: command === 'wslpath' ? 'C:\\workspace\\page.html\n' : '',
|
||||
stderr: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
['wslpath', '-w', '/home/test/page.html'],
|
||||
[
|
||||
'powershell.exe',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"Invoke-Item -LiteralPath 'C:\\workspace\\page.html'",
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,6 +197,7 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -274,8 +275,10 @@ 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 })
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
const value = hostDescribeValueSchema.parse({
|
||||
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2,
|
||||
})
|
||||
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 })
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user