session.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. // Sessions remain resident after creation so their open Remote sources keep running off-screen.
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
  4. import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  5. import type {
  6. IApiClient, SubagentAddress,
  7. } from '@deepseek-ai/dsh-client-connection/client'
  8. import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
  9. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
  10. import {
  11. SessionEventStream,
  12. sessionStreamFailure,
  13. } from '../transport.ts'
  14. import type { SessionJournalChange } from '../transport.ts'
  15. import type {
  16. PromptContentPart,
  17. QueueAction,
  18. SessionAddress,
  19. SessionControlFrame,
  20. SessionEventEntry,
  21. SessionQueuedItem,
  22. SessionRequestId,
  23. SessionError,
  24. } from '../../types.ts'
  25. import type { ClientFailure, ClientResult } from '../contract/result.ts'
  26. import { transportResult } from '../contract/result.ts'
  27. import type { SessionFace } from '../contract/session.ts'
  28. import type {
  29. OpenState, PromptError, SessionSnapshot,
  30. } from '../contract/snapshot.ts'
  31. import { MutableSessionEventSource } from '../contract/events.ts'
  32. import { Notifier } from './notifier.ts'
  33. import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  34. import type { SessionRemotes } from './remotes.ts'
  35. import { ProjectionValueStore } from './projection-store.ts'
  36. import type { ProjectionsBaseline } from './projection-store.ts'
  37. import { resolvedClientTimeZone } from '../time-zone.ts'
  38. import { SessionQueueMirror } from './queue-mirror.ts'
  39. /** Messages requested per history page. */
  40. export const PAGE_MESSAGES = 50
  41. /** Manager-owned observers of a Session object's local state edges. */
  42. export interface SessionOptions {
  43. /** Catalog-discovered address selecting non-activating subagent transport. */
  44. address?: SubagentAddress
  45. /** Whether the exact direct parent Agent was live at the latest catalog read. */
  46. parentAvailable?: boolean
  47. /**
  48. * First ACCEPTED prompt on a blank session (fires at most once, on the
  49. * prompt RPC's success response): the manager mirrors the blank→false flip
  50. * into its list row so the session surfaces without waiting for a host
  51. * frame. Acceptance is the flip point because it proves the user message
  52. * is in the host log; a rejected first prompt keeps the session blank
  53. * (hidden, still reusable by connectWorkspace).
  54. */
  55. onEngaged?(session: Session): void
  56. /**
  57. * Manager-owned projection value store to adopt (frames route through the
  58. * manager and values outlive instantiation); omitted, the Session owns a
  59. * private store (bare object-layer construction).
  60. */
  61. projections?: ProjectionValueStore
  62. }
  63. /**
  64. * Owns a session's event window, lifecycle state, and observable
  65. * snapshot. React bindings remain outside this data layer. Features see only
  66. * the {@link SessionFace} slice (ISession verbs + the snapshot source); the
  67. * remaining public members are Session Controller internals.
  68. */
  69. export class Session implements SessionFace {
  70. // ---- Window and derived state (all private; the snapshot is the only read API) ----
  71. private eventWindow: SessionEvent[] = []
  72. private baseSeq = 0
  73. private hasMore = false
  74. private openState: OpenState = 'cold'
  75. private openError: ClientFailure | null = null
  76. private openPromise: Promise<void> | null = null
  77. /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
  78. * passes drop all writes once the generation moves on. */
  79. private openGeneration = 0
  80. private loadingOlder = false
  81. /** Authoritative stream-only inbox snapshot; pending work never hits history. */
  82. private readonly queueMirror = new SessionQueueMirror()
  83. private running = false
  84. private address: SubagentAddress | undefined
  85. private parentAvailable = false
  86. /**
  87. * Sticky send marker, private input of the composerPhase derivation: set
  88. * synchronously before prompt()'s first await, never reset — the blank →
  89. * engaging edge of the phase machine (see ComposerPhase).
  90. */
  91. private promptAttempted = false
  92. /** A first accepted prompt stays in the engaging phase until its turn is observable. */
  93. private firstPromptPendingTurn = false
  94. /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
  95. private blankBit = true
  96. private removed = false
  97. private promptError: PromptError | null = null
  98. private lastAgentError: string | null = null
  99. /** Owns the addressed page/follow lifecycle while this Session is open. */
  100. private events: SessionEventStream | undefined
  101. /**
  102. * Per-session projection value store (push model; see the session-projection
  103. * subsystem page, docs/subsystems/session-projection.md): finished whole
  104. * values computed on the Host, seeded by the tail page's
  105. * projections block and updated by Session Controller control frames under the
  106. * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
  107. * (the useProjection resolution face); the conversation snapshot never
  108. * carries projection values, and no client-side domain folding exists.
  109. * Manager-owned when constructed through SessionManager (frames route and
  110. * the store outlives instantiation, the title-snapshot precedent); a bare
  111. * construction gets a private store.
  112. */
  113. readonly projections: ProjectionValueStore
  114. /** Contiguous history and live tail consumed by Conversation assembly. */
  115. readonly eventSource = new MutableSessionEventSource()
  116. private snapshotCache: SessionSnapshot
  117. private readonly notifier: Notifier
  118. /**
  119. * Agent-scoped cordis context, bound once by ClientSessions when it
  120. * mints the scope (the client mirror of the host Agent's loopCtx). The
  121. * Session dispatches its own scoped events through it; undefined means
  122. * unbound (bare object-layer construction) or already pruned — both skip
  123. * dispatch-dependent behavior rather than fail.
  124. */
  125. private actx: Context | undefined
  126. /**
  127. * @param sessionId - Host session identity (client sessions are always Host-born).
  128. * @param api - shared wire client.
  129. * @param remote - generated Remote namespaces this session calls.
  130. * @param options - optional manager-owned state observers.
  131. */
  132. constructor(
  133. readonly sessionId: SessionId,
  134. private readonly api: IApiClient,
  135. private readonly remote: SessionRemotes,
  136. private readonly options: SessionOptions = {},
  137. ) {
  138. this.projections = options.projections ?? new ProjectionValueStore()
  139. this.address = options.address
  140. this.parentAvailable = options.parentAvailable ?? false
  141. this.notifier = new Notifier(() => {
  142. this.snapshotCache = this.buildSnapshot()
  143. })
  144. this.snapshotCache = this.buildSnapshot()
  145. }
  146. /**
  147. * Bind the Agent-scoped context minted by ClientSessions (single write;
  148. * a second bind is a wiring error and throws). Direction stays one-way at
  149. * this binding boundary: consumers still reach the Session via `sessions.sessionOf`,
  150. * while the Session holds its own dispatch point (host Agent.loopCtx
  151. * mirror).
  152. * @param actx - the agent's scoped context.
  153. */
  154. bindScope(actx: Context): void {
  155. if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`)
  156. this.actx = actx
  157. }
  158. /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
  159. unbindScope(): void {
  160. this.actx = undefined
  161. }
  162. // ---- Operations ----
  163. /**
  164. * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
  165. * @param content - text plus browser-owned temporary image uploads.
  166. * @param mode - queue appends after the current turn; steer interrupts it.
  167. * @returns the prompt result (also mirrored into promptError on failure).
  168. */
  169. async prompt(
  170. content: PromptContentPart[],
  171. mode: 'queue' | 'steer',
  172. signal?: AbortSignal,
  173. ): Promise<ClientResult<{ accepted: true }>> {
  174. this.promptError = null
  175. this.lastAgentError = null
  176. // Synchronous, before the first await: the blank → engaging edge must be
  177. // visible on the session area's very first frame when a caller sends
  178. // ahead of navigation (first-send flow).
  179. this.promptAttempted = true
  180. if (this.blankBit) this.firstPromptPendingTurn = true
  181. this.notifier.markDirty()
  182. let result: ClientResult<{ accepted: true }>
  183. try {
  184. if (this.address === undefined) {
  185. const clientTimeZone = resolvedClientTimeZone()
  186. result = toSessionResult(await this.remote.session.prompt({
  187. requestId: randomUUID() as SessionRequestId,
  188. sessionId: this.sessionId,
  189. mode,
  190. content,
  191. clientTimeZone,
  192. }, signal))
  193. } else if (this.address.mode === 'one-shot') {
  194. result = {
  195. ok: false,
  196. error: {
  197. code: 'subagent-not-resumable',
  198. message: 'one-shot subagent conversations are read-only',
  199. details: { childSessionId: this.address.childSessionId },
  200. },
  201. }
  202. } else {
  203. if (content.some(part => part.type === 'image')) {
  204. result = {
  205. ok: false,
  206. error: {
  207. code: 'attachment-error',
  208. message: 'Image input is unavailable for subagent continuations.',
  209. details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
  210. },
  211. }
  212. } else {
  213. const routed = (await this.api.subagents.prompt({
  214. ...this.address,
  215. content: content.flatMap(part => part.type === 'text'
  216. ? [{ type: 'text' as const, text: part.text }]
  217. : []),
  218. clientTimeZone: resolvedClientTimeZone(),
  219. }, signal)).result
  220. result = routed.ok ? { ok: true, value: { accepted: true } } : routed
  221. }
  222. }
  223. } catch (error) {
  224. result = transportResult(error)
  225. }
  226. if (!result.ok) {
  227. this.promptError = { op: 'send', error: result.error }
  228. this.notifier.markDirty()
  229. return result
  230. }
  231. // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
  232. // conversation's first turn on the host (the host criterion — a logged
  233. // turn/start — is fact, not optimism; standalone command and projection
  234. // events never flip it), while a rejected first prompt must keep the
  235. // session blank — the client-side blank mirror only ever lowers, so
  236. // flipping early on a failure would surface the session forever and
  237. // strip its connectWorkspace reuse eligibility against the host's
  238. // authority.
  239. if (this.blankBit) {
  240. this.blankBit = false
  241. this.options.onEngaged?.(this)
  242. this.notifier.markDirty()
  243. }
  244. return result
  245. }
  246. /**
  247. * Resolve one image referenced by this session into browser-consumable bytes.
  248. * @param attachmentId - opaque id found in the folded session log.
  249. * @returns the authenticated reference and decoded bytes.
  250. */
  251. async readAttachment(
  252. attachmentId: AttachmentIdType,
  253. ): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
  254. try {
  255. const result = await this.remote.session.attachment({
  256. sessionId: this.sessionId,
  257. attachmentId,
  258. })
  259. if (!result.ok) return toSessionResult(result)
  260. const binary = atob(result.value.data)
  261. const data = Uint8Array.from(binary, char => char.charCodeAt(0))
  262. return { ok: true, value: { attachment: result.value.attachment, data } }
  263. } catch (error) {
  264. return transportResult(error)
  265. }
  266. }
  267. /** Apply one operation to a still-pending queue occurrence. */
  268. async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
  269. try {
  270. return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
  271. } catch (error) {
  272. return transportResult(error)
  273. }
  274. }
  275. /**
  276. * Stop the active turn while the Host preserves pending inbox work; failures
  277. * land in promptError (same error-strip display slot). A continuable
  278. * subagent address routes through `subagent.interrupt`, whose durable
  279. * parent-address authority works without a live parent Agent; a one-shot
  280. * address stays uncancellable (the UI offers no stop action, so this arm is
  281. * defensive).
  282. * @returns the cancel result.
  283. */
  284. async cancel(): Promise<ClientResult<{ accepted: true }>> {
  285. const address = this.address
  286. if (address !== undefined && address.mode === 'one-shot') {
  287. const result: ClientResult<{ accepted: true }> = {
  288. ok: false,
  289. error: {
  290. code: 'subagent-delivery-unavailable',
  291. message: 'subagent activation cancellation is unavailable',
  292. details: { childSessionId: address.childSessionId },
  293. },
  294. }
  295. this.promptError = { op: 'stop', error: result.error }
  296. this.notifier.markDirty()
  297. return result
  298. }
  299. let result: ClientResult<{ accepted: true }>
  300. try {
  301. result = address !== undefined
  302. ? (await this.api.subagents.interrupt(address)).result
  303. : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
  304. } catch (error) {
  305. result = transportResult(error)
  306. }
  307. if (!result.ok) {
  308. this.promptError = { op: 'stop', error: result.error }
  309. this.notifier.markDirty()
  310. }
  311. return result
  312. }
  313. /**
  314. * Rename: contract session.rename 1:1. On success settle the 'title'
  315. * projection cell from the response's `{title, seq}` under the store's
  316. * higher-seq-wins rule (the push frame arriving later is a no-op replay),
  317. * so the list row and any useProjection('title') reader update without
  318. * waiting for the control-stream projection update.
  319. * @param title - raw title text (the host normalizes acceptance).
  320. * @returns the rename result (normalized accepted title + title event seq).
  321. */
  322. async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
  323. try {
  324. const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
  325. if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
  326. return result
  327. } catch (error) {
  328. return transportResult(error)
  329. }
  330. }
  331. /**
  332. * Execute one slash-command line against this session's agent — pure
  333. * admission semantics (the host executor durably logs the lifecycle;
  334. * outcomes render as flow nodes, never as a response echo).
  335. * @param line - the full command line, leading slash included.
  336. * @returns the admission result, or the error branch on transport failure.
  337. */
  338. async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
  339. const result = await this.remote.commands.execute(this.sessionId, line, [])
  340. if (!result.ok) return result
  341. return { ok: true, value: { matched: result.value !== undefined } }
  342. }
  343. /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
  344. open(): Promise<void> {
  345. if (this.openState === 'open') return Promise.resolve()
  346. if (this.openPromise !== null) return this.openPromise
  347. const promise = this.doOpen(this.openGeneration).finally(() => {
  348. // Identity-guarded: a superseded open must not null out the promise resync just started.
  349. if (this.openPromise === promise) this.openPromise = null
  350. })
  351. this.openPromise = promise
  352. return promise
  353. }
  354. /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
  355. async loadOlder(): Promise<void> {
  356. if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
  357. const events = this.events
  358. if (events === undefined) return
  359. this.loadingOlder = true
  360. this.notifier.markDirty()
  361. try {
  362. await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
  363. } catch (error) {
  364. if (sessionStreamFailure(error) === undefined) {
  365. console.error('[session-controller] loadOlder failed:', error)
  366. }
  367. } finally {
  368. this.loadingOlder = false
  369. this.notifier.markDirty()
  370. }
  371. }
  372. /** Rebuild an opened history source after address replacement.
  373. * Invalidates any in-flight open first; queue state belongs to the independently
  374. * reconnecting control stream and remains untouched. */
  375. async resync(): Promise<void> {
  376. if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
  377. this.openGeneration++
  378. const events = this.events
  379. this.events = undefined
  380. await events?.dispose()
  381. this.openPromise = null
  382. this.openState = 'cold'
  383. this.openError = null
  384. this.eventWindow = []
  385. this.baseSeq = 0
  386. this.notifier.markDirty()
  387. await this.open()
  388. }
  389. // ---- Subscription API (useSyncExternalStore direct wiring) ----
  390. /**
  391. * uSES subscription entry.
  392. * @param listener - change callback.
  393. * @returns the unsubscribe function.
  394. */
  395. subscribe(listener: () => void): () => void {
  396. return this.notifier.subscribe(listener)
  397. }
  398. /**
  399. * Cached Session snapshot (rebuilt lazily when dirty with no listeners).
  400. * @returns the cached reference (stable until the next flush).
  401. */
  402. getSnapshot(): SessionSnapshot {
  403. this.notifier.ensureFresh()
  404. return this.snapshotCache
  405. }
  406. // ---- Manager-only entry points (@internal; never called by the UI) ----
  407. /**
  408. * Replace every transient control value for this Session from one stream baseline.
  409. * @param queue - complete pending queue for this Session.
  410. */
  411. replaceControl(queue: readonly SessionQueuedItem[]): void {
  412. this.queueMirror.replace(queue)
  413. this.notifier.markDirty()
  414. }
  415. /**
  416. * Apply one Session-addressed live control update.
  417. * @param frame - queue replacement addressed to this Session.
  418. */
  419. handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
  420. this.queueMirror.replace(frame.items)
  421. this.notifier.markDirty()
  422. }
  423. /**
  424. * Running-bit relay from the host stream (list entry and snapshot stay consistent).
  425. * @param running - the new running state.
  426. */
  427. handleRunning(running: boolean): void {
  428. // Turn-start conversion: a blank session never runs, so the first
  429. // running:true proves another side's first message landed.
  430. if (running && this.blankBit) {
  431. this.blankBit = false
  432. this.notifier.markDirty()
  433. }
  434. if (running) this.firstPromptPendingTurn = false
  435. if (this.running === running) return
  436. this.running = running
  437. this.notifier.markDirty()
  438. }
  439. /**
  440. * Install or clear the catalog-discovered transport address. A changed
  441. * address rebuilds an already-open window through its new history route.
  442. * @param address - direct parent/child address, or undefined for ordinary transport.
  443. * @param parentAvailable - latest exact-parent availability hint.
  444. */
  445. configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
  446. const same = this.address?.parentSessionId === address?.parentSessionId
  447. && this.address?.childSessionId === address?.childSessionId
  448. && this.address?.mode === address?.mode
  449. this.address = address
  450. this.parentAvailable = parentAvailable
  451. if (!same && this.openState !== 'cold') void this.resync()
  452. else this.notifier.markDirty()
  453. }
  454. /**
  455. * Update only the parent availability hint from a catalog refresh.
  456. * @param available - whether the exact direct parent is live.
  457. */
  458. handleSubagentParentAvailable(available: boolean): void {
  459. if (this.parentAvailable === available) return
  460. this.parentAvailable = available
  461. this.notifier.markDirty()
  462. }
  463. /**
  464. * Blank-bit relay from the authoritative summary source (`session.list` and
  465. * `api-session/added`). Monotone: once any signal (local first send,
  466. * running flip, an earlier summary) cleared it, a stale true never
  467. * re-blanks.
  468. * @param blank - the summary's derived empty-log bit.
  469. */
  470. handleBlank(blank: boolean): void {
  471. if (blank === this.blankBit) return
  472. if (blank && (this.promptAttempted || this.running)) return
  473. this.blankBit = blank
  474. this.notifier.markDirty()
  475. }
  476. /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
  477. handleRemoved(): void {
  478. this.removed = true
  479. this.notifier.markDirty()
  480. }
  481. /**
  482. * `api-session/error` relay: the outlet for live failures with no turn position.
  483. * @param message - the stringified error.
  484. */
  485. handleAgentError(message: string): void {
  486. this.lastAgentError = message
  487. this.notifier.markDirty()
  488. }
  489. /**
  490. * Stop the Session's live Remote source.
  491. * @returns when the Remote iterator has completed teardown.
  492. */
  493. async dispose(): Promise<void> {
  494. this.openGeneration++
  495. const events = this.events
  496. this.events = undefined
  497. await events?.dispose()
  498. }
  499. // ---- Private ----
  500. /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
  501. private async doOpen(generation: number): Promise<void> {
  502. this.openState = 'loading'
  503. this.openError = null
  504. this.notifier.markDirty()
  505. const events = new SessionEventStream(this.remote, this.sessionAddress(), {
  506. publish: (change) => {
  507. if (generation !== this.openGeneration || this.events !== events) return
  508. this.acceptEventChange(change)
  509. },
  510. failed: (error) => {
  511. this.failEventStream(events, generation, error)
  512. },
  513. })
  514. this.events = events
  515. try {
  516. await events.open({ maxMessages: PAGE_MESSAGES })
  517. if (generation !== this.openGeneration || this.events !== events) return
  518. this.openState = 'open'
  519. } catch (error) {
  520. if (generation !== this.openGeneration || this.events !== events) return
  521. this.events = undefined
  522. this.openState = 'error'
  523. this.openError = openFailure(error)
  524. } finally {
  525. if (generation === this.openGeneration) this.notifier.markDirty()
  526. }
  527. }
  528. /** Apply one contiguous journal update already reconciled by the Remote stream. */
  529. private acceptEventChange(change: SessionJournalChange): void {
  530. switch (change.type) {
  531. case 'replace':
  532. this.installWindow(change.entries, change.hasMore, change.page.projections)
  533. return
  534. case 'prepend':
  535. this.prependWindow(change.entries, change.hasMore)
  536. return
  537. case 'append':
  538. if (this.appendLive(change.entry)) this.notifier.markDirty()
  539. }
  540. }
  541. /** Replace the complete contiguous window and apply page-owned projection metadata. */
  542. private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
  543. this.eventWindow = entries.map(entry => entry.event as SessionEvent)
  544. this.baseSeq = this.eventWindow[0]?.seq ?? 0
  545. this.hasMore = hasMore
  546. if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
  547. if (projections !== undefined) this.projections.seed(projections)
  548. this.eventSource.replace(entries, hasMore)
  549. this.notifier.markDirty()
  550. }
  551. /** Prepend one stream-validated history page. */
  552. private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
  553. this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
  554. this.baseSeq = this.eventWindow[0]?.seq ?? 0
  555. this.hasMore = hasMore
  556. this.eventSource.prepend(entries, hasMore)
  557. }
  558. /** Append one stream-validated live event. */
  559. private appendLive(entry: SessionEventEntry): boolean {
  560. const event = entry.event as SessionEvent
  561. this.eventWindow.push(event)
  562. const awaitingFirstTurn = this.firstPromptPendingTurn
  563. if (event.type === 'turn/start') this.firstPromptPendingTurn = false
  564. const queueChanged = this.queueMirror.acceptDurable(event)
  565. this.eventSource.append(entry)
  566. return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
  567. }
  568. /** Publish a terminal background failure only while this stream still owns the Session. */
  569. private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
  570. if (generation !== this.openGeneration || this.events !== events) return
  571. this.openGeneration++
  572. this.events = undefined
  573. this.openPromise = null
  574. this.openState = 'error'
  575. this.openError = openFailure(error)
  576. void events.dispose()
  577. this.notifier.markDirty()
  578. }
  579. private buildSnapshot(): SessionSnapshot {
  580. return {
  581. sessionId: this.sessionId,
  582. queue: this.queueMirror.snapshot(),
  583. running: this.running,
  584. subagent: this.address === undefined
  585. ? null
  586. : { address: this.address, parentAvailable: this.parentAvailable },
  587. removed: this.removed,
  588. openState: this.openState,
  589. openError: this.openError,
  590. hasMore: this.hasMore,
  591. loadingOlder: this.loadingOlder,
  592. promptError: this.promptError,
  593. blank: this.blankBit,
  594. lastAgentError: this.lastAgentError,
  595. promptAttempted: this.promptAttempted,
  596. awaitingFirstTurn: this.firstPromptPendingTurn,
  597. }
  598. }
  599. private sessionAddress(): SessionAddress {
  600. return this.address === undefined
  601. ? { kind: 'session', sessionId: this.sessionId }
  602. : { kind: 'subagent', ...this.address }
  603. }
  604. }
  605. /** Convert a terminal Session stream failure to the Client error vocabulary. */
  606. function openFailure(error: unknown): ClientFailure {
  607. const failure = sessionStreamFailure(error)
  608. if (failure !== undefined) return failure as SessionError
  609. const folded = transportResult<never>(error)
  610. /* v8 ignore next -- transportResult never returns an ok result. */
  611. if (folded.ok) throw new Error('transportResult returned an unexpected success')
  612. return folded.error
  613. }
  614. /** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
  615. function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
  616. return result.ok ? result : { ok: false, error: result.error as SessionError }
  617. }