feat(agent): emit live assistant stream frames

This commit is contained in:
Tianyi Cui
2026-09-02 03:36:13 +08:00
parent eb56627f36
commit 30e045dfad
47 changed files with 2052 additions and 125 deletions
@@ -0,0 +1,97 @@
/** Process-local assistant state retained for reconnecting Web followers. */
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionAssistantStreamAttempt,
SessionAssistantStreamBaseline,
} from './types.ts'
type ChunkFrame = Extract<AssistantStreamFrame, { type: 'chunk' }>
interface MutableAttempt {
readonly attemptId: SessionAssistantStreamAttempt['attemptId']
readonly startedTime: number
readonly turn: number
readonly step: number
readonly chunks: ChunkFrame[]
readonly legacyChunkSeqs: number[]
}
const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0, attempts: [] }
/**
* Folds dense Agent frames and materializes one shared immutable reconnect
* baseline per accepted revision.
*/
export class SessionAssistantStreamAccumulator {
private readonly attempts = new Map<string, MutableAttempt>()
private revision = 0
private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE
private dirty = false
/**
* Fold one trusted frame from the current attached Agent lifecycle.
* @param frame - next dense process-local Assistant frame.
*/
accept(frame: AssistantStreamFrame): void {
if (frame.type === 'start' && frame.revision === 1 && this.revision !== 0) {
this.attempts.clear()
this.revision = 0
}
if (frame.revision !== this.revision + 1) {
this.attempts.clear()
this.revision = frame.revision
this.dirty = true
return
}
this.revision = frame.revision
switch (frame.type) {
case 'start':
this.attempts.set(String(frame.attemptId), {
attemptId: frame.attemptId,
startedTime: frame.startedTime,
turn: frame.turn,
step: frame.step,
chunks: [],
legacyChunkSeqs: [],
})
break
case 'chunk': {
const attempt = this.attempts.get(String(frame.attemptId))
if (attempt === undefined || frame.index !== attempt.chunks.length) {
this.attempts.clear()
break
}
attempt.chunks.push(frame)
attempt.legacyChunkSeqs.push(frame.legacyChunkSeq)
break
}
case 'end':
this.attempts.delete(String(frame.attemptId))
break
}
this.dirty = true
}
/**
* Read the cached reconnect baseline, materializing it after a state change.
* @returns the identity-stable baseline for the latest accepted revision.
*/
snapshot(): SessionAssistantStreamBaseline {
if (!this.dirty) return this.snapshotValue
this.snapshotValue = {
revision: this.revision,
attempts: [...this.attempts.values()].map(attempt => ({
attemptId: attempt.attemptId,
startedTime: attempt.startedTime,
turn: attempt.turn,
step: attempt.step,
chunks: attempt.chunks.map(frame => frame.chunk as JsonValue),
legacyChunkSeqs: [...attempt.legacyChunkSeqs],
})),
}
this.dirty = false
return this.snapshotValue
}
}
@@ -0,0 +1,176 @@
/** Web presentation fold joining durable v1 events with transient assistant frames. */
import type {
SessionAssistantStreamBaseline,
SessionAssistantStreamFrame,
} from '../../types.ts'
import type {
SessionEventLikeEntry,
SessionLiveEventEntry,
} from '../contract/events.ts'
interface ActiveAttempt {
readonly startedTime: number
readonly turn: number
readonly step: number
readonly legacyChunkSeqs: Set<number>
nextIndex: number
}
/** One Web publication decision from the assistant stream fold. */
export type ClientAssistantStreamResult =
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
| { readonly type: 'rebaseline' }
| undefined
function positionKey(turn: number, step: number): string {
return `${String(turn)}:${String(step)}`
}
function sameSeqs(left: readonly number[], right: readonly number[]): boolean {
return left.length === right.length && left.every((seq, index) => seq === right[index])
}
/**
* Keeps transient Assistant presentation behind one small interface. Durable
* chunks and final messages publish only at their matching live frame.
*/
export class ClientAssistantStream {
private readonly attempts = new Map<string, ActiveAttempt>()
private readonly pendingChunks = new Map<number, SessionLiveEventEntry>()
private readonly pendingMessages = new Map<string, SessionLiveEventEntry>()
private publishedSeqs = new Set<number>()
/**
* Replace the durable Web window and adopt an optional reconnect baseline.
* @param entries - complete event window from the journal replacement.
* @param baseline - active process-local attempts for a follow opening.
* @returns the same durable window; baseline seqs suppress later duplicate live appends.
*/
replace(
entries: readonly SessionEventLikeEntry[],
baseline?: SessionAssistantStreamBaseline,
): readonly SessionEventLikeEntry[] {
this.pendingChunks.clear()
this.pendingMessages.clear()
this.attempts.clear()
if (baseline !== undefined) {
for (const attempt of baseline.attempts) {
this.attempts.set(String(attempt.attemptId), {
startedTime: attempt.startedTime,
turn: attempt.turn,
step: attempt.step,
legacyChunkSeqs: new Set(attempt.legacyChunkSeqs),
nextIndex: attempt.chunks.length,
})
}
}
const visible = entries.filter((entry) => {
if (entry.type !== 'event' || entry.event.type !== 'assistant/message'
|| entry.event.surfaceOp !== 'append') return true
const attempt = this.attemptForSettlement(entry.event)
if (attempt === undefined) return true
this.pendingMessages.set(positionKey(attempt.turn, attempt.step), entry)
return false
})
this.publishedSeqs = new Set(visible.map(entry => entry.event.seq))
return visible
}
/**
* Stage one durable tail event when an active attempt owns its publication.
* @param entry - next cursor-validated durable event.
* @returns the entry for immediate publication, or undefined while staged.
*/
acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
const event = entry.event
if (event.type === 'assistant/chunk') {
const attempt = this.attemptFor(event.data.turn, event.data.step)
if (attempt === undefined) return this.publish(entry)
this.pendingChunks.set(event.seq, entry)
return undefined
}
if (event.type === 'assistant/message') {
if (event.surfaceOp !== 'append') return this.publish(entry)
const attempt = this.attemptFor(event.data.turn, event.data.step)
if (attempt === undefined) return this.publish(entry)
this.pendingMessages.set(positionKey(event.data.turn, event.data.step), entry)
return undefined
}
return this.publish(entry)
}
/**
* Fold one validated transient frame and release its matching durable event.
* @param frame - next dense process-local frame.
* @returns one durable event whose Web publication commits at this frame.
*/
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
switch (frame.type) {
case 'start':
this.attempts.set(String(frame.attemptId), {
startedTime: frame.startedTime,
turn: frame.turn,
step: frame.step,
legacyChunkSeqs: new Set(),
nextIndex: 0,
})
return undefined
case 'chunk': {
const attempt = this.attempts.get(String(frame.attemptId))
if (attempt === undefined || frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
attempt.nextIndex += 1
attempt.legacyChunkSeqs.add(frame.legacyChunkSeq)
if (this.publishedSeqs.has(frame.legacyChunkSeq)) return undefined
const entry = this.pendingChunks.get(frame.legacyChunkSeq)
if (entry === undefined) return { type: 'rebaseline' }
this.pendingChunks.delete(frame.legacyChunkSeq)
return this.publish(entry)
}
case 'end': {
const attempt = this.attempts.get(String(frame.attemptId))
this.attempts.delete(String(frame.attemptId))
if (attempt === undefined) return { type: 'rebaseline' }
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
if (!sameSeqs([...attempt.legacyChunkSeqs], frame.legacyChunkSeqs)) {
return { type: 'rebaseline' }
}
const key = positionKey(attempt.turn, attempt.step)
const entry = this.pendingMessages.get(key)
if (entry === undefined) {
return frame.outcome === 'aborted' ? undefined : { type: 'rebaseline' }
}
if (entry.event.type !== 'assistant/message') return { type: 'rebaseline' }
const sourceEventSeqs = entry.event.sourceEventSeqs
if (sourceEventSeqs === undefined || !sameSeqs(sourceEventSeqs, frame.legacyChunkSeqs)) {
return { type: 'rebaseline' }
}
this.pendingMessages.delete(key)
return this.publish(entry)
}
}
}
private attemptFor(turn: number, step: number): ActiveAttempt | undefined {
return [...this.attempts.values()].find(attempt => (
attempt.turn === turn && attempt.step === step
))
}
private attemptForSettlement(
event: Extract<SessionLiveEventEntry['event'], { type: 'assistant/message' }>,
): ActiveAttempt | undefined {
const sourceEventSeqs = event.sourceEventSeqs
if (sourceEventSeqs === undefined) return undefined
return [...this.attempts.values()].find(attempt => (
attempt.turn === event.data.turn
&& attempt.step === event.data.step
&& sameSeqs([...attempt.legacyChunkSeqs], sourceEventSeqs)
))
}
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
this.publishedSeqs.add(entry.event.seq)
return { type: 'publish', entry }
}
}
@@ -12,6 +12,7 @@ import type {
PromptContentPart,
QueueAction,
SessionAddress,
SessionAssistantStreamBaseline,
SessionControlFrame,
SessionProjectionBaseline,
SessionQueuedItem,
@@ -35,6 +36,10 @@ import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
import {
ClientAssistantStream,
type ClientAssistantStreamResult,
} from './assistant-stream.ts'
function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline {
return {
@@ -95,6 +100,7 @@ export class Session implements SessionFace {
private jumpPromise: Promise<void> | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private readonly queueMirror = new SessionQueueMirror()
private readonly assistantStream = new ClientAssistantStream()
private running = false
private address: SubagentAddress | undefined
private parentAvailable: boolean | undefined
@@ -620,27 +626,50 @@ export class Session implements SessionFace {
change.entries,
change.hasMore,
change.page.projections === undefined ? undefined : projectionsBaseline(change.page.projections),
change.page.assistantStream,
)
return
case 'prepend':
this.prependWindow(change.entries, change.hasMore)
return
case 'append':
if (this.appendLive(change.entry)) this.notifier.markDirty()
this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry))
return
case 'assistant-stream':
this.publishAssistantEntry(this.assistantStream.acceptFrame(change.frame))
}
}
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
private installWindow(
entries: readonly SessionEventLikeEntry[],
hasMore: boolean,
projections?: ProjectionsBaseline,
assistantStream?: SessionAssistantStreamBaseline,
): void {
const visible = this.assistantStream.replace(entries, assistantStream)
this.baseSeq = SessionLogOffset(visible[0]?.event.seq ?? 0)
this.hasMore = hasMore
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(entries, hasMore)
for (const entry of entries) this.observeSubmissionEvent(entry.event)
this.eventSource.replace(visible, hasMore)
for (const entry of visible) this.observeSubmissionEvent(entry.event)
this.notifier.markDirty()
}
private publishAssistantEntry(result: ClientAssistantStreamResult): void {
if (result?.type === 'rebaseline') {
const events = this.events
queueMicrotask(() => {
if (events !== undefined && this.events === events) events.restart()
})
return
}
if (result?.type === 'publish' && this.appendLive(result.entry)) {
this.notifier.markDirty()
}
}
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
@@ -12,6 +12,8 @@ import {
} from '@deepseek-ai/dsh-api-gateway/client'
import type {
SessionAddress,
SessionAssistantStreamBaseline,
SessionAssistantStreamFrame,
SessionControlFrame,
SessionHistoryRecord,
SessionPage,
@@ -40,6 +42,7 @@ export type SessionRemote = ClientRemote['session']
/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
interface SessionJournalPage extends SessionPage {
readonly projections?: SessionProjectionBaseline
readonly assistantStream?: SessionAssistantStreamBaseline
}
/** One complete publication from the Session journal stream. */
@@ -51,9 +54,12 @@ export type SessionJournalChange =
readonly hasMore: boolean
}
| { readonly type: 'append'; readonly entry: SessionLiveEventEntry }
| { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
function toSessionJournalChange(
change: RemoteJournalChange<SessionJournalPage, SessionHistoryRecord>,
change: RemoteJournalChange<
SessionJournalPage, SessionHistoryRecord, SessionAssistantStreamFrame
>,
): SessionJournalChange {
switch (change.type) {
case 'replace':
@@ -72,6 +78,8 @@ function toSessionJournalChange(
entry: change.entry as unknown as SessionLiveEventEntry,
}
}
case 'notification':
return { type: 'assistant-stream', frame: change.notification }
}
}
@@ -136,7 +144,8 @@ export class SessionEventStream extends RemoteJournalStream<
SessionJournalPage,
SessionHistoryRecord,
number,
ClientSessionPageRequest
ClientSessionPageRequest,
SessionAssistantStreamFrame
> {
/**
* @param remote - generated Session namespace and Gateway stream factory.
@@ -169,12 +178,24 @@ export class SessionEventStream extends RemoteJournalStream<
protected override async * follow(
request: ClientSessionPageRequest,
signal: AbortSignal,
): AsyncIterable<RemoteJournalFrame<SessionHistoryRecord, number, SessionJournalPage>> {
): AsyncIterable<RemoteJournalFrame<
SessionHistoryRecord, number, SessionJournalPage, SessionAssistantStreamFrame
>> {
let assistantRevision: number | undefined
for await (const frame of this.remote.session.follow({
address: this.address,
assistantStream: true,
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
}, signal)) {
if (frame.type === 'snapshot') {
if (frame.assistantStream === undefined) {
throw new RemoteError(
'gateway/internal',
'session assistant stream omitted its opted-in opening baseline',
{},
)
}
assistantRevision = frame.assistantStream.revision
yield {
type: 'opened',
cursor: frame.cursor,
@@ -182,10 +203,22 @@ export class SessionEventStream extends RemoteJournalStream<
records: frame.records,
hasMore: frame.hasMore,
projections: frame.projections,
assistantStream: frame.assistantStream,
},
}
continue
}
if (frame.type === 'assistant-stream') {
const expected = (assistantRevision ?? 0) + 1
if (frame.frame.revision !== expected) {
throw new RemoteStreamCarrierError(
`session assistant stream skipped revision ${String(expected)}`,
)
}
assistantRevision = frame.frame.revision
yield { type: 'notification', notification: frame.frame }
continue
}
yield { type: 'entry', entry: frame }
}
}
+66 -7
View File
@@ -2,6 +2,7 @@
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import {
isAppendSurfaceEvent,
SessionLogOffset,
@@ -18,8 +19,10 @@ import type {
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionAddress,
SessionAssistantStreamFrame,
SessionChunkRun,
SessionEventEntry,
SessionFollowRequest,
@@ -32,6 +35,7 @@ import type {
SessionWireHeader,
SessionWireEvent,
} from './types.ts'
import { SessionAssistantStreamAccumulator } from './assistant-stream.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
@@ -39,6 +43,7 @@ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Implements cold-safe history operations delegated by the Session Controller. */
export class SessionHistoryController {
private readonly closeFollowers = new Set<() => void>()
private readonly assistantStreams = new Map<SessionId, SessionAssistantStreamAccumulator>()
/**
* @param ctx - Host context carrying Session query and projection services.
@@ -48,6 +53,17 @@ export class SessionHistoryController {
private readonly ctx: Context,
private readonly promote: (observation: SessionObservation) => void,
) {
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
let stream = this.assistantStreams.get(agent.session.id)
if (stream === undefined) {
stream = new SessionAssistantStreamAccumulator()
this.assistantStreams.set(agent.session.id, stream)
}
stream.accept(frame)
}, { global: true })
ctx.on('agent/disposed', ({ agent }) => {
this.assistantStreams.delete(agent.session.id)
}, { global: true })
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
this.closeFollowers.clear()
@@ -100,14 +116,22 @@ export class SessionHistoryController {
* Follow events appended after an initial cursor on one durable address.
* @param request - durable address and last committed sequence already held by the caller.
* @param signal - stream cancellation owned by the Remote carrier.
* @returns a complete opening snapshot followed by gap-free event frames.
* @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
*/
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
validateFollowRequest(request)
const { address } = request
const target = addressId(address)
const buffered = new Deque<SessionEvent>()
const buffered = new Deque<
| { readonly type: 'event'; readonly event: SessionEvent }
| {
readonly type: 'assistant-stream'
readonly frame: SessionAssistantStreamFrame
readonly ordinal: number
}
>()
let snapshotCursor: SessionSeqCursor | undefined
let assistantStreamOrdinal = 0
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
@@ -122,7 +146,7 @@ export class SessionHistoryController {
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
buffered.pushBack(event)
buffered.pushBack({ type: 'event', event })
notify()
}, { global: true })
const disposeCreated = this.ctx.on('session/created', (session) => {
@@ -134,10 +158,21 @@ export class SessionHistoryController {
? session.firstLiveSeq
: SessionLogOffset(snapshotCursor + 1))
for (let index = suffix.length - 1; index >= 0; index -= 1) {
buffered.pushFront(suffix[index] as SessionEvent)
buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent })
}
notify()
}, { global: true })
const disposeAssistantStream = request.assistantStream !== true
? undefined
: this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
if (agent.session.id !== target) return
buffered.pushBack({
type: 'assistant-stream',
frame: wireAssistantStreamFrame(frame),
ordinal: ++assistantStreamOrdinal,
})
notify()
}, { global: true })
const onAbort = (): void => { notify() }
signal.addEventListener('abort', onAbort, { once: true })
try {
@@ -147,6 +182,14 @@ export class SessionHistoryController {
const cursor = source.cursor
snapshotCursor = cursor
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
const assistantStream = request.assistantStream === true
? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0, attempts: [] }
: undefined
// The accumulator snapshot and this watermark are synchronous. Frames
// through the cut are represented or superseded by that baseline,
// including larger revisions from a retired Agent; later revision
// resets reach Client continuity validation.
const assistantStreamOrdinalCut = assistantStreamOrdinal
yield {
type: 'snapshot',
header: wireHeader(source.header, source.inheritedEventCount),
@@ -156,6 +199,7 @@ export class SessionHistoryController {
projections: source.projections === undefined
? { asOfSeq: cursor, values: {} }
: projectionBlock(source.projections),
...assistantStream === undefined ? {} : { assistantStream },
}
if (address.kind === 'session' && source.source === 'prepared') {
const promotion = source.retain()
@@ -173,19 +217,26 @@ export class SessionHistoryController {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.type === 'assistant-stream') {
if (item.ordinal > assistantStreamOrdinalCut) {
yield { type: 'assistant-stream', frame: item.frame }
}
continue
}
const expectedSeq = SessionSeq(nextOffset)
if (item.seq < expectedSeq) continue
if (item.seq !== expectedSeq) {
if (item.event.seq < expectedSeq) continue
if (item.event.seq !== expectedSeq) {
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {})
}
nextOffset = SessionLogOffset(nextOffset + 1)
yield entryFor(item)
yield entryFor(item.event)
}
} finally {
this.closeFollowers.delete(close)
signal.removeEventListener('abort', onAbort)
disposeCreated()
disposeEvent()
disposeAssistantStream?.()
}
}
@@ -225,6 +276,14 @@ export class SessionHistoryController {
}
function wireAssistantStreamFrame(frame: AssistantStreamFrame): SessionAssistantStreamFrame {
if (frame.type !== 'chunk') return frame
return {
...frame,
chunk: frame.chunk as JsonValue,
}
}
function projectionBlock(
snapshot: NonNullable<SessionObservation['projections']>,
): SessionProjectionBaseline {
+52 -2
View File
@@ -4,7 +4,7 @@ import type {
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
} from '@deepseek-ai/dsh-attachment'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -446,15 +446,63 @@ export interface SessionPageRequest {
export interface SessionFollowRequest {
readonly address: SessionAddress
readonly maxMessages?: number
/** Include process-local assistant presentation frames for the Web client. */
readonly assistantStream?: true
}
/** One active assistant attempt in a reconnect opening snapshot. */
export interface SessionAssistantStreamAttempt {
readonly attemptId: LlmAttemptId
/** Safe-integer wall-clock time copied from the attempt's start frame. */
readonly startedTime: number
readonly turn: number
readonly step: number
readonly chunks: readonly JsonValue[]
/** Exact durable v1 chunk records already represented by {@link chunks}. */
readonly legacyChunkSeqs: readonly number[]
}
/** Complete process-local assistant state at one follow opening. */
export interface SessionAssistantStreamBaseline {
readonly revision: number
readonly attempts: readonly SessionAssistantStreamAttempt[]
}
/** Browser wire form of one process-local assistant frame. */
export type SessionAssistantStreamFrame =
| {
readonly type: 'start'
readonly attemptId: LlmAttemptId
readonly revision: number
readonly startedTime: number
readonly turn: number
readonly step: number
}
| {
readonly type: 'chunk'
readonly attemptId: LlmAttemptId
readonly revision: number
readonly index: number
readonly chunk: JsonValue
readonly legacyChunkSeq: number
}
| {
readonly type: 'end'
readonly attemptId: LlmAttemptId
readonly revision: number
/** Number of chunk frames represented by this terminal marker. */
readonly index: number
readonly outcome: 'committed' | 'aborted'
readonly legacyChunkSeqs: readonly number[]
}
/** One contiguous backwards page of a Session log. */
export interface SessionPage {
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
}
/** Complete opening window followed by ordered events appended after its cursor. */
/** Complete opening window followed by ordered durable events and opted-in assistant frames. */
export type SessionFollowFrame =
| {
readonly type: 'snapshot'
@@ -463,8 +511,10 @@ export type SessionFollowFrame =
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
readonly projections: SessionProjectionBaseline
readonly assistantStream?: SessionAssistantStreamBaseline
}
| SessionEventEntry
| { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
/** One pending inbox occurrence in the authoritative queue snapshot. */
export interface SessionQueuedItem {