api-proxy-cold.spec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. /**
  2. * Cold-session and degenerate-composition paths of the host ApiProxy:
  3. * sessions.list merging persisted-but-unattached summaries (mtime source,
  4. * createdAt fallbacks, lineage projection), the resume error split when
  5. * the composition has no persistence gate and no agent factory, and the
  6. * agent-busy mapping of a synchronous prompt rejection.
  7. */
  8. import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { describe, expect, it } from 'vitest'
  12. import { Context } from 'cordis'
  13. import SessionStore from '@deepseek-ai/dsh-session'
  14. import AgentRegistry from '@deepseek-ai/dsh-agent'
  15. import type { Agent } from '@deepseek-ai/dsh-agent'
  16. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  17. import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  18. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  19. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  20. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  21. const sid = (id: string): SessionId => id as SessionId
  22. let nextRpc = 1
  23. function request<P>(payload: P): RpcRequest<P> {
  24. return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
  25. }
  26. function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
  27. return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
  28. }
  29. describe('sessions.list cold merge', () => {
  30. it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
  31. const ctx = new Context()
  32. await ctx.plugin(SessionStore)
  33. await ctx.plugin(UserInteractionService)
  34. const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
  35. const logPath = join(root, 'a.log')
  36. writeFileSync(logPath, 'log-bytes')
  37. utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
  38. const metas = [
  39. header('session-a', 1000),
  40. header('session-b', 2000, { parentSession: sid('session-parent') }),
  41. header('session-c', 1500),
  42. ]
  43. // Structural fake of the persistence face list() consumes: list + locate.
  44. // locate: a real per-session file (mtime wins), a backend without one
  45. // (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
  46. // → createdAt).
  47. ctx.provide('sessionPersistence', {
  48. list: () => Promise.resolve(metas),
  49. locate: (meta: SessionHeader) => {
  50. if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
  51. if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
  52. return undefined
  53. },
  54. })
  55. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  56. const response = await api.sessions.list(request({}))
  57. expect(response.result.ok).toBe(true)
  58. if (!response.result.ok) throw new Error('unreachable')
  59. const items = response.result.value.items
  60. expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
  61. const [a, b, c] = items
  62. expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
  63. expect(a?.running).toBe(false)
  64. // Cold summaries are never blank: lazy persistence keeps never-appended
  65. // sessions out of list(), so a listed session necessarily has events.
  66. expect(items.every(item => !item.blank)).toBe(true)
  67. expect(a?.cwd).toBe('/proj')
  68. expect(a?.parentSessionId).toBeUndefined()
  69. expect(b?.updatedAt).toBe(2000)
  70. expect(b?.parentSessionId).toBe('session-parent')
  71. expect(c?.updatedAt).toBe(1500)
  72. })
  73. })
  74. describe('degenerate composition (no persistence, no factory)', () => {
  75. it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
  76. const ctx = new Context()
  77. await ctx.plugin(SessionStore)
  78. await ctx.plugin(AgentRegistry)
  79. await ctx.plugin(UserInteractionService)
  80. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  81. const listed = await api.sessions.list(request({}))
  82. expect(listed.result.ok).toBe(true)
  83. if (listed.result.ok) expect(listed.result.value.items).toEqual([])
  84. // No persistence → the servable gate passes silently; the factory-less
  85. // registry then rejects resume, which is NOT a SessionNotFound.
  86. const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
  87. expect(response.result.ok).toBe(false)
  88. if (!response.result.ok) {
  89. expect(response.result.error.code).toBe('internal')
  90. expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
  91. }
  92. })
  93. })
  94. describe('sessions.prompt synchronous rejection', () => {
  95. it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
  96. const ctx = new Context()
  97. await ctx.plugin(SessionStore)
  98. await ctx.plugin(AgentRegistry)
  99. await ctx.plugin(UserInteractionService)
  100. const session = ctx.sessions.create(sid('session-throwing'))
  101. // A live structural stub whose delivery verbs throw synchronously, the
  102. // shape a disposed loop presents at this seam.
  103. ctx.agents.register({
  104. id: session.id,
  105. session,
  106. status: 'idle',
  107. ctx,
  108. followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
  109. steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
  110. } as unknown as Agent)
  111. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  112. for (const mode of ['queue', 'steer'] as const) {
  113. const response = await api.sessions.prompt(request({
  114. sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }],
  115. }))
  116. expect(response.result.ok).toBe(false)
  117. if (!response.result.ok) {
  118. expect(response.result.error.code).toBe('agent-busy')
  119. expect(response.result.error.message).toBe('prompt rejected')
  120. expect(response.result.error.details).toEqual({
  121. reason: 'Error: agent "session-throwing" lifecycle disposed',
  122. })
  123. }
  124. }
  125. })
  126. })