api-proxy.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099
  1. /**
  2. * Host-side ApiProxy implementation. Signature discipline: unary takes the
  3. * narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
  4. */
  5. import { randomUUID } from 'node:crypto'
  6. import { mkdir, stat } from 'node:fs/promises'
  7. import { join } from 'node:path'
  8. import type { Context } from 'cordis'
  9. import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
  10. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  11. import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
  12. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  13. import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
  14. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  15. import {
  16. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  17. WorkspaceMoveInvalidError, WorkspaceNameConflictError,
  18. } from '@deepseek-ai/dsh-workspace'
  19. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  20. import type {} from '@deepseek-ai/dsh-tools'
  21. import type {
  22. ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
  23. WorkspaceId, WorkspaceView,
  24. } from './api/index.ts'
  25. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  26. import type {} from '@deepseek-ai/dsh-commands'
  27. import type {} from '@deepseek-ai/dsh-skill'
  28. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  29. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  30. import { RpcId } from './api/rpc.ts'
  31. import type {
  32. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  33. } from '@deepseek-ai/dsh-user-interaction'
  34. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  35. import { pickNativeDirectory } from './native-directory-picker.ts'
  36. /** Page size when history is called without maxMessages. */
  37. const DEFAULT_MAX_MESSAGES = 50
  38. /** Surface message event types (the pagination counting unit). */
  39. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  40. /**
  41. * Message-boundary pagination: count maxMessages surface messages backwards from
  42. * the window tail; the cut is the starting seq of the oldest message group
  43. * (chunks group via sourceEventSeqs — never cut mid-message). The tail page
  44. * naturally includes the in-progress partial.
  45. */
  46. function paginate(
  47. events: readonly SessionEvent[],
  48. beforeSeq: number | undefined,
  49. maxMessages: number,
  50. ): { events: SessionEvent[]; hasMore: boolean } {
  51. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  52. let count = 0
  53. let cut = 0
  54. for (let i = window.length - 1; i >= 0; i--) {
  55. const event = window[i] as SessionEvent
  56. if (!MESSAGE_TYPES.has(event.type)) continue
  57. count++
  58. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  59. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  60. if (count >= maxMessages) {
  61. cut = groupStart
  62. break
  63. }
  64. }
  65. const page = window.filter(event => event.seq >= cut)
  66. return { events: page, hasMore: cut > 0 }
  67. }
  68. /** Wrap an ok result echoing the request's rpcId. */
  69. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  70. return { rpcId: request.rpcId, result: { ok: true, value } }
  71. }
  72. /** Wrap an error result echoing the request's rpcId. */
  73. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  74. return { rpcId: request.rpcId, result: { ok: false, error } }
  75. }
  76. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  77. class FrameQueue<F> {
  78. private buffer: F[] = []
  79. private waiter: (() => void) | undefined
  80. private done = false
  81. push(item: F): void {
  82. if (this.done) return
  83. this.buffer.push(item)
  84. this.waiter?.()
  85. }
  86. end(): void {
  87. this.done = true
  88. this.waiter?.()
  89. }
  90. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  91. const onAbort = (): void => { this.end() }
  92. signal.addEventListener('abort', onAbort, { once: true })
  93. try {
  94. while (true) {
  95. while (this.buffer.length > 0) yield this.buffer.shift() as F
  96. if (this.done || signal.aborted) return
  97. await new Promise<void>((resolve) => { this.waiter = resolve })
  98. this.waiter = undefined
  99. }
  100. } finally {
  101. signal.removeEventListener('abort', onAbort)
  102. cleanup()
  103. }
  104. }
  105. }
  106. /**
  107. * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
  108. * for answerable frames belong to the approval/question registry, absent in
  109. * this minimal version).
  110. */
  111. function frame<F>(payload: F): RpcRequest<F> {
  112. return { rpcId: RpcId(randomUUID()), payload }
  113. }
  114. type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
  115. /** Project the latest durable title without exposing title-generation policy. */
  116. function titleFrame(session: Session): SessionTitleFrame | undefined {
  117. const title = foldSessionTitle(session.events)
  118. if (title === undefined) return undefined
  119. return {
  120. type: 'session/title',
  121. sessionId: session.id,
  122. title: title.title,
  123. eventSeq: title.eventSeq,
  124. updatedAt: title.updatedAt,
  125. }
  126. }
  127. /** Queue the subscription baseline followed by its optional title snapshot. */
  128. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  129. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  130. const title = titleFrame(session)
  131. if (title !== undefined) queue.push(frame(title))
  132. }
  133. /** SessionSummary projection for attached (in-memory) sessions. */
  134. function summarize(session: Session, running: boolean): SessionSummary {
  135. return {
  136. sessionId: session.id,
  137. updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
  138. running,
  139. blank: session.events.length === 0,
  140. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  141. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  142. }
  143. }
  144. /**
  145. * SessionSummary projection for cold (persisted, unattached) sessions.
  146. * updatedAt is the log file's mtime; backends without a per-session file
  147. * (locate() undefined) fall back to the header's createdAt.
  148. */
  149. async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
  150. let updatedAt = meta.createdAt
  151. const location = persistence.locate(meta)
  152. if (location !== undefined) {
  153. try {
  154. updatedAt = (await stat(location.path)).mtimeMs
  155. } catch {
  156. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  157. }
  158. }
  159. return {
  160. sessionId: meta.id,
  161. updatedAt,
  162. running: false,
  163. // Lazy persistence keeps never-appended sessions out of list(): a cold
  164. // session necessarily has events, so blank is constantly false here.
  165. blank: false,
  166. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  167. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  168. filters those out (legacy logs are not served); the conditional mirrors
  169. summarize() shape. */
  170. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  171. }
  172. }
  173. /** Resolved Host routing and project-directory defaults consumed by the API implementation. */
  174. export interface ApiProxyDefaults {
  175. provider: string
  176. model: string
  177. /** Default project directory for new sessions whose create request carries no cwd. */
  178. cwd: string
  179. /** Parent directory for name-created workspaces. */
  180. workspaceRoot: string
  181. /** Native single-directory picker; injectable for carrier tests. */
  182. pickDirectory?: (signal: AbortSignal) => Promise<string | null>
  183. }
  184. /** The tool/call payload fields the presenter path reads. */
  185. interface ToolCallData { callId: string; name: string; arguments: string }
  186. /** The tool/result payload fields the presenter path reads. */
  187. interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
  188. /** One host-owned question wait, addressed by the stable server-request id. */
  189. interface PendingQuestion {
  190. rpcId: RpcId
  191. sessionId: SessionId
  192. questions: AskUserQuestionItem[]
  193. resolve: (answer: AskUserQuestionAnswer) => void
  194. reject: (error: UserInteractionError) => void
  195. signal?: AbortSignal
  196. onAbort?: () => void
  197. }
  198. /** Validate one answer batch against the exact question request it resolves. */
  199. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  200. if (payload.sessionId !== pending.sessionId) return false
  201. const answers = payload.answer.answers
  202. if (answers.length !== pending.questions.length) return false
  203. return answers.every((answer, index) => {
  204. const question = pending.questions[index] as AskUserQuestionItem
  205. if (answer.id !== question.id) return false
  206. if (new Set(answer.selected).size !== answer.selected.length) return false
  207. const custom = answer.custom?.trim()
  208. if (custom !== undefined && custom === '') return false
  209. if (custom !== undefined && answer.selected.length > 0) return false
  210. if (question.multiSelect !== true && answer.selected.length > 1) return false
  211. const labels = new Set(question.options?.map(option => option.label) ?? [])
  212. return answer.selected.every(label => labels.has(label))
  213. })
  214. }
  215. /**
  216. * Compute the render intent for a tool/call or tool/result event through the
  217. * presenters registered at this moment; every other event type gets none. A
  218. * result's presenter needs its call's parsed args — `argsFor` supplies them
  219. * (live: the per-session call table; history: an in-page backscan), returning
  220. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  221. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  222. * the client's documented default (generic JSON card) covers every miss.
  223. */
  224. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  225. try {
  226. if (event.type === 'tool/call') {
  227. const { name, arguments: raw } = event.data as ToolCallData
  228. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  229. return view === undefined ? undefined : { for: 'call', view }
  230. }
  231. if (event.type === 'tool/result') {
  232. const { callId, content, isError, meta } = event.data as ToolResultData
  233. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  234. if (call === undefined) return undefined
  235. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
  236. return view === undefined ? undefined : { for: 'result', view }
  237. }
  238. } catch (error: unknown) {
  239. // A throwing presenter (or unparseable arguments) must not break delivery;
  240. // the event still ships, just without a view.
  241. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  242. }
  243. return undefined
  244. }
  245. /**
  246. * Resolve a tool/result's call pairing by scanning a window of events backwards
  247. * for the matching tool/call. Used by the history path (the page is the
  248. * window — a cross-page pairing soft-falls to no view) and by live-path table
  249. * misses after a reconnect-eviction.
  250. */
  251. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  252. for (let i = events.length - 1; i >= 0; i--) {
  253. const event = events[i] as SessionEvent
  254. if (event.type !== 'tool/call') continue
  255. const data = event.data as ToolCallData
  256. if (data.callId !== callId) continue
  257. try {
  258. return { name: data.name, args: JSON.parse(data.arguments) }
  259. } catch {
  260. // Unparseable stored arguments: same soft-fall as a live parse failure.
  261. return undefined
  262. }
  263. }
  264. return undefined
  265. }
  266. /** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
  267. function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
  268. for (let i = events.length - 1; i >= 0; i--) {
  269. const event = events[i]
  270. if (event !== undefined && event.type === 'todo/write') return event.data.todos
  271. }
  272. return undefined
  273. }
  274. /**
  275. * Thrown by the cold-resume path when the id names no servable session
  276. * (absent from the store, or a pre-project legacy log without a cwd).
  277. */
  278. class SessionNotFound extends Error {}
  279. /** Requested identity already belongs to a session with another project cwd. */
  280. class SessionCwdConflict extends Error {
  281. constructor(
  282. readonly sessionId: SessionId,
  283. readonly requestedCwd: string,
  284. readonly existingCwd: string | undefined,
  285. ) {
  286. super(
  287. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  288. + `requested ${JSON.stringify(requestedCwd)}`,
  289. )
  290. }
  291. }
  292. /** Host failed before the registry could adopt a name-created directory. */
  293. class WorkspaceDirectoryCreationError extends Error {}
  294. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  295. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  296. return err(request, {
  297. code: 'workspace-not-found',
  298. message: `workspace "${workspaceId}" not found`,
  299. details: { workspaceId },
  300. })
  301. }
  302. /** Wire projection of one workspace entity (the workspace.* value row). */
  303. function workspaceView(workspace: Workspace): WorkspaceView {
  304. return {
  305. workspaceId: workspace.id,
  306. path: workspace.path,
  307. title: workspace.title,
  308. sessionIds: [...workspace.sessionIds],
  309. createdAt: workspace.createdAt,
  310. updatedAt: workspace.updatedAt,
  311. }
  312. }
  313. /** Wire projection of the durable record carried by `domain/changed`. */
  314. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  315. const record: WorkspaceRecord = workspaceRecord.parse(value)
  316. return {
  317. workspaceId: workspaceId as WorkspaceId,
  318. path: record.path,
  319. title: record.title,
  320. sessionIds: [...record.sessionIds],
  321. createdAt: record.createdAt,
  322. updatedAt: record.updatedAt,
  323. }
  324. }
  325. /**
  326. * Implement ApiProxy over a composed host context.
  327. * @param ctx - a context with the Host spine and Workspace registry mounted.
  328. * @param defaults - host routing and project-directory defaults.
  329. * @returns the ApiProxy implementation.
  330. */
  331. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  332. const agentOptions = { provider: defaults.provider, model: defaults.model }
  333. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  334. const resumes = new Map<SessionId, Promise<Agent>>()
  335. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  336. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  337. /** Serializes path ownership checks with record creation across spellings. */
  338. let workspaceCreationChain = Promise.resolve()
  339. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  340. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  341. /** Send one transient frame to every connected mux consumer. */
  342. function broadcast(payload: MuxFrame): void {
  343. const envelope = frame(payload)
  344. for (const queue of muxQueues) queue.push(envelope)
  345. }
  346. /**
  347. * Per-session inbox mirror serving the mux-open queue snapshot (the same
  348. * refresh-recovery baseline as pending questions). Keyed by the stable
  349. * AgentMessageId: every enqueued id receives exactly one terminal
  350. * `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
  351. * the mirror needs no consumption heuristics or sweeps beyond disposal.
  352. */
  353. const queuedMirror = new Map<SessionId, Map<AgentMessageId, AgentMessage>>()
  354. ctx.effect(() => {
  355. const retire = (agent: Agent, id: AgentMessageId): void => {
  356. const entries = queuedMirror.get(agent.id)
  357. if (entries === undefined) return
  358. entries.delete(id)
  359. if (entries.size === 0) queuedMirror.delete(agent.id)
  360. }
  361. const disposers = [
  362. ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage) => {
  363. let entries = queuedMirror.get(agent.id)
  364. if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
  365. entries.set(message.id, message)
  366. broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
  367. }),
  368. ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
  369. retire(agent, message.id)
  370. }),
  371. ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
  372. for (const message of messages) retire(agent, message.id)
  373. }),
  374. ctx.on('session/disposed', (session: Session) => {
  375. queuedMirror.delete(session.id)
  376. }),
  377. ]
  378. return () => { for (const dispose of disposers) dispose() }
  379. }, 'api-proxy: queued mirror')
  380. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  381. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  382. pendingQuestions.delete(pending.rpcId)
  383. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  384. pending.signal.removeEventListener('abort', pending.onAbort)
  385. }
  386. broadcast({
  387. type: 'question/resolved', sessionId: pending.sessionId,
  388. questionRpcId: pending.rpcId, outcome,
  389. })
  390. }
  391. const disposeProvider = ctx.userInteraction.registerProvider({
  392. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  393. const sessionId = request.agent?.id
  394. if (sessionId === undefined) {
  395. return Promise.reject(new UserInteractionError(
  396. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  397. }
  398. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  399. const rpcId = RpcId(randomUUID())
  400. const pending: PendingQuestion = {
  401. rpcId, sessionId, questions: request.questions, resolve, reject,
  402. ...(request.signal === undefined ? {} : { signal: request.signal }),
  403. }
  404. const onAbort = (): void => {
  405. claimQuestion(pending, 'cancelled')
  406. reject(new UserInteractionError(
  407. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  408. }
  409. pending.onAbort = onAbort
  410. pendingQuestions.set(rpcId, pending)
  411. request.signal?.addEventListener('abort', onAbort, { once: true })
  412. const envelope: RpcRequest<MuxFrame> = {
  413. rpcId,
  414. payload: { type: 'question/requested', sessionId, questions: request.questions },
  415. }
  416. for (const queue of muxQueues) queue.push(envelope)
  417. })
  418. },
  419. })
  420. ctx.effect(() => () => {
  421. disposeProvider()
  422. for (const pending of [...pendingQuestions.values()]) {
  423. claimQuestion(pending, 'cancelled')
  424. pending.reject(new UserInteractionError(
  425. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  426. }
  427. }, 'api-proxy: user-interaction provider')
  428. /**
  429. * Gate the cold path on the store: an id absent from it, or naming a legacy
  430. * log without a cwd (pre-release stance: not served, no compatibility), is
  431. * not-found before any resume is attempted. With the gate passed, a later
  432. * resume failure is genuinely internal. No persistence configured skips the
  433. * gate — resume itself then fails loud with its own diagnostic.
  434. */
  435. async function assertServable(sessionId: SessionId): Promise<void> {
  436. const persistence = ctx.get('sessionPersistence')
  437. if (persistence === undefined) return
  438. const meta = (await persistence.list()).find(m => m.id === sessionId)
  439. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  440. }
  441. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  442. const live = ctx.agents.get(sessionId)
  443. if (live !== undefined) return { agent: live }
  444. let resume = resumes.get(sessionId)
  445. if (resume === undefined) {
  446. resume = (async () => {
  447. try {
  448. await assertServable(sessionId)
  449. const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
  450. return handle.agent
  451. } finally {
  452. resumes.delete(sessionId)
  453. }
  454. })()
  455. resumes.set(sessionId, resume)
  456. }
  457. try {
  458. return { agent: await resume }
  459. } catch (error: unknown) {
  460. if (error instanceof SessionNotFound) {
  461. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  462. }
  463. // The internal details slot is contractually {}; the reason rides the message.
  464. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  465. }
  466. }
  467. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  468. async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
  469. let creation = sessionCreations.get(sessionId)
  470. if (creation === undefined) {
  471. creation = (async () => {
  472. const live = ctx.agents.get(sessionId)
  473. if (live !== undefined) return live
  474. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  475. const stored = persistence === undefined
  476. ? undefined
  477. : (await persistence.list()).find(header => header.id === sessionId)
  478. if (stored !== undefined) {
  479. if (stored.cwd !== cwd) {
  480. throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
  481. }
  482. return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
  483. }
  484. try {
  485. await mkdir(cwd, { recursive: true })
  486. } catch (error: unknown) {
  487. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  488. }
  489. return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
  490. })().catch((error: unknown) => {
  491. // Another Host entry path may have published the same identity while
  492. // this operation crossed an asynchronous persistence/filesystem step.
  493. const live = ctx.agents.get(sessionId)
  494. if (live !== undefined) return live
  495. throw error
  496. }).finally(() => {
  497. sessionCreations.delete(sessionId)
  498. })
  499. sessionCreations.set(sessionId, creation)
  500. }
  501. const agent = await creation
  502. if (agent.session.header.cwd !== cwd) {
  503. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  504. }
  505. return agent
  506. }
  507. /** Resolve or create one path while holding the Host's workspace-create chain. */
  508. function ensureWorkspace(
  509. path: string,
  510. title: string | undefined,
  511. rejectExistingName = false,
  512. createDirectory = false,
  513. ): Promise<{ workspace: Workspace; created: boolean }> {
  514. const operation = workspaceCreationChain.then(async () => {
  515. if (rejectExistingName && title !== undefined
  516. && ctx.workspace.list().some(workspace => workspace.title === title)) {
  517. throw new WorkspaceNameConflictError(title)
  518. }
  519. if (createDirectory) {
  520. try {
  521. await mkdir(path, { recursive: true })
  522. } catch (error: unknown) {
  523. throw new WorkspaceDirectoryCreationError(
  524. `failed to create workspace directory "${path}": ${String(error)}`,
  525. )
  526. }
  527. }
  528. const existing = await ctx.workspace.resolveByPath(path)
  529. if (existing !== undefined) return { workspace: existing, created: false }
  530. return { workspace: await ctx.workspace.create(path, title), created: true }
  531. })
  532. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  533. return operation
  534. }
  535. return {
  536. sessions: {
  537. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  538. // sessions merge in from the persistence store so history survives restarts.
  539. // Legacy logs without a cwd (pre-project stance) are not served — every
  540. // session now records its project at create time.
  541. async list(request) {
  542. const items = ctx.sessions.list().map((session) => {
  543. const agent = ctx.agents.get(session.id)
  544. return summarize(session, agent?.status === 'running')
  545. })
  546. const attached = new Set(items.map(item => item.sessionId))
  547. const persistence = ctx.get('sessionPersistence')
  548. if (persistence !== undefined) {
  549. const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  550. items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
  551. }
  552. items.sort((a, b) => b.updatedAt - a.updatedAt)
  553. return ok(request, { items })
  554. },
  555. async create(request) {
  556. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  557. let workspace: Workspace | undefined
  558. if (request.payload.workspaceId !== undefined) {
  559. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  560. if (workspace === undefined) {
  561. return err(request, {
  562. code: 'workspace-not-found',
  563. message: `workspace "${request.payload.workspaceId}" not found`,
  564. details: { workspaceId: request.payload.workspaceId },
  565. })
  566. }
  567. }
  568. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  569. try {
  570. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
  571. } catch (error: unknown) {
  572. if (error instanceof SessionCwdConflict) {
  573. return err(request, {
  574. code: 'session-conflict',
  575. message: error.message,
  576. details: {
  577. sessionId: error.sessionId,
  578. requestedCwd: error.requestedCwd,
  579. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  580. },
  581. })
  582. }
  583. return err(request, {
  584. code: 'internal',
  585. message: `failed to create session "${sessionId}": ${String(error)}`,
  586. details: {},
  587. })
  588. }
  589. if (workspace !== undefined) {
  590. try {
  591. await workspace.attachSession(sessionId)
  592. } catch (error: unknown) {
  593. return err(request, {
  594. code: 'workspace-attach-failed',
  595. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  596. details: { sessionId, workspaceId: workspace.id },
  597. })
  598. }
  599. }
  600. return ok(request, { sessionId })
  601. },
  602. async history(request) {
  603. const { sessionId, beforeSeq, maxMessages } = request.payload
  604. const found = await agentFor(sessionId)
  605. if ('error' in found) return err(request, found.error)
  606. const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  607. // Views are computed against the registry at pagination time; result
  608. // pairing scans within the page only (message-boundary pagination keeps
  609. // a call and its result on one page — a cross-page miss soft-falls).
  610. const entries: HistoryEntry[] = page.events.map((event) => {
  611. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  612. return { event, ...view === undefined ? {} : { view } }
  613. })
  614. // Tail page carries the session-level todo projection over the FULL
  615. // log (the page window may not contain the last todo/write; a paged
  616. // client cannot reconstruct session-level state from it).
  617. const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
  618. return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
  619. },
  620. async prompt(request) {
  621. const { sessionId, mode, content } = request.payload
  622. const found = await agentFor(sessionId)
  623. if ('error' in found) return err(request, found.error)
  624. const agent = found.agent
  625. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  626. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  627. try {
  628. if (mode === 'steer') agent.steer(content, { source })
  629. else agent.followup(content, { source })
  630. } catch (error: unknown) {
  631. // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
  632. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  633. }
  634. return ok(request, { accepted: true as const })
  635. },
  636. cancel(request) {
  637. const { sessionId } = request.payload
  638. const agent = ctx.agents.get(sessionId)
  639. if (agent === undefined) {
  640. return Promise.resolve(err(request, {
  641. code: 'session-not-found',
  642. message: `session "${sessionId}" not found (not attached)`,
  643. details: { sessionId },
  644. }))
  645. }
  646. agent.cancel()
  647. return Promise.resolve(ok(request, { accepted: true as const }))
  648. },
  649. },
  650. workspace: {
  651. list(request) {
  652. return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
  653. },
  654. // Exactly one of path/name arrives (schema refine). Existing-folder
  655. // adoption reuses its canonical path; create-by-name rejects a name
  656. // already present in the registry.
  657. async create(request) {
  658. const { payload } = request
  659. let path: string
  660. if (payload.name !== undefined) {
  661. const name = payload.name.trim()
  662. if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
  663. return err(request, {
  664. code: 'workspace-invalid-path',
  665. message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
  666. details: { path: payload.name },
  667. })
  668. }
  669. path = join(defaults.workspaceRoot, name)
  670. } else {
  671. path = payload.path as string
  672. }
  673. try {
  674. const name = payload.name?.trim()
  675. const { workspace, created } = await ensureWorkspace(
  676. path,
  677. name,
  678. name !== undefined,
  679. name !== undefined,
  680. )
  681. return ok(request, { workspace: workspaceView(workspace), created })
  682. } catch (error: unknown) {
  683. if (error instanceof WorkspaceNameConflictError) {
  684. return err(request, {
  685. code: 'workspace-name-conflict',
  686. message: error.message,
  687. details: { name: error.workspaceName },
  688. })
  689. }
  690. if (error instanceof WorkspaceDirectoryCreationError) {
  691. return err(request, { code: 'internal', message: error.message, details: {} })
  692. }
  693. // The registry rejects a path that does not resolve to an existing
  694. // directory (realpath ENOENT / not-a-directory) — the business
  695. // error of the typed-path flow, surfaced as a validation failure.
  696. return err(request, {
  697. code: 'workspace-invalid-path',
  698. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  699. details: { path },
  700. })
  701. }
  702. },
  703. async rename(request) {
  704. const { payload } = request
  705. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  706. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  707. const title = payload.title.trim()
  708. // Uniqueness AND the same-title no-op both ride the create chain so
  709. // they observe the state left by earlier queued renames — checked
  710. // up front, a queued A→A could report success while an earlier A→B
  711. // still lands afterwards.
  712. const operation = workspaceCreationChain.then(async () => {
  713. if (title === workspace.title) return
  714. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  715. throw new WorkspaceNameConflictError(title)
  716. }
  717. await workspace.setTitle(title)
  718. })
  719. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  720. try {
  721. await operation
  722. } catch (error: unknown) {
  723. if (error instanceof WorkspaceNameConflictError) {
  724. return err(request, {
  725. code: 'workspace-name-conflict',
  726. message: error.message,
  727. details: { name: error.workspaceName },
  728. })
  729. }
  730. throw error
  731. }
  732. return ok(request, { workspace: workspaceView(workspace) })
  733. },
  734. async delete(request) {
  735. const { workspaceId } = request.payload
  736. const operation = workspaceCreationChain.then(() =>
  737. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  738. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  739. if (!await operation) return workspaceNotFound(request, workspaceId)
  740. return ok(request, { deleted: true as const })
  741. },
  742. async insertSessionBefore(request) {
  743. const { payload } = request
  744. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  745. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  746. try {
  747. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  748. } catch (error: unknown) {
  749. // Only the entity's unaccounted-id rejection is the business code;
  750. // storage/durability failures propagate as internal errors.
  751. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  752. return err(request, {
  753. code: 'workspace-move-invalid',
  754. message: error.message,
  755. details: {
  756. workspaceId: payload.workspaceId,
  757. sessionId: payload.sessionId,
  758. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  759. },
  760. })
  761. }
  762. return ok(request, { workspace: workspaceView(workspace) })
  763. },
  764. },
  765. host: {
  766. describe(request) {
  767. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  768. return Promise.resolve(ok(request, {
  769. version: '0.0.1',
  770. // Same source as session.create's fallback: the UI's default project
  771. // must match where an unspecified-cwd session actually lands.
  772. cwd: defaults.cwd,
  773. provider: defaults.provider,
  774. model: defaults.model,
  775. attachedSessions: ctx.agents.list().length,
  776. }))
  777. },
  778. async pickDirectory(request, signal) {
  779. try {
  780. const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
  781. return ok(request, { path })
  782. } catch (error: unknown) {
  783. if (signal.aborted) {
  784. return err(request, {
  785. code: 'cancelled',
  786. message: 'directory picker was aborted',
  787. details: {},
  788. })
  789. }
  790. return err(request, {
  791. code: 'internal',
  792. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  793. details: {},
  794. })
  795. }
  796. },
  797. },
  798. commands: {
  799. // Both methods address one session's agent (agentFor keeps its
  800. // resume-on-miss: clients only send a sessionId for a published
  801. // session, and resume restores an existing entity).
  802. async list(request) {
  803. // Missing service = the deployment omitted dsh-commands from its
  804. // composition, not an empty catalog: fail loud instead of serving [].
  805. const commands = ctx.get('commands')
  806. if (commands === undefined) {
  807. return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
  808. }
  809. const found = await agentFor(request.payload.sessionId)
  810. if ('error' in found) return err(request, found.error)
  811. return ok(request, { commands: commands.list(found.agent) })
  812. },
  813. async execute(request, signal) {
  814. const commands = ctx.get('commands')
  815. if (commands === undefined) {
  816. return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
  817. }
  818. const { sessionId, line } = request.payload
  819. const found = await agentFor(sessionId)
  820. if ('error' in found) return err(request, found.error)
  821. try {
  822. const result = await commands.execute(found.agent, line, signal)
  823. if (result === undefined) return ok(request, { matched: false })
  824. return ok(request, {
  825. matched: true,
  826. result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
  827. })
  828. } catch (error: unknown) {
  829. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  830. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  831. }
  832. },
  833. },
  834. skills: {
  835. // Skill lookup never touches the Agent registry: the session address
  836. // resolves to a canonical cwd from the host-resident session header, so
  837. // listing skills cannot create or resume an agent as a side effect.
  838. async list(request) {
  839. const { sessionId } = request.payload
  840. const session = ctx.sessions.get(sessionId)
  841. if (session === undefined) {
  842. return err(request, {
  843. code: 'session-not-found',
  844. message: `session "${sessionId}" not found (not attached)`,
  845. details: { sessionId },
  846. })
  847. }
  848. if (session.header.cwd === undefined) {
  849. // Every served session records its project at create time; a
  850. // cwd-less header is a pre-project legacy log (not served).
  851. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  852. }
  853. const cwd = session.header.cwd
  854. // Same stance as the commands domain: a missing service means the
  855. // deployment omitted dsh-skill from its composition, not an empty
  856. // catalog. ctx.get also keeps this handler independent of the gateway
  857. // plugin's inject list (an undeclared `ctx.skills` property read
  858. // fails the reflect proxy).
  859. const skillRegistry = ctx.get('skills')
  860. if (skillRegistry === undefined) {
  861. return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
  862. }
  863. try {
  864. const skills = await skillRegistry.list({ cwd })
  865. return ok(request, {
  866. skills: skills.map(skill => ({
  867. name: skill.name,
  868. description: skill.description,
  869. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  870. })),
  871. })
  872. } catch (error: unknown) {
  873. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  874. }
  875. },
  876. },
  877. events: {
  878. mux(_request, signal) {
  879. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  880. muxQueues.add(queue)
  881. for (const session of ctx.sessions.list()) {
  882. subscribeSession(queue, session)
  883. }
  884. for (const pending of pendingQuestions.values()) {
  885. queue.push({
  886. rpcId: pending.rpcId,
  887. payload: {
  888. type: 'question/requested', sessionId: pending.sessionId,
  889. questions: pending.questions,
  890. },
  891. })
  892. }
  893. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  894. // in arrival order per session; a reconnecting client rebuilds its
  895. // queue view from these alone.
  896. for (const [sessionId, entries] of queuedMirror) {
  897. for (const entry of entries.values()) {
  898. queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
  899. }
  900. }
  901. // Per-session open-call table for result-view pairing. Bounded by the
  902. // per-turn call count: entries clear on turn/end; a table miss (stream
  903. // opened mid-turn) backscans the session's in-memory events instead.
  904. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  905. const disposers = [
  906. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  907. if (event.type === 'tool/call') {
  908. const data = event.data as ToolCallData
  909. try {
  910. let table = openCalls.get(session.id)
  911. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  912. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  913. } catch {
  914. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  915. }
  916. } else if (event.type === 'turn/end') {
  917. openCalls.delete(session.id)
  918. }
  919. const view = viewFor(ctx, event, callId =>
  920. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  921. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  922. if (event.type === 'session/title') {
  923. // The accepted raw event is already in session.events, so the fold must find it.
  924. queue.push(frame(titleFrame(session) as SessionTitleFrame))
  925. }
  926. }),
  927. ctx.on('session/created', (session: Session) => {
  928. subscribeSession(queue, session)
  929. }),
  930. ctx.on('session/disposed', (session: Session) => {
  931. openCalls.delete(session.id)
  932. }),
  933. ]
  934. return queue.iterate(signal, () => {
  935. muxQueues.delete(queue)
  936. for (const dispose of disposers) dispose()
  937. })
  938. },
  939. host(_request, signal) {
  940. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  941. const committedWorkspaceIds = new Set(
  942. ctx.workspace.list().map(workspace => String(workspace.id)),
  943. )
  944. const disposers = [
  945. ctx.on('session/created', (session: Session) => {
  946. queue.push(frame({
  947. type: 'host/session-added',
  948. sessionId: session.id,
  949. // Derived at frame time like summarize(); a just-created session
  950. // has no events yet, so this is constantly true in practice.
  951. blank: session.events.length === 0,
  952. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  953. // cwd rides the frame so the client list needs no refresh to group the new session.
  954. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  955. }))
  956. }),
  957. ctx.on('session/disposed', (session: Session) => {
  958. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  959. }),
  960. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  961. if (status === 'disposed') return
  962. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  963. }),
  964. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
  965. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
  966. }),
  967. ctx.on('domain/changed', (change) => {
  968. if (change.domain !== 'workspace') return
  969. if (change.table === '') {
  970. if (change.operation !== 'put') return
  971. const state = workspaceDomainState.parse(change.value)
  972. for (const workspaceId of state.workspaceIds) {
  973. if (committedWorkspaceIds.has(workspaceId)) continue
  974. const workspace = ctx.workspace.get(workspaceId)
  975. if (workspace === undefined) {
  976. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  977. }
  978. committedWorkspaceIds.add(workspaceId)
  979. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  980. }
  981. return
  982. }
  983. if (change.table !== 'workspaces') return
  984. if (change.operation === 'deleted') {
  985. if (!committedWorkspaceIds.delete(change.key)) return
  986. queue.push(frame({
  987. type: 'host/workspace-removed',
  988. workspaceId: change.key as WorkspaceId,
  989. }))
  990. return
  991. }
  992. if (!committedWorkspaceIds.has(change.key)) return
  993. // Existing-entity table writes are complete attach/touch commits.
  994. // A new entity's first put waits for the global registry write above.
  995. queue.push(frame({
  996. type: 'host/workspace-changed',
  997. workspace: changedWorkspaceView(change.key, change.value),
  998. }))
  999. }),
  1000. ctx.on('commands/change', () => {
  1001. queue.push(frame({ type: 'host/commands-changed' }))
  1002. }),
  1003. ]
  1004. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  1005. },
  1006. },
  1007. respond(message: ClientResponse): Promise<RpcReceipt> {
  1008. const pending = pendingQuestions.get(message.rpcId)
  1009. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  1010. if (!message.result.ok) {
  1011. if (message.result.error.code !== 'cancelled') {
  1012. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1013. }
  1014. claimQuestion(pending, 'cancelled')
  1015. pending.reject(new UserInteractionError(
  1016. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  1017. return Promise.resolve({ accepted: true })
  1018. }
  1019. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  1020. if (!parsed.success) {
  1021. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1022. }
  1023. const payload: QuestionResponsePayload = {
  1024. sessionId: parsed.data.sessionId,
  1025. answer: {
  1026. answers: parsed.data.answer.answers.map(answer => ({
  1027. id: answer.id,
  1028. selected: answer.selected,
  1029. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  1030. })),
  1031. },
  1032. }
  1033. if (!matchesQuestions(payload, pending)) {
  1034. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1035. }
  1036. claimQuestion(pending, 'answered')
  1037. pending.resolve(payload.answer)
  1038. return Promise.resolve({ accepted: true })
  1039. },
  1040. }
  1041. }