refactor: unify agent and session identity

This commit is contained in:
Tianyi Cui
2026-07-14 01:59:21 +08:00
parent 7a33ee94be
commit 709cc7200e
105 changed files with 899 additions and 948 deletions

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
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, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { BashTaskId } from '@deepseek-ai/dsh-bash'
@@ -74,7 +75,7 @@ describe('bash tool through the agent loop', () => {
textResponse('The command printed integration-ok.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-fg'), { model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent)
@@ -106,7 +107,7 @@ describe('bash tool through the agent loop', () => {
textResponse('It failed with code 9.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-exit'), { model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent)
@@ -128,7 +129,7 @@ describe('bash tool through the agent loop', () => {
let taskId = ''
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-bg'), { model: 'mock' })
// Capture the generated id so the deterministic fixture is checked against
// the real executor instead of silently assuming it.

View File

@@ -57,7 +57,7 @@ async function setup() {
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
// token (session.header.id), which is also the agent's durable id. The
// owner token IS the session id, so the notice path must find the agent by
// `session.header.id`, NOT the registry key. Using distinct values here makes
// the test fail if a regression matched on the wrong field (a same-value fake

View File

@@ -3,11 +3,12 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
@@ -119,7 +120,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('repro'), { model: 'mock' })
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
await waitForIdle(ctx, agent)

View File

@@ -56,7 +56,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'agentLoop',
summary: 'Concrete ReactLoopAgent factory and driver service.',
methods: [
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
],
@@ -71,7 +71,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'register(agent: Agent): () => void',
'enter(agent: Agent): () => void',
'announce(agent: Agent): void',
'get(id: AgentId): Agent | undefined',
'get(id: SessionId): Agent | undefined',
'list(): Agent[]',
],
},
@@ -488,7 +488,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise<void>;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise<void>;\n}',
},
{
name: 'AgentFactory',
@@ -498,10 +498,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentHandle',
declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}',
},
{
name: 'AgentId',
declaration: 'export type AgentId = Branded<\'AgentId\'>;',
},
{
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n model?: string;\n}',
@@ -644,7 +640,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateSessionOptions',
@@ -756,7 +752,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'SandboxEnforcement',
@@ -868,7 +864,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRun',
declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentStartRequest',

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -51,7 +52,7 @@ describe('cordis tools through the agent loop', () => {
textResponse('Done.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { model: 'mock' })
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
await waitForIdle(ctx, agent)

View File

@@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core'
// so validation and defaulting can never drift from the owners.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates one under the `main` config label; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -6,8 +6,10 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = { session: { header: { cwd } } } as unknown as Agent
@@ -102,16 +104,18 @@ describe('dsh-agent-core bundle', () => {
it('defaults the agents list to empty (no pre-created agents)', async () => {
const ctx = await mount()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
const ctx = await mount({
agents: [{ id: AgentId('main'), model: 'mock' }],
agents: [{ id: 'main', model: 'mock' }],
persona: 'You are main.',
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
const agent = ctx.get('agents')?.list()[0]
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
await ctx.fiber.dispose()

View File

@@ -12,14 +12,14 @@ Creation and resume are one rollback-covered transaction: construct a private se
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call 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: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-<uuid>` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
@@ -32,7 +32,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
```ts
interface Config {
agents: Array<{
id: string // required
id: string // required stable label; prefixes fresh combined ids
model?: string
resumeSessionId?: string // load this persisted session instead of creating one
cwd?: string // optional workspace cwd for the fresh session

View File

@@ -8,11 +8,11 @@
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
import { Inbox, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
@@ -59,7 +59,7 @@ export interface PreparedReactLoopAgent {
* @returns the agent and closures bound only to that exact instance.
*/
export function prepareReactLoopAgent(
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
ctx: Context, id: SessionId, options: AgentOptions, session: Session,
): PreparedReactLoopAgent {
if (claimedDriverSessions.has(session)) {
throw new Error(`session "${session.id}" already has a concrete agent driver`)
@@ -163,7 +163,7 @@ export class ReactLoopAgent implements Agent {
constructor(
private loopCtx: Context,
public readonly id: AgentId,
public readonly id: SessionId,
public readonly options: AgentOptions,
public readonly session: Session,
) {

View File

@@ -14,7 +14,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import type {
AgentFactory,
AgentHandle,
AgentId,
AgentOptions,
CreateAgentOptions,
ResumeAgentOptions,
@@ -68,7 +67,7 @@ class FactoryOwnership {
}
/** Build the public cancellation error while preserving a caller-supplied cause. */
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
@@ -108,7 +107,7 @@ class AgentCreationTransaction {
private readonly loopCtx: Context,
private readonly ownerCtx: Context,
private readonly ownership: FactoryOwnership,
readonly id: AgentId,
readonly id: SessionId,
signal?: AbortSignal,
) {
ownerCtx.fiber.assertActive()
@@ -325,8 +324,8 @@ declare module 'cordis' {
export interface Config {
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Registry identity for the live agent. */
id: AgentId
/** Stable config label used in logs and as the fresh combined-id prefix. */
id: string
/** Optional workspace for a fresh session. */
cwd?: string
/** Persisted session to resume instead of creating a fresh session. */
@@ -363,13 +362,13 @@ export class AgentLoop extends Service implements AgentFactory {
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
if (resumeSessionId === undefined || resumeSessionId === '') {
this.create(id, options, cwd === undefined ? {} : { cwd })
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
this.create(sessionId, options, cwd === undefined ? {} : { cwd })
continue
}
ctx.effect(() => {
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
agentId: id,
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
@@ -382,19 +381,19 @@ export class AgentLoop extends Service implements AgentFactory {
}
/**
* Create an agent on a fresh per-run session, owned by the accessing fiber.
* Constructor-driven config calls use the loop fiber itself.
* @param id - agent registry id.
* Create an agent and session under one caller-supplied identity, owned by
* the accessing fiber. Constructor-driven config calls mint a fresh combined
* id before entering this boundary.
* @param id - shared agent/session identity.
* @param options - concrete loop options.
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const session = loopCtx.sessions.prepare(sessionId, { meta })
const session = loopCtx.sessions.prepare(id, { meta })
const agent = transaction.prepare(options, session)
transaction.publish('startup')
return agent
@@ -417,7 +416,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.sessionId,
options.signal,
)
try {
@@ -461,7 +460,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.resumeSessionId,
options.signal,
)
try {

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -53,10 +52,10 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, SessionId('first-driver'), { model: 'mock' }, session)
expect(() => prepared.agent.ctx).toThrow('context is not bound')
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
expect(() => prepareReactLoopAgent(ctx, SessionId('second-driver'), { model: 'mock' }, session))
.toThrow('already has a concrete agent driver')
await prepared.dispose()
@@ -66,11 +65,11 @@ describe('ReactLoopAgent', () => {
it('borrows caller options and binds its scoped context exactly once', async () => {
const ctx = await harness(new MockAdapter([textResponse('unused')]))
const options = { model: 'mock' }
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options)
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
expect(agent.session.id).toBe(agent.id)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
@@ -81,7 +80,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -96,7 +95,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -111,7 +110,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -124,7 +123,7 @@ describe('ReactLoopAgent', () => {
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Simulate an OPEN turn in the log while the agent is idle (status is not a
// reliable open-turn signal). inject must append into that open turn, NOT
@@ -150,7 +149,7 @@ describe('ReactLoopAgent', () => {
// A persistence-like listener whose flush rejects.
ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
// flush must be contained (logged), never thrown into the caller.
@@ -163,7 +162,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
@@ -183,7 +182,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Session contains a throwing post-commit turn/end observer. The accepted
@@ -206,7 +205,7 @@ describe('ReactLoopAgent', () => {
// A non-Error rejection exercises the String() normalization branch.
ctx.on('session/flush', () => { throw 'disk gone' })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
@@ -225,7 +224,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
@@ -240,7 +239,7 @@ describe('ReactLoopAgent', () => {
it('steer() when idle falls through to send() and starts a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -258,7 +257,7 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test'))
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session)
const { agent } = prepared
// Start the loop to get the disposer; the agent waits for messages
@@ -280,7 +279,7 @@ describe('ReactLoopAgent', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, SessionId('pre-start-dispose'), { model: 'mock' }, session)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
@@ -294,7 +293,7 @@ describe('ReactLoopAgent', () => {
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
@@ -313,7 +312,7 @@ describe('ReactLoopAgent', () => {
it('whenIdle() resolves immediately when the agent is not running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Fresh agent is idle — whenIdle() takes the not-running fast path and
// resolves without subscribing. await must not hang.
@@ -324,7 +323,7 @@ describe('ReactLoopAgent', () => {
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'queued')
let settled = false
@@ -342,8 +341,8 @@ describe('ReactLoopAgent', () => {
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
// agent/status and resolves on the first transition out of running.
@@ -379,7 +378,7 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session)
const { agent } = prepared
prepared.markPublished()
const dispose = prepared.startDriver()
@@ -404,7 +403,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -425,7 +424,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -446,7 +445,7 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'running') throw new Error('bad running listener')
})
@@ -464,7 +463,7 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'idle') throw new Error('bad idle listener')
})

View File

@@ -16,7 +16,8 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -57,7 +58,7 @@ describe('Agent.cancel()', () => {
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// The loop is parked at the idle wait with nothing queued. A cancel here must
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
@@ -74,7 +75,7 @@ describe('Agent.cancel()', () => {
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// send() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
@@ -93,7 +94,7 @@ describe('Agent.cancel()', () => {
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
const adapter = new MockAdapter([textResponse('x')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Queue work, then register a whenIdle() waiter while in the pre-step window
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
@@ -114,7 +115,7 @@ describe('Agent.cancel()', () => {
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -131,7 +132,7 @@ describe('Agent.cancel()', () => {
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -147,7 +148,7 @@ describe('Agent.cancel()', () => {
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
const adapter = new MockAdapter(['hang', textResponse('second reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// First turn hangs; cancel it mid-step.
send(agent, 'first')
@@ -169,7 +170,7 @@ describe('Agent.cancel()', () => {
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Prefix composition runs before the pre-step seam on the instance's first
// step; a cancel landing inside it must drop the about-to-start step
@@ -203,7 +204,6 @@ describe('Agent.cancel()', () => {
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-prefix'),
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { model: 'mock' },
})
@@ -232,7 +232,7 @@ describe('Agent.cancel()', () => {
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// The first composition is interrupted mid-waterfall and — like an
// abort-aware listener bailing on a firing signal — contributes nothing.
@@ -266,7 +266,7 @@ describe('Agent.cancel()', () => {
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// A turn/start listener fires right after turn/start is appended, BEFORE any
// AbortController is installed for the step. Cancelling there must still drop
@@ -295,7 +295,7 @@ describe('Agent.cancel()', () => {
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// A step/start session-event listener fires AFTER step/start is appended
// (and after the pre-step seam), so cancelling there lands in the SECOND
@@ -334,7 +334,6 @@ describe('Agent.cancel()', () => {
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-step-start'),
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { model: 'mock' },
})
@@ -366,7 +365,7 @@ describe('Agent.cancel()', () => {
// `aborted` and run NO second step.
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let steps = 0
const reasons: TurnEndReason[] = []
@@ -398,7 +397,7 @@ describe('Agent.cancel()', () => {
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
// listener can cancel in the gap between the loop's pre-step check and
@@ -428,7 +427,7 @@ describe('Agent.cancel()', () => {
// so whenIdle() resolves on the replacement turn's running→idle, not before.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let replaced = false
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -458,7 +457,7 @@ describe('Agent.cancel()', () => {
// settle (the quiescence contract), not resolve before B's first event.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
@@ -478,7 +477,7 @@ describe('Agent.cancel()', () => {
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))

View File

@@ -7,7 +7,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -32,7 +33,7 @@ describe('config-driven session id', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const loopFiber = await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('deferred') }],
})
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
@@ -53,11 +54,13 @@ describe('config-driven session id', () => {
await ctx1.plugin(SystemPrompt)
await ctx1.plugin(ToolRegistry)
await ctx1.plugin(AgentRegistry)
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
const a1 = ctx1.agents.list()[0] as ReactLoopAgent
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -70,10 +73,11 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] })
await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
const a2 = ctx2.agents.list()[0] as ReactLoopAgent
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
@@ -96,7 +100,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 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -110,7 +114,7 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
@@ -118,11 +122,12 @@ describe('config-driven session id', () => {
let resumed: ReactLoopAgent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined
resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined
}
expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
// and the prior turn's user message is in the derived history.
expect(resumed!.id).toBe(SessionId('sticky-1'))
expect(resumed!.session.id).toBe('sticky-1')
const derived = resumed!.session.deriveMessages()
expect(JSON.stringify(derived)).toContain('remember me')
@@ -138,16 +143,16 @@ describe('config-driven session id', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
.mockImplementation(() => undefined)
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
// The deferred resume fails (no such session on disk). It must be contained:
// a warning is logged, no 'main' agent is registered, and the app stays up.
// a warning is logged, no agent is registered, and the app stays up.
await new Promise(r => setTimeout(r, 200))
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
expect(ctx.agents.list()).toEqual([])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
warn.mockRestore()
await ctx.fiber.dispose()

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -40,7 +41,7 @@ describe('inbox acceptance', () => {
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let queued = 0
ctx.on('agent/queued', () => { queued += 1 })
@@ -80,7 +81,7 @@ describe('tool JSON parse', () => {
return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -113,7 +114,7 @@ describe('tool JSON parse', () => {
return [{ type: 'text', text: 'ran with empty args' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -126,7 +127,7 @@ describe('toError normalization', () => {
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('internal/dispatch', (_mode, name, args) => {
@@ -152,7 +153,7 @@ describe('toError normalization', () => {
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
const adapter = new MockAdapter([textResponse('irrelevant')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
@@ -180,7 +181,7 @@ describe('coded error data emission', () => {
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
@@ -214,7 +215,7 @@ describe('disposed vs aborted branching', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -242,7 +243,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'boom',
description: 'always fails',

View File

@@ -1,15 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, {
AgentId,
type ContinuationDecision,
type PromptDecision,
type SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -57,7 +53,7 @@ describe('agent/prompt-submit', () => {
it('allow (default via next) records the user/message unchanged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
@@ -76,7 +72,7 @@ describe('agent/prompt-submit', () => {
it('allow with content REWRITES the prompt before it is recorded', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
@@ -94,7 +90,7 @@ describe('agent/prompt-submit', () => {
it('allow with additionalContext injects a separate context/message into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
@@ -127,7 +123,7 @@ describe('agent/prompt-submit', () => {
// elsewhere; this asserts they see each other's effects on the same turn).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
@@ -157,7 +153,7 @@ describe('agent/prompt-submit', () => {
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy' }))
@@ -194,7 +190,7 @@ describe('agent/prompt-submit', () => {
// vetoed prompt and its reason would vanish from the log entirely.
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
@@ -230,7 +226,7 @@ describe('agent/prompt-submit', () => {
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async () => {
@@ -263,7 +259,7 @@ describe('agent/session-start', () => {
const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// fires synchronously at create, before any turn
expect(sources).toEqual(['startup'])
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
@@ -282,7 +278,7 @@ describe('agent/session-start', () => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -300,8 +296,8 @@ describe('agent/session-start', () => {
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
// create must not throw — the listener error is contained/logged
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
expect(agent.id).toBe(AgentId('a1'))
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
expect(agent.id).toBe(SessionId('a1'))
// and the agent still runs
send(agent, 'go')
@@ -314,8 +310,8 @@ describe('agent/session-prefix', () => {
it('dispatches to global and matching agent-scope listeners only', async () => {
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
const ctx = await harness(adapter)
const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' })
const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' })
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' })
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`global:${agent.id}`)
@@ -352,7 +348,7 @@ describe('agent/session-prefix', () => {
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
let composed = 0
@@ -385,7 +381,7 @@ describe('agent/session-prefix', () => {
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
const order: string[] = []
@@ -412,7 +408,7 @@ describe('agent/session-prefix', () => {
it('the canonical prepend pattern composes contributions in registration order', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
// waterfall unwinds innermost-first (the second listener's array is built
@@ -434,7 +430,7 @@ describe('agent/session-prefix', () => {
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// A listener that delegates without contributing — the canonical no-op.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
@@ -450,7 +446,7 @@ describe('agent/session-prefix', () => {
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let mutationError: unknown
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
@@ -479,7 +475,7 @@ describe('agent/session-prefix', () => {
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
@@ -500,7 +496,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
@@ -533,7 +529,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
@@ -563,7 +559,7 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Each call attaches additionalContext naming itself.
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
@@ -599,7 +595,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
@@ -663,7 +659,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'please echo hi')
await waitForIdle(ctx, agent)
@@ -686,7 +682,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -705,7 +701,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
await fiber.dispose()
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a3'), { model: 'mock' })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed

View File

@@ -4,7 +4,8 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -44,7 +45,7 @@ describe('agent loop', () => {
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// All boundaries — turn and step — are durable session events on the
// session/event feed (no agent/* mirror). Record them in fire order to
@@ -92,7 +93,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: `echo: ${args.text}` }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -131,7 +132,7 @@ describe('agent loop', () => {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -155,7 +156,7 @@ describe('agent loop', () => {
return []
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -169,7 +170,6 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const handle = await ctx.agents.create({
agentId: AgentId('a-cwd'),
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
agentOptions: { model: 'mock' },
@@ -192,7 +192,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -233,7 +233,7 @@ describe('agent loop', () => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
return { ...config, model: 'mock' }
})
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -259,7 +259,7 @@ describe('agent loop', () => {
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -288,7 +288,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -300,7 +300,7 @@ describe('agent loop', () => {
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -324,7 +324,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'slow',
description: '',
@@ -356,7 +356,7 @@ describe('agent loop', () => {
it('steering while idle behaves like send (starts a turn)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.steer([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
@@ -366,7 +366,7 @@ describe('agent loop', () => {
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → context/message
@@ -393,7 +393,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// A tool that injects mid-execution: at this point the agent is running, so
// inject must append the context/message into the ALREADY-open turn rather
// than wrap it in its own one-shot turn.
@@ -427,7 +427,7 @@ describe('agent loop', () => {
textResponse('step 3'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
@@ -453,7 +453,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
@@ -469,7 +469,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.llm.registerAdapter(['other-model'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
// The seed is frozen — config is not a mutable per-call knob; a switch
@@ -502,7 +502,7 @@ describe('agent loop', () => {
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
@@ -527,7 +527,7 @@ describe('agent loop', () => {
// the derived request for that step (derive happens after step/start).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let injected = false
ctx.on('agent/pre-step', (subject) => {
@@ -563,7 +563,7 @@ describe('agent loop', () => {
// The loop survives and a follow-up prompt still runs.
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let throwOnce = true
ctx.on('agent/pre-step', () => {
@@ -597,7 +597,7 @@ describe('agent loop', () => {
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -617,7 +617,7 @@ describe('agent loop', () => {
// turn stops by default and ends max-tokens, not completed.
const adapter = new MockAdapter([maxTokensResponse('truncat')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -642,7 +642,7 @@ describe('agent loop', () => {
textResponse('second half'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
@@ -673,7 +673,7 @@ describe('agent loop', () => {
// stop. The per-turn reason must be independent — turn 2 ends completed.
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -706,7 +706,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: 'should not run' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -748,7 +748,7 @@ describe('agent loop', () => {
parameters: { text: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -767,7 +767,7 @@ describe('agent loop', () => {
// on the normal step path suppresses a pure trace-only empty assistant/message.
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -797,7 +797,7 @@ describe('agent loop', () => {
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -824,7 +824,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threw = false
// Post-commit session observers cannot control the loop. The tool call still
// drives the second model request, and the turn completes normally.
@@ -843,7 +843,7 @@ describe('agent loop', () => {
it('chains queued messages into consecutive turns', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const turns: number[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -868,7 +868,7 @@ describe('agent loop', () => {
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let flushed = 0
let flushedBeforeIdle = false
@@ -888,7 +888,7 @@ describe('agent loop', () => {
it('errors from the model surface as agent/error and end the turn', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const errors: Error[] = []
const reasons: TurnEndReason[] = []
@@ -913,10 +913,10 @@ describe('agent loop', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
expect(ctx.agents.get(SessionId('scoped'))).toBe(agent)
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -925,7 +925,7 @@ describe('agent loop', () => {
await agent.done
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined()
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
expect(() => { send(agent, 'too late') }).toThrow('disposed')
})
@@ -938,13 +938,14 @@ describe('agent loop', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('config-agent'), model: 'mock' }],
agents: [{ id: 'config-agent', model: 'mock' }],
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
const agent = ctx.agents.list()[0]! as ReactLoopAgent
expect(agent).toBeDefined()
expect(agent.id).toBe('config-agent')
expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/)
expect(agent.options.model).toBe('mock')
// the agent is alive: send triggers a turn
@@ -961,10 +962,10 @@ describe('agent loop', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }],
agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }],
})
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
const agent = ctx.agents.list()[0]! as ReactLoopAgent
expect(agent.session.header.cwd).toBe('/work/project')
})
@@ -982,7 +983,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'run')
await waitForIdle(ctx, agent)

View File

@@ -14,10 +14,11 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
@@ -95,7 +96,7 @@ describe('agent loop scheduling properties', () => {
async (texts) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
const { seen: trace } = recordStatus(ctx, agent)
const idle = nextIdle(ctx, agent)
// Send all in one synchronous tick: they queue before the loop wakes.
@@ -120,7 +121,7 @@ describe('agent loop scheduling properties', () => {
async (texts) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
for (const text of texts) {
const idle = nextIdle(ctx, agent)
agent.send([{ type: 'text', text }])
@@ -145,7 +146,7 @@ describe('agent loop scheduling properties', () => {
async (steps) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
// Capture an idle waiter before EACH send; the last one is guaranteed
// to resolve because the final send always triggers (or joins) a turn
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a

View File

@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -71,7 +72,7 @@ function waitForIdle(context: Context, agent: Agent): Promise<void> {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
it('every request after the first hits the provider prefix cache', async () => {
ctx = await loopHarness()
const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { model: 'deepseek-v4-flash' })
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { model: 'deepseek-v4-flash' })
// Turn 1: forces a tool call → at least two steps (two model requests).
agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])

View File

@@ -15,7 +15,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -74,7 +75,7 @@ describe('request stability across the loop', () => {
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -95,7 +96,7 @@ describe('request stability across the loop', () => {
it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -109,7 +110,7 @@ describe('request stability across the loop', () => {
it('a compaction replace rewrites the resend, and the log explains it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -142,7 +143,7 @@ describe('request stability across the loop', () => {
it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -166,7 +167,7 @@ describe('request stability across the loop', () => {
it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
@@ -194,7 +195,7 @@ describe('request stability across the loop', () => {
it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
const adapter = new MockAdapter([textResponse('one')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
@@ -215,7 +216,7 @@ describe('request stability across the loop', () => {
it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
const adapter = new MockAdapter([textResponse('one')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('gen1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('gen1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -224,7 +225,6 @@ describe('request stability across the loop', () => {
const adapter2 = new MockAdapter([textResponse('two')])
const ctx2 = await harness(adapter2)
const handle = await ctx2.agents.create({
agentId: AgentId('gen2'),
sessionId: SessionId('gen2-session'),
seed: [...agent.session.events],
agentOptions: { model: 'mock' },
@@ -244,7 +244,7 @@ describe('request stability across the loop', () => {
it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
const config = await next()
@@ -278,7 +278,7 @@ describe('request stability across the loop', () => {
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)

View File

@@ -8,7 +8,8 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
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, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -83,11 +84,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
ctx.on('session/created', () => throwUnknown(failure))
await expect(ctx.agents.resume({
agentId: AgentId('unknown-resume-failure'),
resumeSessionId: sessionId,
})).rejects.toBe(failure)
expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined()
expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -95,27 +95,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
})
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
it('createAgent rejects a duplicate identity without orphaning a session', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
// A second create with the SAME agent id but a fresh session id must reject
// up front — and must NOT leave an orphaned 'sess-b' session behind.
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
const sessionId = SessionId('sess-a')
await ctx.agents.create({ sessionId })
await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/)
expect(ctx.sessions.list()).toHaveLength(1)
await ctx.fiber.dispose()
})
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -125,7 +124,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 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -141,7 +140,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: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -152,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -171,7 +170,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
ctx2.llm.registerAdapter(['mock'], adapter2)
const sources2: string[] = []
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
expect(sources2).toEqual(['resume'])
await ctx2.fiber.dispose()
})
@@ -186,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
ctx.on('session/created', (session) => {
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
expect(ctx.agents.get(sessionId)?.session).toBe(session)
order.push('session/created')
})
ctx.on('agent/created', (agent) => {
@@ -199,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
const resuming = ctx.agents.resume({
agentId: AgentId('resumed-atomic'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
expect(agentCtx.agent?.id).toBe(sessionId)
expect(agentCtx.agent?.session.events).toHaveLength(2)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
@@ -215,7 +213,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})
await setupStarted.promise
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(order).toEqual(['setup:start'])
@@ -236,17 +234,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('successful resume disposal retires its caller-owned transaction effects', async () => {
const sessionId = SessionId('resume-retired-effects-s')
const agentId = AgentId('resume-retired-effects')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const handle = await ctx.agents.resume({
agentId,
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
const transactionLabels = [
`agentLoop.owner(${agentId})`,
`agentLoop.lifecycle(${agentId})`,
`agentLoop.owner(${sessionId})`,
`agentLoop.lifecycle(${sessionId})`,
]
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
@@ -255,7 +251,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => {
const sessionId = SessionId('resume-setup-reject')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
@@ -265,7 +261,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
await expect(ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
@@ -275,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
})).rejects.toThrow('resume setup failed')
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const retry = await ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
@@ -299,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({
agentId: AgentId('resume-owner-race'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
@@ -313,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await owner.dispose()
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
gate.resolve(undefined)
@@ -322,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
const sessionId = SessionId('resume-load-owner-unload')
const agentId = AgentId('resume-load-race')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
@@ -348,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
// owner.dispose() awaited transaction settlement, so the same identities
// can be reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
// owner.dispose() awaited transaction settlement, so the identity can be
// reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
await rejection
expect(loads).toBe(2)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
@@ -370,7 +362,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
expect(ctx.agents.get(agentId)).toBe(retry.agent)
expect(ctx.agents.get(sessionId)).toBe(retry.agent)
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
@@ -380,7 +372,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const agentId = AgentId('resume-load-factory-race')
const root = await persistSession(sessionId)
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -404,14 +395,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
await promptly(loopFiber.dispose())
await rejection
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
@@ -452,7 +443,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: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
expect(a2.session.header.seedLength).toBe(seed.length)
@@ -466,7 +457,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 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent 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' } })
@@ -491,7 +482,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 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent 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' } })
@@ -509,7 +500,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: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -519,7 +510,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 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -539,7 +530,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: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)
@@ -567,7 +558,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') }))
.rejects.toThrow(/session persistence is not configured/)
await ctx.fiber.dispose()
})

View File

@@ -4,7 +4,8 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
@@ -53,7 +54,7 @@ describe('HIGH: session log records what agent/step-result actually produced', (
return [{ type: 'text', text: 'ran' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Plugin rewrites the message: replaces the text AND adds a tool call.
let rewritten = false
@@ -104,7 +105,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
])
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'aborter',
description: '',
@@ -148,7 +149,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
textResponse('continued because of steering'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
@@ -188,7 +189,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
textResponse('after goal reminder'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
@@ -218,7 +219,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const turns: number[] = []
let steeredOnce = false
@@ -244,7 +245,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
const adapter = new MockAdapter(['hang', textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -267,7 +268,7 @@ describe('HIGH: plugin exceptions are contained', () => {
it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
@@ -295,7 +296,7 @@ describe('HIGH: plugin exceptions are contained', () => {
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
let rejectedOnce = false
ctx.on('session/flush', async () => {
@@ -325,7 +326,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const statuses: string[] = []
@@ -348,7 +349,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
ctx.on('agent/status', (_agent, status) => {
@@ -361,7 +362,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
await agent.done // must not hang
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
})
})
@@ -380,7 +381,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
const adapter = new MockAdapter([textResponse('never')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
@@ -395,7 +396,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
const adapter = new MockAdapter([textResponse('routed')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
return { ...config, model: 'mock' }
@@ -410,7 +411,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('agent/queued carries the resolved source; steering/message records its source', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'noop',
description: '',
@@ -438,7 +439,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('send() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('owned-send'), { model: 'mock' })
const content = [{ type: 'text' as const, text: 'accepted-send' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
let notifiedContent: ContentBlock[] | undefined
@@ -474,7 +475,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('running steer() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
@@ -528,7 +529,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
it('a forked agent continues turn numbers after the seed log', async () => {
const first = new MockAdapter([textResponse('turn one')])
const ctx = await harness(first)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -544,7 +545,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const prepared = prepareReactLoopAgent(ctx2, SessionId('forked-agent'), { model: 'mock' }, seeded)
const forked = prepared.agent
prepared.markPublished()
ctx2.effect(() => prepared.startDriver())
@@ -591,7 +592,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -616,7 +617,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([abortedStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -634,7 +635,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -650,7 +651,7 @@ describe('step boundary publication order', () => {
it('the step/start event is in session.events when its session/event listener fires', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { model: 'mock' })
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a step/start listener always finds the matching event already in the
@@ -711,7 +712,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing step/start observer cannot change a successful turn', async () => {
const adapter = new MockAdapter([textResponse('request completed')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { model: 'mock' })
// Session owns post-commit containment. The loop sees a successful append,
// runs the request, and balances the ordinary step and turn boundaries.
@@ -740,7 +741,7 @@ describe('turn and step boundary recovery', () => {
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
@@ -771,7 +772,7 @@ describe('turn and step boundary recovery', () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
@@ -805,7 +806,7 @@ describe('turn and step boundary recovery', () => {
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
@@ -839,7 +840,7 @@ describe('turn and step boundary recovery', () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { model: 'mock' })
let threw = false
ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
@@ -872,7 +873,7 @@ describe('turn and step boundary recovery', () => {
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -901,7 +902,7 @@ describe('turn and step boundary recovery', () => {
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
let threw = false
@@ -935,7 +936,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-preturn'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_session, event) => {
@@ -966,7 +967,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_s, event) => {
@@ -1008,7 +1009,7 @@ describe('turn and step boundary recovery', () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_s, event) => {
@@ -1038,7 +1039,7 @@ describe('turn and step boundary recovery', () => {
// boundary stays authoritative and the loop continues normally.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_s, event) => {
@@ -1085,7 +1086,7 @@ describe('tool result call identity', () => {
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
}, { prepend: true })
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-callid'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -1120,7 +1121,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
role: 'assistant' as const,
@@ -1171,7 +1172,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -1227,7 +1228,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -1282,7 +1283,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -1334,7 +1335,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -1384,7 +1385,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')

View File

@@ -4,7 +4,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
@@ -57,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
}
describe('agent scope lifecycle', () => {
it('rejects an already-aborted creation signal before publishing either identity', async () => {
it('rejects an already-aborted creation signal before publishing either object', async () => {
const ctx = await harness()
const reason = new Error('cancelled before creation')
const controller = new AbortController()
controller.abort(reason)
await expect(ctx.agents.create({
agentId: AgentId('pre-aborted'),
sessionId: SessionId('pre-aborted-s'),
signal: controller.signal,
})).rejects.toBe(reason)
expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined()
expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
const valueController = new AbortController()
valueController.abort('plain cancellation reason')
await expect(ctx.agents.create({
agentId: AgentId('pre-aborted-value'),
sessionId: SessionId('pre-aborted-value-s'),
signal: valueController.signal,
})).rejects.toMatchObject({
message: 'agent "pre-aborted-value" creation aborted',
message: 'agent "pre-aborted-value-s" creation aborted',
cause: 'plain cancellation reason',
})
expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined()
expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -100,12 +99,11 @@ describe('agent scope lifecycle', () => {
})
await expect(ctx.agents.create({
agentId: AgentId('prepare-abort'),
sessionId: SessionId('prepare-abort-s'),
signal: controller.signal,
})).rejects.toBe(reason)
expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined()
expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -124,7 +122,7 @@ describe('agent scope lifecycle', () => {
thrown = createFailure
let createCaught: unknown
try {
ctx.agentLoop.create(AgentId('unknown-create'))
ctx.agentLoop.create(SessionId('unknown-create'))
} catch (error: unknown) {
createCaught = error
}
@@ -133,28 +131,27 @@ describe('agent scope lifecycle', () => {
const ownedFailure = { source: 'createAgent' }
thrown = ownedFailure
await expect(ctx.agents.create({
agentId: AgentId('unknown-owned-create'),
sessionId: SessionId('unknown-owned-create-s'),
})).rejects.toBe(ownedFailure)
expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined()
expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined()
expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
const ctx = await harness()
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
expect(scopeOf(agent.ctx)).toBe(agent)
expect(agent.ctx.agent).toBe(agent)
// The root accessor default: a plain context answers undefined, not a throw.
expect(ctx.agent).toBeUndefined()
await ctx.agents.get(AgentId('a1'))?.whenIdle()
await ctx.agents.get(SessionId('a1'))?.whenIdle()
})
it('scoped registrations live in the agent world and die with the agent', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const { agent } = handle
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
agent.ctx.tools.register({
@@ -179,8 +176,8 @@ describe('agent scope lifecycle', () => {
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' })
const a = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
const b = ctx.agentLoop.create(SessionId('b'), { model: 'mock' })
const heard: string[] = []
a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
@@ -210,7 +207,6 @@ describe('agent scope lifecycle', () => {
})
const handle = await ctx.agents.create({
agentId: AgentId('child'),
sessionId: SessionId('child-s'),
agentOptions: { model: 'mock' },
setup: async (agentCtx) => {
@@ -224,14 +220,14 @@ describe('agent scope lifecycle', () => {
await handle.dispose()
})
it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
it('keeps both objects unpublished until async setup completes, then announces in order', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/created', (session) => {
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
expect(ctx.agents.get(session.id)?.session).toBe(session)
order.push('session/created')
})
ctx.on('agent/created', () => void order.push('agent/created'))
@@ -239,11 +235,10 @@ describe('agent scope lifecycle', () => {
const acceptedOptions = { model: 'mock' }
const creating = ctx.agents.create({
agentId: AgentId('atomic'),
sessionId: SessionId('atomic-s'),
sessionId: SessionId('atomic'),
agentOptions: acceptedOptions,
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
expect(agentCtx.agent?.id).toBe(SessionId('atomic'))
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
@@ -253,7 +248,7 @@ describe('agent scope lifecycle', () => {
},
})
await setupStarted.promise
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
expect(order).toEqual(['setup:start'])
gate.resolve(undefined)
@@ -281,16 +276,14 @@ describe('agent scope lifecycle', () => {
if (started === 2) bothStarted.resolve(undefined)
await gate.promise
}
const agentId = AgentId('concurrent-final-enter')
const sessionId = SessionId('concurrent-final-enter')
const first = ctx.agents.create({
agentId,
sessionId: SessionId('concurrent-final-enter-a'),
sessionId,
agentOptions: { model: 'mock' },
setup,
})
const second = ctx.agents.create({
agentId,
sessionId: SessionId('concurrent-final-enter-b'),
sessionId,
agentOptions: { model: 'mock' },
setup,
})
@@ -304,7 +297,7 @@ describe('agent scope lifecycle', () => {
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
expect(fulfilled).toHaveLength(1)
expect(rejected).toHaveLength(1)
expect(String(rejected[0]!.reason)).toMatch(/already registered/)
expect(String(rejected[0]!.reason)).toMatch(/already exists/)
expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent])
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
@@ -318,7 +311,6 @@ describe('agent scope lifecycle', () => {
const pendingController = new AbortController()
const setupStarted = Promise.withResolvers<undefined>()
const pending = ctx.agents.create({
agentId: AgentId('signal-pending'),
sessionId: SessionId('signal-pending-s'),
agentOptions: { model: 'mock' },
signal: pendingController.signal,
@@ -330,12 +322,11 @@ describe('agent scope lifecycle', () => {
await setupStarted.promise
pendingController.abort(new Error('cancel pending creation'))
await expect(pending).rejects.toThrow('cancel pending creation')
expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined()
expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
const liveController = new AbortController()
const live = await ctx.agents.create({
agentId: AgentId('signal-live'),
sessionId: SessionId('signal-live-s'),
agentOptions: { model: 'mock' },
signal: liveController.signal,
@@ -358,7 +349,6 @@ describe('agent scope lifecycle', () => {
let creating!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
creating = inner.agents.create({
agentId: AgentId('owner-race'),
sessionId: SessionId('owner-race-s'),
agentOptions: { model: 'mock' },
setup: async () => {
@@ -372,7 +362,7 @@ describe('agent scope lifecycle', () => {
await owner.dispose()
await expect(creating).rejects.toThrow(/owner disposed during setup/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
// Let the losing callback settle; Promise.race already observes it.
gate.resolve(undefined)
@@ -386,7 +376,6 @@ describe('agent scope lifecycle', () => {
let creating2!: ReturnType<typeof ctx.agents.create>
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
creating2 = inner.agents.create({
agentId: AgentId('owner-race-2'),
sessionId: SessionId('owner-race-s-2'),
agentOptions: { model: 'mock' },
setup: async () => {
@@ -400,7 +389,7 @@ describe('agent scope lifecycle', () => {
const unload2 = owner2.dispose()
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
await unload2
expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
})
@@ -413,7 +402,6 @@ describe('agent scope lifecycle', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
const creating = ctx.agents.create({
agentId: AgentId('factory-setup-race'),
sessionId: SessionId('factory-setup-race-s'),
agentOptions: { model: 'mock' },
setup: async () => {
@@ -426,7 +414,7 @@ describe('agent scope lifecycle', () => {
await loopFiber.dispose()
await expect(creating).rejects.toThrow(/agent loop is not active/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
gate.resolve(undefined)
@@ -444,7 +432,6 @@ describe('agent scope lifecycle', () => {
})
const creating = ctx.agents.create({
agentId: AgentId('factory-scope-race'),
sessionId: SessionId('factory-scope-race-s'),
agentOptions: { model: 'mock' },
setup: () => { setupCalls += 1 },
@@ -452,7 +439,7 @@ describe('agent scope lifecycle', () => {
await expect(creating).rejects.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(setupCalls).toBe(0)
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
await ctx.fiber.dispose()
@@ -479,7 +466,6 @@ describe('agent scope lifecycle', () => {
const owner = ctx.plugin(Object.assign((inner: Context) => {
ownerFiber = inner.fiber
creating = inner.agents.create({
agentId: AgentId('caller-scope-race'),
sessionId: SessionId('caller-scope-race-s'),
agentOptions: { model: 'mock' },
})
@@ -495,7 +481,7 @@ describe('agent scope lifecycle', () => {
await ownerDisposal
await owner
expect(scopeFiber?.uid).toBeNull()
expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined()
expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
await owner.dispose()
await ctx.fiber.dispose()
@@ -511,17 +497,17 @@ describe('agent scope lifecycle', () => {
void loopFiber.dispose()
})
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { model: 'mock' }))
.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
await ctx.fiber.dispose()
})
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
const ctx = await harness()
const id = AgentId('config-prepare-failure')
const id = SessionId('config-prepare-failure')
expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' }))
.toThrow(/absolute path/)
@@ -542,12 +528,11 @@ describe('agent scope lifecycle', () => {
})
await expect(ctx.agents.create({
agentId: AgentId('factory-scope-throw'),
sessionId: SessionId('factory-scope-throw-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow('scope preparation failed')
await loopFiber.dispose()
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
await ctx.fiber.dispose()
@@ -556,23 +541,21 @@ describe('agent scope lifecycle', () => {
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
const loop = ctx.agentLoop
const agentId = AgentId('factory-live')
const sessionId = SessionId('factory-live')
const handle = await ctx.agents.create({
agentId,
sessionId: SessionId('factory-live-s'),
sessionId,
agentOptions: { model: 'mock' },
})
await loopFiber.dispose()
expect(handle.agent.status).toBe('disposed')
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
// The consumer handle shares the provider's completed quiescence boundary.
await handle.dispose()
await expect(loop.createAgent(ctx, {
agentId: AgentId('factory-inactive'),
sessionId: SessionId('factory-inactive-s'),
})).rejects.toThrow('agent loop is not active')
await ctx.fiber.dispose()
@@ -583,7 +566,6 @@ describe('agent scope lifecycle', () => {
let creating!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
creating = inner.agents.create({
agentId: AgentId('dependency-origin'),
sessionId: SessionId('dependency-origin-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
@@ -623,7 +605,7 @@ describe('agent scope lifecycle', () => {
})
ctx.on('session/created', (session) => {
if (session.id !== SessionId('session-created-barrier-s')) return
const agent = ctx.agents.get(AgentId('session-created-barrier'))!
const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
expect(ctx.sessions.get(session.id)).toBe(session)
expect(agent.session).toBe(session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
@@ -638,7 +620,6 @@ describe('agent scope lifecycle', () => {
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('session-created-barrier'),
sessionId: SessionId('session-created-barrier-s'),
agentOptions: { model: 'mock' },
})
@@ -652,7 +633,7 @@ describe('agent scope lifecycle', () => {
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined()
expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -666,19 +647,19 @@ describe('agent scope lifecycle', () => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
})
ctx.on('agent/created', (agent) => {
if (agent.id !== AgentId('agent-created-barrier')) return
if (agent.id !== SessionId('agent-created-barrier-s')) return
lifecycle.push('agent-created:dispose')
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/created', (agent) => {
if (agent.id !== AgentId('agent-created-barrier')) return
if (agent.id !== SessionId('agent-created-barrier-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
lifecycle.push('agent-created:observer')
})
ctx.on('agent/disposed', (agent) => {
if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed')
if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
})
ctx.on('session/disposed', (session) => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
@@ -687,7 +668,6 @@ describe('agent scope lifecycle', () => {
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('agent-created-barrier'),
sessionId: SessionId('agent-created-barrier-s'),
agentOptions: { model: 'mock' },
})
@@ -703,7 +683,7 @@ describe('agent scope lifecycle', () => {
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined()
expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -715,13 +695,12 @@ describe('agent scope lifecycle', () => {
let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id))
ctx.on('agent/created', (agent) => {
if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose()
if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose()
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('listener-dispose'),
sessionId: SessionId('listener-dispose-s'),
agentOptions: { model: 'mock' },
})
@@ -730,7 +709,7 @@ describe('agent scope lifecycle', () => {
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(starts).toEqual([])
expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined()
expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -744,15 +723,15 @@ describe('agent scope lifecycle', () => {
let scopeDisposed = false
let observerSawLive = false
ctx.on('agent/status', (agent, status) => {
if (agent.id === AgentId('session-start-dispose')) statuses.push(status)
if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== AgentId('session-start-dispose')) return
if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent as ReactLoopAgent
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== AgentId('session-start-dispose')) return
if (agent.id !== SessionId('session-start-dispose-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { scopeDisposed = true })
@@ -762,7 +741,6 @@ describe('agent scope lifecycle', () => {
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('session-start-dispose'),
sessionId: SessionId('session-start-dispose-s'),
agentOptions: { model: 'mock' },
})
@@ -775,7 +753,7 @@ describe('agent scope lifecycle', () => {
expect(observerSawLive).toBe(true)
expect(scopeDisposed).toBe(true)
expect(announced.session.events).toEqual([])
expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined()
expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -787,7 +765,6 @@ describe('agent scope lifecycle', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
await expect(ctx.agents.create({
agentId: AgentId('bad'),
sessionId: SessionId('bad-s'),
agentOptions: { model: 'mock' },
setup: async () => {
@@ -798,13 +775,13 @@ describe('agent scope lifecycle', () => {
// Nothing leaked: no agent, no session, and the ids are reusable.
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
await retry.dispose()
})
it('rejects an exotic durable seed before publishing either identity', async () => {
it('rejects an exotic durable seed before publishing either object', async () => {
const ctx = await harness()
const published: string[] = []
ctx.on('session/created', () => { published.push('session') })
@@ -817,17 +794,15 @@ describe('agent scope lifecycle', () => {
}] as unknown as SessionEvent[]
await expect(ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
seed,
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined()
expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
const retry = await ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
})
@@ -843,13 +818,13 @@ describe('agent scope lifecycle', () => {
if (boom) { boom = false; throw new Error('boom created') }
})
await expect(ctx.agents.create({
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
})).rejects.toThrow('boom created')
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
// The rollback also disposed the scope fiber: re-creating works cleanly.
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
await retry.dispose()
})
@@ -866,18 +841,17 @@ describe('agent scope lifecycle', () => {
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
await expect(ctx.agents.create({
agentId: AgentId('partial-agent'),
sessionId: SessionId('partial-session'),
agentOptions: { model: 'mock' },
})).rejects.toThrow('agent observer failed')
expect(lifecycle).toEqual([
'session-created:partial-session',
'agent-created:partial-agent',
'agent-disposed:partial-agent',
'agent-created:partial-session',
'agent-disposed:partial-session',
'session-disposed:partial-session',
])
expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined()
expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
})
@@ -892,23 +866,23 @@ describe('agent scope lifecycle', () => {
}
})
expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' }))
expect(() => ctx.agentLoop.create(SessionId('config-bad'), { model: 'mock' }))
.toThrow('config publish failed')
expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
})
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
await handle.dispose()
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
})
it('agentEvents fuses carrier and subject for custom drivers', async () => {
const ctx = await harness()
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
const heard: string[] = []
agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
@@ -921,7 +895,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness()
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const { agent } = handle
@@ -930,7 +904,7 @@ describe('agent scope lifecycle', () => {
if (event.type === 'turn/end') order.push('turn-end')
})
ctx.on('agent/disposed', () => {
order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`)
order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
@@ -955,7 +929,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness()
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const teardownDone: string[] = []
@@ -967,23 +941,22 @@ describe('agent scope lifecycle', () => {
// actually finished (the raw wrapper returns undefined on a repeat call).
await handle.dispose()
expect(teardownDone).toContain('unregistered')
expect(ctx.agents.get(AgentId('h1'))).toBeUndefined()
expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
await unload
})
it('successful handle disposal retires its caller ownership effect', async () => {
const ctx = await harness()
const agentId = AgentId('retired-owner-effect')
const sessionId = SessionId('retired-owner-effect')
const handle = await ctx.agents.create({
agentId,
sessionId: SessionId('retired-owner-effect-s'),
sessionId,
agentOptions: { model: 'mock' },
})
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`)
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`)
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
await ctx.fiber.dispose()
})
@@ -994,7 +967,6 @@ describe('agent scope lifecycle', () => {
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({
agentId: AgentId('manual-first'),
sessionId: SessionId('manual-first-s'),
agentOptions: { model: 'mock' },
setup(agentCtx) {
@@ -1014,7 +986,7 @@ describe('agent scope lifecycle', () => {
expect(ownerSettled).toBe(false)
gate.resolve(undefined)
await Promise.all([disposing, unloading])
expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined()
expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -1024,13 +996,11 @@ describe('agent scope lifecycle', () => {
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
const agentId = AgentId('quiescent-reuse')
const sessionId = SessionId('quiescent-reuse-s')
const sessionId = SessionId('quiescent-reuse')
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) sessionDisposed.resolve(undefined)
})
const first = await ctx.agents.create({
agentId,
sessionId,
agentOptions: { model: 'mock' },
setup(agentCtx) {
@@ -1043,10 +1013,10 @@ describe('agent scope lifecycle', () => {
const disposing = first.dispose()
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
const replacement = await ctx.agents.create({ sessionId, agentOptions: { model: 'mock' } })
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
gate.resolve(undefined)
@@ -1058,7 +1028,6 @@ describe('agent scope lifecycle', () => {
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
agentId: AgentId('idle-flush'),
sessionId: SessionId('idle-flush-s'),
agentOptions: { model: 'mock' },
})
@@ -1077,12 +1046,12 @@ describe('agent scope lifecycle', () => {
const disposal = handle.dispose().then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent)
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent)
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
gate.resolve(undefined)
await disposal
expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined()
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
})
})

View File

@@ -10,11 +10,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -57,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
@@ -103,7 +104,7 @@ describe('loop-level canonical tool order', () => {
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -45,7 +46,7 @@ describe('agent/turn-stop', () => {
textResponse('must not be requested'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
@@ -72,7 +73,7 @@ describe('agent/turn-stop', () => {
textResponse('must not become a late-steering turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let injected = false
@@ -98,7 +99,7 @@ describe('agent/turn-stop', () => {
textResponse('queued follow-up answer'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let queued = false
@@ -124,8 +125,8 @@ describe('agent/turn-stop', () => {
])
const ctx = await harness(adapter)
registerEcho(ctx)
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
const stopped = ctx.agentLoop.create(SessionId('stopped'), { model: 'mock' })
const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { model: 'mock' })
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(stopped)
@@ -145,7 +146,7 @@ describe('agent/turn-stop', () => {
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('owned-listener'), { model: 'mock' })
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(agent, 'first turn')
@@ -162,7 +163,7 @@ describe('agent/turn-stop', () => {
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('bad-policy'), { model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: string[] = []
ctx.on('session/event', (session, event) => {

View File

@@ -12,7 +12,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.get(id: SessionId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)

View File

@@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId, AgentOptions } from './types.ts'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export { agentEvents, assembleContextFor } from './dispatch.ts'
@@ -33,15 +33,13 @@ declare module 'cordis' {
/**
* Options for programmatically creating an agent through the registry factory
* ({@link AgentRegistry.create}). The caller supplies the live `sessionId`
* (e.g. an ACP-generated id) and optional session metadata (the validated
* `cwd`, fork lineage); the factory creates the session, the agent, and wires
* them together.
* ({@link AgentRegistry.create}). The caller supplies the single live
* `sessionId` shared by the agent registry and session log (e.g. an
* ACP-generated id), plus optional session metadata (the validated `cwd`, fork
* lineage); the factory creates the session and agent under that identity.
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
readonly agentId: AgentId
/** The live session's id (NOT derived from agentId). */
/** The live agent/session identity. */
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
@@ -93,9 +91,7 @@ export interface CreateAgentOptions {
* ({@link AgentRegistry.resume}).
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
readonly agentId: AgentId
/** The persisted session id to load and resume on. */
/** The persisted session id to load and use as the live agent/session identity. */
readonly resumeSessionId: SessionId
/** Per-agent options (model, …). */
readonly agentOptions?: AgentOptions
@@ -180,7 +176,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
readonly id: AgentId
readonly id: SessionId
readonly agent: Agent
readonly carrier: Scoped<Agent>
announced: boolean
@@ -201,7 +197,7 @@ interface FactorySlot {
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, AgentEntry>()
private store = new Map<SessionId, AgentEntry>()
private factory: FactorySlot | undefined
constructor(ctx: Context) {
@@ -257,7 +253,7 @@ export class AgentRegistry extends Service {
* agent): this constructs the agent and its session. Rejects if no factory is
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
* the owner tear down exactly this agent.
* @param options - agent id, session id/seed/metadata, and agent options.
* @param options - shared identity, session seed/metadata, and agent options.
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async create(options: CreateAgentOptions): Promise<AgentHandle> {
@@ -428,10 +424,10 @@ export class AgentRegistry extends Service {
/**
* Look up a live agent.
* @param id - the agent id to look up.
* @param id - the shared agent/session id to look up.
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
get(id: SessionId): Agent | undefined {
return this.store.get(id)?.agent
}

View File

@@ -32,7 +32,7 @@
* event. A turn/step boundary is a durable fact: it lives in the session log
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
* looks up the agent directly by the event's session id.
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
@@ -45,25 +45,12 @@
* @module @deepseek-ai/dsh-agent/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
/**
* Brand a string as an {@link AgentId}.
* @param id - the raw agent id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function AgentId(id: string): AgentId {
return id as AgentId
}
import type { Session } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
/**
@@ -186,7 +173,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
* package should depend on the implementation.
*/
export interface Agent {
readonly id: AgentId
/** The single identity shared with {@link session}. */
readonly id: SessionId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus

View File

@@ -2,11 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = AgentId(rawId)
const id = SessionId(rawId)
return {
id,
options: {},
@@ -57,7 +58,7 @@ describe('AgentRegistry', () => {
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
})
@@ -158,11 +159,11 @@ describe('AgentRegistry factory seam', () => {
const factory: AgentFactory = {
async createAgent(ownerCtx, options) {
calls.create.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
},
async resume(ownerCtx, options) {
calls.resume.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
},
}
return { factory, calls }
@@ -171,15 +172,15 @@ describe('AgentRegistry factory seam', () => {
it('requires a factory and delegates through the calling context', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
let callerFiber: Context['fiber'] | undefined
await ctx.plugin(Object.assign(async (inner: Context) => {
callerFiber = inner.fiber
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
await inner.agents.create({ sessionId: SessionId('create-s') })
await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
}, { inject: ['agents'] }))
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
@@ -192,9 +193,9 @@ describe('AgentRegistry factory seam', () => {
inner.agents.setFactory(stubFactory().factory)
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined()
await owner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
})
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
@@ -214,18 +215,18 @@ describe('AgentRegistry factory seam', () => {
}
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
this.calls().push('create')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
}
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
this.calls().push('resume')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
}
}
await ctx.plugin(TracedFactory)
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
ctx.agents.setFactory(traced)
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
await ctx.agents.create({ sessionId: SessionId('create-s') })
await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
expect(states.get(raw!)).toEqual(['create', 'resume'])
})

View File

@@ -288,7 +288,12 @@ export class Session {
*/
readonly header: SessionHeader
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId {
return this.header.id
}
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a

View File

@@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
@@ -59,7 +58,7 @@ async function setup(options: SetupOptions = {}) {
/** Mint one production-shaped agent scope that can register scoped tool policy. */
async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
const agent = { id: AgentId(name) } as Agent
const agent = { id: SessionId(name) } as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
{ inject: ['tools', 'systemPrompt'] }))

View File

@@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
@@ -20,7 +22,7 @@ async function mount(): Promise<Context> {
/** Mint a scope whose key doubles as a minimal Agent-like object. */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
const key = { id: name as AgentId } as Agent
const key = { id: name as SessionId } as Agent
let scope!: Scope
// The scoped context resolves services through the MINTING plugin's
// dependency chain — the minter must inject what scope holders will reach
@@ -62,7 +64,7 @@ describe('scoped tool registration', () => {
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
const other = { id: 'other' as SessionId } as Agent
ctx.tools.register(tool('shared'))
scope.ctx.tools.register(tool('mine'))
@@ -195,7 +197,7 @@ describe('scoped execution dispatch', () => {
it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
const other = { id: 'other' as SessionId } as Agent
ctx.tools.register(tool('t'))
const seen: (string | undefined)[] = []
@@ -213,7 +215,7 @@ describe('scoped execution dispatch', () => {
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
const other = { id: 'other' as SessionId } as Agent
let bodyCalls = 0
ctx.tools.register({
...tool('t'),
@@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => {
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
const driftAgent = { id: 'drift' as SessionId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken

View File

@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts'
@@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
ctx = await fsHarness(workdir, SYSTEM)
// agentLoop.create prepares a session with no cwd, so the provider default
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' })
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
@@ -65,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
try {
ctx = await fsHarness(configDir, SYSTEM)
const handle = await ctx.agents.create({
agentId: AgentId('fs-e2e-cwd'),
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
meta: { cwd: sessionDir },
agentOptions: { model: 'deepseek-v4-flash' },

View File

@@ -24,8 +24,8 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de
- **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it.
- **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking.
- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on.
- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state.
- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on.
- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap<Agent, Chain>` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener.
- **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost.
## Reminder delivery

View File

@@ -22,7 +22,7 @@
* exclude: [todo_write] # tool-name patterns transparent to the chain
* ```
*
* Chain state is keyed per {@link AgentId} — the tool registry is a
* Chain state is keyed by the live agent object — the tool registry is a
* context-level singleton whose waterfalls interleave every agent's calls, so
* a shared counter would let one agent's repetition trip another's reminder.
* State is in-memory only: a session resumed from persistence starts with a
@@ -37,7 +37,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -202,9 +202,7 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`)
}
// TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that
// removes the disposal-only status listener and cannot collide on id reuse.
const chains = new Map<AgentId, Chain>()
const chains = new WeakMap<Agent, Chain>()
/** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
function tracked(toolName: string): boolean {
@@ -227,9 +225,9 @@ export function apply(ctx: Context, config: Config): void {
if (!tracked(exec.name)) return undefined
const canonical = canonicalize(exec.arguments)
const key = JSON.stringify([exec.name, canonical])
const chain = chains.get(exec.agent.id)
const chain = chains.get(exec.agent)
const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1
chains.set(exec.agent.id, { key, count })
chains.set(exec.agent, { key, count })
if (!thresholdSet.has(count)) return undefined
const text = count === thresholds[0]
? GENTLE_REMINDER
@@ -259,12 +257,7 @@ export function apply(ctx: Context, config: Config): void {
// loop. Pure reset hook: always delegates (attaching nothing, vetoing
// nothing).
ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise<PromptDecision> => {
chains.delete(agent.id)
chains.delete(agent)
return next()
})
// Drop state when an agent goes away, bounding the map over harness lifetime.
ctx.on('agent/status', (agent, status) => {
if (status === 'disposed') chains.delete(agent.id)
})
}

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
@@ -57,7 +58,7 @@ describe('threshold escalation', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -78,7 +79,7 @@ describe('threshold escalation', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -100,7 +101,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -124,7 +125,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -142,7 +143,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -163,7 +164,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -179,7 +180,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -195,7 +196,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -215,8 +216,8 @@ describe('chain semantics', () => {
toolCallResponse('b3', 'probe', { q: 1 }),
textResponse('done'),
]))
const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' })
const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' })
const agentA = ctx.agentLoop.create(SessionId('a'), { model: 'mock-a' })
const agentB = ctx.agentLoop.create(SessionId('b'), { model: 'mock-b' })
agentA.send([{ type: 'text', text: 'go' }])
agentB.send([{ type: 'text', text: 'go' }])
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
@@ -235,7 +236,7 @@ describe('chain semantics', () => {
textResponse('turn two done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'again' }])
@@ -256,14 +257,14 @@ describe('chain semantics', () => {
// (the loop.spec pattern): a child plugin fiber owns `first`.
let first!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' })
first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first)
await fiber.dispose()
await first.done
const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' })
const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' })
second.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, second)
@@ -279,7 +280,7 @@ describe('chain semantics', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -295,7 +296,7 @@ describe('chain semantics', () => {
toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
textResponse('done'),
]))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -317,7 +318,7 @@ describe('fold onto the downstream decision', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -348,7 +349,7 @@ describe('fold onto the downstream decision', () => {
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)

View File

@@ -5,10 +5,11 @@ import { join } from 'node:path'
import { Context, type Fiber } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -95,7 +96,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
@@ -118,7 +119,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -143,7 +144,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'use danger' }])
await waitForIdle(ctx, agent)
@@ -166,7 +167,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'use safe' }])
await waitForIdle(ctx, agent)
@@ -188,7 +189,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -209,7 +210,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -233,7 +234,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -257,7 +258,7 @@ describe('hooks-claude bridge — SessionStart', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// session-start fires async (detached .then → agent.inject); wait for the
// injected context/message to actually land before sending, rather than a
// fixed sleep that flakes under load.
@@ -294,8 +295,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
// Drive the observe-only lifecycle events directly (no real child needed — the
// bridge just listens). No child agent is registered, so SubagentStart's
// child lookup yields undefined and it simply runs the hook.
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
// Both hooks run async (detached .then); poll for their marker files rather
// than a fixed sleep that flakes under load.
@@ -330,7 +331,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await hooks.dispose()
@@ -359,7 +360,7 @@ describe('hooks-claude bridge — load resilience', () => {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The turn ran normally — no hooks, no crash.
@@ -385,7 +386,7 @@ describe('hooks-claude bridge — load resilience', () => {
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone

View File

@@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -72,7 +73,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
ctx.logger.warn = warn as never
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true) // substituted command ran
@@ -88,7 +89,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war
ctx.logger.warn = warn as never
let sawArgs: unknown
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
@@ -104,7 +105,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', ()
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ran')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The prompt proceeded unchanged; no context/message injected.
@@ -134,7 +135,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', ()
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -159,7 +160,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', ()
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -175,7 +176,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
@@ -192,7 +193,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// A second model request ran → the empty-reason block forced continuation.
@@ -208,9 +209,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const ctx = await harness(path, new MockAdapter([]))
// Register a fake child agent under the id the event carries.
const injected: string[] = []
const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') })
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') })
await waitFor(() => injected.includes('child guidance'))
expect(injected).toContain('child guidance')
})
@@ -224,9 +225,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
const warn = vi.fn(); ctx.logger.warn = warn as never
const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') })
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') })
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
@@ -240,7 +241,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -254,7 +255,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -270,7 +271,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' })
ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' })
await waitFor(() => existsSync(marker))
expect(existsSync(marker)).toBe(true)
})
@@ -283,7 +284,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('no')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
@@ -298,7 +299,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// ask (no reason) → degrades to deny with the registry's generic message.
@@ -313,7 +314,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -342,7 +343,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
// the protocol lib's reference default, not a config knob).
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
@@ -357,7 +358,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(true)
@@ -372,7 +373,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -393,7 +394,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -410,7 +411,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -430,7 +431,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
@@ -448,7 +449,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter) // NB: no projectDir
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
@@ -466,7 +467,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(path, adapter)
// A later listener that blocks every prompt (registered AFTER the bridge).
const { AgentId: AId } = await import('@deepseek-ai/dsh-agent')
const { SessionId: AId } = await import('@deepseek-ai/dsh-session')
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
@@ -492,7 +493,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
@@ -514,7 +515,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -533,7 +534,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
@@ -557,7 +558,7 @@ describe('hooks-claude coverage — executor reject + no-open-turn', () => {
const bash = ctx.bash
bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -573,7 +574,7 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Make inject throw, forcing the SessionStart .catch path.
const original = agent.inject.bind(agent)
let threw = false
@@ -613,7 +614,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
@@ -649,7 +650,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
// Register a live child on its own session cwd; emit subagent/end with its id.
const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
await waitFor(() => existsSync(marker))
@@ -670,7 +671,7 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () =
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
@@ -691,7 +692,7 @@ describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
// Send immediately — do NOT wait for the session-start inject.
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)

View File

@@ -5,10 +5,11 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -82,7 +83,7 @@ describe('hooks-codex bridge', () => {
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'run ls' }])
await waitForIdle(ctx, agent)
@@ -107,7 +108,7 @@ describe('hooks-codex bridge', () => {
// Step 1 has no tool calls → would stop; the Stop hook forces step 2.
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -124,7 +125,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('fine')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// Ran normally; the unknown event was dropped at parse.
@@ -135,7 +136,7 @@ describe('hooks-codex bridge', () => {
const dir = configDir() // no hooks.json written
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
@@ -161,7 +162,7 @@ describe('hooks-codex bridge', () => {
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
@@ -190,7 +191,7 @@ describe('hooks-codex bridge', () => {
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // fires agent/session-start
ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // fires agent/session-start
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await fiber.dispose()

View File

@@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -52,7 +53,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const adapter = new MockAdapter([textResponse('no')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
const te = events(agent).findLast(e => e.type === 'turn/end')
@@ -64,7 +65,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
})
@@ -78,7 +79,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
@@ -96,7 +97,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
expect(req).toContain('from-bridge')
@@ -111,7 +112,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
@@ -125,7 +126,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -138,7 +139,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -151,7 +152,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
@@ -164,7 +165,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
})
@@ -176,7 +177,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
})
@@ -187,7 +188,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
@@ -200,7 +201,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
@@ -223,7 +224,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
@@ -246,7 +247,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
@@ -259,7 +260,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -273,7 +274,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
@@ -285,7 +286,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.inject = (() => { throw new Error('inject boom') })
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
@@ -298,7 +299,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -311,7 +312,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
@@ -327,7 +328,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
@@ -340,7 +341,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
@@ -352,7 +353,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
@@ -369,7 +370,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
expect(payload.tool_input.command).toBe('')
@@ -405,7 +406,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
@@ -419,7 +420,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
@@ -432,7 +433,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
})
@@ -448,7 +449,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
expect(events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
@@ -462,7 +463,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
@@ -473,7 +474,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -487,7 +488,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
})
@@ -502,7 +503,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
expect(payload.tool_name).toBe('shell')
@@ -518,7 +519,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
@@ -530,7 +531,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
@@ -553,7 +554,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(existsSync(marker)).toBe(true)

View File

@@ -37,8 +37,8 @@ import {
type SessionNotification,
type StopReason,
} from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
@@ -190,7 +190,7 @@ function toError(value: unknown): Error {
* @returns the ready run handle for the child subprocess.
*/
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
const id = AgentId(randomUUID())
const id = SessionId(randomUUID())
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -37,7 +38,7 @@ async function setup(script: Script) {
await ctx.plugin(Spawn, { providerName: 'spawn' })
await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent }
}

View File

@@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -43,7 +44,7 @@ async function setup(script: Script) {
await ctx.plugin(SubagentService)
await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent }
}

View File

@@ -9,7 +9,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
@@ -104,7 +104,7 @@ export async function startInProcessRun(
throw new SubagentDepthError(childDepth, request.maxDepth)
}
const childId = AgentId(randomUUID())
const childId = SessionId(randomUUID())
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
const parentModel = parent.options.model
@@ -127,8 +127,7 @@ export async function startInProcessRun(
const flags = { cancelled: false }
const handle = await parent.ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
sessionId: childId,
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
@@ -68,7 +69,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent, adapter, disposeProvider }
}
@@ -333,7 +334,7 @@ describe('in-process structured output', () => {
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
}))).rejects.toThrow(/unsupported output schema/)
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -24,7 +25,7 @@ async function setup(script: Script) {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent }
}

View File

@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { spawnHarness, waitForIdle } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* With-key smoke for the in-process spawn backend: a REAL parent agent delegates
@@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
it('a parent delegates to a child that writes a file on disk', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-'))
ctx = await spawnHarness(workdir)
const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { model: 'deepseek-v4-flash' })
const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { model: 'deepseek-v4-flash' })
parent.send([{ type: 'text', text:
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '

View File

@@ -5,7 +5,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
@@ -36,7 +37,7 @@ async function setup(script: Script) {
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent, adapter }
}
@@ -237,7 +238,6 @@ describe('dsh-subagent-spawn', () => {
const { ctx } = await setup([textResponse('x')])
// A parent WITH a cwd (config agents have none, so create one explicitly).
const parentHandle = await ctx.agents.create({
agentId: AgentId('cwd-parent'),
sessionId: SessionId('cwd-parent-session'),
meta: { cwd: '/tmp/parent-workspace' },
agentOptions: { model: 'mock' },
@@ -254,7 +254,6 @@ describe('dsh-subagent-spawn', () => {
const { ctx } = await setup([textResponse('explicit model child')])
// A parent with NO model (its own turns would need one supplied per-request).
const parentHandle = await ctx.agents.create({
agentId: AgentId('modelless-parent'),
sessionId: SessionId('modelless-parent-session'),
agentOptions: {},
})
@@ -318,7 +317,7 @@ describe('dsh-subagent-spawn', () => {
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
const controller = new AbortController()
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'q' }],
@@ -349,7 +348,7 @@ describe('dsh-subagent-spawn', () => {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
const parentEffects = parent.ctx.fiber.getEffects().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
@@ -442,7 +441,6 @@ describe('dsh-subagent-spawn', () => {
const { ctx } = await setup([])
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
const parentHandle = await ctx.agents.create({
agentId: AgentId('doomed-parent'),
sessionId: SessionId('doomed-s'),
agentOptions: { model: 'mock' },
})
@@ -465,7 +463,6 @@ describe('dsh-subagent-spawn', () => {
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
const { ctx } = await setup([])
const parentHandle = await ctx.agents.create({
agentId: AgentId('setup-race-parent'),
sessionId: SessionId('setup-race-parent-session'),
agentOptions: { model: 'mock' },
})

View File

@@ -18,7 +18,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
SubagentCapabilities,
SubagentProvider,
@@ -96,7 +97,7 @@ export interface SubagentRunInfo {
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
readonly id: AgentId
readonly id: SessionId
}
/** Observe-only outcome detail for a settled subagent run. */
@@ -104,7 +105,7 @@ export interface SubagentRunEndInfo {
/** The provider that ran it. */
readonly provider: string
/** The child agent's id. */
readonly id: AgentId
readonly id: SessionId
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */

View File

@@ -6,8 +6,9 @@
* @module @deepseek-ai/dsh-subagent/types
*/
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/**
@@ -147,7 +148,7 @@ export interface SubagentResult {
*/
export interface SubagentRun {
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
readonly id: AgentId
readonly id: SessionId
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
@@ -12,9 +13,10 @@ import SubagentService, {
type SubagentRun,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId } from '@deepseek-ai/dsh-session'
function fakeParent(id = 'parent-1'): Agent {
return { id: AgentId(id) } as unknown as Agent
return { id: SessionId(id) } as unknown as Agent
}
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
@@ -45,7 +47,7 @@ class StubProvider implements SubagentProvider {
async start(request: SubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
return {
id: AgentId(`child:${this.name}:${request.parent.id}`),
id: SessionId(`child:${this.name}:${request.parent.id}`),
result: Promise.resolve(this.outcome),
async dispose() {},
}
@@ -141,7 +143,7 @@ describe('SubagentService', () => {
const starting = subagents.start('deferred', baseRequest({ parent }))
await Promise.resolve()
expect(events).toEqual([])
ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} })
ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} })
const run = await starting
expect(events).toEqual(['start'])
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
@@ -190,7 +192,7 @@ describe('SubagentService', () => {
capabilities: NO_CAPS,
inheritsParentContext: false,
async start() {
return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} }
return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} }
},
})
const failedRun = await subagents.start('infra', baseRequest())

View File

@@ -4,10 +4,12 @@ import Loader from '@cordisjs/plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as mock from '@deepseek-ai/dsh-subagent-mock'
import * as tool from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
@@ -20,7 +22,7 @@ import * as tool from '../src/index.ts'
/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
function fakeAgent(id = 'parent-1'): Agent {
return { id: AgentId(id) } as unknown as Agent
return { id: SessionId(id) } as unknown as Agent
}
async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
@@ -114,7 +116,7 @@ describe('dsh-tool-subagent', () => {
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('weird-child'),
id: SessionId('weird-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
dispose: async () => {},
}),
@@ -141,7 +143,7 @@ describe('dsh-tool-subagent', () => {
start: async (request) => {
seen = request
return {
id: AgentId('capture-child'),
id: SessionId('capture-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
@@ -170,7 +172,7 @@ describe('dsh-tool-subagent', () => {
start: async (request) => {
seen = request
return {
id: AgentId('bare-child'),
id: SessionId('bare-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
@@ -297,7 +299,7 @@ describe('dsh-tool-subagent', () => {
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('spy-child'),
id: SessionId('spy-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => void disposed(),
}),
@@ -319,7 +321,7 @@ describe('dsh-tool-subagent', () => {
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('spy-child'),
id: SessionId('spy-child'),
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
dispose: async () => void disposed(),
}),
@@ -350,7 +352,7 @@ describe('dsh-tool-subagent', () => {
resolveResult({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('spy-child'),
id: SessionId('spy-child'),
result,
dispose: async () => {},
}
@@ -442,7 +444,7 @@ describe('dsh-tool-subagent', () => {
start: async (request) => {
seen = request
return {
id: AgentId('capture2-child'),
id: SessionId('capture2-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
@@ -499,7 +501,7 @@ describe('dsh-tool-subagent', () => {
start: async (request) => {
seen = request
return {
id: AgentId('capture3-child'),
id: SessionId('capture3-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
@@ -528,7 +530,7 @@ describe('dsh-tool-subagent', () => {
start: async (request) => {
seen = request
return {
id: AgentId('capture4-child'),
id: SessionId('capture4-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}

View File

@@ -13,8 +13,8 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {
SubagentCapabilities,
SubagentProvider,
@@ -65,7 +65,7 @@ class MockSubagentProvider implements SubagentProvider {
// A deterministic child id derived from the parent — no clock/random (both
// banned in deterministic paths here, and unnecessary for a scripted run).
const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`)
const id = SessionId(`mock-subagent:${this.name}:${request.parent.id}`)
const resultFor = (): SubagentResult => ({
output,

View File

@@ -1,13 +1,15 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import * as mock from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** A minimal parent — the mock provider only reads `parent.id`. */
function fakeParent(id = 'parent-1'): Agent {
return { id: AgentId(id) } as unknown as Agent
return { id: SessionId(id) } as unknown as Agent
}
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
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, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -64,7 +65,7 @@ describe('todo_write tool through the agent loop', () => {
textResponse('Plan recorded.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-todo'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-todo'), { model: 'mock' })
agent.send([{ type: 'text', text: 'plan a two-step task' }])
await waitForIdle(ctx, agent)
@@ -92,7 +93,7 @@ describe('todo_write tool through the agent loop', () => {
textResponse('Done planning.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { model: 'mock' })
agent.send([{ type: 'text', text: 'plan then update' }])
await waitForIdle(ctx, agent)

View File

@@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { TodoItem } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import * as tool from '../src/index.ts'
/**
@@ -20,7 +21,7 @@ import * as tool from '../src/index.ts'
/** A parent Agent backed by a real Session — the tool reads `agent.session`. */
function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
const session = new Session(SessionId(id))
return { id: AgentId(id), session } as unknown as Agent & { session: Session }
return { id: SessionId(id), session } as unknown as Agent & { session: Session }
}
async function setup(): Promise<Context> {

View File

@@ -72,7 +72,6 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
@@ -686,7 +685,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
validateMcpServers(params)
const sessionId = SessionId(randomUUID())
const handle = await agents.create({
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
@@ -757,7 +755,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
}
const handle = await agents.resume({
agentId: AgentId(sessionId),
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
})

View File

@@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId } from '@deepseek-ai/dsh-llm'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* The bridge's `approval/request` answerer: an ask for an agent the bridge
@@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => {
): Promise<{ agent: Agent; request: ApprovalRequest }> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.get(AgentId(sessionId))
const agent = h.ctx.agents.get(SessionId(sessionId))
if (agent === undefined) throw new Error('newSession created no agent')
// In production an ask always fires mid-turn (tool execution); open one so
// request()'s turn-enclosure precondition holds for the direct drive below.

View File

@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* End-to-end bridge specs over an in-memory transport: a real
@@ -98,7 +98,7 @@ describe('acp bridge', () => {
required: [],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
@@ -127,7 +127,7 @@ describe('acp bridge', () => {
required: ['custom'],
},
})
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('apollo')
})
@@ -136,7 +136,7 @@ describe('acp bridge', () => {
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
const result = await harness.ctx.userInteraction.ask({
agent,
@@ -167,7 +167,7 @@ describe('acp bridge', () => {
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
@@ -184,7 +184,7 @@ describe('acp bridge', () => {
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await expect(harness.ctx.userInteraction.ask({
agent,
@@ -201,7 +201,7 @@ describe('acp bridge', () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
@@ -225,7 +225,7 @@ describe('acp bridge', () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
const alreadyAborted = new AbortController()
alreadyAborted.abort()
@@ -265,8 +265,8 @@ describe('acp bridge', () => {
expect(b.sessionId).toBeTruthy()
expect(a.sessionId).not.toBe(b.sessionId)
// Both agents are live and independently registered.
expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined()
expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined()
expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined()
expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined()
})
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
@@ -281,7 +281,7 @@ describe('acp bridge', () => {
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
expect(res.sessionId).toBeTruthy()
// The session header records that cwd, so its bash tools run there.
expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp')
expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp')
})
it('rejects non-empty additionalDirectories', async () => {
@@ -321,7 +321,7 @@ describe('acp bridge', () => {
],
})
expect(result.stopReason).toBe('end_turn')
const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message')
const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message')
expect(JSON.stringify(user)).toContain('resource_link')
})

View File

@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse } from './harness.ts'
describe('acp bridge — disposal & HMR safety', () => {
@@ -17,7 +16,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// Start a prompt that hangs in the model stream.
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -62,10 +61,10 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined()
await harness.acpFiber.dispose() // tear down ONLY the bridge
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
await harness.dispose()
})
@@ -92,7 +91,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// Start a prompt that hangs in the model stream. The prompt RPC will never
// return (its transport is severed), so do not await it.
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
@@ -115,7 +114,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
await harness.dispose()
})
@@ -128,7 +127,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -145,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const session = harness.ctx.agents.get(AgentId(sessionId))!.session
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
await harness.ctx.fiber.dispose()
const before = harness.updates.length
@@ -169,12 +168,12 @@ describe('acp bridge — disposal & HMR safety', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length
const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length
expect(liveEvents).toBeGreaterThan(0)
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
// Re-load the session from disk: every live event (incl. the closing
// turn/end) was flushed before the session was detached.
@@ -201,7 +200,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -211,7 +210,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
// teardown (the composite effect runs its disposer chain as a unit).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
// self-report) — NOT a crash-recovery `interrupted` substitute.
@@ -230,21 +229,21 @@ describe('acp bridge — disposal & HMR safety', () => {
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
})
const handleB = await harness.ctx.agents.create({
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
})
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
await handleA.dispose()
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
// B is wholly unaffected.
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
await harness.dispose()
@@ -262,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'go' }])
await handle.agent.whenIdle()
@@ -270,7 +269,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// Dispose: the throwing listener must NOT break the chain before detach.
await handle.dispose()
expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
await harness.dispose()
})
@@ -283,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
// disposed — its exit runs a final session/flush we can gate to hold the
@@ -313,7 +312,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
await harness.dispose()
})

View File

@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
@@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => {
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
foreign.send([{ type: 'text', text: 'hi' }])
await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10))

View File

@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** Concatenate the text of all agent_message_chunk updates. */
@@ -194,7 +193,7 @@ describe('acp bridge — session/load replay', () => {
release() // resume() finishes AFTER teardown
expect(await loadResult).toBe('rejected')
// No live agent was installed for the closed connection.
expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
@@ -215,11 +214,11 @@ describe('acp bridge — session/load replay', () => {
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/cwd mismatch/)
expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined()
expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined()
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
expect(res).toBeDefined()
expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
})
it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
@@ -254,7 +253,7 @@ describe('acp bridge — session/load replay', () => {
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
// the id is not wedged: a later attempt hits the same clean rejection, not a
// duplicate-registration error.
expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined()
expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined()
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/no absolute persisted cwd/)
})

View File

@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** Text of the agent_message_chunk updates scoped to one session id. */
function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
@@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const agentA = harness.ctx.agents.get(AgentId(a))!
const agentB = harness.ctx.agents.get(AgentId(b))!
const agentA = harness.ctx.agents.get(SessionId(a))!
const agentB = harness.ctx.agents.get(SessionId(b))!
// Wait deterministically for BOTH agents to enter `running` (not a fixed
// sleep — agent startup latency is unbounded on a loaded worker).

View File

@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
errorResponse,
@@ -13,6 +12,7 @@ import {
toolCallResponse,
type BridgeHarness,
} from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** Boilerplate: initialize + create one session, returning its id. */
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
@@ -279,7 +279,7 @@ describe('acp bridge — turn outcomes', () => {
// OWN turn with the real model answer.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// On the queued prompt, synchronously inject a one-shot context turn (idle
// inject writes turn/start{injection} → context/message → turn/end). Fire
// once so it lands between install and the prompt turn.
@@ -335,7 +335,7 @@ describe('acp bridge — turn outcomes', () => {
await harness.client.cancel({ sessionId })
const res = await promptDone
expect(res.stopReason).toBe('cancelled')
const agent = harness.ctx.agents.get(AgentId(sessionId))!
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await agent.whenIdle()
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
// no second turn was batched or leaked. (A best-effort abort that left queued

View File

@@ -16,7 +16,6 @@ import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -255,7 +254,6 @@ export class HarnessSdkServer {
private async createSession(sessionId: string): Promise<SessionRecord> {
const handle = await this.ctx.agents.create({
agentId: AgentId(sessionId),
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { model: this.model },

View File

@@ -5,7 +5,8 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -135,7 +136,6 @@ describe('HarnessSdkServer', () => {
expect(llmServer.requests).toHaveLength(2)
const orphanHandle = await ctx.agents.create({
agentId: AgentId('orphan-agent'),
sessionId: SessionId('orphan-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'dsagent-model' },
@@ -170,8 +170,8 @@ describe('HarnessSdkServer', () => {
} as unknown as Agent
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn(async (options: { agentId: AgentId }) =>
String(options.agentId) === 'main' ? mainHandle : otherHandle)
const create = vi.fn(async (options: { sessionId: SessionId }) =>
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
@@ -263,20 +263,18 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, transport)
const parentHandle = await ctx.agents.create({
agentId: AgentId('parent-agent'),
sessionId: SessionId('main'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
const handle = await ctx.agents.create({
agentId: AgentId('child-agent'),
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { model: 'deepseek' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
id: AgentId('child-agent'),
id: SessionId('child-session'),
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
})
@@ -285,7 +283,7 @@ describe('HarnessSdkServer', () => {
method: 'subagent.finished',
params: {
provider: 'spawn',
agentId: 'child-agent',
agentId: 'child-session',
parentSessionId: 'main',
childSessionId: 'child-session',
status: 'ok',
@@ -311,19 +309,16 @@ describe('HarnessSdkServer', () => {
let failedHandle: AgentHandle | undefined
try {
parentHandle = await ctx.agents.create({
agentId: AgentId('fallback-parent-agent'),
sessionId: SessionId('fallback-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
handle = await ctx.agents.create({
agentId: AgentId('fallback-child-agent'),
sessionId: SessionId('fallback-child-session'),
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
agentOptions: { model: 'deepseek' },
})
failedHandle = await ctx.agents.create({
agentId: AgentId('failed-child-agent'),
sessionId: SessionId('failed-child-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
@@ -333,18 +328,18 @@ describe('HarnessSdkServer', () => {
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('fallback-child-agent'),
id: SessionId('fallback-child-session'),
stopReason: 'max-tokens',
lastAssistantMessage: [],
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('failed-child-agent'),
id: SessionId('failed-child-session'),
stopReason: 'error',
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('missing-child-agent'),
id: SessionId('missing-child-agent'),
stopReason: 'error',
})
@@ -352,7 +347,7 @@ describe('HarnessSdkServer', () => {
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'fallback-child-agent',
agentId: 'fallback-child-session',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'error',
@@ -364,7 +359,7 @@ describe('HarnessSdkServer', () => {
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'failed-child-agent',
agentId: 'failed-child-session',
childSessionId: 'failed-child-session',
status: 'error',
stopReason: 'error',

View File

@@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| Plugin | Why it is here |
|---|---|
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating one agent under the `main` config label from this app's `model`, with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
| `stdio-chat` (in-package module) | the readline UI, holding the app-owned agent object directly and rendering it as `main` |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
@@ -25,14 +25,14 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| Key | Default | Routed to |
|---|---|---|
| `model` | (required) | the pre-created `main` agent's model |
| `model` | (required) | the pre-created agent's model |
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header.
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id. Resumed sessions register under the exact `resumeSessionId` and keep the cwd stored in the persisted session header.
## The bin

View File

@@ -3,11 +3,11 @@
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
* persistence, and one pre-created agent the UI drives under its `main` label.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* console (stdout is just the terminal) and always pre-creates one agent the
* readline UI labels `main`. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
@@ -41,7 +41,6 @@
import type { Context } from 'cordis'
import ConsoleExporter from '@cordisjs/plugin-logger-console'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
@@ -54,7 +53,7 @@ export const name = 'stdio-agent'
/**
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
* it. `model`/`resumeSessionId` configure the pre-created agent (through
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
@@ -63,7 +62,7 @@ export const name = 'stdio-agent'
* `welcome` is the UI banner.
*/
export interface Config {
/** Model name for the `main` agent (must have a registered adapter). */
/** Model name for the pre-created agent (must have a registered adapter). */
model: string
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
@@ -78,7 +77,7 @@ export interface Config {
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
skills?: agentCore.SkillConfig
/**
* If set, the `main` agent RESUMES this persisted session id instead of
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/
@@ -103,9 +102,9 @@ export const Config: z<Config> = z.object({
/**
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating the `main` agent from this
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
* (infra), then the agent-core bundle pre-creating one agent from this app's
* `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is
* a leaf concern (see the module doc), so it is not mounted here.
*/
export function apply(ctx: Context, config: Config): void {
@@ -115,7 +114,7 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
agents: [{
id: AgentId('main'),
id: 'main',
model: config.model,
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
@@ -125,5 +124,5 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' })
}

View File

@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
// TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
// precreated `main` agent; remove configurability and its config-only test.
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
})
/**
@@ -98,23 +93,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Loader validation, so it must be self-contained rather than trusting the
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
const welcome = config.welcome ?? 'ready.'
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string. Seed from the
// registry's current agents first: an agent registered before this plugin
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
// reload of just this fiber) already fired its `agent/created`, so the live
// listener alone would miss it and its turns would fall back to the raw
// session id.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// This app owns exactly one pre-created agent. Hold the live object directly:
// its per-run id is intentionally fresh, while `main` remains only the
// terminal's fixed display label.
let target: Agent | undefined = ctx.agents.list()[0]
ctx.on('agent/created', (agent) => { target ??= agent })
ctx.on('agent/disposed', (agent) => {
if (target === agent) target = undefined
})
// Transcript rendering off the durable `session/event` feed — the assistant
// token stream, turn/step boundaries, tool activity, and todos all come from
@@ -136,7 +124,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
output.write(chunk.text)
}
} else if (event.type === 'turn/start') {
const label = labelBySession.get(session.header.id) ?? session.header.id
const label = target?.session === session ? 'main' : session.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
@@ -187,7 +175,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
const agent = target
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit. The handle is tracked so the
@@ -201,7 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (subject !== target) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
@@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
const agent = target
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
ctx.logger.error('ui-stdio: main agent is not running')
return
}
submittedWork = true

View File

@@ -16,7 +16,7 @@ function fakeContext(): Context {
return {
on: vi.fn(() => vi.fn()),
effect: vi.fn((callback: () => () => void) => callback()),
// The UI seeds its label map from the registry at install; this suite only
// The UI seeds its target object from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },

View File

@@ -4,7 +4,8 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
@@ -82,9 +83,12 @@ describe('dsh-stdio-agent app', () => {
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
// The pre-created `main` agent the UI drives.
const agent = ctx.get('agents')?.get(AgentId('main'))
// The sole pre-created agent the UI drives. `main` is its stable config
// label; each fresh process mints a durable combined agent/session id.
const agent = ctx.get('agents')?.list()[0]
expect(agent).toBeDefined()
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
expect(agent?.session.header.cwd).toBe(process.cwd())
await ctx.fiber.dispose()
})
@@ -99,7 +103,7 @@ describe('dsh-stdio-agent app', () => {
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(1)
await ctx.fiber.dispose()
})
@@ -116,7 +120,7 @@ describe('dsh-stdio-agent app', () => {
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
// A resume id defers agent creation until persistence loads; with no backing
// session the resume is contained + logged, so no `main` agent registers —
// session the resume is contained + logged, so no agent registers —
// the branch that maps resumeSessionId through is what this covers.
const ctx = await mount({
model: 'mock',
@@ -125,7 +129,7 @@ describe('dsh-stdio-agent app', () => {
resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(),
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
expect(ctx.get('agents')?.list()).toEqual([])
await ctx.fiber.dispose()
})

View File

@@ -57,17 +57,16 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
status,
sent,
steered,
// A minimal session stub: the UI reads only `session.header.id` (to map the
// session back to its agent id for the turn-boundary label).
session: { header: { id: `${id}-session` } },
// A minimal session stub with the agent's shared durable identity.
session: { id, header: { id } },
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
function makeSession(agentId: string): Session {
return { header: { id: `${agentId}-session` } } as Session
function makeSession(id: string): Session {
return { id, header: { id } } as Session
}
/** An `assistant/chunk` session event carrying one raw stream chunk. */
@@ -75,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent {
return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
}
const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
const CONFIG: Config = { welcome: 'hi there' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
@@ -99,12 +98,11 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toBe('hi there\n> ')
})
it('falls back to default welcome/agent when called with empty config', async () => {
it('falls back to the default welcome when called with empty config', async () => {
// createStdioChat is exported and may be driven directly (bypassing the
// Loader's schemastery validation), so it must default welcome/agent itself.
// Loader's schemastery validation), so it must default the welcome itself.
const { out } = await setup({})
expect(out.text()).toBe('ready.\n> ')
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
@@ -156,9 +154,9 @@ describe('createStdioChat rendering', () => {
it('renders turn/start and turn/end markers from the session feed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
// agent/created populates the session-id → agent-id label map.
// agent/created supplies the app-owned target object.
ctx.emit('agent/created', agent)
const session = makeSession('main')
const session = agent.session
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
} as SessionEvent)
@@ -169,21 +167,20 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\n> ')
})
it('falls back to the session id as the label when no agent is mapped', async () => {
it('uses the session id as the label for a non-target session', async () => {
const { ctx, out } = await setup()
// No agent/created emitted, so the label map is empty the header id shows.
// No target exists, so the event's durable identity is the label.
ctx.emit('session/event', makeSession('orphan'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[orphan-session turn 1] ')
expect(out.text()).toContain('[orphan turn 1] ')
})
it('seeds labels for agents already registered before the UI installs', async () => {
it('uses an agent already registered before the UI installs as its target', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just
// this fiber) fired its `agent/created` before the UI's listener existed, so
// the live listener alone would miss it. Seeding from `ctx.agents.list()` at
// install time is what keeps its turn header showing `[main turn N]` instead
// of the raw session id.
// install time preserves the terminal's fixed `[main turn N]` label.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
@@ -193,7 +190,7 @@ describe('createStdioChat rendering', () => {
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 5] ')
@@ -209,17 +206,28 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('drops the label mapping on agent/disposed', async () => {
it('drops the target object on agent/disposed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/created', agent)
ctx.emit('agent/disposed', agent)
// After disposal the map no longer resolves the agent id — fall back to the
// session header id.
ctx.emit('session/event', makeSession('main'), {
// After disposal the event belongs to a non-target session, so its durable
// identity is rendered directly.
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main-session turn 1] ')
expect(out.text()).toContain('[main turn 1] ')
})
it('keeps the target when a different agent is disposed', async () => {
const { ctx, out } = await setup()
const target = makeAgent('target')
ctx.emit('agent/created', target)
ctx.emit('agent/disposed', makeAgent('other'))
ctx.emit('session/event', target.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 1] ')
})
it('renders tool/call and tool/result session events', async () => {
@@ -666,11 +674,11 @@ describe('createStdioChat input', () => {
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('nobody home')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running')
})
it('drives the agent named in config, not a hardcoded id', async () => {
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
it('drives the app-owned agent without a duplicate id config', async () => {
const { ctx, input } = await setup({ welcome: 'w' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
input.feed('hi')

View File

@@ -4,7 +4,7 @@ The `Branded<B>` nominal-typing primitive — a tiny, **type-only** package (no
## What `Branded` is
A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime.
A brand makes structurally-identical strings non-interchangeable at the type level: a `SessionId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime.
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -21,6 +21,6 @@ Construction goes through the per-id factory in the OWNING package (a plain cast
## Policy: brand ids that cross package boundaries
A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), the shared agent/session `SessionId` in `dsh-session`, and `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.

View File

@@ -4,15 +4,15 @@
* cross-boundary id.
*
* A brand makes structurally-identical strings non-interchangeable at the type
* level: an `AgentId` cannot be passed where a `CallId` is expected, even
* level: a `SessionId` cannot be passed where a `CallId` is expected, even
* though both are plain strings at runtime. Construction goes through a per-id
* factory in the OWNING package (a plain cast inside — zero runtime cost);
* comparison, logging, and serialization all behave as ordinary strings.
*
* Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call
* correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent,
* `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package
* boundaries and could plausibly be confused; not every string needs a brand.
* correlation), and `SessionId` in dsh-session; `BashTaskId`/`OwnerToken` live
* in dsh-bash. Branding is for ids that cross package boundaries and could
* plausibly be confused; not every string needs a brand.
* This package owns ONLY the primitive — no concrete id, no runtime code beyond
* the (erased) type — so the brand vocabulary stays dependency-free and a
* package can brand its ids without depending on an unrelated capability

View File

@@ -4,7 +4,6 @@ import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
@@ -12,6 +11,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as toolWorkflow from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
class StubEngine extends WorkflowService {
@@ -51,7 +51,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
await ctx.plugin(StubEngine)
await ctx.plugin(toolWorkflow, config ?? {})
const engine = ctx.workflows as StubEngine
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
return { ctx, engine, parent }
}
@@ -237,7 +237,7 @@ describe('dsh-tool-workflow', () => {
await ctx.plugin(SubagentService)
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
const controller = new AbortController()
const pending = execute(ctx, {
script: 'await new Promise(() => {})\nreturn 1',

View File

@@ -38,8 +38,8 @@
*/
import * as vm from 'node:vm'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
@@ -319,7 +319,7 @@ export class WorkflowExecution {
await run.dispose()
throw this.cancelledError()
}
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) }
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) }
this.observer.agentStart(info)
try {
let result

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -37,7 +38,7 @@ async function setup(script: Script) {
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(WorkerWorkflowEngine, {})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' })
return { ctx, parent, adapter }
}
@@ -73,7 +74,7 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
// Both children were disposed to quiescence — no live child agents remain.
expect(childIds.length).toBe(2)
for (const childId of childIds) {
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
expect(ctx.agents.get(SessionId(childId))).toBeUndefined()
}
})

View File

@@ -6,10 +6,10 @@
import { expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
// A fresh thread compiles the source runtime. Leave contention headroom on
// shared CI runners without weakening any engine-level timeout assertion.
@@ -19,7 +19,7 @@ it('runs the default config through the source worker', async () => {
const ctx = new Context()
const subagents = await ctx.plugin(SubagentService)
const engine = await ctx.plugin(WorkerWorkflowEngine, {})
const parent = { id: AgentId('workflow-compat-parent'), options: {} } as unknown as Agent
const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent
try {
const run = ctx.workflows.start({
script: 'return 6 * 7',

View File

@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -62,7 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
ctx = await harness()
const parentHandle = await ctx.agents.create({
agentId: AgentId('wf-worker-e2e-parent'),
sessionId: 'wf-worker-e2e-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
})
@@ -95,7 +95,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key
expect(childIds.length).toBe(2)
// The children were disposed to quiescence after collection.
for (const childId of childIds) {
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
expect(ctx.agents.get(SessionId(childId))).toBeUndefined()
}
await parentHandle.dispose()
}, 240_000)

View File

@@ -3,17 +3,17 @@ import { fileURLToPath } from 'node:url'
import type { Worker } from 'node:worker_threads'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
import * as workerEngineModule from '../src/index.ts'
import WorkerWorkflowEngine, { HostToWorkerType, WorkerToHostType, type Config } from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** A minimal parent stand-in: the engine only threads it through to the provider. */
function fakeParent(): Agent {
return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent
return { id: SessionId('workflow-parent'), options: {} } as unknown as Agent
}
// Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on
@@ -117,7 +117,7 @@ class StubProvider implements SubagentProvider {
}
if (request.signal.aborted) throw new Error('child start aborted before publication')
return {
id: AgentId(`stub-child-${index}`),
id: SessionId(`stub-child-${index}`),
result: terminal.promise,
dispose: () => {
controlled.disposeCalls += 1
@@ -366,7 +366,7 @@ describe('dsh-workflow-workerthread', () => {
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('reject-child'),
id: SessionId('reject-child'),
result: Promise.reject(new Error('backend exploded')),
dispose: () => Promise.resolve(),
}),
@@ -400,7 +400,7 @@ describe('dsh-workflow-workerthread', () => {
stopReason: 'completed',
} as unknown as SubagentResult
const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
id: AgentId('raw-invalid-child'),
id: SessionId('raw-invalid-child'),
result: Promise.resolve(invalid),
dispose: () => Promise.resolve(),
})
@@ -423,7 +423,7 @@ describe('dsh-workflow-workerthread', () => {
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('bad-dispose-child'),
id: SessionId('bad-dispose-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
cancel: () => { /* settled already */ },
dispose: () => { throw new Error('dispose exploded') },
@@ -444,7 +444,7 @@ describe('dsh-workflow-workerthread', () => {
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: async () => ({
id: AgentId('trap-child'),
id: SessionId('trap-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
cancel: () => { /* settled already */ },
// The rejection VALUE's own coercion throws: a warn built with bare
@@ -771,7 +771,7 @@ describe('dsh-workflow-workerthread', () => {
settle({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('signal-only-child'),
id: SessionId('signal-only-child'),
result,
dispose: () => Promise.resolve(),
}
@@ -1092,7 +1092,7 @@ describe('dsh-workflow-workerthread', () => {
expect(request.signal.reason).toBe('workflow worker gone')
ready.resolve({
id: AgentId('late-ready-child'),
id: SessionId('late-ready-child'),
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
dispose: () => {
disposeCalls += 1
@@ -1128,7 +1128,7 @@ describe('dsh-workflow-workerthread', () => {
handle.cancel('reentered from worker-death signal cleanup')
}, { once: true })
return {
id: AgentId('doomed-child'),
id: SessionId('doomed-child'),
result: new Promise(() => { /* never settles; the reap is the teardown */ }),
dispose: () => Promise.reject(new Error('dispose exploded during reap')),
}

View File

@@ -7,7 +7,8 @@
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Identifies one workflow run. */
export type WorkflowRunId = Branded<'WorkflowRunId'>
@@ -145,7 +146,7 @@ export interface WorkflowAgentInfo {
/** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */
phase?: string
/** The child agent's id on the subagent seam. */
childId: AgentId
childId: SessionId
}
/** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */