diff --git a/AGENTS.md b/AGENTS.md index d4b8474865..1b60cc7fc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ packages/ Harness packages, all named @deepseek-ai/dsh-: 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 diff --git a/docs/architecture.md b/docs/architecture.md index 04b7f111f6..ef143153bb 100644 --- a/docs/architecture.md +++ b/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. diff --git a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md index 9b9dc8e7b6..3e68024fa9 100644 --- a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md +++ b/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` 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. diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 388ec04880..8cc2237eff 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/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. diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index ca303691e9..fbe9b10db5 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/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 { +export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 6fd2dcb567..b720efa8c9 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/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) diff --git a/packages/README.md b/packages/README.md index 8d4c81286c..ed60df1a04 100644 --- a/packages/README.md +++ b/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`) | diff --git a/packages/acp/README.md b/packages/acp/README.md index c514361b15..7db1ee8785 100644 --- a/packages/acp/README.md +++ b/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`. diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 66be7458ec..780e907559 100644 --- a/packages/acp/src/index.ts +++ b/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 diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md index fbfc745808..308e5f022b 100644 --- a/packages/agent-loop/README.md +++ b/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-` (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-` (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`) diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index 96a1158c66..e964685b86 100644 --- a/packages/agent-loop/src/agent.ts +++ b/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' diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index 71170db49a..f118959fce 100644 --- a/packages/agent-loop/src/index.ts +++ b/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) { diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index 359329e062..dc1cbcf278 100644 --- a/packages/agent-loop/src/loop.ts +++ b/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 { +export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { 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 { +async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise { 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, diff --git a/packages/agent-loop/tests/agent.spec.ts b/packages/agent-loop/tests/agent.spec.ts index 4fd7237061..7c46df956c 100644 --- a/packages/agent-loop/tests/agent.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { }) } -function waitForStatus(ctx: Context, agent: LoopAgent, expected: LoopAgent['status']): Promise { +function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise { 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'] })) diff --git a/packages/agent-loop/tests/config-session-id.spec.ts b/packages/agent-loop/tests/config-session-id.spec.ts index 52f0223564..13a62bca8c 100644 --- a/packages/agent-loop/tests/config-session-id.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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-), diff --git a/packages/agent-loop/tests/coverage-edges.spec.ts b/packages/agent-loop/tests/coverage-edges.spec.ts index 542df6faef..96c061d2dd 100644 --- a/packages/agent-loop/tests/coverage-edges.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { }) } -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'] })) diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/agent-loop/tests/loop.spec.ts index c4f09a24bf..f004e02f91 100644 --- a/packages/agent-loop/tests/loop.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { }) } -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') diff --git a/packages/agent-loop/tests/properties.spec.ts b/packages/agent-loop/tests/properties.spec.ts index e7df7f9d74..da457f4ddc 100644 --- a/packages/agent-loop/tests/properties.spec.ts +++ b/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 { +function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { /** 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) diff --git a/packages/agent-loop/tests/resume.spec.ts b/packages/agent-loop/tests/resume.spec.ts index 3586dcab3c..10313fdd26 100644 --- a/packages/agent-loop/tests/resume.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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) diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index 711177f62e..3f65ae737e 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { }) } -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'] })) diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 56805a4a23..762d325068 100644 --- a/packages/agent/src/types.ts +++ b/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 { diff --git a/packages/tool-bash/src/index.ts b/packages/tool-bash/src/index.ts index 2cdc28b1f0..c0522524f7 100644 --- a/packages/tool-bash/src/index.ts +++ b/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 "" 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 diff --git a/packages/tool-bash/tests/integration.spec.ts b/packages/tool-bash/tests/integration.spec.ts index 2683f7ec55..d31b3a5a7e 100644 --- a/packages/tool-bash/tests/integration.spec.ts +++ b/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 { +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { }) } -function events(agent: LoopAgent): SessionEvent[] { +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }