Selaa lähdekoodia

fix(web): retain live capacity before session creation

Hypatia May 2 kuukautta sitten
vanhempi
sitoutus
fc6eb4e7a3

+ 16 - 0
packages/client/runtime/src/client/sessions/manager.ts

@@ -57,6 +57,12 @@ export class SessionManager {
    *  drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
    *  drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
    *  frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
    *  frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
   private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
   private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
+  /**
+   * Latest model capacity observed for an uninstantiated session on the
+   * current mux generation. Unlike durable history, this transient frame
+   * cannot be backfilled when get() lazily creates the Session.
+   */
+  private readonly modelRequestContextWindows = new Map<SessionId, number>()
   /** Per-session projection value stores, retained independently of instance arrival (the
   /** Per-session projection value stores, retained independently of instance arrival (the
    *  title-snapshot precedent, generalized): push frames land here whether or not the Session
    *  title-snapshot precedent, generalized): push frames land here whether or not the Session
    *  is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
    *  is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -160,6 +166,7 @@ export class SessionManager {
   }
   }
 
 
   private createSession(sessionId: SessionId): Session {
   private createSession(sessionId: SessionId): Session {
+    const modelRequestContextWindow = this.modelRequestContextWindows.get(sessionId)
     return new Session(sessionId, this.api, {
     return new Session(sessionId, this.api, {
       // The sender's local first-send flip mirrors into the list row so the
       // The sender's local first-send flip mirrors into the list row so the
       // session surfaces (lists filter on blank) before any host frame lands.
       // session surfaces (lists filter on blank) before any host frame lands.
@@ -167,6 +174,7 @@ export class SessionManager {
         this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
         this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
       },
       },
       projections: this.projectionStore(sessionId),
       projections: this.projectionStore(sessionId),
+      ...(modelRequestContextWindow === undefined ? {} : { modelRequestContextWindow }),
     })
     })
   }
   }
 
 
@@ -328,7 +336,14 @@ export class SessionManager {
       this.notifier.markDirty()
       this.notifier.markDirty()
       return
       return
     }
     }
+    if (frame.type === 'session/model-request') {
+      // Transient and non-replayable: retain the latest capacity until lazy
+      // instantiation. An absent value explicitly clears an earlier one.
+      if (frame.contextWindow === undefined) this.modelRequestContextWindows.delete(frame.sessionId)
+      else this.modelRequestContextWindows.set(frame.sessionId, frame.contextWindow)
+    }
     if (frame.type === 'session/subscribed') {
     if (frame.type === 'session/subscribed') {
+      this.modelRequestContextWindows.delete(frame.sessionId)
       // Rows past the host's durable baseline rode state a restart lost; drop
       // Rows past the host's durable baseline rode state a restart lost; drop
       // them so last-wins cannot pin a phantom value over recomputed truth.
       // them so last-wins cannot pin a phantom value over recomputed truth.
       this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
       this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
@@ -391,6 +406,7 @@ export class SessionManager {
         this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
         this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
         this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
         this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
         this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
         this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
+        this.modelRequestContextWindows.delete(frame.sessionId) // connection-local request capacity dies with the Host session
         this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
         this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
         return
         return
       }
       }

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

@@ -43,6 +43,8 @@ export interface SessionOptions {
    * private store (bare object-layer construction).
    * private store (bare object-layer construction).
    */
    */
   projections?: ProjectionValueStore
   projections?: ProjectionValueStore
+  /** Model capacity already observed on this mux generation before lazy construction. */
+  modelRequestContextWindow?: number
 }
 }
 
 
 /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
 /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
@@ -172,6 +174,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     private readonly options: SessionOptions = {},
     private readonly options: SessionOptions = {},
   ) {
   ) {
     this.projections = options.projections ?? new ProjectionValueStore()
     this.projections = options.projections ?? new ProjectionValueStore()
+    this.contextWindow = options.modelRequestContextWindow
     this.snapshotCache = this.buildSnapshot()
     this.snapshotCache = this.buildSnapshot()
   }
   }
 
 
@@ -479,10 +482,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.notifier.markDirty()
     this.notifier.markDirty()
   }
   }
 
 
-  /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
+  /** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */
   handleRemoved(): void {
   handleRemoved(): void {
+    const changed = !this.removed || this.contextWindow !== undefined
     this.removed = true
     this.removed = true
-    this.notifier.markDirty()
+    this.contextWindow = undefined
+    if (changed) this.notifier.markDirty()
   }
   }
 
 
   /**
   /**

+ 121 - 0
packages/client/runtime/tests/manager.spec.ts

@@ -41,6 +41,127 @@ describe('instances', () => {
     expect(manager.get(S2).getSnapshot().pending).toEqual([])
     expect(manager.get(S2).getSnapshot().pending).toEqual([])
   })
   })
 
 
+  it('retains the latest transient model capacity until lazy instantiation', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleMuxEnvelope({
+      rpcId: 'request-1' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 1,
+        provider: 'test',
+        model: 'alpha',
+        contextWindow: 128_000,
+      },
+    })
+    manager.handleMuxEnvelope({
+      rpcId: 'request-2' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 2,
+        provider: 'test',
+        model: 'beta',
+        contextWindow: 256_000,
+      },
+    })
+
+    expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBe(256_000)
+  })
+
+  it('retains explicit capacity clearing before lazy instantiation', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleMuxEnvelope({
+      rpcId: 'request-with-capacity' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 1,
+        provider: 'test',
+        model: 'alpha',
+        contextWindow: 128_000,
+      },
+    })
+    manager.handleMuxEnvelope({
+      rpcId: 'request-without-capacity' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 2,
+        provider: 'test',
+        model: 'unknown-capacity',
+      },
+    })
+
+    expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBeUndefined()
+  })
+
+  it('clears retained capacity on subscribed and resident capacity on removal', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleMuxEnvelope({
+      rpcId: 'request-before-subscribe' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 1,
+        provider: 'test',
+        model: 'alpha',
+        contextWindow: 128_000,
+      },
+    })
+    manager.handleMuxEnvelope({
+      rpcId: 'subscribed' as never,
+      payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
+    })
+    const session = manager.get(S1)
+    expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
+
+    manager.handleMuxEnvelope({
+      rpcId: 'request-after-subscribe' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S1,
+        turn: 1,
+        step: 2,
+        provider: 'test',
+        model: 'beta',
+        contextWindow: 256_000,
+      },
+    })
+    expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
+    manager.handleHostEnvelope({
+      rpcId: 'removed' as never,
+      payload: { type: 'host/session-removed', sessionId: S1 },
+    })
+    expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
+
+    manager.handleMuxEnvelope({
+      rpcId: 'request-before-lazy-removal' as never,
+      payload: {
+        type: 'session/model-request',
+        sessionId: S2,
+        turn: 1,
+        step: 1,
+        provider: 'test',
+        model: 'gamma',
+        contextWindow: 64_000,
+      },
+    })
+    manager.handleHostEnvelope({
+      rpcId: 'lazy-removed' as never,
+      payload: { type: 'host/session-removed', sessionId: S2 },
+    })
+    expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined()
+  })
+
   it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
   it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
     const api = new FakeApiClient()
     const api = new FakeApiClient()
     const manager = new SessionManager(api)
     const manager = new SessionManager(api)