Просмотр исходного кода

fix(session): bound trajectory history projections

_Kerman 2 месяцев назад
Родитель
Сommit
67fcd8ea6d

+ 56 - 2
packages/client/runtime/src/client/sessions/session.ts

@@ -114,6 +114,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
   private callSchemas = new Map<string, ToolSchema>()
   private callSchemasRev = 0
   private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
+  private modelRequestsRev = 0
+  private modelRequestsCache: {
+    rev: number
+    value: readonly ModelRequestView[]
+  } | null = null
+  private compactionRequestsRev = 0
+  private compactionRequestsCache: {
+    rev: number
+    value: readonly CompactionRequestView[]
+  } | null = null
   private promptChangesRev = 0
   private promptChangesCache: {
     rev: number
@@ -614,6 +624,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
   /** Per-event side effects (right column of the §A.9 dispatch table):
    *  chunk accumulation / partial clear on finalize / openCalls add-remove. */
   private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
+    if (affectsModelRequests(event)) this.modelRequestsRev++
+    if (affectsCompactionRequests(event)) this.compactionRequestsRev++
     // The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
     // the host-side dsh-tools plugin whose types cannot enter the client
     // program (its host Context merges collide with the client's), so this
@@ -783,6 +795,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.activeToolSchemas = new Map()
     this.callSchemas = new Map()
     this.callSchemasRev++
+    this.modelRequestsRev++
+    this.compactionRequestsRev++
     this.promptChangesRev++
     for (let i = 0; i < this.events.length; i++) {
       const event = this.events[i]
@@ -823,6 +837,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
       this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
     }
+    if (
+      this.modelRequestsCache === null
+      || this.modelRequestsCache.rev !== this.modelRequestsRev
+    ) {
+      this.modelRequestsCache = {
+        rev: this.modelRequestsRev,
+        value: deriveModelRequests(this.events),
+      }
+    }
+    if (
+      this.compactionRequestsCache === null
+      || this.compactionRequestsCache.rev !== this.compactionRequestsRev
+    ) {
+      this.compactionRequestsCache = {
+        rev: this.compactionRequestsRev,
+        value: deriveCompactionRequests(this.events),
+      }
+    }
     if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
       this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
     }
@@ -840,8 +872,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       sessionId: this.sessionId,
       nodes,
       contexts,
-      compactionRequests: deriveCompactionRequests(this.events),
-      requestAttempts: deriveModelRequests(this.events),
+      compactionRequests: this.compactionRequestsCache.value,
+      requestAttempts: this.modelRequestsCache.value,
       promptChanges: this.promptChangesCache.value,
       foldDegraded: degraded,
       partial,
@@ -901,6 +933,28 @@ function modelRequestKey(turn: number, step: number): string {
   return `${turn}\u0000${step}`
 }
 
+function affectsModelRequests(event: SessionEvent): boolean {
+  switch (event.type) {
+    case 'request/header':
+    case 'step/start':
+    case 'assistant/message':
+    case 'step/end':
+      return true
+    case 'turn/end':
+      return event.data.reason.kind === 'error'
+    default:
+      return (event.type as string) === 'llm/retry'
+  }
+}
+
+function affectsCompactionRequests(event: SessionEvent): boolean {
+  const type = event.type as string
+  return type === 'compact/start'
+    || type === 'compact/summary'
+    || type === 'compact/end'
+    || (event.type === 'user/message' && isCompactionSource(event.data.source))
+}
+
 /** Project every durable step into one provider request, retaining failed retry attempts. */
 function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
   const requests: ModelRequestView[] = []

+ 12 - 5
packages/client/runtime/tests/session.spec.ts

@@ -844,20 +844,27 @@ describe('reference stability (the memo contract)', () => {
     await session.open()
     const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
     feed(ev.turnStart(6, 1))
-    feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
+    feed(ev.stepStart(7, 1))
+    feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
     session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
     const before = session.getSnapshot()
-    // A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
-    feed(ev.chunkStart(8, 1))
-    feed(ev.chunkText(9, 1, '与工具无关的流式'))
+    // A chunk storm touches partial/nodes only: unrelated projections keep identity.
+    feed(ev.chunkStart(9, 1))
+    feed(ev.chunkText(10, 1, '与工具无关的流式'))
     const after = session.getSnapshot()
     expect(after).not.toBe(before)
     expect(after.runningCalls).toBe(before.runningCalls)
     expect(after.pending).toBe(before.pending)
+    expect(after.requestAttempts).toBe(before.requestAttempts)
+    expect(after.compactionRequests).toBe(before.compactionRequests)
     // And a mutation on the tracked domain swaps that array.
-    feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
+    feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
     const resolved = session.getSnapshot()
     expect(resolved.runningCalls).not.toBe(after.runningCalls)
     expect(resolved.pending).toBe(after.pending)
+    feed(ev.assistant(12, 1, '完成'))
+    const completed = session.getSnapshot()
+    expect(completed.requestAttempts).not.toBe(resolved.requestAttempts)
+    expect(completed.compactionRequests).toBe(resolved.compactionRequests)
   })
 })

+ 41 - 30
packages/core/session/src/surface.ts

@@ -270,14 +270,11 @@ function planSurfaceEvent(
   }
 }
 
-/** Apply one event and return replacement metadata only when one occurred. */
-function applySurfaceEvent(
+/** Commit one validated transition and return replacement metadata when one occurred. */
+function applySurfacePlan(
   state: SurfaceFoldState,
-  event: SessionEvent,
-  expectedSeq: number,
-  events: readonly SessionEvent[],
+  plan: SurfacePlan | undefined,
 ): SurfaceFoldReplacement | undefined {
-  const plan = planSurfaceEvent(state, event, expectedSeq, events)
   if (plan?.kind === 'append') {
     state.nodes.push(plan.seq)
   } else if (plan?.kind === 'replace') {
@@ -293,6 +290,16 @@ function applySurfaceEvent(
   }
 }
 
+/** Apply one event and return replacement metadata only when one occurred. */
+function applySurfaceEvent(
+  state: SurfaceFoldState,
+  event: SessionEvent,
+  expectedSeq: number,
+  events: readonly SessionEvent[],
+): SurfaceFoldReplacement | undefined {
+  return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events))
+}
+
 /**
  * Replay a complete session log through the canonical surface fold.
  * @param events - session events in contiguous seq order.
@@ -309,14 +316,35 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
   return { nodes: [...state.nodes], replacements }
 }
 
+/** Reconstruct every surface generation only for consumers that request history. */
+function foldSurfaceContexts(events: readonly SessionEvent[]): SurfaceFoldContext[] {
+  const state = createFoldState()
+  const contexts: SurfaceFoldContext[] = []
+  let origin: SurfaceFoldReplacement | undefined
+  for (const [index, event] of events.entries()) {
+    const plan = planSurfaceEvent(state, event, index, events)
+    const priorNodes = plan?.kind === 'replace' ? [...state.nodes] : undefined
+    const replacement = applySurfacePlan(state, plan)
+    if (replacement === undefined || priorNodes === undefined) continue
+    contexts.push({
+      generation: state.replaceGeneration - 1,
+      nodes: priorNodes,
+      ...(origin === undefined ? {} : { origin }),
+    })
+    origin = replacement
+  }
+  contexts.push({
+    generation: state.replaceGeneration,
+    nodes: [...state.nodes],
+    ...(origin === undefined ? {} : { origin }),
+  })
+  return contexts
+}
+
 /** Incremental ordered surface view and append-boundary validator. */
 export class SurfaceManager implements SessionSurface {
   /** Shared transition state for the live surface. */
   private _state = createFoldState()
-  /** Frozen generations completed by replacements. */
-  private _contexts: SurfaceFoldContext[] = []
-  /** Replacement that created the live generation. */
-  private _contextOrigin: SurfaceFoldReplacement | undefined
   /** Last processed seq; -1 folds a seeded log on first access. */
   private _lastProcessedSeq = -1
 
@@ -343,17 +371,10 @@ export class SurfaceManager implements SessionSurface {
     return this._state.nodes
   }
 
-  /** Frozen generations followed by a detached snapshot of the live generation. */
+  /** Surface generations reconstructed on demand without burdening ordinary live sessions. */
   get contexts(): readonly SurfaceFoldContext[] {
     if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
-    return [
-      ...this._contexts,
-      {
-        generation: this._state.replaceGeneration,
-        nodes: [...this._state.nodes],
-        ...(this._contextOrigin === undefined ? {} : { origin: this._contextOrigin }),
-      },
-    ]
+    return foldSurfaceContexts(this.log)
   }
 
   /** Fold events appended since the previous access. */
@@ -361,17 +382,7 @@ export class SurfaceManager implements SessionSurface {
     for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
       const event = this.log[i]!
-      const op = surfaceOpOf(event)
-      const priorNodes = typeof op === 'object' ? [...this._state.nodes] : undefined
-      const replacement = applySurfaceEvent(this._state, event, i, this.log)
-      if (replacement !== undefined && priorNodes !== undefined) {
-        this._contexts.push({
-          generation: this._state.replaceGeneration - 1,
-          nodes: priorNodes,
-          ...(this._contextOrigin === undefined ? {} : { origin: this._contextOrigin }),
-        })
-        this._contextOrigin = replacement
-      }
+      applySurfaceEvent(this._state, event, i, this.log)
       this._lastProcessedSeq = i
     }
   }

+ 1 - 0
packages/core/session/tests/surface.spec.ts

@@ -218,6 +218,7 @@ describe('SurfaceManager', () => {
     expect(s.surface.nodes).toEqual([1])
     const manager = s.surface as unknown as { _state: object }
     expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
+    expect(Object.hasOwn(manager, '_contexts')).toBe(false)
     expect(foldSurface(s.events).replacements).toEqual([
       { seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
     ])