api-proxy-models.spec.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. /**
  2. * Web session model-directory and selection behavior: dynamic provider grouping,
  3. * provider-local catalog failures, logged-target restoration, advisory unlisted
  4. * models, and the prompt-assembly boundary for a running selection change.
  5. */
  6. import { describe, expect, it, vi } from 'vitest'
  7. import { Context, FiberState } from 'cordis'
  8. import type { Fiber } from 'cordis'
  9. import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
  10. import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
  11. import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  12. import type {
  13. GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
  14. LlmResolvedModelInfo, StreamChunk,
  15. } from '@deepseek-ai/dsh-llm'
  16. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  17. import type { Session } from '@deepseek-ai/dsh-session'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  20. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  21. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  22. import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  23. import { createApiProxy } from '../src/api-proxy.ts'
  24. let nextRpc = 1
  25. function request<P>(payload: P): RpcRequest<P> {
  26. return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
  27. }
  28. class CatalogAdapter extends LlmAdapter {
  29. constructor(
  30. private readonly name: string,
  31. private readonly models: readonly LlmModelInfo[] | Error,
  32. private readonly reasoning?: LlmModelReasoningInfo,
  33. private readonly exactError?: Error,
  34. ) {
  35. super()
  36. }
  37. override providerInfo(provider: string): LlmProviderInfo {
  38. return { id: provider, name: this.name }
  39. }
  40. override listModels(): Promise<readonly LlmModelInfo[]> {
  41. return this.models instanceof Error
  42. ? Promise.reject(this.models)
  43. : Promise.resolve(this.models)
  44. }
  45. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  46. if (this.exactError !== undefined) return Promise.reject(this.exactError)
  47. return Promise.resolve({
  48. provider,
  49. id: model,
  50. name: model,
  51. context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 },
  52. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  53. })
  54. }
  55. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  56. // Catalog tests never enter provider streaming.
  57. }
  58. }
  59. class DeferredCatalogAdapter extends CatalogAdapter {
  60. readonly pending: {
  61. result: PromiseWithResolvers<LlmResolvedModelInfo>
  62. signal: AbortSignal | undefined
  63. }[] = []
  64. constructor() {
  65. super('Deferred', [
  66. { provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model' },
  67. ])
  68. }
  69. override resolveModel(
  70. _provider: string,
  71. _model: string,
  72. signal?: AbortSignal,
  73. ): Promise<LlmResolvedModelInfo> {
  74. const result = Promise.withResolvers<LlmResolvedModelInfo>()
  75. this.pending.push({ result, signal })
  76. return result.promise
  77. }
  78. resolve(index: number, contextWindow: number): void {
  79. const pending = this.pending[index]
  80. if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`)
  81. pending.result.resolve({
  82. provider: 'deferred',
  83. id: 'lifecycle-model',
  84. name: 'Lifecycle model',
  85. context: { contextWindow },
  86. })
  87. }
  88. }
  89. const REASONING: LlmModelReasoningInfo = {
  90. efforts: [
  91. { id: ReasoningEffortId('off'), name: 'Off' },
  92. { id: ReasoningEffortId('high'), name: 'High' },
  93. { id: ReasoningEffortId('max'), name: 'Max' },
  94. ],
  95. defaultEffort: ReasoningEffortId('high'),
  96. }
  97. async function hostContext(
  98. onSessions?: (fiber: Fiber) => void,
  99. onAgents?: (fiber: Fiber) => void,
  100. ): Promise<Context> {
  101. const ctx = new Context()
  102. const sessionsFiber = await ctx.plugin(SessionStore)
  103. onSessions?.(sessionsFiber)
  104. await ctx.plugin(SystemPrompt, { persona: '' })
  105. await ctx.plugin(LlmService)
  106. await ctx.plugin(UserInteractionService)
  107. const agentsFiber = await ctx.plugin(AgentRegistry)
  108. onAgents?.(agentsFiber)
  109. ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
  110. { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  111. { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  112. ], REASONING))
  113. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  114. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  115. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  116. ], undefined, new Error('reasoning metadata offline')))
  117. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  118. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  119. { provider: 'duplicate', id: 'same', name: 'Same' },
  120. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  121. ]))
  122. return ctx
  123. }
  124. async function harness(logged?: {
  125. provider: string
  126. model: string
  127. reasoningEffort?: ReasoningEffortId
  128. }): Promise<{
  129. ctx: Context
  130. agent: Agent
  131. sessionId: SessionId
  132. }> {
  133. const ctx = await hostContext()
  134. const session = ctx.sessions.create()
  135. if (logged !== undefined) {
  136. session.append('request/header', { header: { config: logged }, reason: 'initial' })
  137. }
  138. const agent = {
  139. id: session.id,
  140. session,
  141. status: 'running',
  142. ctx,
  143. } as Agent
  144. ctx.agents.register(agent)
  145. return { ctx, agent, sessionId: session.id }
  146. }
  147. function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
  148. if (!response.result.ok) throw new Error('expected successful response')
  149. return response.result.value
  150. }
  151. async function nextMetrics(
  152. iterator: AsyncIterator<RpcRequest<MuxFrame>>,
  153. ): Promise<Extract<MuxFrame, { type: 'session/metrics' }>['metrics']> {
  154. for (;;) {
  155. const next = await iterator.next()
  156. if (next.done) throw new Error('mux ended before a metrics frame')
  157. if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics
  158. }
  159. }
  160. function attachLifecycleSession(
  161. ctx: Context,
  162. sessionId: SessionId,
  163. withMarker = false,
  164. ): { session: Session; detach: () => void } {
  165. const session = ctx.sessions.prepare(sessionId)
  166. session.append('request/header', {
  167. header: { config: { provider: 'deferred', model: 'lifecycle-model' } },
  168. reason: 'initial',
  169. })
  170. if (withMarker) {
  171. session.append('user/message', {
  172. content: [{ type: 'text', text: 'replacement marker' }],
  173. source: { kind: 'plugin', plugin: 'test' },
  174. }, { surfaceOp: 'append' })
  175. }
  176. const detach = ctx.sessions.enter(session)
  177. ctx.sessions.announce(session)
  178. return { session, detach }
  179. }
  180. function attachLifecycleAgent(
  181. ctx: Context,
  182. session: Session,
  183. ): () => void {
  184. const agent = {
  185. id: session.id,
  186. session,
  187. status: 'running',
  188. ctx,
  189. } as Agent
  190. const detach = ctx.agents.enter(agent, undefined)
  191. ctx.agents.announce(agent)
  192. return detach
  193. }
  194. function settleCapacityCompletion(): Promise<void> {
  195. return new Promise<void>((resolve) => { setImmediate(resolve) })
  196. }
  197. function installDeferredAdapter(
  198. ctx: Context,
  199. adapter: DeferredCatalogAdapter,
  200. ): Fiber & PromiseLike<Fiber> {
  201. return ctx.plugin(Object.assign((inner: Context) => {
  202. inner.llm.registerAdapter(['deferred'], adapter)
  203. }, { inject: ['llm'] }))
  204. }
  205. describe('Web session model selection', () => {
  206. it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
  207. const { ctx, sessionId } = await harness({
  208. provider: 'deepseek',
  209. model: 'private-preview',
  210. reasoningEffort: ReasoningEffortId('max'),
  211. })
  212. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  213. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  214. expect(catalog.current).toEqual({
  215. provider: 'deepseek',
  216. model: 'private-preview',
  217. reasoningEffort: 'max',
  218. })
  219. expect(catalog.groups).toEqual([{
  220. id: 'deepseek',
  221. name: 'DeepSeek',
  222. models: [
  223. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  224. {
  225. id: 'deepseek-reasoner',
  226. name: 'DeepSeek Reasoner',
  227. description: 'Reasoning model',
  228. reasoning: REASONING,
  229. },
  230. {
  231. id: 'private-preview',
  232. name: 'private-preview',
  233. unlisted: true,
  234. reasoning: REASONING,
  235. },
  236. ],
  237. }])
  238. expect(catalog.failures).toEqual([
  239. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  240. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  241. {
  242. id: 'duplicate',
  243. name: 'Duplicate Provider',
  244. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  245. },
  246. ])
  247. await ctx.fiber.dispose()
  248. })
  249. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  250. const { ctx, agent, sessionId } = await harness()
  251. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  252. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  253. const signal = new AbortController().signal
  254. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  255. .toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
  256. expect((await ctx.systemPrompt.assemble()).variables)
  257. .toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  258. const selected = expectValue(await api.sessions.selectModel(request({
  259. sessionId,
  260. provider: 'deepseek',
  261. model: 'private-preview',
  262. reasoningEffort: 'max',
  263. })))
  264. expect(selected.selected).toEqual({
  265. provider: 'deepseek',
  266. model: 'private-preview',
  267. reasoningEffort: 'max',
  268. })
  269. await expect(agentEvents(ctx, agent).waterfall(
  270. 'agent/request', 1, 0, signal, () => Promise.resolve(seed),
  271. )).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  272. expect((await ctx.systemPrompt.assemble()).variables)
  273. .toMatchObject({ provider: 'deepseek', model: 'private-preview' })
  274. await expect(agentEvents(ctx, agent).waterfall(
  275. 'agent/request', 1, 1, signal, () => Promise.resolve(seed),
  276. )).resolves.toMatchObject({
  277. provider: 'deepseek',
  278. model: 'private-preview',
  279. reasoningEffort: 'max',
  280. })
  281. const unsupported = await api.sessions.selectModel(request({
  282. sessionId,
  283. provider: 'deepseek',
  284. model: 'private-preview',
  285. reasoningEffort: 'medium',
  286. }))
  287. expect(unsupported.result).toMatchObject({
  288. ok: false,
  289. error: {
  290. code: 'model-unavailable',
  291. message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
  292. },
  293. })
  294. const rejected = await api.sessions.selectModel(request({
  295. sessionId,
  296. provider: 'missing',
  297. model: 'model',
  298. }))
  299. expect(rejected.result).toEqual({
  300. ok: false,
  301. error: {
  302. code: 'model-unavailable',
  303. message: 'no adapter registered for provider "missing"',
  304. details: { provider: 'missing', model: 'model' },
  305. },
  306. })
  307. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  308. .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
  309. await ctx.fiber.dispose()
  310. })
  311. it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => {
  312. const { ctx, sessionId } = await harness()
  313. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  314. expectValue(await api.sessions.models(request({ sessionId })))
  315. const controller = new AbortController()
  316. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  317. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  318. expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
  319. expectValue(await api.sessions.selectModel(request({
  320. sessionId,
  321. provider: 'deepseek',
  322. model: 'private-preview',
  323. })))
  324. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  325. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  326. controller.abort()
  327. await iterator.return?.()
  328. await ctx.fiber.dispose()
  329. })
  330. it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => {
  331. const ctx = await hostContext()
  332. const api = createApiProxy(ctx, {
  333. provider: 'deepseek',
  334. model: 'deepseek-chat',
  335. cwd: '/tmp',
  336. workspaceRoot: '/tmp',
  337. })
  338. const controller = new AbortController()
  339. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  340. const initialMetrics = nextMetrics(iterator)
  341. const session = ctx.sessions.create()
  342. expect((await initialMetrics).contextWindow).toBeUndefined()
  343. session.append('request/header', {
  344. header: { config: { provider: 'deepseek', model: 'private-preview' } },
  345. reason: 'change',
  346. })
  347. const foreign = {
  348. id: session.id,
  349. session,
  350. status: 'running',
  351. ctx,
  352. } as Agent
  353. const foreignTarget: AgentLlmTargetRef = {
  354. current: { provider: 'foreign', model: 'foreign-model' },
  355. assembled: undefined,
  356. }
  357. const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget)
  358. const scheduledMetrics = nextMetrics(iterator)
  359. ctx.agents.register(foreign)
  360. expect((await scheduledMetrics).contextWindow).toBeUndefined()
  361. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  362. expect((await ctx.systemPrompt.assemble()).variables)
  363. .toMatchObject({ provider: 'foreign', model: 'foreign-model' })
  364. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  365. const signal = new AbortController().signal
  366. await expect(agentEvents(ctx, foreign).waterfall(
  367. 'agent/request', 1, 0, signal, () => Promise.resolve(seed),
  368. )).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
  369. disposeForeignTarget()
  370. expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider')
  371. await expect(agentEvents(ctx, foreign).waterfall(
  372. 'agent/request', 1, 1, signal, () => Promise.resolve(seed),
  373. )).resolves.toBe(seed)
  374. controller.abort()
  375. await iterator.return?.()
  376. await ctx.fiber.dispose()
  377. })
  378. it('drops capacity completion from a replaced agent that retains the exact session', async () => {
  379. const ctx = await hostContext()
  380. const deferred = new DeferredCatalogAdapter()
  381. ctx.llm.registerAdapter(['deferred'], deferred)
  382. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-lifecycle'))
  383. const retire = attachLifecycleAgent(ctx, lifecycle.session)
  384. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  385. const controller = new AbortController()
  386. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  387. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  388. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  389. retire()
  390. const detachLive = attachLifecycleAgent(ctx, lifecycle.session)
  391. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  392. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
  393. deferred.resolve(0, 64_000)
  394. await settleCapacityCompletion()
  395. deferred.resolve(1, 128_000)
  396. await settleCapacityCompletion()
  397. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  398. controller.abort()
  399. await iterator.return?.()
  400. detachLive()
  401. lifecycle.detach()
  402. await ctx.fiber.dispose()
  403. })
  404. it('drops capacity completion from a replaced session while its old agent remains live', async () => {
  405. const ctx = await hostContext()
  406. const deferred = new DeferredCatalogAdapter()
  407. ctx.llm.registerAdapter(['deferred'], deferred)
  408. const sessionId = SessionId('capacity-session-lifecycle')
  409. const retiredSession = attachLifecycleSession(ctx, sessionId)
  410. const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
  411. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  412. const controller = new AbortController()
  413. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  414. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  415. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  416. retiredSession.detach()
  417. const liveSession = attachLifecycleSession(ctx, sessionId, true)
  418. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  419. deferred.resolve(0, 64_000)
  420. await settleCapacityCompletion()
  421. retireAgent()
  422. const detachLiveAgent = attachLifecycleAgent(ctx, liveSession.session)
  423. const scheduled = await nextMetrics(iterator)
  424. expect(scheduled.logRevision).toBe(2)
  425. expect(scheduled.contextWindow).toBeUndefined()
  426. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
  427. deferred.resolve(1, 128_000)
  428. await settleCapacityCompletion()
  429. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  430. controller.abort()
  431. await iterator.return?.()
  432. detachLiveAgent()
  433. liveSession.detach()
  434. await ctx.fiber.dispose()
  435. })
  436. it('does not project retired agent capacity into replacement session snapshots', async () => {
  437. const ctx = await hostContext()
  438. const deferred = new DeferredCatalogAdapter()
  439. ctx.llm.registerAdapter(['deferred'], deferred)
  440. const sessionId = SessionId('capacity-snapshot-lifecycle')
  441. const retiredSession = attachLifecycleSession(ctx, sessionId)
  442. const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
  443. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  444. const primaryController = new AbortController()
  445. const primary = api.events.mux(request({}), primaryController.signal)[Symbol.asyncIterator]()
  446. expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
  447. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  448. deferred.resolve(0, 64_000)
  449. await settleCapacityCompletion()
  450. expect((await nextMetrics(primary)).contextWindow).toBe(64_000)
  451. retiredSession.detach()
  452. const replacement = attachLifecycleSession(ctx, sessionId)
  453. const createdBaseline = await nextMetrics(primary)
  454. replacement.session.append('user/message', {
  455. content: [{ type: 'text', text: 'replacement marker' }],
  456. source: { kind: 'plugin', plugin: 'test' },
  457. }, { surfaceOp: 'append' })
  458. const scheduledFlush = await nextMetrics(primary)
  459. const reconnectController = new AbortController()
  460. const reconnect = api.events.mux(request({}), reconnectController.signal)[Symbol.asyncIterator]()
  461. const reconnectBaseline = await nextMetrics(reconnect)
  462. expect(createdBaseline.logRevision).toBe(1)
  463. for (const metrics of [scheduledFlush, reconnectBaseline]) {
  464. expect(metrics.logRevision).toBe(2)
  465. }
  466. expect({
  467. created: createdBaseline.contextWindow,
  468. scheduled: scheduledFlush.contextWindow,
  469. reconnect: reconnectBaseline.contextWindow,
  470. }).toEqual({ created: undefined, scheduled: undefined, reconnect: undefined })
  471. retireAgent()
  472. const detachReplacementAgent = attachLifecycleAgent(ctx, replacement.session)
  473. expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
  474. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
  475. deferred.resolve(1, 128_000)
  476. await settleCapacityCompletion()
  477. expect((await nextMetrics(primary)).contextWindow).toBe(128_000)
  478. primaryController.abort()
  479. reconnectController.abort()
  480. await primary.return?.()
  481. await reconnect.return?.()
  482. detachReplacementAgent()
  483. replacement.detach()
  484. await ctx.fiber.dispose()
  485. })
  486. it('refreshes same-route capacity after adapter owner replacement', async () => {
  487. const ctx = await hostContext()
  488. const retiredAdapter = new DeferredCatalogAdapter()
  489. const retiredFiber = await installDeferredAdapter(ctx, retiredAdapter)
  490. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-lifecycle'))
  491. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  492. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  493. const controller = new AbortController()
  494. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  495. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  496. await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
  497. retiredAdapter.resolve(0, 64_000)
  498. await settleCapacityCompletion()
  499. expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
  500. await retiredFiber.dispose()
  501. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  502. const replacementAdapter = new DeferredCatalogAdapter()
  503. const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
  504. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  505. await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
  506. replacementAdapter.resolve(0, 128_000)
  507. await settleCapacityCompletion()
  508. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  509. expect(lifecycle.session.requestHeader()?.config).toMatchObject({
  510. provider: 'deferred',
  511. model: 'lifecycle-model',
  512. })
  513. controller.abort()
  514. await iterator.return?.()
  515. detachAgent()
  516. lifecycle.detach()
  517. await replacementFiber.dispose()
  518. await ctx.fiber.dispose()
  519. })
  520. it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => {
  521. const ctx = await hostContext()
  522. const retiredAdapter = new DeferredCatalogAdapter()
  523. const releaseUnload = Promise.withResolvers<undefined>()
  524. const releaseCancellationWait = Promise.withResolvers<undefined>()
  525. const abortObserved = Promise.withResolvers<undefined>()
  526. const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => {
  527. inner.llm.registerAdapter(['deferred'], retiredAdapter)
  528. inner.effect(
  529. () => () => releaseUnload.promise,
  530. 'test: hold adapter unload',
  531. )
  532. inner.effect(() => () => {
  533. const signal = retiredAdapter.pending[0]?.signal
  534. if (signal === undefined) throw new Error('pending capacity signal missing')
  535. const cancellation = new Promise<void>((resolve) => {
  536. const finish = () => {
  537. abortObserved.resolve(undefined)
  538. resolve()
  539. }
  540. if (signal.aborted) finish()
  541. else signal.addEventListener('abort', finish, { once: true })
  542. })
  543. return Promise.race([cancellation, releaseCancellationWait.promise])
  544. }, 'test: await capacity cancellation')
  545. }, { inject: ['llm'] }))
  546. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading'))
  547. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  548. const api = createApiProxy(ctx, {
  549. provider: 'deepseek',
  550. model: 'deepseek-chat',
  551. cwd: '/tmp',
  552. workspaceRoot: '/tmp',
  553. })
  554. const controller = new AbortController()
  555. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  556. const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
  557. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  558. await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
  559. expect(resolveModelInfo).toHaveBeenCalledOnce()
  560. const listSessions = vi.spyOn(ctx.sessions, 'list')
  561. listSessions.mockClear()
  562. const pendingFrame = iterator.next()
  563. const disposing = retiredFiber.dispose()
  564. try {
  565. await vi.waitFor(() => {
  566. expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true)
  567. })
  568. await abortObserved.promise
  569. expect(retiredFiber.state).toBe(FiberState.UNLOADING)
  570. retiredAdapter.resolve(0, 64_000)
  571. await settleCapacityCompletion()
  572. expect(resolveModelInfo).toHaveBeenCalledOnce()
  573. expect(listSessions).not.toHaveBeenCalled()
  574. const outcome = await Promise.race([
  575. pendingFrame.then(() => 'frame' as const),
  576. new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
  577. ])
  578. expect(outcome).toBe('idle')
  579. } finally {
  580. releaseCancellationWait.resolve(undefined)
  581. releaseUnload.resolve(undefined)
  582. await disposing
  583. }
  584. expect(retiredFiber.state).toBe(FiberState.DISPOSED)
  585. const settledFrame = await pendingFrame
  586. if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') {
  587. throw new Error('expected settled metrics refresh')
  588. }
  589. expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined()
  590. await settleCapacityCompletion()
  591. expect(resolveModelInfo).toHaveBeenCalledTimes(2)
  592. const replacementAdapter = new DeferredCatalogAdapter()
  593. const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
  594. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  595. await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
  596. expect(resolveModelInfo).toHaveBeenCalledTimes(3)
  597. replacementAdapter.resolve(0, 128_000)
  598. await settleCapacityCompletion()
  599. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  600. controller.abort()
  601. await iterator.return?.()
  602. detachAgent()
  603. lifecycle.detach()
  604. await replacementFiber.dispose()
  605. await ctx.fiber.dispose()
  606. })
  607. it('aborts pending capacity when the API proxy fiber is disposed', async () => {
  608. const ctx = await hostContext()
  609. const deferred = new DeferredCatalogAdapter()
  610. ctx.llm.registerAdapter(['deferred'], deferred)
  611. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown'))
  612. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  613. const proxy = Promise.withResolvers<ReturnType<typeof createApiProxy>>()
  614. const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => {
  615. proxy.resolve(createApiProxy(inner, {
  616. provider: 'deepseek',
  617. model: 'deepseek-chat',
  618. cwd: '/tmp',
  619. workspaceRoot: '/tmp',
  620. }))
  621. }, { inject: ['agents', 'sessions', 'userInteraction'] }))
  622. const api = await proxy.promise
  623. const controller = new AbortController()
  624. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  625. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  626. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  627. expect(deferred.pending[0]?.signal?.aborted).toBe(false)
  628. await proxyFiber.dispose()
  629. expect(deferred.pending[0]?.signal?.aborted).toBe(true)
  630. deferred.resolve(0, 64_000)
  631. await settleCapacityCompletion()
  632. const pendingFrame = iterator.next()
  633. const outcome = await Promise.race([
  634. pendingFrame.then(() => 'frame' as const),
  635. new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
  636. ])
  637. expect(outcome).toBe('idle')
  638. controller.abort()
  639. await expect(pendingFrame).resolves.toMatchObject({ done: true })
  640. await iterator.return?.()
  641. detachAgent()
  642. lifecycle.detach()
  643. await ctx.fiber.dispose()
  644. })
  645. it('does not read the sessions service after its disposal status', async () => {
  646. let sessionsFiber: Fiber | undefined
  647. const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
  648. if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
  649. createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  650. const sessions = ctx.get('sessions')
  651. if (sessions === undefined) throw new Error('sessions service missing')
  652. const list = vi.spyOn(sessions, 'list').mockImplementation(() => {
  653. throw new Error('disposed sessions service read')
  654. })
  655. await expect(sessionsFiber.dispose()).resolves.toBeUndefined()
  656. expect(list).not.toHaveBeenCalled()
  657. await ctx.fiber.dispose()
  658. })
  659. it('invalidates pending capacity before SessionStore teardown can reach its callback', async () => {
  660. let sessionsFiber: Fiber | undefined
  661. const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
  662. if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
  663. const deferred = new DeferredCatalogAdapter()
  664. ctx.llm.registerAdapter(['deferred'], deferred)
  665. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-session-store-teardown'))
  666. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  667. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  668. const controller = new AbortController()
  669. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  670. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  671. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  672. const agents = ctx.get('agents')
  673. if (agents === undefined) throw new Error('agent registry missing')
  674. expect(agents.get(lifecycle.session.id)).toBeDefined()
  675. const getAgent = vi.spyOn(agents, 'get')
  676. await sessionsFiber.dispose()
  677. getAgent.mockClear()
  678. deferred.resolve(0, 64_000)
  679. await settleCapacityCompletion()
  680. expect(getAgent).not.toHaveBeenCalled()
  681. const pendingFrame = iterator.next()
  682. const outcome = await Promise.race([
  683. pendingFrame.then(() => 'frame' as const),
  684. new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
  685. ])
  686. expect(outcome).toBe('idle')
  687. controller.abort()
  688. await expect(pendingFrame).resolves.toMatchObject({ done: true })
  689. await iterator.return?.()
  690. detachAgent()
  691. lifecycle.detach()
  692. await ctx.fiber.dispose()
  693. })
  694. it('publishes unknown metrics after AgentRegistry terminal disposal with mux active', async () => {
  695. let agentsFiber: Fiber | undefined
  696. const ctx = await hostContext(undefined, (fiber) => { agentsFiber = fiber })
  697. if (agentsFiber === undefined) throw new Error('agent registry fiber missing')
  698. const deferred = new DeferredCatalogAdapter()
  699. ctx.llm.registerAdapter(['deferred'], deferred)
  700. const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-registry-disposed'))
  701. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  702. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  703. const controller = new AbortController()
  704. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  705. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  706. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  707. deferred.resolve(0, 64_000)
  708. await settleCapacityCompletion()
  709. expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
  710. await agentsFiber.dispose()
  711. const refresh = nextMetrics(iterator).then(
  712. metrics => ({ kind: 'metrics' as const, metrics }),
  713. () => ({ kind: 'error' as const }),
  714. )
  715. const outcome = await Promise.race([
  716. refresh,
  717. new Promise<{ kind: 'idle' }>((resolve) => {
  718. setImmediate(() => { resolve({ kind: 'idle' }) })
  719. }),
  720. ])
  721. controller.abort()
  722. await refresh
  723. await iterator.return?.()
  724. detachAgent()
  725. lifecycle.detach()
  726. await ctx.fiber.dispose()
  727. expect(outcome.kind).toBe('metrics')
  728. if (outcome.kind === 'metrics') {
  729. expect(outcome.metrics.contextWindow).toBeUndefined()
  730. expect(outcome.metrics.logRevision).toBe(1)
  731. }
  732. })
  733. it.each(['agents', 'sessions'] as const)(
  734. 'drops capacity completion while %s is unavailable during unload',
  735. async (serviceName) => {
  736. let sessionsFiber: Fiber | undefined
  737. let agentsFiber: Fiber | undefined
  738. const ctx = await hostContext(
  739. (fiber) => { sessionsFiber = fiber },
  740. (fiber) => { agentsFiber = fiber },
  741. )
  742. const heldFiber = serviceName === 'sessions' ? sessionsFiber : agentsFiber
  743. if (heldFiber === undefined) throw new Error(`${serviceName} fiber missing`)
  744. const unloadStarted = Promise.withResolvers<undefined>()
  745. const releaseUnload = Promise.withResolvers<undefined>()
  746. heldFiber.ctx.effect(() => () => {
  747. unloadStarted.resolve(undefined)
  748. return releaseUnload.promise
  749. }, `test: hold ${serviceName} unload`)
  750. const deferred = new DeferredCatalogAdapter()
  751. ctx.llm.registerAdapter(['deferred'], deferred)
  752. const lifecycle = attachLifecycleSession(ctx, SessionId(`capacity-${serviceName}-unloading`))
  753. const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
  754. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  755. const controller = new AbortController()
  756. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  757. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  758. await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
  759. const agents = ctx.get('agents')
  760. if (agents === undefined) throw new Error('agent registry missing')
  761. const sessions = ctx.get('sessions')
  762. if (sessions === undefined) throw new Error('sessions service missing')
  763. const getAgent = vi.spyOn(agents, 'get')
  764. const getSession = vi.spyOn(sessions, 'get')
  765. const disposing = heldFiber.dispose()
  766. await unloadStarted.promise
  767. await vi.waitFor(() => { expect(ctx.get(serviceName)).toBeUndefined() })
  768. expect(heldFiber.state).toBe(FiberState.UNLOADING)
  769. getAgent.mockClear()
  770. getSession.mockClear()
  771. deferred.resolve(0, 64_000)
  772. await settleCapacityCompletion()
  773. const agentReads = getAgent.mock.calls.length
  774. const sessionReads = getSession.mock.calls.length
  775. const pendingFrame = iterator.next()
  776. const outcome = await Promise.race([
  777. pendingFrame.then(() => 'frame' as const),
  778. new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
  779. ])
  780. controller.abort()
  781. await expect(pendingFrame).resolves.toMatchObject({ done: true })
  782. await iterator.return?.()
  783. releaseUnload.resolve(undefined)
  784. await disposing
  785. detachAgent()
  786. lifecycle.detach()
  787. await ctx.fiber.dispose()
  788. expect(agentReads).toBe(0)
  789. expect(sessionReads).toBe(0)
  790. expect(outcome).toBe('idle')
  791. },
  792. )
  793. })