Merge remote-tracking branch 'origin/master' into session-surface
Reconciles the session-surface work (surfaceOp/sourceEventSeqs provenance as the sole derivation path) with master's worktree-subagent series (fork-seed boundary + out-of-process subagent backends). Semantic reconciliations beyond the textual auto-merge: - SQLite SCHEMA_VERSION: both sides bumped 2->3. Merged to a single v3 carrying BOTH column families — master's seed_length on `sessions` and surface's source_event_seqs/surface_op on `events`. writeRow + both INSERT sites bind the full set; the schema doc lists all three added columns as the v2->v3 gap. - agent-loop runStep request: master's `sessionId: session.id` and surface's per-append surfaceOp/sourceEventSeqs coexist (different regions). - Fork seed + surface: a fork seeds the child from the parent's LIVE events, which now carry surfaceOp, so the child's surface rebuilds correctly. Verified end-to-end — the subagent-fork replay recalls the inherited "SAFFRON" codeword through the seeded prefix. - Subagent snapshot fixtures (recorded pre-surface) re-enriched via KEYLESS deterministic replay: only surfaceOp/sourceEventSeqs added onto existing recorded lines (matched by seq), no recorded value changed. Not re-recorded against the live API. Gates: typecheck, test (1112), test:snapshot (14), doc-sync, lint, build, hygiene all green.
This commit is contained in:
@@ -5,17 +5,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -10,26 +10,35 @@ The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assi
|
||||
|
||||
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.
|
||||
|
||||
## Nested agents: per-session keying
|
||||
|
||||
A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script.
|
||||
|
||||
Replay keys every call by its calling session id (`GenerateOptions.sessionId`, stamped by the agent loop). Live session ids are freshly random each run and never equal the recorded ones, so a live session binds to a recorded script by **first-call order**: scripts are ordered by header `createdAt` (parent first — it streams before it can delegate), and the first live session to make any call claims the first script, the next new session the next, and so on. Each session then advances its own cursor. A call with no `sessionId` is one anonymous session bound to the primary script, so single-session scenarios behave exactly as before. More distinct live sessions than recorded scripts fails loud.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the per-scenario `session.jsonl` fixture. Required (config or env). |
|
||||
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the derived script. |
|
||||
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
|
||||
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. |
|
||||
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
|
||||
|
||||
```yaml
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
# file/overrideFile default to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE,
|
||||
# set by the snapshot harness per scenario.
|
||||
# file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE /
|
||||
# $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot
|
||||
# harness per scenario.
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
- `installLlmReplay(ctx, config)` — install the `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for a scenario (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` — the pure helpers that turn a recorded session log into a script. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `ReplayConfig` / `Config`.
|
||||
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `Config`.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
* therefore "run the real agent once and harvest the `.jsonl`", done by the
|
||||
* snapshot harness — this plugin does not record.
|
||||
*
|
||||
* A NESTED-agent scenario records more than one log: the parent plus one per
|
||||
* in-process subagent (each subagent runs as its own {@link Session} on the same
|
||||
* context). Replay loads them all ({@link loadSessionScripts}), derives a script
|
||||
* per recorded session, and keys each live call by its calling session id
|
||||
* (`GenerateOptions.sessionId`, stamped by the loop). Live session ids are fresh
|
||||
* random values, so a live session binds to a recorded script by FIRST-CALL
|
||||
* order (parent first — it streams before it delegates); see
|
||||
* {@link installLlmReplay}.
|
||||
*
|
||||
* Two failure modes are NOT reconstructable from `assistant/chunk` alone — a
|
||||
* pure throw before any chunk (e.g. an HTTP 401: the log holds only a
|
||||
* `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content).
|
||||
@@ -36,6 +45,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -65,14 +75,49 @@ export type ReplayEntry =
|
||||
|
||||
/** Resolved plugin configuration. */
|
||||
export interface ReplayConfig {
|
||||
/** Path to the per-scenario `session.jsonl` fixture (the recorded log). */
|
||||
/**
|
||||
* Path to the PRIMARY (parent) `session.jsonl` fixture. For a single-session
|
||||
* scenario this is the only log; for a nested-agent scenario it is the parent,
|
||||
* and the child logs ride in {@link childFiles}.
|
||||
*/
|
||||
file: string
|
||||
/**
|
||||
* Optional path to a `ReplayEntry[]` sidecar that REPLACES the derived
|
||||
* script. Used by the two scenarios not expressible as `assistant/chunk`
|
||||
* (pure throw-before-chunk, cancel/hang). Absent for normal scenarios.
|
||||
* Optional `ReplayEntry[]` sidecar that REPLACES the derived script for the
|
||||
* PRIMARY session. Used by the two single-session scenarios not expressible as
|
||||
* `assistant/chunk` (pure throw-before-chunk, cancel/hang). Absent for normal
|
||||
* and nested scenarios.
|
||||
*/
|
||||
overrideFile?: string
|
||||
/**
|
||||
* Additional recorded child-session logs (a nested-agent scenario's subagent
|
||||
* sessions). Each is derived independently; the full set is ordered by
|
||||
* `createdAt` so the parent (earliest) binds to the first live session. Empty
|
||||
* for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded session's replay script: the per-call entries plus the header
|
||||
* facts needed to ORDER and key it. Live session ids are freshly random at
|
||||
* replay time and never equal the recorded `id`, so the recorded id is only a
|
||||
* diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it
|
||||
* (a parent is created before its children) and each newly-seen live session is
|
||||
* bound to the next script in that order (= first-call order in the synchronous
|
||||
* nested cut, where the parent streams before it delegates).
|
||||
*/
|
||||
export interface SessionScript {
|
||||
/** The recorded session id (diagnostics only — the live id differs). */
|
||||
recordedId: string
|
||||
/** Session creation time; the deterministic ordering key (parent < child). */
|
||||
createdAt: number
|
||||
/** The per-`stream()`-call replay entries, in recorded call order. */
|
||||
entries: ReplayEntry[]
|
||||
/**
|
||||
* Whether this is the PRIMARY (parent) session. Breaks a `createdAt` tie in
|
||||
* favor of the parent, which always issues the first model call.
|
||||
*/
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +138,26 @@ export function parseSessionLog(text: string): SessionEvent[] {
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the identifying facts off a session log's header line (line 0): the
|
||||
* recorded session `id` (diagnostics), `createdAt` (the deterministic ordering
|
||||
* key that binds a recorded script to a live session — see
|
||||
* {@link SessionScript}), and `seedLength` (the seed boundary — how many leading
|
||||
* events were INHERITED via a fork seed rather than produced by this session's
|
||||
* own model calls; absent ⇒ 0). A header missing a field falls back to a stable
|
||||
* default (`''` / `0` / `0`) rather than throwing: a no-model fixture is
|
||||
* header-only and still orders fine as the single (primary) script.
|
||||
*/
|
||||
export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } {
|
||||
const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown }
|
||||
return {
|
||||
id: typeof parsed.id === 'string' ? parsed.id : '',
|
||||
createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0,
|
||||
seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the per-`stream()` replay script from a recorded session log.
|
||||
*
|
||||
@@ -144,11 +209,11 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the replay script for a scenario: the sidecar override if present,
|
||||
* otherwise the script derived from the recorded session JSONL. Fail-loud if
|
||||
* the JSONL fixture is missing (the scenario was never recorded) — never
|
||||
* silently returns an empty script, so a coverage hole can't masquerade as a
|
||||
* passing replay.
|
||||
* Build the replay script for the PRIMARY session: the sidecar override if
|
||||
* present, otherwise the script derived from the recorded session JSONL.
|
||||
* Fail-loud if the JSONL fixture is missing (the scenario was never recorded) —
|
||||
* never silently returns an empty script, so a coverage hole can't masquerade
|
||||
* as a passing replay.
|
||||
*/
|
||||
export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
if (config.overrideFile !== undefined && existsSync(config.overrideFile)) {
|
||||
@@ -164,6 +229,69 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every recorded session's script for a scenario, ordered by `createdAt`
|
||||
* (earliest first), ready to bind to live sessions in first-call order.
|
||||
*
|
||||
* The PRIMARY session (`config.file`, with its optional `overrideFile`) is the
|
||||
* parent; each `config.childFiles` entry is a recorded subagent session. A
|
||||
* single-session scenario has no `childFiles`, so this returns one script and
|
||||
* behaves exactly like the old single-cursor replay. The primary always sorts
|
||||
* first when ties occur (a sub-millisecond parent/child `createdAt` collision):
|
||||
* the parent issues the FIRST model call (it must stream before it can delegate
|
||||
* in the synchronous nested cut), so binding it to the first live session is
|
||||
* correct regardless of a timestamp tie.
|
||||
*/
|
||||
export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
const primaryEntries = loadReplayScript(config)
|
||||
// The override path replaces the derived script but carries no header; read
|
||||
// the header off the JSONL when it exists, else use a stable default so an
|
||||
// override-only fixture (header-less) still orders first as the primary.
|
||||
const primaryHeader = existsSync(config.file)
|
||||
? parseSessionHeader(readFileSync(config.file, 'utf8'))
|
||||
: { id: '', createdAt: 0 }
|
||||
const primary: SessionScript = {
|
||||
recordedId: primaryHeader.id, createdAt: primaryHeader.createdAt, entries: primaryEntries, primary: true,
|
||||
}
|
||||
const children: SessionScript[] = []
|
||||
for (const childFile of config.childFiles ?? []) {
|
||||
if (!existsSync(childFile)) {
|
||||
throw new Error(`llm-replay: child fixture not found: ${childFile} — re-record the scenario`)
|
||||
}
|
||||
const text = readFileSync(childFile, 'utf8')
|
||||
const header = parseSessionHeader(text)
|
||||
// Derive the child's script from its OWN events only — events AT OR AFTER
|
||||
// the seed boundary. A FORK child's log begins with the seeded parent prefix
|
||||
// (the parent's events, including its `assistant/chunk`s); replaying those as
|
||||
// the child's model calls would feed the child the PARENT's recorded
|
||||
// responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op
|
||||
// there.
|
||||
const ownEvents = parseSessionLog(text).slice(header.seedLength)
|
||||
children.push({
|
||||
recordedId: header.id,
|
||||
createdAt: header.createdAt,
|
||||
entries: deriveReplayScript(ownEvents),
|
||||
primary: false,
|
||||
})
|
||||
}
|
||||
// The primary (parent) always binds first — it issues the first model call,
|
||||
// because it must run a turn before it can delegate. Children follow in
|
||||
// createdAt order. In the current synchronous cut sibling children are created
|
||||
// STRICTLY SEQUENTIALLY — the subagent tool awaits one child's result and
|
||||
// disposes it before the parent's next tool call can start the next — so their
|
||||
// createdAt values are strictly ordered and match first-call order exactly.
|
||||
// The recordedId tiebreak only makes a degenerate same-millisecond collision
|
||||
// (unreachable in this cut) deterministic; it does NOT recover first-call
|
||||
// order, so it is arbitrary if such a tie ever occurs.
|
||||
// XXX(concurrent-subagents): a future cut that runs siblings concurrently or
|
||||
// backgrounded could create two children in the same millisecond, where this
|
||||
// createdAt+id order may diverge from first-call order. That cut must thread a
|
||||
// real first-call ordinal (the order live sessions first stream) instead of
|
||||
// leaning on createdAt — see the per-session-replay RFC.
|
||||
children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId))
|
||||
return [primary, ...children]
|
||||
}
|
||||
|
||||
/** Yield a recorded stream back, honoring abort like a real adapter. */
|
||||
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
|
||||
switch (entry.kind) {
|
||||
@@ -206,22 +334,70 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
* disposer (so a fiber dispose removes it — HMR safety). Exported separately
|
||||
* from {@link apply} so unit tests can drive it without the Loader or env vars.
|
||||
*
|
||||
* Replay is POSITIONAL: the Nth `stream()` call serves the Nth script entry.
|
||||
* This is deterministic only with at most one model stream in flight at a time;
|
||||
* the snapshot harness runs one ACP session per scenario to guarantee that. The
|
||||
* cursor is advanced synchronously at listener-invocation time (not lazily
|
||||
* inside the generator) so call ORDER, not iteration order, fixes the mapping.
|
||||
* Replay is PER-SESSION POSITIONAL: each recorded session has its own script
|
||||
* (parent + any subagent children, loaded by {@link loadSessionScripts} ordered
|
||||
* by `createdAt`), and the Nth `stream()` call FROM A GIVEN SESSION serves that
|
||||
* session's Nth entry. The calling session is read off `options.sessionId` (the
|
||||
* agent loop stamps it from `agent.session.id`).
|
||||
*
|
||||
* Live session ids are freshly random and never equal the recorded ones, so a
|
||||
* live session binds to a recorded script by FIRST-CALL ORDER: the first live
|
||||
* session to make any call takes the first ordered script (the parent — earliest
|
||||
* `createdAt`, and the first to stream because it must run before it delegates),
|
||||
* the next new live session takes the next script, and so on. This keys by WHO
|
||||
* calls rather than global call order, so it stays correct even if subagents
|
||||
* ever run concurrently/backgrounded (a global cursor would interleave them).
|
||||
*
|
||||
* A call with no `sessionId` (a direct unit-test `ctx.llm.stream` that omits it)
|
||||
* is treated as one anonymous session — it binds to the first script, so the
|
||||
* single-session path behaves exactly as the old global cursor did.
|
||||
*
|
||||
* Each per-session cursor advances synchronously at listener-invocation time
|
||||
* (not lazily inside the generator) so call ORDER within a session, not
|
||||
* iteration order, fixes the mapping.
|
||||
*/
|
||||
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
|
||||
const entries = loadReplayScript(config)
|
||||
let cursor = 0
|
||||
const scripts = loadSessionScripts(config)
|
||||
// Live-session → its bound script + cursor. A new live session id claims the
|
||||
// next not-yet-bound script (scripts are in bind order); `nextScript` is the
|
||||
// index of the next unclaimed one.
|
||||
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 index = cursor++
|
||||
const entry: ReplayEntry | undefined = entries[index]
|
||||
const key = options.sessionId ?? ANON
|
||||
let state = bound.get(key)
|
||||
let unrecorded = false
|
||||
if (state === undefined) {
|
||||
const script = scripts[nextScript]
|
||||
if (script === undefined) {
|
||||
// More distinct live sessions made calls than the scenario recorded —
|
||||
// an unrecorded subagent appeared. Defer the throw into the returned
|
||||
// generator (the listener must return an AsyncIterable, not throw).
|
||||
unrecorded = true
|
||||
state = { entries: [], cursor: 0 }
|
||||
} else {
|
||||
nextScript++
|
||||
state = { entries: script.entries, cursor: 0 }
|
||||
bound.set(key, state)
|
||||
}
|
||||
}
|
||||
const boundState = state
|
||||
const seenSessions = nextScript
|
||||
const totalScripts = scripts.length
|
||||
const index = boundState.cursor++
|
||||
const entry: ReplayEntry | undefined = boundState.entries[index]
|
||||
return (async function* () {
|
||||
if (unrecorded) {
|
||||
throw new Error(
|
||||
`llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); `
|
||||
+ `the scenario recorded only ${totalScripts} session(s) — re-record it`,
|
||||
)
|
||||
}
|
||||
if (entry === undefined) {
|
||||
throw new Error(
|
||||
`llm-replay: script exhausted — requested model call #${index + 1} but the fixture has only ${entries.length}; re-record the scenario`,
|
||||
`llm-replay: script exhausted — session requested model call #${index + 1} `
|
||||
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
|
||||
)
|
||||
}
|
||||
yield* replayEntry(entry, options.signal)
|
||||
@@ -237,6 +413,12 @@ export interface Config {
|
||||
file?: string
|
||||
/** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
|
||||
overrideFile?: string
|
||||
/**
|
||||
* Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a
|
||||
* path-separator-delimited list). Each is a recorded subagent session log for
|
||||
* a nested-agent scenario; absent/empty for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
@@ -245,5 +427,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
|
||||
}
|
||||
const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
|
||||
installLlmReplay(ctx, overrideFile === undefined || overrideFile.length === 0 ? { file } : { file, overrideFile })
|
||||
const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES
|
||||
const childFiles = config.childFiles
|
||||
?? (childEnv !== undefined && childEnv.length > 0 ? childEnv.split(pathDelimiter) : [])
|
||||
installLlmReplay(ctx, {
|
||||
file,
|
||||
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
|
||||
...childFiles.length > 0 ? { childFiles } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
type ReplayEntry,
|
||||
type SessionScript,
|
||||
apply,
|
||||
deriveReplayScript,
|
||||
inject,
|
||||
installLlmReplay,
|
||||
loadReplayScript,
|
||||
loadSessionScripts,
|
||||
name,
|
||||
parseSessionHeader,
|
||||
parseSessionLog,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -32,9 +35,15 @@ const TEXT_CHUNKS: StreamChunk[] = [
|
||||
]
|
||||
|
||||
/** Build a minimal session-JSONL string: a header line + the given events. */
|
||||
function sessionJsonl(events: SessionEvent[]): string {
|
||||
const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
|
||||
return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
|
||||
function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string {
|
||||
const headerLine = JSON.stringify({
|
||||
type: 'session',
|
||||
version: 0,
|
||||
id: header?.id ?? 's1',
|
||||
createdAt: header?.createdAt ?? 0,
|
||||
...header?.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
})
|
||||
return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** A SessionEvent of type assistant/chunk for (turn, step). */
|
||||
@@ -361,13 +370,219 @@ describe('installLlmReplay (through the real waterfall)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSessionHeader', () => {
|
||||
it('reads id, createdAt, and seedLength off the header line', () => {
|
||||
expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 })))
|
||||
.toEqual({ id: 'abc', createdAt: 42, seedLength: 0 })
|
||||
})
|
||||
|
||||
it('reads a non-zero seedLength (a fork child header)', () => {
|
||||
expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n'))
|
||||
.toEqual({ id: 'child', createdAt: 7, seedLength: 4 })
|
||||
})
|
||||
|
||||
it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => {
|
||||
expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
|
||||
})
|
||||
|
||||
it('falls back on an empty buffer (no header line)', () => {
|
||||
expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSessionScripts', () => {
|
||||
/** Write a session log file and return its path. */
|
||||
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = []
|
||||
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
|
||||
const path = join(dir, filename)
|
||||
writeFileSync(path, sessionJsonl(events, header), 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
it('returns one primary script for a single-session scenario', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts: SessionScript[] = loadSessionScripts({ file: f })
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(scripts[0]).toMatchObject({ recordedId: 'p', createdAt: 100, primary: true })
|
||||
expect(scripts[0]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
|
||||
})
|
||||
|
||||
it('orders parent + children by createdAt with the primary first on a tie', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
// One child created LATER, one child sharing the parent's createdAt (tie).
|
||||
const later = writeSession('session.1.jsonl', { id: 'late', createdAt: 200 }, [TEXT_CHUNKS])
|
||||
const tie = writeSession('session.2.jsonl', { id: 'tie', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [later, tie] })
|
||||
// parent (100, primary) → tie (100, non-primary) → late (200).
|
||||
expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'tie', 'late'])
|
||||
expect(scripts[0]?.primary).toBe(true)
|
||||
})
|
||||
|
||||
it('throws when a declared child fixture is missing', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
expect(() => loadSessionScripts({ file: f, childFiles: [join(dir, 'absent.jsonl')] }))
|
||||
.toThrow(/child fixture not found/)
|
||||
})
|
||||
|
||||
it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => {
|
||||
// A fork child's log begins with the seeded parent prefix — the parent's
|
||||
// events, INCLUDING its assistant/chunk events. Deriving the child script
|
||||
// from the whole log would replay the PARENT's recorded responses as the
|
||||
// child's model calls. With seedLength recorded, the child script must
|
||||
// contain only the child's OWN chunks (those after the boundary).
|
||||
const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' }
|
||||
const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
// The child fixture: 2 seeded parent events (a chunk + its finish) then the
|
||||
// child's own turn. seedLength = 2 marks where the inherited prefix ends.
|
||||
const childEvents: SessionEvent[] = [
|
||||
chunkEvent(0, 1, 1, parentChunk),
|
||||
chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }),
|
||||
chunkEvent(2, 2, 1, childChunks[0]!),
|
||||
chunkEvent(3, 2, 1, childChunks[1]!),
|
||||
]
|
||||
const childPath = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8')
|
||||
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [childPath] })
|
||||
// The child script is ONLY the child's own model call — the parent's seeded
|
||||
// chunk is gone.
|
||||
expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: childChunks }])
|
||||
})
|
||||
|
||||
it('uses the override for the primary and still derives children', () => {
|
||||
writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
const override: ReplayEntry[] = [{ kind: 'hang' }]
|
||||
writeFileSync(overrideFile, JSON.stringify(override), 'utf8')
|
||||
const child = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file, overrideFile, childFiles: [child] })
|
||||
expect(scripts[0]?.entries).toEqual(override)
|
||||
expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
|
||||
})
|
||||
|
||||
it('defaults the primary header to id="" / createdAt=0 when only an override (no JSONL) exists', () => {
|
||||
// An override-only fixture: config.file does NOT exist, the override drives
|
||||
// the primary script, so the header default branch applies.
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8')
|
||||
const scripts = loadSessionScripts({ file: join(dir, 'absent.jsonl'), overrideFile })
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(scripts[0]).toMatchObject({ recordedId: '', createdAt: 0, primary: true })
|
||||
})
|
||||
|
||||
it('orders two same-createdAt children deterministically after the primary', () => {
|
||||
// Two children sharing a createdAt (both non-primary): exercises the sort
|
||||
// tie-break\'s "both same primary-ness" arm and a non-primary-vs-primary arm.
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const c1 = writeSession('session.1.jsonl', { id: 'c1', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const c2 = writeSession('session.2.jsonl', { id: 'c2', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [c1, c2] })
|
||||
// Primary first (its createdAt ties the children but primary wins); the two
|
||||
// children keep a stable relative order.
|
||||
expect(scripts[0]?.recordedId).toBe('parent')
|
||||
expect(scripts.every(s => s.createdAt === 100)).toBe(true)
|
||||
expect(scripts.map(s => s.primary)).toEqual([true, false, false])
|
||||
})
|
||||
|
||||
it('keeps the primary first even when a child sorts BEFORE it in input order', () => {
|
||||
// The primary is appended first internally but the child has an EARLIER
|
||||
// createdAt — the primary must still win on the tie-break against a
|
||||
// later-but-equal child, and lose only to a genuinely earlier child via
|
||||
// createdAt (here the child is earlier, so order is child-then-primary only
|
||||
// if createdAt strictly less; equal createdAt keeps primary first).
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [earlier] })
|
||||
// Equal createdAt → primary first.
|
||||
expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'early'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installLlmReplay (per-session keying)', () => {
|
||||
const second: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'child' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
/** Write a session log file and return its path. */
|
||||
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = []
|
||||
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
|
||||
const path = join(dir, filename)
|
||||
writeFileSync(path, sessionJsonl(events, header), 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
|
||||
it('routes each live session to its own script by FIRST-CALL order', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'rec-parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const childFile = writeSession('session.1.jsonl', { id: 'rec-child', createdAt: 200 }, [second])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] })
|
||||
// The first live session to call binds to the parent script; a different
|
||||
// live session id binds to the child script — regardless of recorded ids.
|
||||
expect(await drain(ctx.llm.stream(live('live-A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('live-B')))).toEqual(second)
|
||||
// The first session's SECOND call would exhaust its 1-entry script.
|
||||
await expect(drain(ctx.llm.stream(live('live-A')))).rejects.toThrow(/exhausted/)
|
||||
})
|
||||
|
||||
it('keeps each session\'s cursor independent (interleaved calls)', async () => {
|
||||
const a2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'a2' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const b2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'b2' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS, a2])
|
||||
const childFile = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [second, b2])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] })
|
||||
// Interleave: A#1, B#1, A#2, B#2 — each cursor advances per-session.
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(second)
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(a2)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(b2)
|
||||
})
|
||||
|
||||
it('treats a call with no sessionId as the single anonymous (primary) session', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile })
|
||||
// No sessionId at all — the legacy single-session path.
|
||||
expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
})
|
||||
|
||||
it('fails loud when more distinct live sessions call than were recorded', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile }) // only ONE recorded session
|
||||
expect(await drain(ctx.llm.stream(live('first')))).toEqual(TEXT_CHUNKS)
|
||||
// A SECOND distinct live session has no script to bind to.
|
||||
await expect(drain(ctx.llm.stream(live('second')))).rejects.toThrow(/unrecorded session/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply (the plugin entry)', () => {
|
||||
const ORIG = { file: process.env.DSH_SNAPSHOT_FILE, override: process.env.DSH_SNAPSHOT_OVERRIDE }
|
||||
const ORIG = {
|
||||
file: process.env.DSH_SNAPSHOT_FILE,
|
||||
override: process.env.DSH_SNAPSHOT_OVERRIDE,
|
||||
children: process.env.DSH_SNAPSHOT_CHILD_FILES,
|
||||
}
|
||||
afterEach(() => {
|
||||
if (ORIG.file === undefined) delete process.env.DSH_SNAPSHOT_FILE
|
||||
else process.env.DSH_SNAPSHOT_FILE = ORIG.file
|
||||
if (ORIG.override === undefined) delete process.env.DSH_SNAPSHOT_OVERRIDE
|
||||
else process.env.DSH_SNAPSHOT_OVERRIDE = ORIG.override
|
||||
if (ORIG.children === undefined) delete process.env.DSH_SNAPSHOT_CHILD_FILES
|
||||
else process.env.DSH_SNAPSHOT_CHILD_FILES = ORIG.children
|
||||
})
|
||||
|
||||
it('exposes the namespace plugin shape (name/inject, no default export)', () => {
|
||||
@@ -418,4 +633,52 @@ describe('apply (the plugin entry)', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
expect(() => { apply(ctx, { file: '' }) }).toThrow(/a fixture path is required/)
|
||||
})
|
||||
|
||||
it('loads child fixtures from config.childFiles (per-session routing)', async () => {
|
||||
const childSecond: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'kid' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const childFile = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx, { file, childFiles: [childFile] })
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(childSecond)
|
||||
})
|
||||
|
||||
it('falls back to $DSH_SNAPSHOT_CHILD_FILES (path-delimited) when config omits childFiles', async () => {
|
||||
const childChunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'env-kid' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const childFile = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8')
|
||||
process.env.DSH_SNAPSHOT_FILE = file
|
||||
process.env.DSH_SNAPSHOT_CHILD_FILES = childFile // single entry, no delimiter needed
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx)
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(childChunks)
|
||||
})
|
||||
|
||||
it('ignores an empty $DSH_SNAPSHOT_CHILD_FILES (single-session)', async () => {
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
process.env.DSH_SNAPSHOT_FILE = file
|
||||
process.env.DSH_SNAPSHOT_CHILD_FILES = ''
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx)
|
||||
expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
19
packages/support/subagent-mock/README.md
Normal file
19
packages/support/subagent-mock/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-subagent-mock
|
||||
|
||||
A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md).
|
||||
|
||||
It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly.
|
||||
|
||||
## Usage
|
||||
|
||||
Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | `mock` | Registry name to register the provider under. |
|
||||
| `reply` | `mock subagent reply` | The scripted child's final answer text. |
|
||||
| `stopReason` | `completed` | The stop reason `result` settles with. |
|
||||
| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. |
|
||||
| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. |
|
||||
|
||||
A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable.
|
||||
40
packages/support/subagent-mock/package.json
Normal file
40
packages/support/subagent-mock/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-mock",
|
||||
"description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
112
packages/support/subagent-mock/src/index.ts
Normal file
112
packages/support/subagent-mock/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a
|
||||
* model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a
|
||||
* test drive the service and the model-facing tool through the REAL cordis
|
||||
* Loader / export path, exercising registration, capability validation, the
|
||||
* run lifecycle, and the structured-output branch deterministically.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default —
|
||||
* a functional plugin (it only registers a provider; it is never injected).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-mock
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const
|
||||
|
||||
const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
|
||||
|
||||
/**
|
||||
* A scripted provider: every {@link start} returns a run whose `result`
|
||||
* resolves on a microtask with the configured reply (and a structured value
|
||||
* when the request asked for one and the capability is on). `dispose` is a
|
||||
* no-op; a `cancel()` before the result settles flips the stop reason to
|
||||
* `aborted`, so the cancellation path is observable in a test.
|
||||
*/
|
||||
class MockSubagentProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly config: Config,
|
||||
) {
|
||||
this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities }
|
||||
}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
const reply = this.config.reply ?? 'mock subagent reply'
|
||||
const output: ContentBlock[] = [{ type: 'text', text: reply }]
|
||||
const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema
|
||||
const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed'
|
||||
let cancelled = false
|
||||
|
||||
// A deterministic child id derived from the parent — no clock/random (both
|
||||
// banned in deterministic paths here, and unnecessary for a scripted run).
|
||||
const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`)
|
||||
|
||||
const resultFor = (): SubagentResult => ({
|
||||
output,
|
||||
structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined,
|
||||
stopReason: cancelled ? 'aborted' : baseStop,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
result: Promise.resolve().then(resultFor),
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
async dispose() {
|
||||
// Scripted run holds no resources — nothing to await.
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'subagent-mock'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config for the mock provider; all optional with test-friendly defaults. */
|
||||
export interface Config {
|
||||
/** Registry name to register under. */
|
||||
name: string
|
||||
/** The text the scripted child "returns" as its final answer. */
|
||||
reply?: string
|
||||
/** The stop reason the run settles with. */
|
||||
stopReason?: SubagentStopReason
|
||||
/** Which start-time capabilities to advertise (default: all `true`). */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/**
|
||||
* Structured value surfaced when a request carries an `outputSchema` and the
|
||||
* `outputSchema` capability is on (default: `{ reply }`).
|
||||
*/
|
||||
structured?: unknown
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
name: z.string().default('mock'),
|
||||
reply: z.string(),
|
||||
stopReason: z.union(STOP_REASONS),
|
||||
capabilities: z.object({
|
||||
outputSchema: z.boolean(),
|
||||
depthLimit: z.boolean(),
|
||||
toolFilter: z.boolean(),
|
||||
}),
|
||||
structured: z.any(),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config))
|
||||
}
|
||||
101
packages/support/subagent-mock/tests/subagent-mock.spec.ts
Normal file
101
packages/support/subagent-mock/tests/subagent-mock.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import * as mock from '../src/index.ts'
|
||||
|
||||
/** A minimal parent — the mock provider only reads `parent.id`. */
|
||||
function fakeParent(id = 'parent-1'): Agent {
|
||||
return { id: AgentId(id) } as unknown as Agent
|
||||
}
|
||||
|
||||
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over }
|
||||
}
|
||||
|
||||
async function mount(config: Partial<mock.Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(mock, { name: 'mock', ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('dsh-subagent-mock', () => {
|
||||
it('registers a provider on ctx.subagents and returns the scripted reply', async () => {
|
||||
const ctx = await mount({ reply: 'hello from mock' })
|
||||
expect(ctx.subagents.list()).toEqual(['mock'])
|
||||
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'hello from mock' }],
|
||||
structured: undefined,
|
||||
stopReason: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
it('registers under a configurable name', async () => {
|
||||
const ctx = await mount({ name: 'spawn' })
|
||||
expect(ctx.subagents.list()).toEqual(['spawn'])
|
||||
})
|
||||
|
||||
it('surfaces a structured result when the request carries an outputSchema', async () => {
|
||||
const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
|
||||
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
|
||||
await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
|
||||
})
|
||||
|
||||
it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
|
||||
const ctx = await mount({ reply: 'fallback reply' })
|
||||
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
|
||||
await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
|
||||
})
|
||||
|
||||
it('omits structured output when outputSchema capability is off', async () => {
|
||||
const ctx = await mount({ capabilities: { outputSchema: false } })
|
||||
// The service rejects an outputSchema request against a no-cap provider, so
|
||||
// the structured path is only reachable when the cap is on; with it off and
|
||||
// no schema requested, the result has no structured field.
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toMatchObject({ structured: undefined })
|
||||
})
|
||||
|
||||
it('honors a configured stop reason', async () => {
|
||||
const ctx = await mount({ stopReason: 'refusal' })
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
|
||||
})
|
||||
|
||||
it('flips the stop reason to aborted when cancelled before the result settles', async () => {
|
||||
const ctx = await mount()
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
run.cancel()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(mock, { name: 'mock' })
|
||||
expect(ctx.subagents.list()).toEqual(['mock'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in mock).toBe(false)
|
||||
expect(mock.name).toBe('subagent-mock')
|
||||
expect(mock.inject).toEqual(['subagents'])
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(mock) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(mock)
|
||||
expect(unwrapped.name).toBe('subagent-mock')
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
30
packages/support/subagent-mock/tsconfig.json
Normal file
30
packages/support/subagent-mock/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,17 +5,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
Reference in New Issue
Block a user