Merge remote-tracking branch 'origin/master' into web-e2e-lane
# Conflicts: # docs/config-catalog.md # docs/testing.i18n.yaml # docs/testing.md # docs/testing.zh.md # packages/host/runtime/src/start.ts # packages/support/acp-snapshot/README.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-llm-replay
|
||||
|
||||
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
|
||||
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
|
||||
|
||||
Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
|
||||
|
||||
@@ -8,7 +8,7 @@ Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stre
|
||||
|
||||
The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header.
|
||||
|
||||
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script.
|
||||
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update.
|
||||
|
||||
## Nested agents: per-session keying
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module @deepseek-ai/dsh-llm-replay
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
|
||||
@@ -22,7 +22,11 @@ import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
export type ReplayEntry =
|
||||
| { kind: 'chunks'; chunks: StreamChunk[] }
|
||||
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string }
|
||||
| { kind: 'hang' }
|
||||
| {
|
||||
kind: 'hang'
|
||||
/** Optional marker written after the prefix chunks are consumed and before the stream waits for cancellation. */
|
||||
readyFile?: string
|
||||
}
|
||||
|
||||
/** One model exposed by a replay-only provider catalog. */
|
||||
export interface ReplayModelConfig {
|
||||
@@ -42,7 +46,7 @@ export interface ReplayProviderConfig {
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Advisory models exposed to clients such as ACP editors. */
|
||||
/** Advisory models exposed to replay scenarios that exercise discovery. */
|
||||
models?: ReplayModelConfig[]
|
||||
}
|
||||
|
||||
@@ -348,6 +352,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined,
|
||||
// chunk, then wait for abort and surface it as the consumer expects.
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
if (entry.readyFile !== undefined) writeFileSync(entry.readyFile, '')
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new Error('aborted')); return }
|
||||
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -379,7 +379,8 @@ describe('installLlmReplay (through the real LlmService)', () => {
|
||||
it('rejects a hang entry when the signal fires DURING the wait (abort listener path)', async () => {
|
||||
writeFileSync(file, sessionJsonl([]), 'utf8')
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8')
|
||||
const readyFile = join(dir, 'stream-ready')
|
||||
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang', readyFile }]), 'utf8')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file, overrideFile })
|
||||
@@ -392,6 +393,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
|
||||
expect((await iterator.next()).value).toMatchObject({ type: 'text-delta' })
|
||||
const pending = iterator.next()
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(existsSync(readyFile)).toBe(true)
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toThrow('aborted')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user