Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # packages/client/runtime/tests/session.spec.ts
This commit is contained in:
@@ -8,7 +8,7 @@ import type {
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
return this.inspectionCache.value
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Notifier: subscription + microtask-batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
|
||||
// Notifier: subscription + batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
|
||||
// N markFrameDirty calls collapse into one animation-frame flush;
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
@@ -9,12 +10,13 @@
|
||||
// swallow the notification — push subscribers (object-layer watchers) would
|
||||
// otherwise starve whenever any reader pulls first.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled = false
|
||||
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
|
||||
private scheduleGeneration = 0
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
constructor(private readonly rebuild: () => void) {}
|
||||
@@ -35,19 +37,16 @@ export class Notifier {
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
if (this.scheduled === 'microtask') return
|
||||
this.schedule('microtask')
|
||||
}
|
||||
|
||||
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
|
||||
markFrameDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled !== 'none') return
|
||||
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +56,8 @@ export class Notifier {
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.notifyPending = false
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
this.invalidateSchedule()
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,4 +69,35 @@ export class Notifier {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private schedule(kind: 'microtask' | 'frame'): void {
|
||||
const generation = ++this.scheduleGeneration
|
||||
this.scheduled = kind
|
||||
const publish = () => {
|
||||
if (generation !== this.scheduleGeneration) return
|
||||
this.scheduled = 'none'
|
||||
this.flush()
|
||||
}
|
||||
if (kind === 'frame') {
|
||||
globalThis.requestAnimationFrame(publish)
|
||||
} else {
|
||||
queueMicrotask(publish)
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSchedule(): void {
|
||||
this.scheduleGeneration++
|
||||
this.scheduled = 'none'
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
|
||||
import { toAssistantBlock } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* Whether a stream chunk changes the partial assistant projection shown by the UI.
|
||||
* @param type - Stream chunk discriminant.
|
||||
* @returns Whether publishing the accumulated partial can change the visible snapshot.
|
||||
*/
|
||||
export function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
|
||||
export class PartialAccumulator {
|
||||
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
|
||||
import { TranscriptAdapter } from './transcript-adapter.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
@@ -690,6 +690,10 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
this.appendLive(event, view)
|
||||
if (event.type === 'assistant/chunk') {
|
||||
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
|
||||
return
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
|
||||
* laziness, synchronous notifyNow, and unsubscribe.
|
||||
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
|
||||
* no-listener laziness, synchronous notifyNow, and unsubscribe.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Notifier } from '../src/client/sessions/notifier.ts'
|
||||
|
||||
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Notifier', () => {
|
||||
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
|
||||
const order: string[] = []
|
||||
@@ -60,6 +64,57 @@ describe('Notifier', () => {
|
||||
expect(rebuilds).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses frame-dirty changes into one cumulative frame publication', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const order: string[] = []
|
||||
const notifier = new Notifier(() => order.push('rebuild'))
|
||||
notifier.subscribe(() => order.push('notify'))
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
|
||||
expect(order).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.shift()!(0)
|
||||
expect(order).toEqual(['rebuild', 'notify'])
|
||||
})
|
||||
|
||||
it('lets a structural microtask publication supersede a pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markDirty()
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching when animation frames are unavailable', async () => {
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
expect(notifications).toBe(0)
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribed listeners stop receiving notifications', async () => {
|
||||
let calls = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* enough.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
@@ -163,6 +167,40 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
|
||||
feed(ev.chunkStart(6, 1))
|
||||
feed(ev.chunkText(7, 1, '累'))
|
||||
feed(ev.chunkText(8, 1, '计'))
|
||||
expect(published).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
|
||||
Reference in New Issue
Block a user