session.ts 29 KB

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