refactor: unify agent and session identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' }])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 () => {},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user