fake-remote.client.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * The Remote slice, scripted: stats answered by the spec, one push source per
  3. * opened `changes` generation, and a supervisor that runs one generation and
  4. * classifies its end the way the real one does.
  5. */
  6. import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  7. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  8. import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts'
  9. import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts'
  10. /** One scripted Host `changes` generation: frames pushed by the spec, ended by abort. */
  11. export class Source<T> implements AsyncIterable<T> {
  12. private readonly queue: Array<
  13. { kind: 'value'; value: T; delivered?: () => void } | { kind: 'end' } | { kind: 'fail'; error: unknown }
  14. > = []
  15. private wake: (() => void) | undefined
  16. aborted = false
  17. constructor(signal: AbortSignal) {
  18. this.aborted = signal.aborted
  19. signal.addEventListener('abort', () => {
  20. this.aborted = true
  21. this.wake?.()
  22. }, { once: true })
  23. }
  24. push(value: T): void {
  25. this.queue.push({ kind: 'value', value })
  26. this.wake?.()
  27. }
  28. /** Resolve after the consumer processes this frame and asks for the next one. */
  29. deliver(value: T): Promise<void> {
  30. return new Promise((resolve) => {
  31. this.queue.push({ kind: 'value', value, delivered: resolve })
  32. this.wake?.()
  33. })
  34. }
  35. end(): void {
  36. this.queue.push({ kind: 'end' })
  37. this.wake?.()
  38. }
  39. fail(error: unknown): void {
  40. this.queue.push({ kind: 'fail', error })
  41. this.wake?.()
  42. }
  43. async *[Symbol.asyncIterator](): AsyncIterator<T> {
  44. while (true) {
  45. if (this.aborted) return
  46. const next = this.queue.shift()
  47. if (next === undefined) {
  48. await new Promise<void>((resolve) => { this.wake = resolve })
  49. this.wake = undefined
  50. continue
  51. }
  52. if (next.kind === 'value') {
  53. yield next.value
  54. next.delivered?.()
  55. continue
  56. }
  57. if (next.kind === 'end') return
  58. throw next.error
  59. }
  60. }
  61. }
  62. /** One `stat` call awaiting the spec's answer. */
  63. export interface PendingStat {
  64. readonly sessionId: SessionId
  65. readonly path: string
  66. readonly signal: AbortSignal | undefined
  67. resolve(result: RemoteResult<WorkspaceFileStat>): void
  68. }
  69. /** One opened Host watch whose acknowledgement and changes the spec controls. */
  70. interface OpenedWatch {
  71. readonly sessionId: SessionId
  72. readonly source: Source<WorkspaceFileWatchFrame>
  73. }
  74. /** The scripted Remote: every stat waits for the spec, every session stream is a {@link Source}. */
  75. export class FakeRemote implements WorkspaceFilesRemote {
  76. readonly calls: Array<'changes' | 'accept' | 'stat'> = []
  77. readonly opened: OpenedWatch[] = []
  78. readonly disposed: string[] = []
  79. readonly stats: PendingStat[] = []
  80. private readonly statWaiters = new Map<number, Array<(stat: PendingStat) => void>>()
  81. private readonly watchWaiters = new Map<number, Array<(watch: OpenedWatch) => void>>()
  82. /** False lets a spec keep the Host subscription unacknowledged. */
  83. autoReady = true
  84. /** When set, every stream dispose waits for it before settling. */
  85. disposeGate: Promise<void> | undefined
  86. /** Wait for an indexed stat request without advancing or assuming scheduler timing. */
  87. waitForStat(index: number): Promise<PendingStat> {
  88. const stat = this.stats[index]
  89. if (stat !== undefined) return Promise.resolve(stat)
  90. return new Promise((resolve) => {
  91. const waiters = this.statWaiters.get(index) ?? []
  92. waiters.push(resolve)
  93. this.statWaiters.set(index, waiters)
  94. })
  95. }
  96. /** Wait until the Client calls changes, independently of the Host acknowledgement. */
  97. waitForChanges(index: number): Promise<OpenedWatch> {
  98. const watch = this.opened[index]
  99. if (watch !== undefined) return Promise.resolve(watch)
  100. return new Promise((resolve) => {
  101. const waiters = this.watchWaiters.get(index) ?? []
  102. waiters.push(resolve)
  103. this.watchWaiters.set(index, waiters)
  104. })
  105. }
  106. $stream<Item>(options: SupervisedStreamOptions<Item>): SupervisedStream<Item> {
  107. const controller = new AbortController()
  108. const disposed = this.disposed
  109. const calls = this.calls
  110. const done = Promise.withResolvers<undefined>()
  111. return {
  112. async *[Symbol.asyncIterator]() {
  113. try {
  114. let accepted = false
  115. for await (const value of options.open(controller.signal)) {
  116. if (controller.signal.aborted) return
  117. yield { value, accept: () => { accepted = true; calls.push('accept') } }
  118. }
  119. if (controller.signal.aborted) return
  120. throw options.ended(accepted)
  121. } finally {
  122. done.resolve(undefined)
  123. }
  124. },
  125. dispose: async () => {
  126. disposed.push(options.name)
  127. controller.abort(new Error('disposed'))
  128. await this.disposeGate
  129. await done.promise
  130. },
  131. }
  132. }
  133. readonly workspaceFiles = {
  134. stat: (sessionId: SessionId, path: string, signal?: AbortSignal): Promise<RemoteResult<WorkspaceFileStat>> =>
  135. new Promise((resolve) => {
  136. this.calls.push('stat')
  137. const index = this.stats.length
  138. const stat = { sessionId, path, signal, resolve }
  139. this.stats.push(stat)
  140. for (const waiter of this.statWaiters.get(index) ?? []) waiter(stat)
  141. this.statWaiters.delete(index)
  142. }),
  143. changes: (sessionId: SessionId, signal?: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> => {
  144. this.calls.push('changes')
  145. if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream')
  146. const source = new Source<WorkspaceFileWatchFrame>(signal)
  147. const watch = { sessionId, source }
  148. const index = this.opened.length
  149. this.opened.push(watch)
  150. for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch)
  151. this.watchWaiters.delete(index)
  152. if (this.autoReady) source.push({ kind: 'ready' })
  153. return source
  154. },
  155. }
  156. }
  157. /** Let queued microtasks and background pumps settle. */
  158. export const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
  159. /** The next item, or `'silent'` when none arrives within a tick. */
  160. export async function peek<T>(it: AsyncIterator<T>): Promise<IteratorResult<T> | 'silent'> {
  161. return Promise.race([it.next(), settle().then(() => 'silent' as const)])
  162. }