1
0
Эх сурвалжийг харах

refactor(agent-loop): rename LoopAgent to ReactLoopAgent

Rename the concrete Agent class to make its ReAct-style reasoning loop
explicit in the name. Package name, default-export plugin (`AgentLoop`),
and the `ctx.agentLoop` service key are unchanged.
Tianyi Cui 3 сар өмнө
parent
commit
224c6f029a

+ 1 - 1
AGENTS.md

@@ -28,7 +28,7 @@ packages/    Harness packages, all named @deepseek-ai/dsh-<name>:
   system-prompt/  prompt-section + tool-schema assembly registry
   tools/          tool registry + tools/execute waterfall
   agent/          Agent interface, registry, agent/* event vocabulary
-  agent-loop/     THE concrete plugin: LoopAgent + the loop driver
+  agent-loop/     THE concrete plugin: ReactLoopAgent + the loop driver
   invariants/     dev-mode event-contract invariants + session-log freeze
   bash/           abstract bash executor seam (ctx.bash) — interface only
   bash-local/     local-subprocess BashExecutor implementation

+ 1 - 1
docs/architecture.md

@@ -49,7 +49,7 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop`
 | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` |
 | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall |
 | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam |
-| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `LoopAgent`s and drives their loops |
+| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops |
 | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
 
 All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically.

+ 2 - 2
docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md

@@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a
 
 The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap<Agent, sessionId>` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once.
 
-Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.abort()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `LoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.
+Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.abort()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.
 
 **Dependency note (architecture rule).** [docs/architecture.md](../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback.
 
@@ -67,7 +67,7 @@ New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sd
 
 Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang.
 
-Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `LoopAgent`-only), not orphan awaits on a closed pipe.
+Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `ReactLoopAgent`-only), not orphan awaits on a closed pipe.
 
 The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time.
 

+ 1 - 1
docs/rfc/proposed/2026-06-14-acp-multi-session.md

@@ -15,7 +15,7 @@ This paragraph is historical: the multi-session bridge has landed. The remaining
 
 The harness core already supports many agents (`AgentRegistry.list()` and `AgentLoop.create` impose no count limit), so multiplexing is a bridge-layer change in `@deepseek-ai/dsh-acp`, not a loop or core change.
 
-- Lift the single-session guard in `session/new`; allow N live sessions, each mapped to its own `LoopAgent`.
+- Lift the single-session guard in `session/new`; allow N live sessions, each mapped to its own `ReactLoopAgent`.
 - The bridge's `sessionId→agent` and `Session→sessionId` maps (introduced single-entry by [the ACP support RFC](2026-06-14-acp-agent-client-protocol.md)) become true multi-entry, plus a third `agent→sessionId` reverse map: the `tools/execute` permission gate receives only `exec.agent` (no sessionId), so it needs an O(1) reverse lookup to find the owning session. Every `agent/*` event and every `session/event` is demuxed strictly by id, so two sessions streaming at once never interleave their `session/update` notifications.
 - Per-session prompt queues: [the ACP support RFC](2026-06-14-acp-agent-client-protocol.md)'s single-entry in-flight-prompt state becomes multi-entry — one in-flight prompt *per session*, tracked per `sessionId`.
 - Per-session cancel routing: `session/cancel` aborts only its own session's agent and settles only that session's in-flight prompt. `agent.abort()` drives a per-agent `AbortController`, so the per-session `exec.signal` is the natural isolation fence.

+ 2 - 2
examples/coding-agent/tests/harness.ts

@@ -5,7 +5,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -39,7 +39,7 @@ export async function codingHarness(workdir: string, persistenceRoot?: string):
   return ctx
 }
 
-export function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 3 - 3
examples/coding-agent/tests/resume.e2e.ts

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { Context } from 'cordis'
-import type { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
 
 /**
@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
       agentId: 'resume-1',
       sessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT },
-    }) as LoopAgent
+    }) as ReactLoopAgent
     first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
     await waitForIdle(ctx, first)
     await ctx.fiber.dispose()
@@ -55,7 +55,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
       agentId: 'resume-2',
       resumeSessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT },
-    }) as LoopAgent
+    }) as ReactLoopAgent
     expect(resumed.session.id).toBe(SESSION_ID)
     // The prior user turn is in the rehydrated log before the model is asked.
     expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)

+ 1 - 1
packages/README.md

@@ -31,7 +31,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l
 | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
 | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
 | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
-| `agent-loop/` | THE concrete loop plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` |
+| `agent-loop/` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
 | `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
 | `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
 | `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |

+ 1 - 1
packages/acp/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-acp
 
-The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
+The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
 
 It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
 

+ 1 - 1
packages/acp/src/index.ts

@@ -16,7 +16,7 @@
  * - `session/cancel` → `agent.abort()` + settle the in-flight prompt
  *
  * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
- * its own `LoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
+ * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
  * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
  * `session/event` and `agent/*` event is routed strictly to its owning session
  * record, so two sessions streaming at once never interleave their

+ 3 - 3
packages/agent-loop/README.md

@@ -1,6 +1,6 @@
 # dsh-agent-loop
 
-THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
+THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
 
 This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
 
@@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every
 
 ### Public API
 
-- `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
+- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
 
 `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
 
@@ -35,7 +35,7 @@ Agents listed in config are auto-created at startup.
 
 ### Classes
 
-- `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
+- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
 - `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
 
 ### Loop lifecycle (`loop.ts`)

+ 2 - 2
packages/agent-loop/src/agent.ts

@@ -1,5 +1,5 @@
 /**
- * The concrete Agent implementation: LoopAgent plus its inbox. Everything
+ * The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything
  * observable happens through session events and the agent/* event taxonomy —
  * plugins never need this class.
  *
@@ -21,7 +21,7 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
  * the loop driver. Everything observable happens through session events and
  * the agent/* event taxonomy — plugins never need this class.
  */
-export class LoopAgent implements Agent {
+export class ReactLoopAgent implements Agent {
   readonly inbox = new Inbox()
 
   private _status: AgentStatus = 'idle'

+ 8 - 8
packages/agent-loop/src/index.ts

@@ -1,5 +1,5 @@
 /**
- * THE concrete agent plugin: creates LoopAgents, runs their loops, and
+ * THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
  * registers them in ctx.agents. Deliberately thin — every behavior beyond
  * "call the model, run the tools, repeat" belongs to plugins on the event
  * taxonomy.
@@ -18,9 +18,9 @@ import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tools'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
-import { LoopAgent } from './agent.ts'
+import { ReactLoopAgent } from './agent.ts'
 
-export { LoopAgent } from './agent.ts'
+export { ReactLoopAgent } from './agent.ts'
 export { Inbox, type InboxMessage } from './inbox.ts'
 export { runLoop } from './loop.ts'
 
@@ -48,7 +48,7 @@ export interface Config {
 }
 
 /**
- * The agent-loop plugin (`ctx.agentLoop`): creates {@link LoopAgent}s, runs
+ * The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
  * their loops, and registers them in `ctx.agents`. Also implements the
  * {@link AgentFactory} seam, so plugins create/resume agents through
  * `ctx.agents` (the interface) without depending on this concrete package.
@@ -118,7 +118,7 @@ export class AgentLoop extends Service implements AgentFactory {
    * fork seeds the new Session with the parent's event log, spawn starts
    * fresh; the child is returned as a regular Agent handle.
    */
-  create(id: string, options: AgentOptions = {}): LoopAgent {
+  create(id: string, options: AgentOptions = {}): ReactLoopAgent {
     this.assertAgentIdFree(id)
     const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
     return this.start(AgentId(id), options, session)
@@ -219,9 +219,9 @@ export class AgentLoop extends Service implements AgentFactory {
     }
   }
 
-  /** Shared: construct a LoopAgent, register it, and start its loop (LIFO). */
-  private start(id: AgentId, options: AgentOptions, session: Session): LoopAgent {
-    const agent = new LoopAgent(this.ctx, id, options, session)
+  /** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
+  private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
+    const agent = new ReactLoopAgent(this.ctx, id, options, session)
     // Generator effect: stop and unregister are independent disposables
     // (LIFO), so a throwing stop() cannot leak the registry entry.
     this.ctx.effect(function* (this: AgentLoop) {

+ 6 - 6
packages/agent-loop/src/loop.ts

@@ -13,7 +13,7 @@ import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
 import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
 import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tools'
-import type { LoopAgent } from './agent.ts'
+import type { ReactLoopAgent } from './agent.ts'
 
 /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
 type CodedError = Error & { code?: string }
@@ -98,7 +98,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
 
 /**
  * Ambient handles the loop driver receives from the agent. Decouples the
- * pure function `runLoop` from the mutable LoopAgent fields, making the
+ * pure function `runLoop` from the mutable ReactLoopAgent fields, making the
  * loop testable without a real agent.
  */
 export interface LoopHandle {
@@ -141,7 +141,7 @@ export interface LoopHandle {
  *   idle (emit agent/status) unless more queued
  * ```
  */
-export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
+export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
   const { session } = agent
 
   while (!handle.isDisposed()) {
@@ -180,7 +180,7 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
   }
 }
 
-async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
+async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise<void> {
   const { session } = agent
 
   // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
@@ -446,7 +446,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
 }
 
 /** Drain the steering queue into the session. Returns whether any arrived. */
-function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
+function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
   const messages = agent.inbox.drainSteering()
   for (const message of messages) {
     agent.session.append('steering/message', { turn, content: message.content, source: message.source })
@@ -458,7 +458,7 @@ function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
 /** One step: assemble request → stream model → record → execute tools. */
 async function runStep(
   ctx: Context,
-  agent: LoopAgent,
+  agent: ReactLoopAgent,
   turn: number,
   step: number,
   signal: AbortSignal,

+ 14 - 14
packages/agent-loop/tests/agent.spec.ts

@@ -6,7 +6,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 async function harness(adapter: MockAdapter) {
@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -32,7 +32,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
   })
 }
 
-function waitForStatus(ctx: Context, agent: LoopAgent, expected: LoopAgent['status']): Promise<void> {
+function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === expected) {
@@ -43,15 +43,15 @@ function waitForStatus(ctx: Context, agent: LoopAgent, expected: LoopAgent['stat
   })
 }
 
-function send(agent: LoopAgent, text: string) {
+function send(agent: ReactLoopAgent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
-describe('LoopAgent', () => {
+describe('ReactLoopAgent', () => {
   it('send() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -66,7 +66,7 @@ describe('LoopAgent', () => {
   it('steer() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -81,7 +81,7 @@ describe('LoopAgent', () => {
   it('inject() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -226,12 +226,12 @@ describe('LoopAgent', () => {
   })
 
   it('disposer is idempotent (double-stop)', async () => {
-    // Create a bare LoopAgent and call start() directly to get the disposer.
+    // Create a bare ReactLoopAgent and call start() directly to get the disposer.
     // Then call it twice — the second call hits the early-return branch.
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const session = ctx.sessions.create('test')
-    const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
+    const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
 
     // Start the loop to get the disposer; the agent waits for messages
     // (idle, never-resolving cancel), so it will stay idle.
@@ -323,7 +323,7 @@ describe('LoopAgent', () => {
   it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
     // Covers the waiter's disposed arm: whenIdle() queues an internal waiter
     // while running (not the fast path), then the disposer settles it and chains
-    // `done` (loop exit), not an eager resolve. A bare LoopAgent + direct
+    // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
     // start() disposer keeps the emit synchronous.
     const ctx = new Context()
     await ctx.plugin(LlmService)
@@ -334,7 +334,7 @@ describe('LoopAgent', () => {
     const adapter = new MockAdapter(['hang'])
     ctx.llm.registerAdapter(['mock'], adapter)
     const session = ctx.sessions.create('bare')
-    const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
+    const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
     const dispose = agent.start()
     agent.send([{ type: 'text', text: 'go' }])
     await new Promise(r => setTimeout(r, 30))
@@ -355,7 +355,7 @@ describe('LoopAgent', () => {
     // it. Regression for the round-3 whenIdle finding.
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -376,7 +376,7 @@ describe('LoopAgent', () => {
     // only after `done` — i.e. the loop has actually exited.
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))

+ 7 - 7
packages/agent-loop/tests/config-session-id.spec.ts

@@ -9,13 +9,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 const dirs: string[] = []
 afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -38,7 +38,7 @@ describe('config-driven session id', () => {
     await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
-    const a1 = ctx1.agents.get('cfg') as LoopAgent
+    const a1 = ctx1.agents.get('cfg') as ReactLoopAgent
     expect(a1.session.id).toMatch(idPattern)
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
@@ -55,7 +55,7 @@ describe('config-driven session id', () => {
     await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
-    const a2 = ctx2.agents.get('cfg') as LoopAgent
+    const a2 = ctx2.agents.get('cfg') as ReactLoopAgent
     expect(a2.session.id).toMatch(idPattern)
     expect(a2.session.id).not.toBe(a1.session.id)
     a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
     await ctx1.plugin(AgentLoop, { agents: [] })
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
-    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as LoopAgent
+    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent
     a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -97,10 +97,10 @@ describe('config-driven session id', () => {
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
 
     // The deferred resume runs on a microtask after the backend is available.
-    let resumed: LoopAgent | undefined
+    let resumed: ReactLoopAgent | undefined
     for (let i = 0; i < 50 && !resumed; i++) {
       await new Promise(r => setTimeout(r, 5))
-      resumed = ctx2.agents.get('main') as LoopAgent | undefined
+      resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined
     }
     expect(resumed).toBeDefined()
     // The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),

+ 4 - 4
packages/agent-loop/tests/coverage-edges.spec.ts

@@ -5,7 +5,7 @@ import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
 async function harness(adapter: MockAdapter) {
@@ -20,7 +20,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -31,7 +31,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
   })
 }
 
-function send(agent: LoopAgent, text: string) {
+function send(agent: ReactLoopAgent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -281,7 +281,7 @@ describe('disposed vs aborted branching', () => {
   it('handles dispose during model streaming producing reason "disposed"', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))

+ 5 - 5
packages/agent-loop/tests/loop.spec.ts

@@ -5,7 +5,7 @@ import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
 
 async function harness(adapter: MockAdapter) {
@@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter) {
  * invoke this right after send(), when the loop hasn't woken yet (status is
  * still 'idle' synchronously), so polling the current status would lie.
  */
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -36,7 +36,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
   })
 }
 
-function send(agent: LoopAgent, text: string) {
+function send(agent: ReactLoopAgent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -570,7 +570,7 @@ describe('agent loop', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -601,7 +601,7 @@ describe('agent loop', () => {
     })
     ctx.llm.registerAdapter(['mock'], adapter)
 
-    const agent = ctx.agents.get('config-agent')! as LoopAgent
+    const agent = ctx.agents.get('config-agent')! as ReactLoopAgent
     expect(agent).toBeDefined()
     expect(agent.id).toBe('config-agent')
     expect(agent.options.model).toBe('mock')

+ 5 - 5
packages/agent-loop/tests/properties.spec.ts

@@ -18,7 +18,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { type LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import fc from 'fast-check'
 
 /** A never-exhausting adapter: every model call returns the same short reply. */
@@ -47,7 +47,7 @@ async function harness() {
 }
 
 /** Resolve on the agent's next transition to idle (event-based, not polled). */
-function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -60,7 +60,7 @@ function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
 
 /** Record every status transition for the legal-machine assertion. Returns
  * the seen list plus a disposer for the listener (per the registry convention). */
-function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose: () => void } {
+function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
   const seen: string[] = []
   const dispose = ctx.on('agent/status', (subject, status) => {
     if (subject === agent) seen.push(status)
@@ -68,13 +68,13 @@ function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose
   return { seen, dispose }
 }
 
-function userMessageTexts(agent: LoopAgent): string[] {
+function userMessageTexts(agent: ReactLoopAgent): string[] {
   return agent.session.events
     .filter(e => e.type === 'user/message')
     .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
 }
 
-function turnNumbers(agent: LoopAgent): number[] {
+function turnNumbers(agent: ReactLoopAgent): number[] {
   return agent.session.events
     .filter(e => e.type === 'turn/start')
     .map(e => (e.data as { turn: number }).turn)

+ 10 - 10
packages/agent-loop/tests/resume.spec.ts

@@ -10,7 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 const dirs: string[] = []
@@ -31,7 +31,7 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
   return { ctx, root }
 }
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // Lifecycle 1: create a no-cwd session and run a turn.
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as LoopAgent
+    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as LoopAgent
+    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent
     expect(a2.session.header.cwd).toBeUndefined()
     await ctx2.fiber.dispose()
   })
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as LoopAgent
+    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent
     expect(a2.session.header.parentSession).toBe('parent-sess')
     expect(a2.session.header.cwd).toBe('/w')
     await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // disk, since a crash before the next turn would otherwise lose it.
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
+    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // drop it on reload (the bug this guards).
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
+    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as LoopAgent
+    const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent
     const flat = JSON.stringify(a2.session.deriveMessages())
     expect(flat).toContain('background task 42 finished')
     await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // Lifecycle 1: run one full turn, persisting it.
     const adapter1 = new MockAdapter([textResponse('first answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as LoopAgent
+    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent
     a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
 
-    const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as LoopAgent
+    const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent
     // The resumed session carries the prior history…
     expect(a2.session.id).toBe('sess-resume')
     expect(a2.session.events.length).toBe(events1.length)

+ 9 - 9
packages/agent-loop/tests/review-fixes.spec.ts

@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
@@ -26,7 +26,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -37,7 +37,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
   })
 }
 
-function send(agent: LoopAgent, text: string) {
+function send(agent: ReactLoopAgent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -297,7 +297,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -320,7 +320,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('scoped', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -430,7 +430,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
     ctx2.llm.registerAdapter(['mock'], second)
 
     const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
-    const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
+    const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
     ctx2.effect(() => forked.start())
 
     const turns: number[] = []
@@ -660,7 +660,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
   }
 
   /** Count turn/step boundary events for balance assertions. */
-  function boundaryCounts(agent: LoopAgent) {
+  function boundaryCounts(agent: ReactLoopAgent) {
     const e = [...agent.session.events]
     return {
       turnStart: e.filter(x => x.type === 'turn/start').length,
@@ -757,7 +757,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // balanced with reason disposed (no error event for a disposal).
     const adapter = new MockAdapter(['hang'])
     const ctx = await balancedHarness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('a-dispose', { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -788,7 +788,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // throw. This is the only path that exercises that catch sub-branch.
     const adapter = new MockAdapter(['hang'])
     const ctx = await balancedHarness(adapter)
-    let agent!: LoopAgent
+    let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' })
     }, { inject: ['agentLoop'] }))

+ 1 - 1
packages/agent/src/types.ts

@@ -40,7 +40,7 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
 /**
  * The agent handle — the surface every plugin (UI, hooks, orchestrators)
  * programs against. The concrete implementation lives in
- * `@deepseek-ai/dsh-agent-loop` (class `LoopAgent`); nothing outside the loop
+ * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
  * package should depend on the implementation.
  */
 export interface Agent {

+ 1 - 1
packages/tool-bash/src/index.ts

@@ -303,7 +303,7 @@ export function apply(ctx: Context): void {
       )
     } catch (error: unknown) {
       // The ONE expected failure: the agent was disposed between task
-      // completion and this injection (LoopAgent.inject throws
+      // completion and this injection (ReactLoopAgent.inject throws
       // `agent "<id>" is disposed`). That race is benign — drop the notice.
       // Anything else is a real bug and must surface, not be swallowed.
       if (error instanceof Error && error.message.includes('is disposed')) return

+ 3 - 3
packages/tool-bash/tests/integration.spec.ts

@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts'
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
   })
 }
 
-function events(agent: LoopAgent): SessionEvent[] {
+function events(agent: ReactLoopAgent): SessionEvent[] {
   return [...agent.session.events]
 }