Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows
Conflicts: the four generated catalog docs (regenerated over merged sources), session index.ts exports (keep chunk-rows exports + master's SessionSurface re-export), stdio/acp demo config schema and persistence wiring (thread packChunks through master's DEFAULT_PERSISTENCE_ROOT/UI shape), stdio README config table, and the jsonl spec import line. The packed-chunk fixture also gains the provenance field master made required on assistant/message.
This commit is contained in:
@@ -11,8 +11,8 @@ import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* One recorded model call. `throw` may replay prefix chunks before failing;
|
||||
@@ -21,9 +21,29 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
*/
|
||||
export type ReplayEntry =
|
||||
| { kind: 'chunks'; chunks: StreamChunk[] }
|
||||
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number }
|
||||
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string }
|
||||
| { kind: 'hang' }
|
||||
|
||||
/** One model exposed by a replay-only provider catalog. */
|
||||
export interface ReplayModelConfig {
|
||||
/** Model id used for replay requests. */
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Optional selector description. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** One provider route exposed by the replay adapter. */
|
||||
export interface ReplayProviderConfig {
|
||||
/** Provider route used for replay requests. */
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Advisory models exposed to clients such as ACP editors. */
|
||||
models?: ReplayModelConfig[]
|
||||
}
|
||||
|
||||
/** Resolved plugin configuration. */
|
||||
export interface ReplayConfig {
|
||||
/**
|
||||
@@ -46,6 +66,12 @@ export interface ReplayConfig {
|
||||
* for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
/**
|
||||
* Optional provider catalog. When non-empty, replay registers an adapter for
|
||||
* these routes; when absent or empty, it retains the catch-all waterfall used
|
||||
* by tests that do not need discovery.
|
||||
*/
|
||||
providers?: ReplayProviderConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +231,42 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
return [primary, ...children]
|
||||
}
|
||||
|
||||
/** Replay adapter that makes a configured provider catalog discoverable without provider I/O. */
|
||||
class ReplayAdapter extends LlmAdapter {
|
||||
private readonly providers: ReadonlyMap<string, ReplayProviderConfig>
|
||||
|
||||
constructor(
|
||||
providers: readonly ReplayProviderConfig[],
|
||||
private readonly replay: (options: GenerateOptions) => AsyncIterable<StreamChunk>,
|
||||
) {
|
||||
super()
|
||||
this.providers = new Map(providers.map(provider => [provider.id, provider]))
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
const configured = this.providers.get(provider)
|
||||
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
|
||||
if (configured === undefined) return super.providerInfo(provider)
|
||||
return { id: provider, name: configured.name ?? provider }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
const configured = this.providers.get(provider)
|
||||
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
|
||||
if (configured === undefined) return Promise.resolve([])
|
||||
return Promise.resolve((configured.models ?? []).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
})))
|
||||
}
|
||||
|
||||
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.replay(options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Yield a recorded stream back, honoring abort like a real adapter. */
|
||||
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
|
||||
switch (entry.kind) {
|
||||
@@ -223,7 +285,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
if (signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
throw new LlmError(entry.message, entry.code, entry.status)
|
||||
throw new LlmError(entry.message, entry.code)
|
||||
case 'hang':
|
||||
// Replay a stream that stalls until cancelled (mirrors MockAdapter): one
|
||||
// chunk, then wait for abort and surface it as the consumer expects.
|
||||
@@ -245,12 +307,14 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
/**
|
||||
* Install per-session positional replay. A newly seen live session takes the
|
||||
* next ordered recorded script, then advances its own cursor synchronously at
|
||||
* invocation time; calls without `sessionId` share one anonymous session.
|
||||
* Returns the effect disposer for HMR-safe removal.
|
||||
* invocation time; calls without `sessionId` share one anonymous session. A
|
||||
* non-empty provider catalog registers a routed replay adapter; otherwise a
|
||||
* catch-all waterfall intercepts requests. Returns the effect disposer for
|
||||
* HMR-safe removal.
|
||||
*
|
||||
* @param ctx - the context whose `llm/stream` waterfall the listener short-circuits.
|
||||
* @param ctx - the context whose LLM service receives the replay route or waterfall.
|
||||
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
|
||||
* @returns the `ctx.on` disposer that removes the listener.
|
||||
* @returns the disposer that removes the registered adapter or listener.
|
||||
*/
|
||||
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
|
||||
const scripts = loadSessionScripts(config)
|
||||
@@ -260,7 +324,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
|
||||
const bound = new Map<string, { entries: ReplayEntry[]; cursor: number }>()
|
||||
let nextScript = 0
|
||||
const ANON = '\0anon\0' // the key for a call that carries no sessionId
|
||||
return ctx.on('llm/stream', (options: GenerateOptions, _next) => {
|
||||
const replay = (options: GenerateOptions): AsyncIterable<StreamChunk> => {
|
||||
const key = options.sessionId ?? ANON
|
||||
let state = bound.get(key)
|
||||
let unrecorded = false
|
||||
@@ -298,7 +362,12 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
|
||||
}
|
||||
yield* replayEntry(entry, options.signal)
|
||||
})()
|
||||
})
|
||||
}
|
||||
const providers = config.providers ?? []
|
||||
if (providers.length > 0) {
|
||||
return ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
|
||||
}
|
||||
return ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
|
||||
}
|
||||
|
||||
export const name = 'llm-replay'
|
||||
@@ -316,6 +385,8 @@ export interface Config {
|
||||
* a nested-agent scenario; absent/empty for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
/** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
|
||||
providers?: ReplayProviderConfig[]
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
@@ -331,5 +402,6 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
file,
|
||||
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
|
||||
...childFiles.length > 0 ? { childFiles } : {},
|
||||
...config.providers !== undefined ? { providers: config.providers } : {},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user