mock-acp-server.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. /**
  2. * A minimal mock ACP AGENT, run as a subprocess, for the keyless
  3. * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
  4. * fully scripted by environment variables — no model, no network:
  5. *
  6. * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
  7. * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
  8. * (`end_turn` default, or `max_tokens`/`refusal`/…).
  9. * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
  10. * a `session/cancel`), to exercise the client's cancel path.
  11. * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
  12. * `session/cancel` but NEVER resolves the pending prompt
  13. * and never exits — a non-cooperative child. The backend's
  14. * `result` must still settle `aborted` on its own and
  15. * `dispose()` must still kill the process.
  16. * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
  17. * before answering, to exercise the client's auto-answer.
  18. * - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead:
  19. * the agent PROCESS's `process.cwd()` and the `cwd` the
  20. * client announced in `session/new` — so a test can assert
  21. * where the child actually ran and what workspace it was
  22. * told it has.
  23. * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
  24. * handler is in flight (it has streamed its chunk). A test
  25. * polls for this file to cancel on a CONDITION rather than
  26. * an arbitrary timeout (subprocess cold-start is variable).
  27. * - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new`
  28. * response to exercise startup rollback.
  29. * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
  30. * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
  31. * acp-agent's EOF-driven quiesce+flush, then touches this
  32. * path and exits ON ITS OWN — no signal. Stands in for a
  33. * child whose durable flush completes only if dispose
  34. * gives EOF a real window before escalating to SIGTERM.
  35. * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare
  36. * timer) but install a SIGTERM handler that exits (and, if
  37. * MOCK_SIGTERM_FILE is set, touches it as an observable
  38. * proof the SIGTERM rung fired). The child ignores the
  39. * graceful EOF window yet dies cooperatively on SIGTERM —
  40. * exercising dispose's middle tier (exit during the SIGTERM
  41. * grace, before the SIGKILL escalation). Touches
  42. * MOCK_READY_FILE once armed.
  43. *
  44. * It is not a test spec: the specs launch this protocol-only fixture through
  45. * the mode-aware example resolver (tsx in source mode, Node type stripping in
  46. * built mode). It imports no harness code or workspace paths.
  47. *
  48. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
  49. */
  50. import { randomUUID } from 'node:crypto'
  51. import { existsSync, writeFileSync } from 'node:fs'
  52. import { Readable, Writable } from 'node:stream'
  53. import {
  54. AgentSideConnection,
  55. ndJsonStream,
  56. PROTOCOL_VERSION,
  57. type Agent,
  58. type CancelNotification,
  59. type AuthenticateRequest,
  60. type InitializeRequest,
  61. type InitializeResponse,
  62. type NewSessionRequest,
  63. type NewSessionResponse,
  64. type PromptRequest,
  65. type PromptResponse,
  66. type StopReason,
  67. } from '@agentclientprotocol/sdk'
  68. const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
  69. const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
  70. const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
  71. const HANG = process.env.MOCK_HANG === '1'
  72. const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
  73. const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
  74. const THOUGHT = process.env.MOCK_THOUGHT === '1'
  75. const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
  76. const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
  77. const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
  78. const READY_FILE = process.env.MOCK_READY_FILE
  79. const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
  80. // When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
  81. // until GO appears — letting a test cancel mid-newSession deterministically.
  82. const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined
  83. ? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO }
  84. : undefined
  85. function makeAgent(conn: AgentSideConnection): Agent {
  86. // Pending cancel resolver for the HANG path: a `session/cancel` resolves the
  87. // prompt with `cancelled`.
  88. let resolveCancel: ((reason: StopReason) => void) | undefined
  89. // The cwd the client announced in `session/new`, echoed under MOCK_ECHO_CWD.
  90. let sessionCwd: string | undefined
  91. return {
  92. initialize(_params: InitializeRequest): Promise<InitializeResponse> {
  93. return Promise.resolve({
  94. protocolVersion: PROTOCOL_VERSION,
  95. agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
  96. authMethods: [],
  97. })
  98. },
  99. async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
  100. sessionCwd = params.cwd
  101. // Optionally signal "newSession reached" and block until released, so a
  102. // test can cancel DURING newSession (the early-cancel race window) on a
  103. // condition rather than a timeout.
  104. if (NEWSESSION_GATE !== undefined) {
  105. writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
  106. while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
  107. }
  108. if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse
  109. return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() }
  110. },
  111. authenticate(_params: AuthenticateRequest): Promise<void> {
  112. // No auth methods advertised; nothing to do.
  113. return Promise.resolve()
  114. },
  115. async prompt(params: PromptRequest): Promise<PromptResponse> {
  116. if (CRASH_ON_PROMPT) process.exit(1)
  117. if (WANT_PERMISSION) {
  118. // Ask the client to approve before answering; honor its decision. Under
  119. // MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
  120. // client finds no allow option and must fall back to cancelled.
  121. const options = NO_ALLOW
  122. ? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }]
  123. : [
  124. { optionId: 'yes', name: 'Allow', kind: 'allow_once' as const },
  125. { optionId: 'no', name: 'Reject', kind: 'reject_once' as const },
  126. ]
  127. const decision = await conn.requestPermission({
  128. sessionId: params.sessionId,
  129. toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
  130. options,
  131. })
  132. if (decision.outcome.outcome === 'cancelled') {
  133. return { stopReason: 'cancelled' }
  134. }
  135. }
  136. // Optionally emit a NON-message update first (a thought), so the client's
  137. // sessionUpdate sees an update it must consume-but-not-accumulate.
  138. if (THOUGHT) {
  139. await conn.sessionUpdate({
  140. sessionId: params.sessionId,
  141. update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
  142. })
  143. }
  144. // Stream the canned assistant text as one chunk (or, under MOCK_ECHO_CWD,
  145. // the observable process cwd + announced session cwd).
  146. await conn.sessionUpdate({
  147. sessionId: params.sessionId,
  148. update: {
  149. sessionUpdate: 'agent_message_chunk',
  150. content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT },
  151. },
  152. })
  153. // Signal "prompt is in flight" by touching the readiness file, so a test
  154. // can wait on a CONDITION (file exists) rather than an arbitrary timeout
  155. // before cancelling — deterministic regardless of subprocess cold-start.
  156. if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready')
  157. if (HANG) {
  158. // Never resolve on our own: wait for session/cancel to settle us.
  159. return new Promise<PromptResponse>((resolve) => {
  160. resolveCancel = (reason) => { resolve({ stopReason: reason }) }
  161. })
  162. }
  163. return { stopReason: STOP }
  164. },
  165. cancel(_params: CancelNotification): Promise<void> {
  166. if (CRASH_ON_CANCEL) {
  167. // Exit hard instead of answering — tears the ACP pipe, so the client's
  168. // pending prompt REJECTS (exercises the backend's catch-while-cancelled
  169. // path: a transport failure after a cancel settles `aborted`).
  170. process.exit(1)
  171. }
  172. if (IGNORE_CANCEL) {
  173. // A NON-COOPERATIVE child: receive session/cancel but never resolve the
  174. // pending prompt and never exit. The backend's `result` must still settle
  175. // `aborted` on its own (the cancel-settle race), and `dispose()` must
  176. // still kill the process — proving cancellation does not depend on the
  177. // child cooperating.
  178. return Promise.resolve()
  179. }
  180. resolveCancel?.('cancelled')
  181. return Promise.resolve()
  182. },
  183. }
  184. }
  185. new AgentSideConnection(
  186. makeAgent,
  187. ndJsonStream(
  188. Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
  189. Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
  190. ),
  191. )
  192. // Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
  193. // neither quiesces on EOF nor dies on the graceful signal — exercising the
  194. // backend dispose path's SIGKILL escalation. Without this the process exits
  195. // normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
  196. // a test waits for that CONDITION before disposing (the trap must be in place,
  197. // not merely the process spawned — otherwise SIGTERM hits the default handler).
  198. if (process.env.MOCK_TRAP_SIGTERM === '1') {
  199. process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
  200. // Keep the event loop alive (a bare timer) so nothing else lets it exit.
  201. setInterval(() => { /* stay alive until SIGKILL */ }, 1000)
  202. if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
  203. }
  204. // Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on
  205. // stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to
  206. // "flush", then touch the marker and exit ON OUR OWN — no signal involved. The
  207. // beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before
  208. // the beat completes (no graceful window, or an EOF grace shorter than the
  209. // flush) default-terminates this process and the marker is missing; a dispose
  210. // that gives the EOF quiesce enough window first lets the flush land.
  211. if (FLUSH_ON_EOF !== undefined) {
  212. const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
  213. process.stdin.on('end', () => {
  214. setTimeout(() => {
  215. writeFileSync(FLUSH_ON_EOF, 'flushed')
  216. process.exit(0)
  217. }, flushDelayMs)
  218. })
  219. }
  220. // Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF
  221. // window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the
  222. // child ignores the graceful EOF window yet dies cooperatively on SIGTERM,
  223. // exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the
  224. // SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an
  225. // OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle
  226. // rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs
  227. // and the marker is missing. Touch READY_FILE once armed (a test waits on it).
  228. if (process.env.MOCK_IGNORE_EOF === '1') {
  229. const sigtermFile = process.env.MOCK_SIGTERM_FILE
  230. process.on('SIGTERM', () => {
  231. if (sigtermFile !== undefined) writeFileSync(sigtermFile, 'sigterm')
  232. process.exit(0)
  233. })
  234. setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
  235. if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
  236. }