Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md # docs/rfc/implemented/architecture/2026-06-20-branded-ids.md # docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md # docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md # docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/guard/repeat-tool-guard/src/index.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/subagent/subagent-acp/tests/mock-acp-server.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/stdio-agent/src/index.ts # packages/ui/stdio-agent/src/stdio-chat.ts # packages/ui/stdio-agent/tests/stdio-chat.spec.ts # packages/util/brand/src/index.ts
This commit is contained in:
@@ -1,29 +1,8 @@
|
||||
/**
|
||||
* Pure normalizers for the ACP snapshot goldens. They replace the
|
||||
* non-deterministic values in the two captured surfaces — the stdout JSON-RPC
|
||||
* transcript and the persisted session JSONL — with stable tokens, so a golden
|
||||
* compare reflects behavior, not run-to-run noise. Kept dependency-free and
|
||||
* side-effect-free so they unit-test trivially.
|
||||
*
|
||||
* Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp`
|
||||
* cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header);
|
||||
* JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event
|
||||
* `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's
|
||||
* `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq`
|
||||
* (deterministic — `seq = log.length`, part of the event-log contract).
|
||||
*
|
||||
* Separate, composable normalizers keep bulky request-header content out of
|
||||
* session fixtures. {@link scrubSystemPrompts} replaces the composed system
|
||||
* prompt in EVERY fixture; {@link scrubRequestHeaders} additionally replaces
|
||||
* tool schemas and the session prefix outside each suite's header-pinning
|
||||
* scenario. They are deliberately NOT folded into
|
||||
* {@link normalizeSessionLog}: the suite factory composes the right scrub for
|
||||
* each scenario and snapshots the pin's actual prompt as Markdown (see the
|
||||
* pinned-header RFC,
|
||||
* docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md).
|
||||
*
|
||||
* See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
|
||||
*
|
||||
* Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids,
|
||||
* timestamps, and hook duration while preserving deterministic event sequence numbers.
|
||||
* Request-header scrubbers stay separate so one scenario per header class can pin tools and a
|
||||
* readable prompt while other fixtures omit duplicated header bulk.
|
||||
* @module @deepseek-ai/dsh-acp-snapshot/normalize
|
||||
*/
|
||||
|
||||
@@ -68,12 +47,10 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a
|
||||
* stable golden in the SAME shape as the wire: one compact JSON frame per line
|
||||
* (NDJSON), with the JSON-RPC `id` rewritten to a per-transcript sequence
|
||||
* (1, 2, 3, …) and all volatile strings scrubbed. Throws if any non-empty line
|
||||
* is not valid JSON — that doubles as the stdout-purity check (no logger leaked
|
||||
* onto the protocol).
|
||||
* Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable golden
|
||||
* in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC
|
||||
* `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed.
|
||||
* Invalid JSON throws, doubling as a protocol-stdout purity check.
|
||||
*
|
||||
* @param rawStdout The captured stdout bytes, decoded utf8.
|
||||
* @param ctx The run's volatile values to scrub.
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
/**
|
||||
* Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. Speaks
|
||||
* newline-delimited JSON-RPC on stdio like the real `dsh-acp-agent` bin, but
|
||||
* every behavior — how prompts settle, whether session/new rejects, which
|
||||
* session logs get persisted, what filesystem noise to leave — comes from a
|
||||
* `behavior.json` sitting NEXT to the `$DSH_SNAPSHOT_FILE` fixture, so a spec
|
||||
* scripts a whole subprocess run from data. The specs launch it through the
|
||||
* REAL `runScenario` spawn path (tsx loader, temp cwd, env plumbing), so the
|
||||
* harness plumbing is exercised for real; only the agent behind the protocol
|
||||
* is scripted.
|
||||
*
|
||||
* The specs (not the golden tier) own this bin: it asserts nothing, echoes
|
||||
* observable facts into `session/update` text chunks (env probe, permission
|
||||
* outcome, seeded-workspace listing) for the spec to read off `rawStdout`, and
|
||||
* exits 0 on stdin EOF after writing the scripted logs — mirroring the real
|
||||
* bin's dispose-flush-exit shape.
|
||||
* Scripted ACP agent for snapshot-kit tests. A fixture-adjacent `behavior.json` controls the
|
||||
* subprocess reached through the real harness path; the bin reports observations over ACP and
|
||||
* writes scripted logs before exiting on stdin EOF.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -537,11 +537,8 @@ describe('runScenario', () => {
|
||||
|
||||
it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ permissionProbe: true })
|
||||
// The fake bin offers allow_once/reject_once; scripting allow_always is a
|
||||
// scenario bug. The agent is answered `cancelled` (it must not be able to
|
||||
// absorb the bug as an error-means-denial), and the RUN fails: a callback
|
||||
// throw would only reach the agent as a JSON-RPC error response, letting
|
||||
// a tolerant agent carry on and the scenario pass — or record.
|
||||
// The fake offers only allow_once/reject_once. The harness must reject an impossible click,
|
||||
// not merely send an RPC error that a tolerant agent could absorb.
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
|
||||
@@ -17,21 +17,14 @@ import {
|
||||
} from '../src/suite.ts'
|
||||
|
||||
/**
|
||||
* Unit tests for the suite factory, by running it: two synthetic suites over
|
||||
* the scripted fake ACP bin (./fixtures/fake-acp-agent.ts) register REAL
|
||||
* describe/it trees at collection time, so every factory path — golden and log
|
||||
* compares, the per-suite header pin and its uniformity guard, record-mode
|
||||
* fixture write-back, skip semantics, and the fixture guard block — executes
|
||||
* as an ordinary green test. The pure helpers get direct cases below.
|
||||
* Unit tests for the suite factory, by running it: two synthetic suites over the scripted fake
|
||||
* ACP bin (./fixtures/fake-acp-agent.ts) register real describe/it trees at collection time,
|
||||
* so every factory path — golden and log compares, the per-suite header pin and its uniformity
|
||||
* guard, record-mode fixture write-back, skip semantics, and the fixture guard block —
|
||||
* executes as an ordinary green test.
|
||||
*
|
||||
* The replay suite runs against the committed fixtures in ./fixtures/suite.
|
||||
* The record suite runs against a TEMP COPY of ./fixtures/record-suite
|
||||
* (record mode writes session fixtures back into its snapshots dir; a run must
|
||||
* never touch the committed tree). To re-bootstrap the record tree's goldens
|
||||
* after changing the fake bin's output, run this spec once with
|
||||
* `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` (points the record suite at the committed
|
||||
* tree so vitest creates/updates the goldens and the write-back lands there),
|
||||
* then commit the result.
|
||||
* Record tests use a temp copy. To intentionally rebuild their committed fixtures, run this
|
||||
* spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree.
|
||||
*/
|
||||
|
||||
const AGENT = {
|
||||
@@ -43,12 +36,7 @@ const AGENT = {
|
||||
const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url))
|
||||
const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url))
|
||||
|
||||
// The replay suite doubles as the header-CLASS coverage: every scenario names
|
||||
// the same explicit class (the record suite exercises the 'default' fallback),
|
||||
// and plain-turn boots through a per-scenario configPath override (the same
|
||||
// dummy path the agent default carries — the plumbing, not the composition,
|
||||
// is what this suite can exercise; the real overlay boot is the acp-agent
|
||||
// example's code-mode scenarios).
|
||||
// Replay pins explicit header classes; recording covers the default fallback.
|
||||
const REPLAY_SCENARIOS: Scenario[] = [
|
||||
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' },
|
||||
{ name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath },
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
/**
|
||||
* Runtime invariants: a pure-listener plugin that asserts relationships in
|
||||
* the harness event contract. It is intended for development diagnostics but
|
||||
* has no environment guard, so it is active in every composition that mounts
|
||||
* it (including the default `dsh-agent-core` bundle).
|
||||
*
|
||||
* Everything is a plugin — this is just listeners on `session/created`,
|
||||
* `session/event`, `agent/status`, and the scoped dispatch and request seams.
|
||||
* Custom compositions can omit it when the runtime assertion cost is
|
||||
* undesirable. When mounted, a contract violation is a loud failure rather
|
||||
* than a subtle one. It doubles as executable documentation of the event
|
||||
* taxonomy: the assertions below are the contract.
|
||||
*
|
||||
* Session owns immutable log storage: it snapshots and deep-freezes every
|
||||
* accepted event at the source. This plugin checks relationships that one
|
||||
* event's types and immutability cannot express, including turn/step nesting,
|
||||
* scoped dispatch, status transitions, and request reconstructability.
|
||||
*
|
||||
* Runtime listeners that fail loudly when cross-event contracts are broken:
|
||||
* turn and step nesting, scoped dispatch, status transitions, and request
|
||||
* reconstruction. The plugin has no environment guard and is active wherever
|
||||
* mounted, including the default `dsh-agent-core` bundle; custom compositions
|
||||
* may omit it. Sessions still own event snapshots and freezing.
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
@@ -330,19 +318,15 @@ function replayEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
applyTransition(trace, validateEvent(trace, event))
|
||||
}
|
||||
|
||||
/** Legal agent status transitions (the only state machine the loop guarantees). */
|
||||
/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */
|
||||
function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
|
||||
// First observation: any status is a valid starting point.
|
||||
if (from === undefined) return
|
||||
// A no-op transition is illegal — setStatus dedups, so we never see it.
|
||||
if (from === to) {
|
||||
throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
|
||||
}
|
||||
// Leaving `disposed` is illegal — disposal is terminal.
|
||||
if (from === 'disposed') {
|
||||
throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
|
||||
}
|
||||
// idle↔running and (idle|running)→disposed are all legal; nothing else exists.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -325,19 +325,17 @@ describe('HMR state rebuild', () => {
|
||||
it('rebuilds trace state for a session that exists at (re-)apply time', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// First registration, mid-turn: a turn is open when the plugin reloads.
|
||||
const first = await ctx.plugin(Invariants)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await first.dispose()
|
||||
|
||||
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
|
||||
// step is known — the next chunk must NOT be a false positive.
|
||||
// Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log.
|
||||
await ctx.plugin(Invariants)
|
||||
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
|
||||
.not.toThrow()
|
||||
// And a genuine violation is still caught after the rebuild.
|
||||
// Rebuild must not disable later violations.
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
@@ -541,25 +539,17 @@ describe('surface invariants', () => {
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => {
|
||||
// The unknown-seq check fires when a ref passes the "earlier" test but is
|
||||
// not in knownSeqs — only possible with a gap in seqs. We create a gap by
|
||||
// directly manipulating the private log array to skip a seq.
|
||||
// Create an impossible-through-public-API gap so seq 2 is earlier but unknown.
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
// Push a fake event at seq 3 into the internal log, creating a gap at seq 2.
|
||||
// The invariants plugin replays session.events on every append, so it sees
|
||||
// this gap during trace reconstruction.
|
||||
;(session as unknown as { log: unknown[] }).log.push({
|
||||
type: 'assistant/chunk',
|
||||
seq: 3,
|
||||
time: Date.now(),
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
|
||||
})
|
||||
// Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes
|
||||
// is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not
|
||||
// in knownSeqs ({0, 1, 3} — gap at 2).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
||||
}).toThrow(/unknown seq 2/)
|
||||
@@ -803,12 +793,8 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
|
||||
|
||||
describe('request cross-check ordering (prepend)', () => {
|
||||
it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => {
|
||||
// The replay adapter returns its chunks WITHOUT calling next(), which
|
||||
// would silence a later-registered check — snapshot compositions load
|
||||
// replay before the app bundle that loads invariants. The check prepends,
|
||||
// so it fires ahead of append-registered listeners regardless of load
|
||||
// order. (Prepend orders it against APPENDED listeners only; correctness
|
||||
// rests on the seq-bounded rebuild, not on listener timing.)
|
||||
// Replay short-circuits without next(), so the check prepends ahead of ordinary listeners;
|
||||
// correctness still comes from its sequence-bounded rebuild, not listener timing.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
|
||||
|
||||
@@ -1,50 +1,8 @@
|
||||
/**
|
||||
* Replay LLM plugin for snapshot tests.
|
||||
*
|
||||
* Installs a single `llm/stream` waterfall listener that short-circuits the
|
||||
* waterfall (never calls `next()`) and yields model streams reconstructed from
|
||||
* a recorded **session JSONL** fixture — so a snapshot test can boot the real
|
||||
* agent against a fixed model transcript with no API key. See
|
||||
* docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
|
||||
*
|
||||
* The fixture IS the persisted session log (`<scenario>/session.jsonl`): its
|
||||
* `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by
|
||||
* `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model
|
||||
* call per loop step — see packages/core/agent-loop/src/loop.ts). 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.
|
||||
*
|
||||
* 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).
|
||||
* A scenario that needs those supplies an optional sidecar
|
||||
* (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the
|
||||
* derived script.
|
||||
*
|
||||
* It lives in its own package (not under `examples/`) so its derive/parse/
|
||||
* replay logic falls under the per-file 100% coverage gate on package `src`
|
||||
* trees — its tests previously lived under `examples/`, which the gate does
|
||||
* not measure, leaving these branches (clean chunks / mid-stream throw / hang)
|
||||
* unguarded. Its consumer is the ACP snapshot harness in `examples/acp-agent`,
|
||||
* which loads it (via `cordis.snapshot.yml`) in place of a real LLM adapter.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* Keyless snapshot-test LLM replay. It derives one model-call script per
|
||||
* recorded session from `assistant/chunk` events and binds fresh live sessions
|
||||
* to parent/child scripts by first-call order. Throw and hang cases require an
|
||||
* explicit override because a session log cannot reconstruct them alone.
|
||||
* @module @deepseek-ai/dsh-llm-replay
|
||||
*/
|
||||
|
||||
@@ -56,21 +14,9 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* One recorded model call. A discriminated union (not a bare `StreamChunk[]`)
|
||||
* so it can faithfully replay BOTH branches of the documented LLM failure
|
||||
* contract — an adapter may THROW from `stream()` or end with a `finish` error
|
||||
* chunk — plus a `hang` marker for cancellation scenarios (mirrors the
|
||||
* `MockAdapter` `hang` support in packages/core/agent-loop/tests).
|
||||
*
|
||||
* A `throw` entry carries any `chunks` the adapter emitted BEFORE it threw, so
|
||||
* a mid-stream transport failure (partial output then `STREAM_CLOSED`) replays
|
||||
* the partial chunks first and only then throws — exactly what the agent loop
|
||||
* saw live (it may already have emitted partial assistant chunks).
|
||||
*
|
||||
* The normal/finish-terminated cases are DERIVED from the session JSONL
|
||||
* ({@link deriveReplayScript}); only the throw and hang cases need a
|
||||
* hand-authored sidecar entry (a thrown stream leaves no terminal `finish` in
|
||||
* the log, so it cannot be derived as `chunks`).
|
||||
* One recorded model call. `throw` may replay prefix chunks before failing;
|
||||
* `hang` models cancellation. Only ordinary chunk entries derive from JSONL;
|
||||
* the other variants come from an override sidecar.
|
||||
*/
|
||||
export type ReplayEntry =
|
||||
| { kind: 'chunks'; chunks: StreamChunk[] }
|
||||
@@ -102,13 +48,8 @@ export interface ReplayConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Recorded calls plus header facts used to order parent and child scripts.
|
||||
* Recorded ids are diagnostic; fresh live ids bind by ordered first use.
|
||||
*/
|
||||
export interface SessionScript {
|
||||
/** The recorded session id (diagnostics only — the live id differs). */
|
||||
@@ -134,9 +75,7 @@ export interface SessionScript {
|
||||
export function parseSessionLog(text: string): SessionEvent[] {
|
||||
const lines = text.split('\n').filter(line => line.trim().length > 0)
|
||||
const events: SessionEvent[] = []
|
||||
// Skip line 0 (the header). A reader distinguishes it by its `type:'session'`
|
||||
// tag; we simply drop the first line, which the JSONL backend guarantees is
|
||||
// the header.
|
||||
// The JSONL backend guarantees line 0 is the session header.
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const parsed: unknown = JSON.parse(lines[i] as string)
|
||||
events.push(parsed as SessionEvent)
|
||||
@@ -145,14 +84,8 @@ export function parseSessionLog(text: string): SessionEvent[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Read replay identity, ordering, and fork-seed facts from the JSONL header.
|
||||
*
|
||||
* @param text - the raw `.jsonl` file contents (only the header line is read).
|
||||
* @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent.
|
||||
*/
|
||||
@@ -169,21 +102,9 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe
|
||||
/**
|
||||
* Reconstruct the per-`stream()` replay script from a recorded session log.
|
||||
*
|
||||
* The agent loop makes exactly one `ctx.llm.stream()` call per step and appends
|
||||
* every chunk as an `assistant/chunk` event tagged with the current
|
||||
* `(turn, step)`. Grouping those events by `(turn, step)` in log order
|
||||
* therefore yields one `{kind:'chunks'}` entry per model call, in call order.
|
||||
*
|
||||
* A group is only valid if it ends in a `finish` chunk — the adapter contract
|
||||
* guarantees a successful (or finish-error) stream terminates with `finish`,
|
||||
* and the loop relies on it. A group WITHOUT a terminal `finish` is the
|
||||
* fingerprint of a *thrown* `stream()` (the loop recorded the prefix chunks,
|
||||
* then an `error`/`turn/end`, but no `finish`): such a stream cannot be
|
||||
* faithfully replayed as `{kind:'chunks'}` (that would look like a clean stop),
|
||||
* so deriving it is an error — the scenario must supply a `replay.override.json`
|
||||
* sidecar with an explicit `throw` (or `hang`) entry instead. {@link
|
||||
* deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing
|
||||
* override fails loud rather than silently replaying a thrown call as success.
|
||||
* Groups `assistant/chunk` events by turn and step. Every group must end in a
|
||||
* `finish`; a missing terminator means the live stream threw, so derivation
|
||||
* rejects and the scenario must provide an explicit override.
|
||||
* @param events - the recorded session's events; only `assistant/chunk` is consulted.
|
||||
* @returns one `chunks` entry per recorded model call, in call order.
|
||||
*/
|
||||
@@ -242,17 +163,9 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every recorded session's script for a scenario, ordered by `createdAt`
|
||||
* (earliest first), ready to bind to live sessions in first-call order.
|
||||
* Load the primary and child scripts in bind order. Child derivation begins at
|
||||
* `seedLength` so inherited parent chunks are never replayed as child calls.
|
||||
*
|
||||
* 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.
|
||||
* @param config - the fixture paths: the primary log plus any recorded child logs.
|
||||
* @returns the primary script first, then the child scripts in bind order.
|
||||
*/
|
||||
@@ -274,12 +187,8 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
}
|
||||
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.
|
||||
// Derive the child's script from its own events only — events AT OR after the seed
|
||||
// boundary.
|
||||
const ownEvents = parseSessionLog(text).slice(header.seedLength)
|
||||
children.push({
|
||||
recordedId: header.id,
|
||||
@@ -288,20 +197,8 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
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.
|
||||
// Synchronous children start in creation order; the id only stabilizes timestamp ties.
|
||||
// XXX(concurrent-subagents): concurrent children need an explicit first-call ordinal.
|
||||
children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId))
|
||||
return [primary, ...children]
|
||||
}
|
||||
@@ -344,31 +241,11 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the replay `llm/stream` listener on `ctx`. Returns the listener
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* @param ctx - the context whose `llm/stream` waterfall the listener short-circuits.
|
||||
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
|
||||
* @returns the `ctx.on` disposer that removes the listener.
|
||||
|
||||
@@ -427,11 +427,8 @@ describe('loadSessionScripts', () => {
|
||||
})
|
||||
|
||||
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).
|
||||
// A fork log includes the parent's assistant chunks before `seedLength`. Deriving from the
|
||||
// whole log would replay parent responses as child calls, so only child-owned chunks qualify.
|
||||
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])
|
||||
@@ -488,11 +485,8 @@ describe('loadSessionScripts', () => {
|
||||
})
|
||||
|
||||
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).
|
||||
// The primary is appended first internally. A strictly earlier child sorts before it, while
|
||||
// equal creation times preserve primary-first order regardless of input order.
|
||||
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] })
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Scripted, model-free subagent provider for deterministic coverage of registration,
|
||||
* capability checks, lifecycle, the model-facing tool, and structured results through the real
|
||||
* loader path. It is a named-export functional plugin; no default export.
|
||||
* @module @deepseek-ai/dsh-subagent-mock
|
||||
*/
|
||||
|
||||
@@ -28,12 +22,7 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal']
|
||||
|
||||
const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
|
||||
/**
|
||||
* A scripted provider: every {@link start} returns a ready run whose `result`
|
||||
* resolves on the next task with the configured reply (and a structured value
|
||||
* when the request asked for one and the capability is on). The required
|
||||
* signal and `dispose()` both flip an unsettled result to `aborted`.
|
||||
*/
|
||||
/** Scripted provider whose configured result aborts if disposed or signalled first. */
|
||||
class MockSubagentProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities
|
||||
readonly inheritsParentContext: boolean
|
||||
|
||||
@@ -107,10 +107,7 @@ describe('dsh-subagent-mock', () => {
|
||||
})
|
||||
|
||||
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.
|
||||
// A default export would make Loader unwrap only that value and drop `inject`.
|
||||
expect('default' in mock).toBe(false)
|
||||
expect(mock.name).toBe('subagent-mock')
|
||||
expect(mock.inject).toEqual(['subagents'])
|
||||
|
||||
Reference in New Issue
Block a user