Files
deepseek-harness/packages/core/agent-loop/tests/MIGRATION.md

5.3 KiB

Agent-loop test migration guide (naive-machine contract)

The loop was rewritten in the naive-agent shape. packages/core/agent-loop/src/agent.ts is the single source of truth — read it before migrating a spec. Key changes:

Event seams (old → new)

Old seam Replacement
agent/pre-step (serial, before step/start) agent/step (serial, before EVERY request derives; same position)
agent/post-step (serial, after tools, before step/end) REMOVED — use agent/step of the next step, or agent/idle after the turn
agent/session-prefix (waterfall, request-only prefix) REMOVED — requests carry no unlogged prefix; durable context via agent.inject() at agent/session-start
agent/step-result (waterfall, rewrite assistant msg) REMOVED — the assembled message is recorded as-is
agent/request-error (waterfall, retry/fail decision) REMOVED — observe agent/idle with reason.kind === 'error', repair, then agent.retry()
agent/turn-continuation (waterfall, ContinuationDecision) agent/continue (waterfall of boolean; handler (agent, turn, signal, next))
agent/turn-stop (serial, terminal stop) REMOVED — agent/continue returning false stops the turn
agent/request (agent, turn, step, config, signal, next) (agent, turn, step, signal, next) — the config comes only from await next()
agent/prompt-submit unchanged

New emit: agent/idle (agent, turn, reason: IdleReason) fires once per closed turn (after turn/end + flush, with busy already false, so listeners may synchronously retry()/send()). IdleReason = completed | aborted | { kind: 'error', error, failure? }.

Verb semantics

  • send() — unchanged (queued FIFO, one turn each).
  • steer() while running — enters the outbox; taken whole at the next step boundary. A turn failure leaves untaken steering staged without waking the agent; retry() or a later prompt takes it.
  • inject() while the machine is busy — enters the outbox (a context/message appears at the NEXT step boundary, not immediately). While idle — writes a one-shot turn (turn/start(injection) + context/message + turn/end) and requests a flush. Enclosure is decided by busy, NOT by scanning the log for an open turn.
  • retry() — NEW verb: re-opens a turn on the current log with trigger { kind: 'retry' }. Throws while busy ("cannot retry while busy") and after disposal. Legal from a synchronous agent/idle listener.
  • cancel() — unchanged surface. No more "pre-run cancelled" bookkeeping: clearing the queue before a run starts simply means no run starts.

Machine shape (timing-sensitive tests)

  • kick() runs SYNCHRONOUSLY from send() when idle: status flips to running inside the send() call. There is no parked driver loop, no waitForQueued, no microtask collection window.
  • One run() = one turn. After turn/end, it emits agent/idle, then either starts the next waking queued prompt or flips status to idle. Residual outbox input does not wake the agent. Status stays running continuously across queued turns.
  • step/end is appended INSIDE the step (after tools + the in-step outbox drain), before agent/continue runs. The old post-step → step/end window no longer exists.
  • Request messages = session.deriveMessages() snapshot taken right before step/start — no messagePrefix. request/header events no longer carry a messagePrefix field.
  • Provider/model config waterfall (agent/request) runs INSIDE the step (after step/start), seeded from agent options (first request) or the folded logged header (later requests).
  • The assembled assistant message is recorded verbatim (with replayState when present); there is no rewrite path and no "content-less anchor on rejection".
  • A model failure (thrown by the adapter or a failure finish chunk) closes the turn: balanced step/end + turn/end { kind:'error', step, failure } + agent/error emit + agent/idle { kind:'error', error, failure }. There are no in-turn recovery steps.
  • Cancellation classification: signal reason user/parent → turn/end aborted; disposal → disposed. IdleReason for both is aborted.
  • A blocked prompt (prompt-submit → block) records prompt/blocked, closes a zero-step turn rejected in turn/end, and emits agent/idle { kind: 'completed' } (rejection is a policy outcome, not an error).
  • Accept-validation error message is now "agent message content and source must be losslessly JSON-serializable".
  • dispose() (the prepared disposer / factory teardown) returns undefined when the machine is not busy — do not .resolves it unconditionally; use await Promise.resolve(dispose()).

What to do with tests of removed seams

  • Rewrite the scenario against the nearest new seam when the protected behavior still exists (e.g. turn-stop tests → agent/continue returning false; request-error retry tests → agent/idle + retry() flows).
  • Delete tests whose subject no longer exists at all (session-prefix reconstruction, step-result rewrite provenance, post-step ordering windows, pre-run-cancel bookkeeping). Do not keep zombie tests alive by weakening their assertions.
  • Keep the durable-log invariants strong: balanced turn/step boundaries, ordered tool call/result pairs, header change tracking — those still hold.