Files
apes-sidebar/src/assistant-live.ts
T
Menghuan1918 ba0114749f feat(dsh): adapt to DSH 0.1.5-alpha.1
Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
2026-09-09 11:15:30 +08:00

141 lines
6.0 KiB
TypeScript

/**
* Process-local live assistant stream buffer (DSH 0.1.5+).
*
* 0.1.2 appended a durable `assistant/chunk` session event for every model
* delta, so the side-chat transcript and the inherited in-progress snapshot
* could read streaming text straight out of the session log. 0.1.5 removed
* that event: an in-flight attempt now publishes `agent/assistant-stream`
* frames (start / chunk / end) that are NOT part of the log, and the durable
* record lands only at settlement — `assistant/message` (with the exact
* `stream` embedded) or `assistant/attempt` (a failed attempt that committed
* no message, also with its `stream`).
*
* This module folds those frames into a bounded per-session buffer of
* normalized chunks — the same information the old `assistant/chunk` events
* carried — so the plugin keeps a live transcript and an honest in-progress
* snapshot. The buffer is cleared when an attempt ends, so a settled step
* never duplicates its durable message.
*/
import type { Context } from './context-types.ts'
/** One normalized live delta, keyed by its attempt and dense position. */
export interface AssistantLiveChunk {
/** The attempt these chunks belong to (DSH `LlmAttemptId`). */
readonly attemptId: string
/** The turn the attempt belongs to. */
readonly turn: number
/** The step the attempt belongs to. */
readonly step: number
/** Dense zero-based position within the attempt (DSH's own index). */
readonly index: number
/** Safe-integer timestamp of the frame (reused by the durable stream). */
readonly time: number
/** The raw model stream chunk (`text-delta` / `reasoning-delta` / …). */
readonly chunk: Record<string, unknown>
}
/** The plugin's read face over the live frames of every session. */
export interface AssistantLiveBuffer {
/**
* The live chunks of one session's active attempt, in index order.
* @param sessionId - the session whose attempt is streaming.
* @returns the chunks; empty when nothing is streaming for that session.
*/
chunksFor(sessionId: string): readonly AssistantLiveChunk[]
/** Stop observing the agent stream. */
dispose(): void
}
/** Per-attempt buffer ceiling; beyond it the oldest deltas are dropped. */
export const LIVE_CHUNK_CAP = 4000
/** How many sessions may hold a live buffer at once (the oldest is dropped). */
export const LIVE_SESSION_CAP = 64
/** One session's active attempt as it accumulates. */
interface Attempt {
readonly attemptId: string
readonly turn: number
readonly step: number
readonly chunks: AssistantLiveChunk[]
/** The next dense frame index this attempt accepts (independent of the
* window below, which drops the oldest chunks once the cap is reached). */
nextIndex: number
}
/** A frame's `chunk` payload must be a JSON record to be worth buffering. */
function chunkOf(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/** A finite non-negative integer, or undefined. */
function countOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
/**
* Fold `agent/assistant-stream` frames into per-session live buffers.
*
* Frames arrive for every attached agent, so the buffer is keyed by the
* emitting session and ignores anything it cannot identify. An out-of-order
* or mismatched chunk drops the attempt rather than splicing a gap: a
* transcript with a hole is worse than one that settles at the next durable
* event.
* @param ctx - host plugin context (its `on` subscribes the session feed).
* @param cap - per-attempt chunk ceiling.
* @returns the read face and a disposer unbinding the listener.
*/
export function createAssistantLiveBuffer(ctx: Context, cap: number = LIVE_CHUNK_CAP): AssistantLiveBuffer {
const attempts = new Map<string, Attempt>()
const off = ctx.on('agent/assistant-stream', (payload) => {
const record = payload as { agent?: unknown; frame?: unknown } | null
const frame = record?.frame as Record<string, unknown> | undefined
if (frame === undefined) return
const session = (record?.agent as { session?: { id?: unknown } } | undefined)?.session
const sessionId = typeof session?.id === 'string' ? session.id : undefined
if (sessionId === undefined) return
const type = frame['type']
if (type === 'end') {
attempts.delete(sessionId)
return
}
const attemptId = typeof frame['attemptId'] === 'string' ? frame.attemptId : undefined
if (attemptId === undefined) return
if (type === 'start') {
const turn = countOf(frame['turn'])
const step = countOf(frame['step'])
if (turn === undefined || step === undefined) return
const known = attempts.has(sessionId)
attempts.delete(sessionId)
// Only a NEW session may evict the oldest tracked one.
if (!known && attempts.size >= LIVE_SESSION_CAP) {
const oldest = attempts.keys().next().value
if (oldest !== undefined) attempts.delete(oldest)
}
attempts.set(sessionId, { attemptId, turn, step, chunks: [], nextIndex: 0 })
return
}
if (type !== 'chunk') return
const attempt = attempts.get(sessionId)
if (attempt === undefined || attempt.attemptId !== attemptId) return
const index = countOf(frame['index'])
const chunk = chunkOf(frame['chunk'])
// Dense positions only: a gap means the buffer missed frames (a
// replacement attempt, a reconnect) and its text would be wrong.
if (index === undefined || chunk === undefined || index !== attempt.nextIndex) {
attempts.delete(sessionId)
return
}
attempt.nextIndex = index + 1
const time = countOf(frame['time']) ?? 0
attempt.chunks.push({ attemptId, turn: attempt.turn, step: attempt.step, index, time, chunk })
if (attempt.chunks.length > cap) attempt.chunks.splice(0, attempt.chunks.length - cap)
})
return {
chunksFor: sessionId => attempts.get(sessionId)?.chunks ?? [],
dispose: () => { off() },
}
}