headless.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
  3. * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
  4. * collide), then in-process isomorphic injection (InProcessApiClient over
  5. * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
  6. * serialization, zod, SSE framing — really runs). The printed URL opens the
  7. * live session in a browser while the task runs. Runs one task turn, prints
  8. * the final assistant text, exits (completed → 0, else 1).
  9. */
  10. import { fileURLToPath } from 'node:url'
  11. import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  12. import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  13. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  14. import type { SessionId } from '@deepseek-ai/dsh-session'
  15. import { AppCLIEntry } from './app-cli-entry.ts'
  16. /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
  17. interface TurnOutcome {
  18. text: string
  19. reason: string
  20. }
  21. /** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
  22. async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
  23. if (response.result.ok) return response.result.value
  24. const { code, message } = response.result.error
  25. process.stderr.write(`dsh: ${code}: ${message}\n`)
  26. await dispose()
  27. process.exit(1)
  28. }
  29. /**
  30. * Consume mux frames until the task turn ends, per the cli-demo runOneShot
  31. * correlation precedent: anchor on the first turn/start whose trigger kind is
  32. * 'message' (startup-injected turns are skipped), aggregate text from that
  33. * turn's assistant/message events (last one wins), finish on its turn/end.
  34. */
  35. async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
  36. let targetTurn: number | undefined
  37. let text = ''
  38. try {
  39. for await (const frame of frames) {
  40. const payload = frame.payload
  41. if (payload.type === 'stream/error') {
  42. process.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
  43. return { text, reason: 'error' }
  44. }
  45. if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
  46. const event = payload.event
  47. if (targetTurn === undefined) {
  48. if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
  49. continue
  50. }
  51. if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
  52. const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
  53. if (joined !== '') text = joined
  54. }
  55. if (event.type === 'turn/end' && event.data.turn === targetTurn) {
  56. return { text, reason: event.data.reason.kind }
  57. }
  58. }
  59. } catch (error: unknown) {
  60. process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
  61. }
  62. return { text, reason: 'error' }
  63. }
  64. /**
  65. * Run one headless turn for `task` and exit (completed → 0, else 1). The task
  66. * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
  67. * (the adapter rejects an empty task, so no guard is needed here).
  68. * @param task - the prompt text for the single turn.
  69. */
  70. export async function runHeadless(task: string): Promise<void> {
  71. // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
  72. const entry = new AppCLIEntry({
  73. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  74. dev: false,
  75. port: 0,
  76. })
  77. const { ctx, port } = await entry.run()
  78. const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
  79. // The headless session is web-observable while it runs (same composition).
  80. process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
  81. const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
  82. const created = await unwrap(await api.sessions.create({}), dispose)
  83. // Open the stream before prompting so no frame is lost — kept in this order
  84. // even though in-process delivery has no race, so the code survives a move
  85. // to a remote HTTP carrier unchanged.
  86. const abort = new AbortController()
  87. const frames = api.events.mux({}, abort.signal)
  88. const done = consumeUntilTurnEnd(frames, created.sessionId)
  89. await unwrap(await api.sessions.prompt({
  90. sessionId: created.sessionId,
  91. mode: 'queue',
  92. content: [{ type: 'text', text: task }],
  93. }), dispose)
  94. const outcome = await done
  95. process.stdout.write(outcome.text + '\n')
  96. abort.abort()
  97. await dispose()
  98. process.exit(outcome.reason === 'completed' ? 0 : 1)
  99. }