fake-runtime.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. #!/usr/bin/env node
  2. /**
  3. * Scripted stand-in for the DeepSeek Harness SDK runtime, driven entirely by
  4. * env vars — no model, no network, no harness imports. Speaks the runtime's
  5. * newline-delimited JSON-RPC protocol on stdio: answers `initialize`,
  6. * `session/prompt` (streaming scripted `session.event` notifications, then
  7. * `session.finished`, then the response), and `shutdown`.
  8. *
  9. * Script vocabulary (all optional):
  10. * - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
  11. * - `FAKE_STATUS`: the `session.finished` status (default `ok`).
  12. * - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
  13. * - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
  14. * - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
  15. * - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
  16. * - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
  17. * - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted).
  18. * - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7.
  19. * - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this
  20. * marker file does NOT exist yet, creating it — so the first runtime
  21. * process fails the handshake and a respawned one succeeds (retry probe).
  22. * - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd
  23. * (wire-visible spawn-cwd probe).
  24. * - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
  25. * the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
  26. * array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
  27. * member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
  28. * string (wire-validation probes).
  29. * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
  30. * assistant/message for a usage-only max-tokens step.
  31. * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
  32. * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
  33. * arrives, then poll for the GO file before answering (deterministic
  34. * cancel-during-handshake window).
  35. * - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
  36. * - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
  37. * answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
  38. * before the protocol failure (partial-output retention probe).
  39. * - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe).
  40. * - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe).
  41. * - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
  42. * - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
  43. * - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
  44. * - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
  45. */
  46. import { appendFileSync, existsSync, writeFileSync } from 'node:fs'
  47. import process from 'node:process'
  48. import { createInterface } from 'node:readline'
  49. const env = process.env
  50. if (env.FAKE_STDERR !== undefined) process.stderr.write(`${env.FAKE_STDERR}\n`)
  51. if (env.FAKE_STDERR_NO_NEWLINE !== undefined) process.stderr.write(env.FAKE_STDERR_NO_NEWLINE)
  52. if (env.FAKE_EXIT_BEFORE_INIT !== undefined) process.exit(3)
  53. if (env.FAKE_IGNORE_EOF !== undefined) {
  54. // Simulate a runtime that never quiesces from EOF so the dispose ladder
  55. // must escalate; record which rung fired.
  56. process.stdin.resume()
  57. process.stdin.on('end', () => { setInterval(() => {}, 1_000) })
  58. process.on('SIGTERM', () => {
  59. if (env.FAKE_SIGTERM_FILE !== undefined) writeFileSync(env.FAKE_SIGTERM_FILE, 'sigterm\n')
  60. if (env.FAKE_TRAP_SIGTERM === undefined) process.exit(0)
  61. })
  62. }
  63. function write(message: object): void {
  64. process.stdout.write(`${JSON.stringify(message)}\n`)
  65. }
  66. function notify(method: string, params: object): void {
  67. write({ jsonrpc: '2.0', method, params })
  68. }
  69. let seq = 0
  70. function event(sessionId: string, type: string, data: object): void {
  71. notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } })
  72. }
  73. function assistantText(): string {
  74. const parts: string[] = []
  75. if (env.FAKE_ECHO_CWD !== undefined) parts.push(`cwd=${process.cwd()}`)
  76. for (const name of (env.FAKE_ECHO_ENV ?? '').split(',').filter(entry => entry.length > 0)) {
  77. parts.push(`${name}=${env[name] ?? ''}`)
  78. }
  79. parts.push(env.FAKE_TEXT ?? 'hello from fake runtime')
  80. return parts.join('\n')
  81. }
  82. function runTurn(sessionId: string): void {
  83. const text = assistantText()
  84. if (env.FAKE_MALFORMED_EVENT !== undefined) {
  85. notify('session.event', { sessionId, event: 42 })
  86. return
  87. }
  88. event(sessionId, 'turn/start', { turn: 0 })
  89. event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
  90. if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
  91. event(sessionId, 'assistant/message', {
  92. turn: 0,
  93. step: 0,
  94. message: {
  95. id: 'fake-malformed-message',
  96. role: 'assistant',
  97. content: 'not-an-array',
  98. source: { kind: 'model', provider: 'fake', model: 'fake' },
  99. },
  100. })
  101. return
  102. }
  103. if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) {
  104. notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
  105. return
  106. }
  107. event(sessionId, 'assistant/message', {
  108. turn: 0,
  109. step: 0,
  110. message: {
  111. id: `fake-assistant-${seq}`,
  112. role: 'assistant',
  113. // Model the usage-only message recorded after a max-tokens step that
  114. // assembled no output blocks.
  115. content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
  116. source: { kind: 'model', provider: 'fake', model: 'fake' },
  117. },
  118. })
  119. const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
  120. event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
  121. if (env.FAKE_SUBAGENT !== undefined) {
  122. const childId = `${sessionId}-child`
  123. notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
  124. event(childId, 'assistant/message', {
  125. turn: 0,
  126. step: 0,
  127. content: [{ type: 'text', text: 'child says hi' }],
  128. provenance: { provider: 'fake', model: 'fake' },
  129. })
  130. notify('subagent.finished', {
  131. provider: 'spawn',
  132. agentId: childId,
  133. parentSessionId: sessionId,
  134. childSessionId: childId,
  135. status: 'ok',
  136. stopReason: 'completed',
  137. lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
  138. })
  139. }
  140. }
  141. function sessionIdOf(params: Record<string, unknown> | undefined): string {
  142. const value = params?.sessionId
  143. return typeof value === 'string' ? value : ''
  144. }
  145. const reader = createInterface({ input: process.stdin })
  146. reader.on('line', (line) => {
  147. if (line.trim().length === 0) return
  148. const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
  149. if (frame.method === undefined || frame.id === undefined) return
  150. const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
  151. switch (frame.method) {
  152. case 'initialize':
  153. if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`)
  154. if (env.FAKE_HANG_INIT !== undefined) return
  155. if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) {
  156. writeFileSync(env.FAKE_INIT_READY, 'ready\n')
  157. const go = env.FAKE_INIT_GO
  158. const id = frame.id
  159. const poll = setInterval(() => {
  160. if (!existsSync(go)) return
  161. clearInterval(poll)
  162. write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } })
  163. }, 5)
  164. return
  165. }
  166. if (env.FAKE_INIT_ERROR !== undefined) {
  167. write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
  168. return
  169. }
  170. if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
  171. writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
  172. write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
  173. return
  174. }
  175. if (env.FAKE_MALFORMED !== undefined) {
  176. respond({})
  177. return
  178. }
  179. if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
  180. respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
  181. return
  182. }
  183. respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
  184. return
  185. case 'session/prompt': {
  186. const sessionId = sessionIdOf(frame.params)
  187. const messageId = `fake-user-${seq}`
  188. event(sessionId, 'agent/inbox/spliced', {
  189. target: 'next-turn',
  190. start: 0,
  191. inserted: [{
  192. id: messageId,
  193. role: 'user',
  194. content: [],
  195. source: { kind: 'user' },
  196. }],
  197. })
  198. notify('session.status', { sessionId, status: 'running' })
  199. if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
  200. event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
  201. respond({})
  202. return
  203. }
  204. if (env.FAKE_HANG_PROMPT !== undefined) return
  205. if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
  206. respond({})
  207. return
  208. }
  209. runTurn(sessionId)
  210. notify('session.status', { sessionId, status: 'idle' })
  211. respond({ messageId })
  212. return
  213. }
  214. case 'shutdown':
  215. respond({})
  216. // An EOF-ignoring fake also refuses the protocol exit, so the client's
  217. // dispose ladder (not this cooperative path) must reap it.
  218. if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0))
  219. return
  220. default:
  221. write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } })
  222. }
  223. })