fake-api.client.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
  2. // data source on a real clock; behavior tests need per-case responses and
  3. // deferred-controlled timing). The generation source is a hand pump.
  4. import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts'
  5. import type { ConnectionGenerationSource } from '../src/client/connection.ts'
  6. import { RpcId } from '../src/client/api.ts'
  7. export interface Deferred<T> {
  8. promise: Promise<T>
  9. resolve(value: T): void
  10. reject(error: unknown): void
  11. }
  12. /** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
  13. export function deferred<T>(): Deferred<T> {
  14. let resolve!: (value: T) => void
  15. let reject!: (error: unknown) => void
  16. const promise = new Promise<T>((res, rej) => {
  17. resolve = res
  18. reject = rej
  19. })
  20. return { promise, resolve, reject }
  21. }
  22. let nextRpc = 0
  23. export function ok<T>(value: T): RpcResponse<T> {
  24. return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
  25. }
  26. type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
  27. interface StreamConn {
  28. feed(item: StreamItem): void
  29. }
  30. export class FakeApiClient implements IApiClient {
  31. /** Chronological call record: [method, payload]. */
  32. readonly calls: { method: string; payload: unknown }[] = []
  33. // Programmable slots (defaults answer OK-empty); reassign per case.
  34. onDescribe: (payload: unknown) => Promise<RpcResponse<{
  35. version: string
  36. cwd: string
  37. attachedSessions: number
  38. home: string
  39. canOpenPath: boolean
  40. }>> =
  41. () => Promise.resolve(ok({
  42. version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
  43. }))
  44. onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
  45. () => Promise.resolve(ok({ path: null }))
  46. onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
  47. () => Promise.resolve(ok({ opened: true as const }))
  48. onListDirectory: (payload: unknown) => Promise<RpcResponse<{
  49. path: string
  50. home: string
  51. crumbs: { name: string; path: string; hidden: boolean }[]
  52. entries: { name: string; path: string; hidden: boolean }[]
  53. truncated: boolean
  54. }>> =
  55. () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
  56. onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
  57. () => Promise.resolve(ok({ path: '/home/fake/new' }))
  58. private readonly generationConns: StreamConn[] = []
  59. readonly subagents: IApiClient['subagents'] = {
  60. list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
  61. entries: [],
  62. parentAvailable: true,
  63. }))),
  64. prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
  65. messageId: 'fake-message' as never,
  66. }))),
  67. interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
  68. accepted: true as const,
  69. }))),
  70. }
  71. readonly host: IApiClient['host'] = {
  72. describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
  73. pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
  74. listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
  75. createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
  76. openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
  77. }
  78. // Payloads stay `unknown` (lint-lane note above); response rows are the real
  79. // wire shapes so cases can program catalogs and skill lists without casts.
  80. onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
  81. = () => Promise.resolve(ok({ skills: [] }))
  82. readonly agentPresets: IApiClient['agentPresets'] = {
  83. list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
  84. select: (payload: { agentPreset: string }) =>
  85. this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
  86. read: (payload: { agentPreset: string }) =>
  87. this.record('agentPreset.read', payload, Promise.resolve(ok({
  88. agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
  89. }))),
  90. copy: (payload: { agentPreset: string }) =>
  91. this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
  92. openDocument: (payload: { agentPreset: string }) =>
  93. this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
  94. remove: (payload: { agentPreset: string }) =>
  95. this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
  96. }
  97. readonly skills: IApiClient['skills'] = {
  98. list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
  99. }
  100. readonly goals: IApiClient['goals'] = {
  101. create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
  102. edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
  103. pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
  104. resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
  105. complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
  106. clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
  107. }
  108. readonly settings: IApiClient['settings'] = {
  109. describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
  110. openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
  111. update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
  112. replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
  113. mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
  114. }
  115. readonly credentials: IApiClient['credentials'] = {
  116. describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
  117. set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
  118. unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
  119. }
  120. readonly llm: IApiClient['llm'] = {
  121. providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
  122. models: payload => this.record('llm.models', payload, Promise.resolve(ok({
  123. default: { provider: 'fixture', model: 'fixture' },
  124. routableProviders: [],
  125. groups: [],
  126. failures: [],
  127. }))),
  128. discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
  129. }
  130. /** When true, the source never reports ready. */
  131. suppressGenerationReady = false
  132. /** When true, ready callbacks remain parked until the test releases them. */
  133. holdGenerationReady = false
  134. private heldOpens: (() => void)[] = []
  135. releaseGenerationReady(): void {
  136. const held = this.heldOpens
  137. this.heldOpens = []
  138. for (const fire of held) fire()
  139. }
  140. readonly generation: ConnectionGenerationSource = (signal, ready) =>
  141. this.openGeneration(signal, ready)
  142. /** End (clean close) or fail (throw) every open stream — reconnect-path material. */
  143. endStreams(): void {
  144. for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' })
  145. }
  146. failStreams(error: unknown): void {
  147. for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error })
  148. }
  149. get openGenerationCount(): number {
  150. return this.generationConns.length
  151. }
  152. callsOf(method: string): unknown[] {
  153. return this.calls.filter(c => c.method === method).map(c => c.payload)
  154. }
  155. private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
  156. this.calls.push({ method, payload })
  157. return response
  158. }
  159. private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise<void> {
  160. const inbox: StreamItem[] = []
  161. let wake: (() => void) | null = null
  162. const conn: StreamConn = {
  163. feed: (item) => {
  164. inbox.push(item)
  165. wake?.()
  166. },
  167. }
  168. this.generationConns.push(conn)
  169. if (this.holdGenerationReady) this.heldOpens.push(onOpen)
  170. else if (!this.suppressGenerationReady) onOpen()
  171. try {
  172. while (!signal.aborted) {
  173. while (inbox.length > 0) {
  174. const item = inbox.shift() as StreamItem
  175. if (item.kind === 'end') return
  176. if (item.kind === 'fail') throw item.error
  177. }
  178. await new Promise<void>((resolve) => {
  179. wake = resolve
  180. signal.addEventListener('abort', () => { resolve() }, { once: true })
  181. })
  182. wake = null
  183. }
  184. } finally {
  185. this.generationConns.splice(this.generationConns.indexOf(conn), 1)
  186. }
  187. }
  188. }