diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 586d7b4e7b..2f8ae62c9a 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -172,7 +172,7 @@ describe('ApiSession Agent lookup and recovery', () => { it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => { const { ctx } = await harness() const live = agent(ctx, header('live')) - ctx.agents.register(live) + await ctx.agents.register(live) providePersistence(ctx, { list: () => Promise.resolve([]), inspect: vi.fn(), @@ -193,7 +193,7 @@ describe('ApiSession Agent lookup and recovery', () => { }) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { - ordinary.ctx.agents.register(winner) + await ordinary.ctx.agents.register(winner) throw new Error('raced publication') }) await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner }) @@ -326,7 +326,7 @@ describe('ApiSession create or adoption', () => { const ordinaryMeta = header('create-race', cwd) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { - ordinary.ctx.agents.register(winner) + await ordinary.ctx.agents.register(winner) throw new Error('raced creation') }) await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index d7559bc047..79838ef7f0 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -82,7 +82,7 @@ async function commandHarness( followup: vi.fn(), cancel, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 4c88ba21df..46b3dff031 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -59,7 +59,7 @@ async function harness(withJobs: boolean): Promise<{ runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(agent) + await ctx.agents.register(agent) const control = new SessionControlController(ctx) await new Promise(resolve => setTimeout(resolve, 0)) return { ctx, session, agent, control } diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 8f303fa6d0..43d1394851 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -74,7 +74,7 @@ describe('SessionController facade', () => { status: 'idle', ctx, } as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) const resolveUploadAgent = (id: SessionId): Promise => { if (uploadResolver === undefined) throw new Error('file upload resolver was not registered') return uploadResolver(id) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index eaca25062f..8d88ea5ded 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -202,7 +202,7 @@ describe('attached updatedAt tracks human prompts', () => { ], meta: { cwd: '/proj', createdAt: 500 }, }) - ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) + await ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) @@ -330,7 +330,7 @@ describe('Remote Agent and Session lookup policy', () => { meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, }) const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent - ctx.agents.register(liveAgent) + await ctx.agents.register(liveAgent) const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') @@ -478,7 +478,7 @@ describe('subagent ownership fence', () => { await ctx.plugin(AgentRegistry) const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent - ctx.agents.register(parent) + await ctx.agents.register(parent) const originSession = ctx.sessions.create(sid('session-origin-child'), { meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, @@ -493,7 +493,7 @@ describe('subagent ownership fence', () => { cancel, updateInbox, } as unknown as Agent - ctx.agents.register(originChild) + await ctx.agents.register(originChild) const startingSession = ctx.sessions.create(sid('session-starting-child'), { meta: { cwd: '/proj', parentSession: parent.id }, @@ -549,7 +549,7 @@ describe('subagent ownership fence', () => { const agent = { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.prompt(promptRequest({ @@ -570,7 +570,7 @@ describe('subagent ownership fence', () => { const agent = { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', @@ -781,7 +781,7 @@ describe('sessions.prompt synchronous rejection', () => { const session = ctx.sessions.create(sid('session-throwing')) // A live structural stub whose delivery verbs throw synchronously, the // shape a disposed loop presents at this gateway boundary. - ctx.agents.register({ + await ctx.agents.register({ id: session.id, session, inbox: inboxFor(), @@ -820,7 +820,7 @@ describe('sessions.prompt synchronous rejection', () => { // while the generic cold resume is in flight, so the resume collides. const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent - ctx.agents.register(parent) + await ctx.agents.register(parent) const childSession = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, }) @@ -828,7 +828,7 @@ describe('sessions.prompt synchronous rejection', () => { vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { // The parent's `enter()` wins the identity between the pre-resume // re-check and publication; the generic resume then collides. - ctx.agents.register(child) + await ctx.agents.register(child) throw new Error('session id already published') }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 1661f505bc..6c4f189c52 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -40,7 +40,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise const agentCtx = ownerCtx Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) await options.setup?.(agentCtx, agent) - ctx.agents.register(agent) + await ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } }, resume: () => Promise.reject(new Error('fork test sources are live')), @@ -51,13 +51,13 @@ async function composed(workspaces: readonly Workspace[] = []): Promise /** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */ type Tail = 'none' | 'open' | 'aborted' -function liveAgent( +async function liveAgent( ctx: Context, id: string, turns: number, tail: Tail = 'none', lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, -): Session { +): Promise { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } }) for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn }) @@ -78,7 +78,7 @@ function liveAgent( reason: { kind: 'aborted', reason: { kind: 'user' } }, }) } - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return session } @@ -90,7 +90,7 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { describe('sessions.fork', () => { it('cuts at the anchored completed turn and records lineage and cwd', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-source', 2) + const source = await liveAgent(ctx, 'session-source', 2) const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 })) expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return @@ -112,13 +112,13 @@ describe('sessions.fork', () => { attachSession, } as unknown as Workspace const ctx = await composed([workspace]) - const owner = liveAgent(ctx, 'session-owner', 1) + const owner = await liveAgent(ctx, 'session-owner', 1) accounted.push(owner.id) - const child = liveAgent(ctx, 'session-child', 1, 'none', { + const child = await liveAgent(ctx, 'session-child', 1, 'none', { parentSession: owner.id, origin: 'subagent', }) - const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', { + const grandchild = await liveAgent(ctx, 'session-grandchild', 1, 'none', { parentSession: child.id, origin: 'subagent', }) @@ -204,7 +204,7 @@ describe('sessions.fork', () => { it('uses the last completed turn only for omitted and past-end anchors', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-tail', 2, 'open') + const source = await liveAgent(ctx, 'session-tail', 2, 'open') const proxy = remote(ctx) const expectedTypes = [ 'turn/start', 'user/message', 'turn/end', @@ -240,7 +240,7 @@ describe('sessions.fork', () => { it('cuts through an aborted turn: stopped is closed, not open', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') + const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted') // What a stopped message's fork button anchors on: the frozen node sits // one event before its turn/end, floored client-side to that event's seq. const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 @@ -257,7 +257,7 @@ describe('sessions.fork', () => { it('rejects an in-log anchor whose turn is still open', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-open', 1, 'open') + const source = await liveAgent(ctx, 'session-open', 1, 'open') const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ @@ -270,7 +270,7 @@ describe('sessions.fork', () => { it('installs the latest logged model selection before the child can run', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-routed', 1) + const source = await liveAgent(ctx, 'session-routed', 1) source.append('request/header', { header: { config: { diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index cdfbbda39b..1388aafe5e 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -26,8 +26,8 @@ async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; att return { ctx, remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), - attach: (session) => { - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + attach: async (session) => { + await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, } } diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index fe3ef628b5..bb9e77b4e2 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -132,7 +132,7 @@ async function harness(logged?: { ctx, inbox: { nextTurn: [], nextStep: [] }, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) return { ctx, agent, sessionId: session.id } } diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index d917ccb777..b8976c0c1a 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -68,7 +68,7 @@ async function harness(presets?: readonly string[]) { const agent = stubAgent(session) ;(agent as { ctx?: Context }).ctx = ctx await options.setup?.(ctx, agent) - const unregister = ctx.agents.register(agent) + const unregister = await ctx.agents.register(agent) return { agent, dispose: () => { unregister(); return Promise.resolve() } } }, async resume() { diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index 60987608b9..0e4754d8b0 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -36,13 +36,13 @@ async function composed(withTitles = true): Promise { // forwarded seed/meta (the store validates the balanced prefix) and // registers an idle agent stub over it. ctx.agents.setFactory({ - createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise => { + createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { const session = ctx.sessions.create(options.sessionId, { ...options.seed === undefined ? {} : { seed: [...options.seed] }, ...options.meta === undefined ? {} : { meta: options.meta }, }) const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) return Promise.resolve({ agent, dispose: () => Promise.resolve() }) }, resume: () => Promise.reject(new Error('resume must not run: every source is attached')), @@ -51,7 +51,7 @@ async function composed(withTitles = true): Promise { } /** Register one live agent whose log holds `turns` completed turns. */ -function liveAgent(ctx: Context, id: string, turns: number): Session { +async function liveAgent(ctx: Context, id: string, turns: number): Promise { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn }) @@ -61,7 +61,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return session } @@ -70,7 +70,7 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSele describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-rename', 1) + const source = await liveAgent(ctx, 'session-rename', 1) const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) expect(renamed.ok).toBe(true) @@ -83,7 +83,7 @@ describe('sessions.rename', () => { it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-rename-bad', 1) + const source = await liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) @@ -104,8 +104,8 @@ describe('sessions.rename', () => { // title service's liveness check throws a plain Error, which must not // read as the user's fault. const foreign = await composed(false) - const stale = liveAgent(foreign, 'session-rename-stale', 1) - ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) + const stale = await liveAgent(foreign, 'session-rename-stale', 1) + await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) @@ -114,7 +114,7 @@ describe('sessions.rename', () => { it('answers internal when the composition mounts no session-title service', async () => { const ctx = await composed(false) - const source = liveAgent(ctx, 'session-no-titles', 1) + const source = await liveAgent(ctx, 'session-no-titles', 1) const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6f583fad95..956a2ab18b 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -114,7 +114,7 @@ async function bench(script: Script): Promise<{ } await options.setup?.(ownerCtx, agent) script.before?.(session) - ctx.agents.register(agent) + await ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } }, resume: () => Promise.reject(new Error('not used')), diff --git a/packages/context/file-reference-local/src/index.ts b/packages/context/file-reference-local/src/index.ts index 34e2b75674..0eca490ef8 100644 --- a/packages/context/file-reference-local/src/index.ts +++ b/packages/context/file-reference-local/src/index.ts @@ -63,8 +63,9 @@ export class LocalFileReferenceService extends FileReferenceService { } validateConfig(this.config) - const installPrompt = (agent: Agent): void => { - if (this.promptFibers.has(agent)) return + const installPrompt = (agent: Agent): ReturnType => { + const existing = this.promptFibers.get(agent) + if (existing !== undefined) return existing const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => { scope.systemPrompt.section({ name: 'context:file-reference', @@ -73,6 +74,7 @@ export class LocalFileReferenceService extends FileReferenceService { }) }) this.promptFibers.set(agent, fiber) + return fiber } const disposePrompt = (agent: Agent): void => { const fiber = this.promptFibers.get(agent) @@ -87,7 +89,7 @@ export class LocalFileReferenceService extends FileReferenceService { }) } for (const agent of ctx.agents.list()) installPrompt(agent) - ctx.on('agent/created', ({ agent }) => { installPrompt(agent) }) + ctx.on('agent/created', async ({ agent }) => { await installPrompt(agent) }) ctx.on('agent/disposed', ({ agent }) => { this.searches.get(agent)?.dispose() this.searches.delete(agent) diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts index 72af52d8b2..e9a6353a52 100644 --- a/packages/context/file-reference-local/tests/service.spec.ts +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -51,10 +51,29 @@ async function stubAgent( cancel() {}, whenIdle: () => Promise.resolve(), } as unknown as Agent - return { agent, dispose: ctx.agents.register(agent) } + return { agent, dispose: await ctx.agents.register(agent) } } describe('LocalFileReferenceService', () => { + it('defers guidance until optional prompt and tool services become available', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileReferenceService) + try { + const { agent } = await stubAgent(ctx, 'deferred-prompt') + expect(ctx.agents.get(agent.id)).toBe(agent) + await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + await ctx.plugin(ToolRegistry) + ctx.tools.register(defineContentToolFixture({ + name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]), + })) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT) + } finally { + await ctx.fiber.dispose() + } + }) + it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => { const ctx = await harness() const { agent } = await stubAgent(ctx) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index aa967f87c0..3081fded59 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,7 +10,6 @@ import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' -import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory, @@ -215,7 +214,7 @@ interface PreparedAgent { /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */ signal: AbortSignal /** Enter registries, announce, notify session-start, and start the machine. */ - publish(source: SessionStartSource): AgentHandle + publish(source: SessionStartSource): Promise /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */ dispose(): Promise } @@ -569,6 +568,7 @@ export class AgentLoop extends Service implements AgentFactory { let detachSession: (() => void) | undefined let detachAgent: (() => void) | undefined let disposing: Promise | undefined + let publication: ReturnType> | undefined const machineReady = Promise.withResolvers() // Reverse teardown, memoized so every racing owner awaits one quiescence: // stop the machine, drain and close the session's write path, leave the @@ -582,6 +582,8 @@ export class AgentLoop extends Service implements AgentFactory { // disposal rejects with what failed so every racing owner observes it. const failures: unknown[] = [] try { + // Creation listeners retain the session and scope through their awaits. + if (publication !== undefined) await publication.promise // Disposal IS a disposed-cause cancel followed by quiescence. New work // sent after this point is the sender's bug — the registries are about // to drop the agent, so nothing should still hold it. @@ -659,22 +661,23 @@ export class AgentLoop extends Service implements AgentFactory { return { agent, signal: abort.signal, - publish: (source) => { - assertLive() - detachSession = agent.ctx.sessions.enter(session) - // The mounted backend routes announced live events into the active - // write handle by session id; the loop only owns the handle itself. - detachAgent = loopCtx.agents.enter(agent, parentAgent) - agent.ctx.sessions.announce(session) - assertLive() - loopCtx.agents.announce(agent) - assertLive() - // A synchronous announce/session-start listener may have started - // teardown; the machine is already live (delivery works from the - // session-start extension point), so only the liveness recheck is owed. - emitAgentEvent(loopCtx, agent, 'agent/session-start', { source }) - assertLive() - return { agent, dispose } + publish: async (source) => { + publication = Promise.withResolvers() + try { + assertLive() + detachSession = agent.ctx.sessions.enter(session) + // The mounted backend routes announced live events into the active + // write handle by session id; the loop only owns the handle itself. + detachAgent = loopCtx.agents.enter(agent, parentAgent) + agent.ctx.sessions.announce(session) + assertLive() + await loopCtx.agents.announce(agent, source, abort.signal) + assertLive() + return { agent, dispose } + } finally { + publication.resolve() + publication = undefined + } }, dispose, } @@ -706,14 +709,10 @@ export class AgentLoop extends Service implements AgentFactory { await stored?.handle.close().catch(() => {}) throw error } - try { + return (await this.initializeAgent(prepared, async () => { await this.appendUnstoredSuffix(stored, preparation.session) - return prepared.publish('startup').agent - } catch (error: unknown) { - // Rollback swallows a disposal rejection: the setup failure is primary. - void prepared.dispose().catch(() => {}) - throw error - } + return await prepared.publish('startup') + })).agent } /** @@ -821,11 +820,24 @@ export class AgentLoop extends Service implements AgentFactory { await stored?.handle.close().catch(() => {}) throw error } - try { + return await this.initializeAgent(prepared, async () => { const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id) setupCommit?.commit() await this.appendUnstoredSuffix(stored, session) - return prepared.publish(source) + return await prepared.publish(source) + }) + } + + private async initializeAgent(prepared: PreparedAgent, initialize: () => Promise): Promise { + try { + return await prepared.agent.runMaintenance(async () => { + try { + return await initialize() + } catch (error: unknown) { + prepared.agent.cancel({ kind: 'disposed' }) + throw error + } + }) } catch (error: unknown) { // Rollback swallows a disposal rejection (a failing final handle close): // the setup failure is the primary error the caller must see. diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs new file mode 100644 index 0000000000..3ddc41b52b --- /dev/null +++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs @@ -0,0 +1,24 @@ +/** Awaited creation context shared by TypeScript and Python SDK snapshots. */ +import { randomUUID } from 'node:crypto' +import { setImmediate } from 'node:timers/promises' + +export const name = 'serial-created-fixture' +export const inject = ['agents'] + +/** Install ordered creation listeners whose context must precede the first turn. */ +export function apply(ctx) { + const ready = new WeakSet() + ctx.on('agent/created', async ({ agent }) => { + await setImmediate() + agent.inject({ + id: randomUUID(), + role: 'user', + content: [{ type: 'text', text: 'Serial agent creation completed.' }], + source: { kind: 'plugin', plugin: name }, + }) + ready.add(agent) + }) + ctx.on('agent/created', ({ agent }) => { + if (!ready.has(agent)) throw new Error('creation listeners ran out of order') + }) +} diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 81bfc64abd..95f3ebb76c 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -61,6 +61,97 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { + it('awaits serial creation before session-start and caller readiness', async () => { + const ctx = await harness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const order: string[] = [] + ctx.on('agent/created', async () => { + order.push('created:start') + entered.resolve() + await release.promise + order.push('created:end') + }) + ctx.on('agent/created', () => { order.push('created:second') }) + ctx.on('agent/session-start', () => { order.push('session-start') }) + const creating = ctx.agentLoop.create(SessionId('serial-start')).then(agent => { + order.push('ready') + return agent + }) + try { + await entered.promise + expect(order).toEqual(['created:start']) + release.resolve() + await creating + expect(order).toEqual(['created:start', 'created:end', 'created:second', 'session-start', 'ready']) + } finally { + release.resolve() + await ctx.fiber.dispose() + } + }) + + it('retains both entries and scoped effects until an asynchronous creation listener settles on owner unload', async () => { + const ctx = await harness() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) + const order: string[] = [] + const id = SessionId('serial-owner-dispose') + ctx.on('agent/created', async ({ agent }) => { + entered.resolve() + await release.promise + expect(ctx.agents.get(id)).toBe(agent) + expect(ctx.sessions.get(id)).toBe(agent.session) + expect(order).toEqual([]) + order.push('created:end') + }) + ctx.on('agent/session-start', () => { order.push('session-start') }) + ctx.on('agent/disposed', () => { order.push('agent-disposed') }) + ctx.on('session/disposed', () => { order.push('session-disposed') }) + const creating = ownerCtx.agents.create({ + sessionId: id, + setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) }, + }) + const rejected = expect(creating).rejects.toThrow('owner disposed during setup') + try { + await entered.promise + const disposing = owner.dispose() + expect(ctx.agents.get(id)).toBeDefined() + release.resolve() + await rejected + await disposing + expect(order).toEqual(['created:end', 'scope-disposed', 'agent-disposed', 'session-disposed']) + expect(ctx.agents.get(id)).toBeUndefined() + expect(ctx.sessions.get(id)).toBeUndefined() + } finally { + release.resolve() + await ctx.fiber.dispose() + } + }) + + it('rolls back both registries and the scope when asynchronous creation fails', async () => { + const ctx = await harness() + const reason = new Error('async creation veto') + const order: string[] = [] + ctx.on('agent/created', async () => { + await Promise.resolve() + throw reason + }) + ctx.on('agent/created', () => { order.push('later-listener') }) + ctx.on('agent/session-start', () => { order.push('session-start') }) + ctx.on('agent/disposed', () => { order.push('agent-disposed') }) + ctx.on('session/disposed', () => { order.push('session-disposed') }) + await expect(ctx.agents.create({ + sessionId: SessionId('async-veto'), + setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) }, + })).rejects.toBe(reason) + expect(order).toEqual(['scope-disposed', 'agent-disposed', 'session-disposed']) + expect(ctx.agents.list()).toEqual([]) + expect(ctx.sessions.list()).toEqual([]) + await ctx.fiber.dispose() + }) + it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') diff --git a/packages/core/agent-loop/tests/serial-listener-review.spec.ts b/packages/core/agent-loop/tests/serial-listener-review.spec.ts new file mode 100644 index 0000000000..472b7584e6 --- /dev/null +++ b/packages/core/agent-loop/tests/serial-listener-review.spec.ts @@ -0,0 +1,119 @@ +import { setImmediate } from 'node:timers/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import LocalFileReference from '@deepseek-ai/dsh-file-reference-local' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import * as SubagentTool from '@deepseek-ai/dsh-tool-subagent' +import Selection from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' +import * as Schedule from '@deepseek-ai/dsh-schedule' + +const roots: string[] = [] +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) + +async function core(persistenceRoot?: string) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + if (persistenceRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) + await ctx.plugin(AgentLoop, { agents: [] }) + return ctx +} + +describe('serial creation listener integrations', () => { + it('rolls back creation when file-reference prompt installation fails', async () => { + const ctx = await core() + const errors: string[] = [] + ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error + await ctx.plugin(LocalFileReference) + try { + await expect(ctx.agents.create({ + sessionId: SessionId('review-file-reference'), + setup(agentCtx) { + agentCtx.systemPrompt.section({ name: 'context:file-reference', order: 0, text: 'occupied' }) + }, + })).rejects.toThrow('context:file-reference') + expect(errors.some(error => error.includes('context:file-reference'))).toBe(true) + expect(ctx.agents.list()).toEqual([]) + expect(ctx.sessions.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('rolls back creation when a shared preset tool installation fails', async () => { + const ctx = await core() + const errors: string[] = [] + ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error + await ctx.plugin(Selection) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + const preset = createScope(ctx, { preset: 'review' }) + await preset.ctx.plugin(SubagentTool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable' }) + try { + await expect(ctx.agents.create({ + sessionId: SessionId('review-tool'), + setup(agentCtx) { + const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + agentCtx.effect(() => () => { binding.dispose() }) + agentCtx.tools.register(defineContentToolFixture({ name: 'subagent', description: 'occupied', parameters: {}, execute: () => Promise.resolve([]) })) + }, + })).rejects.toThrow('subagent') + expect(errors.some(error => error.includes('subagent'))).toBe(true) + expect(ctx.agents.list()).toEqual([]) + expect(ctx.sessions.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('starts due Schedule work only after every creation listener finishes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-serial-schedule-')) + roots.push(root) + const ctx = await core(root) + await ctx.plugin(Schedule) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const turn = Promise.withResolvers() + let turns = 0 + let created = false + let sessionStarted = false + ctx.on('agent/created', async () => { entered.resolve(); await release.promise; created = true }) + ctx.on('agent/session-start', () => { sessionStarted = true }) + ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') { turns += 1; turn.resolve() } }) + const creating = ctx.agents.create({ + sessionId: SessionId('review-schedule'), + setup(_agentCtx, agent) { + agent.session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: Schedule.createAfterScheduleRecord(Schedule.ScheduleId('schedule-1'), 'due reminder', 1, Date.now() - 2_000), + }) + }, + }) + try { + await entered.promise + // A full event-loop turn lets the due runtime run if it starts during creation. + await setImmediate() + expect(turns).toBe(0) + expect(sessionStarted).toBe(false) + release.resolve() + await creating + await turn.promise + expect(created).toBe(true) + expect(sessionStarted).toBe(true) + } finally { + release.resolve() + await creating + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 21c95b5c60..ca6d5a3c3a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -13,7 +13,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Agent } from './types.ts' -import type { AgentOptions } from './runtime-types.ts' +import type { AgentOptions, SessionStartSource } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' @@ -413,16 +413,16 @@ export class AgentRegistry extends Service { } /** - * Register a live agent. Throws if an agent with the same id is already - * registered. Emits `agent/created` on registration and `agent/disposed` + * Register a live agent. Rejects if the id is already registered or a + * serial `agent/created` listener fails. Emits `agent/disposed` * when the calling fiber is disposed — both with the agent's scope carrier * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always * requires passing the carrier). The entry is a runtime root; factory-backed - * creation uses `options.parentAgent` for child ownership. Returns the disposer. + * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent. * @param agent - the already-constructed agent to record in the store. - * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * @returns the awaitable Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a * teardown ORDER — the agent factory's lifecycle chain — must yield THIS @@ -431,13 +431,11 @@ export class AgentRegistry extends Service { * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ - register(agent: Agent): () => void { - const dispose = this.ctx.effect(function* (this: AgentRegistry) { + register(agent: Agent, source: SessionStartSource = 'startup'): ReturnType { + return this.ctx.effect(async function* (this: AgentRegistry) { yield this.enter(agent, undefined) - this.announce(agent) + await this.announce(agent, source) }.bind(this), 'agents.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose } /** @@ -452,8 +450,8 @@ export class AgentRegistry extends Service { * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a - * synchronous `agent/created` listener, removal and disposal wait until - * that creation dispatch unwinds. + * `agent/created` listener, removal and disposal wait until the serial + * creation dispatch settles. */ enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id @@ -482,7 +480,7 @@ export class AgentRegistry extends Service { // live entry, and disposal must follow creation. A listener may own // the advanced detach capability, so make that ordering structural: // visibility and the paired disposal are deferred until announce()'s - // synchronous dispatch has unwound. + // serial dispatch has settled. if (entry.announcing) { entry.detachRequested = true return @@ -526,11 +524,12 @@ export class AgentRegistry extends Service { /** * Announce an agent previously inserted with {@link enter}. * @param agent - the live inserted agent to announce. + * @returns completion of the serial creation listeners; a listener failure rejects. * @throws if `agent` is not the exact live registry entry for its id, or its * creation announcement already began (including a reentrant call from a * creation listener). */ - announce(agent: Agent): void { + async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise { const entry = this.store.get(agent.id) if (entry === undefined || entry.agent !== agent) { throw new Error(`agent "${agent.id}" is not live in this registry`) @@ -542,17 +541,12 @@ export class AgentRegistry extends Service { // lifecycle edge; detach still pairs a partially delivered first edge. entry.announcing = true entry.announced = true - const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }] try { - for (const callback of this.ctx.events.dispatch('emit', args)) { - // A synchronous creation failure vetoes publication and rolls back. - // Returned-promise rejection happens after this synchronous boundary, so - // observe and report it instead of leaking an unhandled rejection. - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`) - }) - } + await this.ctx.serial(entry.carrier, 'agent/created', { + agent: entry.agent, + source, + ...signal === undefined ? {} : { signal }, + }) } finally { entry.announcing = false if (entry.detachRequested) this.detachEntered(entry) diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 31338e8d8d..38a642c65d 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -244,18 +244,18 @@ declare module './types.ts' { declare module '@deepseek-ai/cordis' { interface Events { - // ---- lifecycle (emit) ---- + // ---- lifecycle ---- /** * A fully configured agent and live session were published. Setup is * composition-only; `agent/session-start` is the first startup-driving extension point. - * Synchronous listener failure vetoes publication, while returned-promise - * rejection is reported. Detach requested during dispatch waits until every - * creation listener has observed the stable entry. + * Listeners run in order and are awaited before session-start. A throw or + * rejection vetoes publication and skips later listeners. Disposal waits + * for dispatch to settle; a listener must not await its own owner's disposal. * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit + * @mode serial */ - 'agent/created'(this: Scoped, payload: { agent: Agent }): void + 'agent/created'(this: Scoped, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise /** * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom @@ -302,19 +302,6 @@ declare module '@deepseek-ai/cordis' { * @mode emit */ 'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void - // ---- session lifecycle (emit) ---- - /** - * The session lifecycle began, once before the first turn. Use - * `agent.inject()` to seed model-facing context. This is a notification, not - * a veto; disposal requested by a lifecycle owner is rechecked before the - * driver starts. - * @param payload.agent - the agent whose session lifecycle began. - * @param payload.source - why the session started (fresh startup, resume, …). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void - // ---- the machine's extension points ---- /** * Reject a proposed step or replace the messages that enter it. Calling diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index ed652ef558..3ba62cdf02 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -45,7 +45,7 @@ describe('AgentRegistry', () => { await agentFiber await ctx.plugin(TypertRegistry) const agent = stubAgent('remote-agent') - const disposeAgent = ctx.agents.register(agent) + const disposeAgent = await ctx.agents.register(agent) const lookup = ctx.typert.lookups.get('agent') expect(lookup).toMatchObject({ @@ -73,11 +73,11 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const agent = stubAgent('a1') - const dispose = ctx.agents.register(agent) + const dispose = await ctx.agents.register(agent) expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) expect(ctx.agents.roots()).toEqual([agent]) - expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) + await expect(Promise.resolve(ctx.agents.register(stubAgent('a1')))).rejects.toThrow(/already registered/) dispose() expect(ctx.agents.get(agent.id)).toBeUndefined() @@ -100,9 +100,9 @@ describe('AgentRegistry', () => { const root = stubAgent('root') const child = stubAgent('child') const detachRoot = ctx.agents.enter(root, undefined) - ctx.agents.announce(root) + await ctx.agents.announce(root) const detachChild = ctx.agents.enter(child, root) - ctx.agents.announce(child) + await ctx.agents.announce(child) expect(ctx.agents.list()).toEqual([root, child]) expect(ctx.agents.roots()).toEqual([root]) @@ -123,35 +123,64 @@ describe('AgentRegistry', () => { ctx.on('agent/created', () => { throw new Error('creation veto') }) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) - expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') + await expect(Promise.resolve(ctx.agents.register(stubAgent('vetoed')))).rejects.toThrow('creation veto') expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) - it('contains asynchronous creation rejection and every disposal-listener failure', async () => { + it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const warnings: string[] = [] const heard: string[] = [] ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) + ctx.on('agent/created', () => Promise.reject(new Error('created async'))) + ctx.on('agent/created', () => { heard.push('unreachable') }) ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id)) - const dispose = ctx.agents.register(stubAgent('contained')) - await Promise.resolve() - dispose() + await expect(Promise.resolve(ctx.agents.register(stubAgent('contained')))).rejects.toThrow('created async') await Promise.resolve() expect(heard).toEqual(['contained']) + expect(ctx.agents.list()).toEqual([]) expect(warnings).toEqual([ - 'agent "contained": agent/created listener rejected: Error: created async', 'agent "contained": agent/disposed listener threw: Error: disposed sync', 'agent "contained": agent/disposed listener rejected: Error: disposed async', ]) }) + it('awaits each creation listener before the next listener and registration completion', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const order: string[] = [] + ctx.on('agent/created', async () => { + order.push('first:start') + entered.resolve() + await release.promise + order.push('first:end') + }) + ctx.on('agent/created', () => { order.push('second') }) + const agent = stubAgent('serial') + const registration = ctx.agents.register(agent) + const ready = Promise.resolve(registration).then(() => { order.push('ready') }) + try { + await entered.promise + expect(order).toEqual(['first:start']) + await expect(ctx.agents.announce(agent)).rejects.toThrow('already announced') + release.resolve() + await ready + expect(order).toEqual(['first:start', 'first:end', 'second', 'ready']) + } finally { + release.resolve() + await registration() + await ctx.fiber.dispose() + } + }) + it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -162,8 +191,8 @@ describe('AgentRegistry', () => { const first = stubAgent('split') const detachFirst = ctx.agents.enter(first, undefined) expect(lifecycle).toEqual([]) - ctx.agents.announce(first) - expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) + await ctx.agents.announce(first) + await expect(ctx.agents.announce(first)).rejects.toThrow(/already announced/) detachFirst() detachFirst() @@ -171,7 +200,7 @@ describe('AgentRegistry', () => { const detachReplacement = ctx.agents.enter(replacement, undefined) detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) - expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) + await expect(ctx.agents.announce(first)).rejects.toThrow(/not live/) detachReplacement() expect(lifecycle).toEqual(['created:split', 'disposed:split']) }) @@ -189,7 +218,7 @@ describe('AgentRegistry', () => { ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) ctx.on('agent/disposed', () => void order.push('disposed')) const detach = ctx.agents.enter(agent, undefined) - ctx.agents.announce(agent) + await ctx.agents.announce(agent) expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 8a35f59873..d0e4a9cb97 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -18,7 +18,6 @@ const scopedSubjectResolvers: Readonly (args[0] as Record)['agent'], 'agent/request': args => (args[0] as Record)['agent'], 'agent/request-error': args => (args[0] as Record)['agent'], - 'agent/session-start': args => (args[0] as Record)['agent'], 'agent/status': args => (args[0] as Record)['agent'], 'agent/turn-stopping': args => (args[0] as Record)['agent'], 'approval/request': args => (args[0] as Record)['agent'], diff --git a/packages/e2b/e2b/tests/fixtures/composition/bin.ts b/packages/e2b/e2b/tests/fixtures/composition/bin.ts index d762a2dda7..2252a705d3 100644 --- a/packages/e2b/e2b/tests/fixtures/composition/bin.ts +++ b/packages/e2b/e2b/tests/fixtures/composition/bin.ts @@ -42,7 +42,7 @@ const owner: Agent = { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } -const unregisterOwner = ctx.agents.register(owner) +const unregisterOwner = await ctx.agents.register(owner) let terminalId: Awaited>['sessionId'] | undefined try { const sandbox = await ctx.e2b.getSandbox() diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 6fa0500eeb..8c677f9ab1 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -108,7 +108,7 @@ export class TeamService extends TypertRemoteService { this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) ctx.on('session/event', (session, event) => { this.mailbox.observeSessionEvent(session, event) }) - ctx.on('agent/session-start', ({ agent }) => { this.scheduleRecovery(agent) }) + ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) }) ctx.on('agent/status', ({ agent }) => { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index e59020bfc6..79192dfff8 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -55,7 +55,7 @@ async function harness(): Promise { await ctx.plugin(SessionStore) const plugin = await ctx.plugin(commandFeedback) const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) - ctx.agents.register(agent) + await ctx.agents.register(agent) return { ctx, agent, session, plugin } } diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 6ab826e516..273657e579 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -26,7 +26,7 @@ afterEach(async () => { }) /** Register one idle agent over a store-owned session, as an app's spine does. */ -function agent(ctx: Context): Agent { +async function agent(ctx: Context): Promise { const scope = ctx.plugin(() => {}) const id = SessionId('feedback-loader-agent') const session = ctx.sessions.create(id) @@ -46,7 +46,7 @@ function agent(ctx: Context): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -83,7 +83,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await context.loader.await() - const owner = agent(context) + const owner = await agent(context) const signal = new AbortController().signal // Discoverable through the composed registry, as a UI adapter finds it. diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e50f87f3a3..1f893f37fe 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -27,7 +27,7 @@ afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) -function agent(ctx: Context, cwd: string): Agent { +async function agent(ctx: Context, cwd: string): Promise { const id = SessionId(`str-replace-editor-owner-${callNumber}`) const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { @@ -48,7 +48,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -88,7 +88,7 @@ async function setup( } if (options.fsPolicy === true) await ctx.plugin(FsPolicy) const fiber = await ctx.plugin(ToolStrReplaceEditor, config) - return { ctx, root, fiber, owner: agent(ctx, root) } + return { ctx, root, fiber, owner: await agent(ctx, root) } } describe('tool-str-replace-editor', () => { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1c1761cf74..5e683246a1 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -52,7 +52,7 @@ async function harness(): Promise { await ctx.plugin(GoalService) const plugin = await ctx.plugin(commandGoal) const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`) - ctx.agents.register(agent) + await ctx.agents.register(agent) return { ctx, agent, session, plugin } } diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index 0074c5178d..cac74fe66c 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/packages/goal/goal-round-driver/src/index.ts @@ -248,9 +248,8 @@ export function apply(ctx: Context): void { disarm(state) }) - ctx.on('agent/created', ({ agent }) => { stateFor(agent) }) ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) }) - ctx.on('agent/session-start', ({ agent }) => { + ctx.on('agent/created', ({ agent }) => { const state = stateFor(agent) state.attempt = undefined state.competingQueued = false diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 8fa9f6d39b..48a02e485c 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -252,7 +252,7 @@ export class GoalService extends TypertRemoteService { this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } - ctx.on('agent/session-start', ({ agent }) => { + ctx.on('agent/created', ({ agent }) => { this.setActivation(agent.session, 'disarmed') }) ctx.sessionProjections.register(goalProjectionDefinition) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index ac32cd3765..9209937c74 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -87,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx) - ctx.agents.register(stub.agent) + await ctx.agents.register(stub.agent) return { ctx, ...stub } } @@ -165,7 +165,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) const stub = stubAgent('goal-direct-construction') - ctx.agents.register(stub.agent) + await ctx.agents.register(stub.agent) const goals = new GoalService(ctx) await new Promise(resolve => setImmediate(resolve)) expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ @@ -193,7 +193,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) - ctx.agents.register(resumed.agent) + await ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ id: created.id, roundsStarted: 2, @@ -208,12 +208,12 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx) - ctx.agents.register(parent.agent) + await ctx.agents.register(parent.agent) const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 }) appendRound(parent.session, goal, 1) const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx) - ctx.agents.register(child.agent) + await ctx.agents.register(child.agent) expect(ctx.goals.get(child.agent)).toMatchObject({ id: goal.id, objective: goal.objective, @@ -264,7 +264,7 @@ describe('GoalService creation and replay', () => { const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') - ctx.agents.register(stub.agent) + await ctx.agents.register(stub.agent) const goal = ctx.goals.create(stub.agent, { objective: 'survive service reload' }) await fiber.dispose() @@ -472,7 +472,7 @@ describe('GoalService mutations', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx) - ctx.agents.register(stub.agent) + await ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent) @@ -492,7 +492,7 @@ describe('GoalService mutations', () => { await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } - ctx.agents.register(stub.agent) + await ctx.agents.register(stub.agent) expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({ objective: 'persist directly', diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 45cd1ddb37..98bc360f2e 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -29,7 +29,7 @@ interface Bench { } /** Register a minimal registry-compatible live agent over a store session. */ -function liveAgent(ctx: Context, session: Session): Agent { +async function liveAgent(ctx: Context, session: Session): Promise { const status: AgentStatus = 'idle' const agent: Agent = { id: session.id, @@ -46,7 +46,7 @@ function liveAgent(ctx: Context, session: Session): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - ctx.agents.register(agent) + await ctx.agents.register(agent) return agent } @@ -57,7 +57,7 @@ async function harness(withGoal: boolean): Promise { await ctx.plugin(SessionProjectionRegistry) if (withGoal) await ctx.plugin(GoalService) const session = ctx.sessions.create() - const agent = liveAgent(ctx, session) + const agent = await liveAgent(ctx, session) return { ctx, session, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index b4b4d54808..6db0e2e688 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -101,7 +101,7 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) - ctx.agents.register(root.agent) + await ctx.agents.register(root.agent) return { ctx, fiber, root } } @@ -256,7 +256,7 @@ describe('goal tool execution authority', () => { const child = stubAgent('goal-tool-child') ctx.agents.enter(child.agent, root.agent) - ctx.agents.announce(child.agent) + await ctx.agents.announce(child.agent) openTurn(child, { kind: 'user' }) const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') @@ -290,7 +290,7 @@ describe('goal tool execution authority', () => { isSeeded: true, }, SessionLogOffset(root.session.seq)) const fork = stubAgent(forkId, forkSession) - ctx.agents.register(fork.agent) + await ctx.agents.register(fork.agent) expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' }) openTurn(fork, { kind: 'user' }, '继续这个目标') @@ -345,7 +345,7 @@ describe('goal tool execution authority', () => { it('rejects an initiator different from exec.agent', async () => { const { ctx, root } = await harness() const other = stubAgent('goal-tool-other') - ctx.agents.register(other.agent) + await ctx.agents.register(other.agent) openTurn(other, { kind: 'user' }) const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index 9c46a465b7..469c1c0dc4 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -199,18 +199,18 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } - // SessionStart injects context when its detached hook resolves; a slow hook - // may miss the first request. - // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', ({ agent, source }) => { - detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) + ctx.on('agent/created', async ({ agent, source, signal }) => { + const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal]) + const run = runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: ownerSignal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context) }) .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`) - })) + }) + detached.track(run) + await run }) // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 8627a970cd..47af7bcf33 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -238,7 +238,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }, session: { id: SessionId('child-x'), header: { id: 'child-x' } }, } as unknown as Parameters[0] - ctx.agents.register(child) + await ctx.agents.register(child) ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') @@ -254,7 +254,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] - ctx.agents.register(child) + await ctx.agents.register(child) ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 6c42a063e5..037252dcde 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -181,16 +181,16 @@ export function apply(ctx: Context, config: Config): void { return [ours, ...theirs ?? []] } - // SessionStart injects plain stdout when its detached hook resolves; a slow - // hook may miss the first request. - // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', ({ agent, source }) => { - detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) + ctx.on('agent/created', async ({ agent, source, signal }) => { + const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal]) + const run = runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: ownerSignal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context) }) - .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })) + .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }) + detached.track(run) + await run /* jscpd:ignore-end */ }) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 14f4ecd847..76e9aa614a 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -135,8 +135,8 @@ describe('LocalJobRegistry.start', () => { const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) - ctx.agents.register(served) - ctx.agents.register(unserved) + await ctx.agents.register(served) + await ctx.agents.register(unserved) expect(() => ctx.jobs.start(producer({ owner: served }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer({ owner: unserved }).spec)) @@ -154,7 +154,7 @@ describe('LocalJobRegistry.start', () => { // holds them and every owner's read includes it. await attachControllerIn(ctx) const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) - ctx.agents.register(scoped) + await ctx.agents.register(scoped) expect(() => ctx.jobs.start(producer({ owner: scoped }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer().spec)).not.toThrow() @@ -239,17 +239,17 @@ describe('LocalJobRegistry.start', () => { it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { const ctx = await harness({ maxConcurrentJobsPerOwner: 1 }) const oldOwner = stubAgent(ctx, 'shared-session') - const detachOld = ctx.agents.register(oldOwner) + const detachOld = await ctx.agents.register(oldOwner) const oldTask = producer({ owner: oldOwner }) ctx.jobs.start(oldTask.spec) const otherOwner = stubAgent(ctx, 'other-session') - ctx.agents.register(otherOwner) + await ctx.agents.register(otherOwner) expect(() => ctx.jobs.start(producer({ owner: otherOwner }).spec)).not.toThrow() detachOld() const replacement = stubAgent(ctx, 'shared-session') - ctx.agents.register(replacement) + await ctx.agents.register(replacement) expect(() => ctx.jobs.start(producer({ owner: replacement }).spec)).not.toThrow() ctx.jobs.start(producer().spec) @@ -561,7 +561,7 @@ describe('LocalJobRegistry owner isolation', () => { it('fences read/kill/wait to the owning session and keeps unowned jobs open', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const other = stubAgent(ctx, 'other') const owned = ctx.jobs.start(producer({ owner }).spec) @@ -582,8 +582,8 @@ describe('LocalJobRegistry owner isolation', () => { const ctx = await harness() const alice = stubAgent(ctx, 'alice') const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) + await ctx.agents.register(alice) + await ctx.agents.register(bob) const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec) const bobTask = ctx.jobs.start(producer({ owner: bob }).spec) @@ -615,7 +615,7 @@ describe('LocalJobRegistry owner isolation', () => { expect(ctx.jobs.list(ghost)).toEqual([]) // A later valid registration must still attach cleanup for the same object. - ctx.agents.register(ghost) + await ctx.agents.register(ghost) const cancels: (string | undefined)[] = [] let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ @@ -636,11 +636,11 @@ describe('LocalJobRegistry owner isolation', () => { it('rejects a stale owner instance after another agent reuses its id', async () => { const ctx = await harness() const staleOwner = stubAgent(ctx, 'owner') - const unregisterStale = ctx.agents.register(staleOwner) + const unregisterStale = await ctx.agents.register(staleOwner) unregisterStale() const currentOwner = stubAgent(ctx, 'owner') - ctx.agents.register(currentOwner) + await ctx.agents.register(currentOwner) const current = producer({ owner: currentOwner }) ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. @@ -665,7 +665,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('drains the owner: cancels live jobs, awaits settlement, drops snapshots', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) // The producer settles only when cancelled — models a child that stops on request. let settle!: (outcome: JobOutcome) => void @@ -693,7 +693,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('publishes the settled visible set before announcing completion', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const p = producer({ owner }) ctx.jobs.start(p.spec) // Registered after start so only the settlement's notifications are ordered. @@ -713,7 +713,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('reports a teardown-cancelled record so completion reporters stay quiet', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const seen: JobSnapshot[] = [] ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) @@ -738,7 +738,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const first = producer({ owner }) const second = producer({ owner }) @@ -755,7 +755,7 @@ describe('LocalJobRegistry owner cleanup', () => { it('does not let an old scope cleanup cancel a same-id/session replacement job', async () => { const ctx = await harness() const oldOwner = stubAgent(ctx, 'owner') - const detachOld = ctx.agents.register(oldOwner) + const detachOld = await ctx.agents.register(oldOwner) const cancels: string[] = [] function start(owner: Agent, label: string): JobId { @@ -774,7 +774,7 @@ describe('LocalJobRegistry owner cleanup', () => { start(oldOwner, 'old job') detachOld() const replacement = stubAgent(ctx, 'owner') - ctx.agents.register(replacement) + await ctx.agents.register(replacement) const replacementId = start(replacement, 'replacement job') await disposeAgentScope(oldOwner) @@ -791,7 +791,7 @@ describe('LocalJobRegistry owner cleanup', () => { const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const ownerCleanupEffects = () => owner.ctx.fiber.getEffects() .filter(effect => effect.label === 'jobs.ownerCleanup()') @@ -815,7 +815,7 @@ describe('LocalJobRegistry owner cleanup', () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const seen: JobSnapshot[] = [] ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) @@ -927,7 +927,7 @@ describe('LocalJobRegistry disposal', () => { const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', @@ -962,7 +962,7 @@ describe('LocalJobRegistry disposal', () => { }, }) const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) - ctx.agents.register(owner) + await ctx.agents.register(owner) expect(() => ctx.jobs.start(producer({ owner }).spec)).not.toThrow() await mount.dispose() @@ -994,7 +994,7 @@ describe('LocalJobRegistry.onJobsChanged', () => { it('fires after registration, the stopping transition, and settlement', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) + await ctx.agents.register(owner) const seen: (string | undefined)[] = [] ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) @@ -1028,8 +1028,8 @@ describe('LocalJobRegistry.onJobsChanged', () => { const ctx = await harness() const owner = stubAgent(ctx, 'alice') const bystander = stubAgent(ctx, 'bob') - ctx.agents.register(owner) - ctx.agents.register(bystander) + await ctx.agents.register(owner) + await ctx.agents.register(bystander) const p = producer({ owner }) ctx.jobs.start(p.spec) @@ -1087,7 +1087,7 @@ describe('LocalJobRegistry teardown change notifications', () => { it('announces the stopping transition during owner teardown, before settlement', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) + await ctx.agents.register(owner) const p = producer({ owner }) const id = ctx.jobs.start(p.spec) diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4c6a81e48a..1f20f1f2dc 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -41,7 +41,7 @@ interface FakeDelivery { * A fake agent with the shared agent/session identity, registered in * `ctx.agents` with a dedicated lifecycle scope. */ -function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent { +async function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Promise { const scopeFiber = ctx.plugin(() => {}) const id = SessionId(sessionId) const agent = { @@ -52,7 +52,7 @@ function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}) status: delivery.status ?? 'running', session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent - agentRegistryDisposers.set(agent, ctx.agents.register(agent)) + agentRegistryDisposers.set(agent, await ctx.agents.register(agent)) agentScopeFibers.set(agent, scopeFiber) return agent } @@ -353,7 +353,7 @@ describe('job_list', () => { const { ctx } = await setup() expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)') - const alice = fakeAgent(ctx, 'sess-alice') + const alice = await fakeAgent(ctx, 'sess-alice') ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec) ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec) const p = producer({ owner: alice, label: 'build' }) @@ -377,7 +377,7 @@ describe('job_list', () => { 'bash-2 [bash] completed — build', ].join('\n')) // A different caller sees only the unowned job. - const bob = fakeAgent(ctx, 'sess-bob') + const bob = await fakeAgent(ctx, 'sess-bob') expect(text(await call(ctx, 'job_list', {}, bob))).toBe('subagent-1 [subagent] running — open research') }) }) @@ -536,7 +536,7 @@ describe('completion notices across scoped mounts', () => { inject, session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } }, } as unknown as Agent - const dispose = ctx.agents.register(owner) + const dispose = await ctx.agents.register(owner) try { // No waiter: `settle()` leaves `reported` false, which is the only path @@ -558,7 +558,7 @@ describe('completion notice delivery', () => { const { ctx } = await setup() const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) const p = producer({ owner, label: 'pnpm test' }) ctx.jobs.start(p.spec) @@ -572,7 +572,7 @@ describe('completion notice delivery', () => { const { ctx } = await setup({ completionDelivery: 'quiet' }) const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) const p = producer({ owner }) ctx.jobs.start(p.spec) @@ -586,7 +586,7 @@ describe('completion notice delivery', () => { const { ctx } = await setup({ maxConsecutiveWakes: 2 }) const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) await settleTasks(ctx, owner, 3) // A woken turn that starts another job is the self-exciting case: the @@ -599,7 +599,7 @@ describe('completion notice delivery', () => { const { ctx } = await setup({ maxConsecutiveWakes: 1 }) const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) await settleTasks(ctx, owner, 2) expect(followup).toHaveBeenCalledTimes(1) @@ -616,7 +616,7 @@ describe('completion notice delivery', () => { const { ctx } = await setup() const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', @@ -641,7 +641,7 @@ describe('completion notice delivery', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const inject = vi.fn() const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) ctx.jobs.start({ kind: 'bash', label: 'broken producer', @@ -666,7 +666,7 @@ describe('completion notice delivery', () => { it('keeps the budget spent when the owner only claims plugin notices', async () => { const { ctx } = await setup({ maxConsecutiveWakes: 1 }) const followup = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' }) + const owner = await fakeAgent(ctx, 'sess-1', { followup, status: 'idle' }) await settleTasks(ctx, owner, 1) emitAgentEvent(ctx, owner, 'agent/inbox/claimed', { @@ -685,7 +685,7 @@ describe('completion notices', () => { it('injects a notice into the owning agent when an unreported job settles', async () => { const { ctx } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const p = producer({ owner, label: 'pnpm test' }) ctx.jobs.start(p.spec) @@ -708,7 +708,7 @@ describe('completion notices', () => { it('preserves job ids and collection guidance in bounded completion notices', async () => { const { ctx } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const first = producer({ owner, kind: 'subagent', @@ -762,7 +762,7 @@ describe('completion notices', () => { await tick() } const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const target = producer({ owner, kind: 'pty-send', @@ -783,7 +783,7 @@ describe('completion notices', () => { it('reserves the collection-action tail when a producer supplies a smaller budget', async () => { const { ctx } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) ctx.jobs.start(tiny.spec) @@ -804,7 +804,7 @@ describe('completion notices', () => { it('suppresses the notice for a job the model already killed', async () => { const { ctx } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const p = producer({ owner }) ctx.jobs.start(p.spec) @@ -817,7 +817,7 @@ describe('completion notices', () => { it('suppresses the notice when a wait returned the terminal state', async () => { const { ctx } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) const p = producer({ owner, kind: 'subagent' }) ctx.jobs.start(p.spec) @@ -842,13 +842,13 @@ describe('completion notices', () => { // terminal state, so the notice lands in the old owner's (detached) // session instead of throwing or re-routing. const oldInject = vi.fn() - const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject }) + const oldOwner = await fakeAgent(ctx, 'shared', { inject: oldInject }) const p = producer({ owner: oldOwner }) ctx.jobs.start(p.spec) detachAgent(oldOwner) const replacementInject = vi.fn() - fakeAgent(ctx, 'shared', { inject: replacementInject }) + await fakeAgent(ctx, 'shared', { inject: replacementInject }) p.settle({ status: 'completed' }) await tick() @@ -859,7 +859,7 @@ describe('completion notices', () => { it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => { const { ctx } = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } }) + const owner = await fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } }) const p = producer({ owner }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) @@ -872,7 +872,7 @@ describe('completion notices', () => { it('keeps using the exact owner after the agent registry is gone', async () => { const { ctx, agentsFiber } = await setup() const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) + const owner = await fakeAgent(ctx, 'sess-1', { inject }) // Settlement must not depend on a later registry lookup: the exact owner // supplied at start remains the destination while its own scope is live. diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 280afb90b8..e40660de3c 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -119,7 +119,7 @@ describe('DeepSeek plugin package inventory', () => { const id = SessionId('bare-agent') const agentScope = createScope(ctx, {}) - ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent) + await ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent) const bare = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id }) expect(bare.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }]) }) @@ -218,7 +218,7 @@ describe('DeepSeek plugin package inventory', () => { const agentScope = createScope(ctx, agentKey, { parent: standingKey }) const id = SessionId('preset-agent') const agent = { id, ctx: agentScope.ctx, session: { id } } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id }) expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'preset-only', version: '4.0.0' }]) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index f4d46d5b62..3c2fc36b5c 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -29,7 +29,7 @@ async function harness(withPlanMode: boolean): Promise { await ctx.plugin(SessionProjectionRegistry) if (withPlanMode) await ctx.plugin(PlanModeController, { section: 'plan policy' }) const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return { ctx, session, diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index f216babd7d..d5cab06a4a 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -97,7 +97,7 @@ async function harness(): Promise { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } - const disposeAgent = ctx.agents.register(agent) + const disposeAgent = await ctx.agents.register(agent) ctx.on('session/event', (_session, event) => { if (event.type === 'schedule/change' && event.data.operation === 'dispatch') order.push('dispatch') }) diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 35e4dc1724..a34281c1b5 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -51,7 +51,7 @@ async function harness(withPersistence = true): Promise { await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`) - ctx.agents.register(agent) + await ctx.agents.register(agent) const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> } if (withPersistence) { ctx.on('session/flush', async () => { @@ -550,7 +550,7 @@ describe('Schedule persistence failure boundaries', () => { stop() const other = stubAgent(test.ctx, `other-${Math.random()}`) - test.ctx.agents.register(other) + await test.ctx.agents.register(other) expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 }, other))) .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' }) expect(value(await execute(test, 'schedule_list', {}, other))) diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 142d7c611c..2bd1850525 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider { } } -function agent(ctx: Context, cwd: string): Agent { +async function agent(ctx: Context, cwd: string): Promise { const id = SessionId('persistent-bash-loader-agent') const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { @@ -59,7 +59,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -129,7 +129,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await context.loader.await() - const owner = agent(context, root) + const owner = await agent(context, root) const signal = new AbortController().signal const execute = (id: string, command: string) => context!.tools.execute({ signal, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 7f3ed90c52..ce453d18ce 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -27,7 +27,7 @@ afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() }) -function agent(ctx: Context, cwd: string | undefined): Agent { +async function agent(ctx: Context, cwd: string | undefined): Promise { const id = SessionId(`persistent-bash-owner-${callNumber}`) const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { @@ -52,7 +52,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -309,7 +309,7 @@ async function setup( const stub = stubBackend(initialMode) ctx.terminals.registerBackend(stub.backend) const fiber = await ctx.plugin(ToolBashPersistent, config) - return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } + return { ctx, stub, fiber, owner: await agent(ctx, '/workspace') } } describe('tool-bash-persistent', () => { @@ -333,7 +333,7 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(1) expect(stub.sessions[0]?.sends).toBe(3) - const ownerWithoutCwd = agent(ctx, undefined) + const ownerWithoutCwd = await agent(ctx, undefined) expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub\n[Command finished with exit code 0]') expect(stub.sessions).toHaveLength(2) @@ -569,7 +569,7 @@ describe('tool-bash-persistent', () => { }), }) const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' }) - const owner = agent(ctx, '/workspace') + const owner = await agent(ctx, '/workspace') const running = call(ctx, owner, 'pwd') await spawnStarted.promise await fiber.dispose() diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 1dfbd27b81..4adc6080ba 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -67,7 +67,7 @@ async function setupWithTasks() { * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. */ -function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { +async function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Promise { const scopeFiber = ctx.plugin(() => {}) const id = SessionId(sessionId) const agent = { @@ -76,7 +76,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un inject, session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) return agent } let callCounter = 0 @@ -493,7 +493,7 @@ describe('background execution through the job runtime', () => { it('a background job started by an agent is registered with that agent as owner', async () => { // The producer must forward exec.agent as the job owner. const ctx = await setupWithTasks() - const agent = registerFakeAgent(ctx, 'sess-owner') + const agent = await registerFakeAgent(ctx, 'sess-owner') const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent) expect(text(started)).toBe('started background job bash-1') @@ -663,7 +663,7 @@ describe('sandbox escalation through the generic task producer', () => { const { ctx, bash } = await setupSandboxed(true) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const agent = sandboxAgent(undefined, ctx) - ctx.agents.register(agent) + await ctx.agents.register(agent) const foreground = await ctx.tools.execute({ callId: ToolCallId('sandbox-signal'), name: 'bash', @@ -683,7 +683,7 @@ describe('sandbox escalation through the generic task producer', () => { const agent = sandboxAgent(undefined, ctx, (type) => { if (type === 'approval/decided') controller.abort() }) - ctx.agents.register(agent) + await ctx.agents.register(agent) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const start = vi.spyOn(bash, 'start') @@ -1140,7 +1140,7 @@ describe('the model-facing bash tool builds its request from named args only (no it('injects built-ins and the stable session id into a foreground request', async () => { const { ctx, bash } = await setupRecording() - const agent = registerFakeAgent(ctx, 'request-fg', () => undefined) + const agent = await registerFakeAgent(ctx, 'request-fg', () => undefined) const ambient = process.env.DSH_SESSION_ID await ctx.tools.execute({ @@ -1161,7 +1161,7 @@ describe('the model-facing bash tool builds its request from named args only (no it('injects the same trusted variables into a background request without forwarding model env', async () => { const { ctx, bash } = await setupRecording() - const agent = registerFakeAgent(ctx, 'request-bg', () => undefined) + const agent = await registerFakeAgent(ctx, 'request-bg', () => undefined) await ctx.tools.execute({ signal: testToolSignal, @@ -1186,8 +1186,8 @@ describe('the model-facing bash tool builds its request from named args only (no it('keeps parent and child agent session environments isolated', async () => { const { ctx, bash } = await setupRecording() - const parent = registerFakeAgent(ctx, 'request-parent', () => undefined) - const child = registerFakeAgent(ctx, 'request-child', () => undefined) + const parent = await registerFakeAgent(ctx, 'request-parent', () => undefined) + const child = await registerFakeAgent(ctx, 'request-child', () => undefined) for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { await ctx.tools.execute({ diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 05116a6b5c..d002811ef4 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -45,7 +45,7 @@ class PassthroughSandbox extends SandboxProvider { } } -function agent(ctx: Context, cwd: string): Agent { +async function agent(ctx: Context, cwd: string): Promise { const id = SessionId('persistent-pwsh-loader-agent') const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { @@ -66,7 +66,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -140,7 +140,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await context.loader.await() - const owner = agent(context, root) + const owner = await agent(context, root) const signal = new AbortController().signal const execute = (id: string, command: string) => context!.tools.execute({ signal, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index e65859e4c8..54d88197e8 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -27,7 +27,7 @@ afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() }) -function agent(ctx: Context, cwd: string | undefined): Agent { +async function agent(ctx: Context, cwd: string | undefined): Promise { const id = SessionId(`persistent-pwsh-owner-${callNumber}`) const scope = ctx.plugin(() => {}) const session = Session.create(id, [], { @@ -52,7 +52,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -323,7 +323,7 @@ async function setup( const stub = stubBackend(initialMode) ctx.terminals.registerBackend(stub.backend) const fiber = await ctx.plugin(ToolPwshPersistent, config) - return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } + return { ctx, stub, fiber, owner: await agent(ctx, '/workspace') } } describe('tool-pwsh-persistent', () => { @@ -347,7 +347,7 @@ describe('tool-pwsh-persistent', () => { expect(stub.sessions).toHaveLength(1) expect(stub.sessions[0]?.sends).toBe(3) - const ownerWithoutCwd = agent(ctx, undefined) + const ownerWithoutCwd = await agent(ctx, undefined) expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') expect(stub.sessions).toHaveLength(2) @@ -603,7 +603,7 @@ describe('tool-pwsh-persistent', () => { }), }) const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' }) - const owner = agent(ctx, '/workspace') + const owner = await agent(ctx, '/workspace') const running = call(ctx, owner, 'pwd') await spawnStarted.promise await fiber.dispose() diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index dbd1f13bdd..e5be637176 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -289,7 +289,7 @@ function sandboxAgent( * The fake session carries an empty event log (the sandbox-policy resolver * folds the log for mode overrides, mirroring a real session). */ -function registerFakeAgent(ctx: Context, sessionId: string): Agent { +async function registerFakeAgent(ctx: Context, sessionId: string): Promise { const scopeFiber = ctx.plugin(() => {}) const id = SessionId(sessionId) const agent = { @@ -305,7 +305,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { snapshotEvents: () => [], }, } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) return agent } @@ -397,7 +397,7 @@ describe('execution through the bash seam', () => { tempDirs.push(dshHome) const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') - const agent = registerFakeAgent(ctx, 'session-1') + const agent = await registerFakeAgent(ctx, 'session-1') Object.assign(agent.session.header, { cwd: '/sessions/s1' }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', @@ -420,7 +420,7 @@ describe('execution through the bash seam', () => { it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('ok\n') - const agent = registerFakeAgent(ctx, 'session-cwd') + const agent = await registerFakeAgent(ctx, 'session-cwd') Object.assign(agent.session.header, { cwd: '/sessions/s1' }) await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) @@ -546,7 +546,7 @@ describe('per-call sandbox policy resolution', () => { const { ctx, bash } = await setupSandboxed() const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) tempDirs.push(sessionCwd) - const agent = registerFakeAgent(ctx, 'policy-session') + const agent = await registerFakeAgent(ctx, 'policy-session') Object.assign(agent.session.header, { cwd: sessionCwd }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) expect(result.isError).toBe(false) @@ -668,7 +668,7 @@ describe('sandbox escalation through ctx.approval', () => { const { ctx, bash } = await setupSandboxed(true) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const agent = sandboxAgent(undefined, ctx) - ctx.agents.register(agent) + await ctx.agents.register(agent) const foreground = await ctx.tools.execute({ callId: ToolCallId('sandbox-signal'), name: 'pwsh', @@ -688,7 +688,7 @@ describe('sandbox escalation through ctx.approval', () => { const agent = sandboxAgent(undefined, ctx, (type) => { if (type === 'approval/decided') controller.abort() }) - ctx.agents.register(agent) + await ctx.agents.register(agent) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const start = vi.spyOn(bash, 'start') @@ -771,7 +771,7 @@ describe('background execution through the job runtime', () => { it('a background job started by an agent is registered with that agent as owner', async () => { const { ctx } = await setupWithTasks() - const agent = registerFakeAgent(ctx, 'sess-owner') + const agent = await registerFakeAgent(ctx, 'sess-owner') const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) expect(text(started)).toBe('started background job pwsh-1') diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 5ca770d7d7..4161a125e6 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -661,8 +661,10 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const installing = new WeakSet() const belongsToComposition = (candidate: Agent): boolean => scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) - const installScoped = (candidate: Agent): void => { - if (scopedInstalls.has(candidate) || installing.has(candidate)) return + const installScoped = (candidate: Agent): ReturnType | undefined => { + const existing = scopedInstalls.get(candidate) + if (existing !== undefined) return existing + if (installing.has(candidate)) return // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) @@ -676,6 +678,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { installing.delete(candidate) } scopedInstalls.set(candidate, fiber) + return fiber } const removeScoped = (candidate: Agent): void => { const fiber = scopedInstalls.get(candidate) @@ -695,8 +698,8 @@ export function apply(ctx: Context, config: Config, session?: Session): void { // The preset-scoped listener admits descendant Agents and installs the // sampled tool definition in each Agent's own scope, so a later settings // change cannot mutate a live session. - ctx.on('agent/created', ({ agent: created }) => { - installScoped(created) + ctx.on('agent/created', async ({ agent: created }) => { + await installScoped(created) }) ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) // Reparenting an Agent between standing presets changes its inherited tool diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1e79190deb..680207120f 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -801,7 +801,7 @@ describe('dsh-tool-subagent', () => { describe('dsh-tool-subagent background mode', () => { /** A live parent with a dedicated scope fiber for structural task cleanup. */ - function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { + async function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Promise { const scopeFiber = ctx.plugin(() => {}) const id = SessionId(sessionId) const agent = { @@ -811,7 +811,7 @@ describe('dsh-tool-subagent background mode', () => { options: {}, session: Session.create(id), } as unknown as Agent - ctx.agents.register(agent) + await ctx.agents.register(agent) return agent } @@ -825,7 +825,7 @@ describe('dsh-tool-subagent background mode', () => { it('keeps a continuable-capable provider one-shot when backgroundMode selects one-shot', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') let prepareCalls = 0 ctx.subagents.registerProvider({ name: 'resumable', @@ -866,7 +866,7 @@ describe('dsh-tool-subagent background mode', () => { it('returns a job id immediately and the answer is collected through job_output', async () => { const ctx = await backgroundSetup({ provider: 'mock' }, { reply: 'background answer' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent }) expect(start.isError).toBe(false) @@ -900,7 +900,7 @@ describe('dsh-tool-subagent background mode', () => { diagnostic: 'Claude Code cancelled an unattended dialog', stopReason: 'error', }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') const started = await ctx.tools.execute({ signal: testToolSignal, @@ -933,7 +933,7 @@ describe('dsh-tool-subagent background mode', () => { it('skips background startup when the tool signal is already aborted', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') const controller = new AbortController() controller.abort() const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal }) @@ -950,7 +950,7 @@ describe('dsh-tool-subagent background mode', () => { provider: 'mock', agentOptions: { provider: 'alpha', model: 'selected-model' }, }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') const adapter = new MockAdapter([]) let releasePreflight!: () => void const preflightGate = new Promise((resolve) => { releasePreflight = resolve }) @@ -1021,7 +1021,7 @@ describe('dsh-tool-subagent background mode', () => { it('settles an asynchronous provider-start failure as a failed task', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start', capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -1050,7 +1050,7 @@ describe('dsh-tool-subagent background mode', () => { it('kills a subagent task while provider readiness is still pending', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'pending-start', capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -1087,7 +1087,7 @@ describe('dsh-tool-subagent background mode', () => { it('reports startup rollback failure after cancellation as a failed job', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start-rollback', capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -1130,7 +1130,7 @@ describe('dsh-tool-subagent background mode', () => { it('forwards job_kill reasons through the run signal (and defaults one when absent)', async () => { // Use a provider that remains live until its signal is aborted. const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }) - const parent = ownerAgent(ctx, 'sess-parent') + const parent = await ownerAgent(ctx, 'sess-parent') const cancels: (string | undefined)[] = [] let starts = 0 ctx.subagents.registerProvider({ @@ -1354,7 +1354,7 @@ describe('background preflight failure (no orphaned child, by construction)', () options: {}, session: Session.create(id), } as unknown as Agent - ctx.agents.register(parent) + await ctx.agents.register(parent) let starts = 0 ctx.subagents.registerProvider({ diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index af707d8269..4bfb888801 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -602,7 +602,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(owner) + await ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const created = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -652,7 +652,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(owner) + await ctx.agents.register(owner) const gate = Promise.withResolvers() await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise)) const spawning = ctx.terminals.spawn(owner, { type: 'stub' }) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 9800a79d54..98edf7724a 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -79,7 +79,7 @@ async function harness( maxReadBytes: 16_384, }) const agent = stubAgent(ctx, `agent-${mode}`) - ctx.agents.register(agent) + await ctx.agents.register(agent) return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index c5c552d18f..b0bf91af6a 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -160,8 +160,8 @@ describe('TerminalSessionService ownership and lifecycle', () => { ctx.terminals.registerBackend(b.provider) const owner = stubAgent(ctx, 'owner') const foreign = stubAgent(ctx, 'foreign') - ctx.agents.register(owner) - ctx.agents.register(foreign) + await ctx.agents.register(owner) + await ctx.agents.register(foreign) const created = await ctx.terminals.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' }) expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } }) @@ -177,7 +177,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') await expect(ctx.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' }) - ctx.agents.register(owner) + await ctx.agents.register(owner) await expect(ctx.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' }) const b = backend() ctx.terminals.registerBackend(b.provider) @@ -209,7 +209,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const session = new StubSession() ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const pending = ctx.terminals.spawn(owner, { type: 'slow', name: 'main' }) await expect(ctx.terminals.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' }) const disposal = disposeAgentScope(owner) @@ -225,7 +225,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const session = new StubSession() ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const controller = new AbortController() const reason = new Error('cancelled by caller') @@ -245,7 +245,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { session.rejectClose = true ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const controller = new AbortController() const reason = new Error('cancelled by caller') @@ -274,7 +274,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { }), }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const controller = new AbortController() const reason = new Error('cancelled by caller') @@ -300,7 +300,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { }), }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const controller = new AbortController() const reason = new Error('cancelled by caller') @@ -337,7 +337,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { }, }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const pending = ctx.terminals.spawn(owner, { type: 'slow' }) const pendingFailure = pending.then( @@ -369,7 +369,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { session.rejectClose = true ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const pending = ctx.terminals.spawn(owner, { type: 'slow' }) const pendingFailure = expect(pending).rejects.toThrow('PTY spawn and rollback both failed') @@ -402,7 +402,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { }), }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const pending = ctx.terminals.spawn(owner, { type: 'cleanup-failing' }) await started.promise @@ -438,7 +438,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise, }) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const first = ctx.terminals.spawn(owner, { type: 'slow', name: 'one' }) const second = ctx.terminals.spawn(owner, { type: 'slow', name: 'two' }) firstGate.resolve(new StubSession()) @@ -458,7 +458,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { it('omits optional pid metadata when a backend has no process id', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const session = new StubSession() Object.defineProperty(session, 'pid', { value: undefined }) ctx.terminals.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) }) @@ -468,7 +468,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { it('reports rollback and close failures without publishing false success', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const failedSpawn = new StubSession() failedSpawn.rejectClose = true let ownerDisposal = Promise.resolve() @@ -494,7 +494,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(ownerDisposal).rejects.toThrow('failed to clean up PTY lifecycle') const nextOwner = stubAgent(ctx, 'next') - ctx.agents.register(nextOwner) + await ctx.agents.register(nextOwner) const b = backend('bad-close') ctx.terminals.registerBackend(b.provider) const created = await ctx.terminals.spawn(nextOwner, { type: 'bad-close' }) @@ -506,7 +506,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { it('joins an already-running close and refuses new sends while closing', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const b = backend() ctx.terminals.registerBackend(b.provider) const created = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -525,7 +525,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const b = backend() const disposeBackend = ctx.terminals.registerBackend(b.provider) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) const created = await ctx.terminals.spawn(owner, { type: 'stub' }) disposeBackend() expect(ctx.terminals.listBackends()).toEqual([]) @@ -542,8 +542,8 @@ describe('TerminalSessionService ownership and lifecycle', () => { ctx.terminals.registerBackend(b.provider) const first = stubAgent(ctx, 'first') const second = stubAgent(ctx, 'second') - ctx.agents.register(first) - ctx.agents.register(second) + await ctx.agents.register(first) + await ctx.agents.register(second) const a = await ctx.terminals.spawn(first, { type: 'stub' }) await ctx.terminals.spawn(second, { type: 'stub' }) expect(await ctx.terminals.kill(first, a.sessionId)).toBe(true) @@ -561,7 +561,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const b = backend() ctx.terminals.registerBackend(b.provider) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) await ctx.terminals.spawn(owner, { type: 'stub' }) b.sessions[0]!.rejectClose = true const internal = service as unknown as { @@ -587,7 +587,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { const b = backend() service.registerBackend(b.provider) const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + await ctx.agents.register(owner) await service.spawn(owner, { type: 'stub' }) b.sessions[0]!.rejectClose = true const internal = service as unknown as { diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index c6f8f494de..ace67b33ab 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider { } } -function agent(ctx: Context): Agent { +async function agent(ctx: Context): Promise { const scope = ctx.plugin(() => {}) const id = SessionId('pty-loader-agent') const session = Session.create(id) @@ -51,7 +51,7 @@ function agent(ctx: Context): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -115,7 +115,7 @@ suite('terminal real Loader composition through cordis.yml', () => { await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await context.loader.await() - const owner = agent(context) + const owner = await agent(context) const signal = new AbortController().signal const spawn = await context.tools.execute({ signal, callId: ToolCallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 8c0c7f6292..07d4cbfec1 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -14,7 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' -function fakeAgent(ctx: Context, rawId: string): Agent { +async function fakeAgent(ctx: Context, rawId: string): Promise { const scope = ctx.plugin(() => {}) const id = SessionId(rawId) const session = Session.create(id) @@ -27,7 +27,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(agent) + await ctx.agents.register(agent) return agent } @@ -116,7 +116,7 @@ async function setupBase(jobs: boolean) { await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) } - return { ctx, stub, agent: fakeAgent(ctx, jobs ? 'with-tasks' : 'foreground') } + return { ctx, stub, agent: await fakeAgent(ctx, jobs ? 'with-tasks' : 'foreground') } } let callNumber = 0 diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index b99909fa2a..fcd27b8f37 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -29,7 +29,7 @@ afterEach(async () => { root = undefined }) -function agent(ctx: Context): Agent { +async function agent(ctx: Context): Promise { const scope = ctx.plugin(() => {}) const id = SessionId('todo-loader-agent') const session = Session.create(id) @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - ctx.agents.register(value) + await ctx.agents.register(value) return value } @@ -102,7 +102,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(description).toContain('Keep AT MOST ONE todo `in_progress`') expect(description).not.toContain('several at once') - const owner = agent(ctx) + const owner = await agent(ctx) const result = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('parallel'), @@ -120,7 +120,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? '' expect(description).toContain('several at once when work genuinely runs in parallel') - const owner = agent(ctx) + const owner = await agent(ctx) const result = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('parallel-enabled'), diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 1e790eb96c..20975df8c0 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -37,7 +37,7 @@ async function harness(withTodoTool: boolean): Promise { await ctx.plugin(SessionProjectionRegistry) if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return { ctx, session, diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d8be7d7445..640f15ca3e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -541,7 +541,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ tryMembership: (candidate: Agent) => candidate === agent ? membership : undefined, membership: () => membership, } as unknown as TeamService) - await ctx.plugin(Object.assign((inner: Context) => { + await ctx.plugin(Object.assign(async (inner: Context) => { agent = { id: session.id, session, @@ -549,7 +549,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ status: 'idle', } as unknown as Agent Object.assign(agent, { ctx: createScope(inner, agent).ctx }) - inner.agents.register(agent) + await inner.agents.register(agent) }, { inject: ['tools', 'systemPrompt', 'agents', 'agentTeams'] })) await ctx.plugin(ToolTeam) catalogChildScopes.set(ctx, agent) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 866c01e098..9e9308dcf3 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1301,13 +1301,19 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) Path(__file__).resolve().parent.parent / "snapshots/sdk/text-turn/feedback-producer.mjs" ).as_uri()}, ]}]) + creation_patch = write_profile_patch(root, "creation.patch.yml", sessions, [ + {"insert": [{ + "id": "serial-created-fixture", + "name": str(Path(__file__).resolve().parents[1] / "packages/core/agent-loop/tests/fixtures/serial-created.mjs"), + }]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), dsh_bin=str(executable), dsh_home=str(dsh_home), - patches=(str(patch), str(feedback_patch)), + patches=(str(patch), str(feedback_patch), str(creation_patch)), env={ "DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", diff --git a/snapshots/sdk/serial-created/cordis.snapshot.yml b/snapshots/sdk/serial-created/cordis.snapshot.yml new file mode 100644 index 0000000000..c5741704cc --- /dev/null +++ b/snapshots/sdk/serial-created/cordis.snapshot.yml @@ -0,0 +1,20 @@ +# Keyless TypeScript SDK replay patch over the live profile patch. + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + +- id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + config: + enabled: true diff --git a/snapshots/sdk/serial-created/cordis.yml b/snapshots/sdk/serial-created/cordis.yml new file mode 100644 index 0000000000..1065247b4b --- /dev/null +++ b/snapshots/sdk/serial-created/cordis.yml @@ -0,0 +1,38 @@ +# TypeScript SDK snapshot-record patch over `dsh --profile sdk`. + +# The fixture corpus must not depend on the developer's personal skill roots. +# Ordinary product launches retain the sdk profile's default skill discovery. +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + includeDefaultRoots: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + enableRunInBackground: false + maxDepth: 1 + +- id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + config: + enabled: true + +- insert: + - id: serial-created-fixture + name: '../../../packages/core/agent-loop/tests/fixtures/serial-created.mjs' diff --git a/snapshots/sdk/serial-created/session.jsonl b/snapshots/sdk/serial-created/session.jsonl new file mode 100644 index 0000000000..ff28de89da --- /dev/null +++ b/snapshots/sdk/serial-created/session.jsonl @@ -0,0 +1,25 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":11}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/serial-created/snapshot.yml b/snapshots/sdk/serial-created/snapshot.yml new file mode 100644 index 0000000000..1250e4a74f --- /dev/null +++ b/snapshots/sdk/serial-created/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: serial-created +profile: sdk +composition: sdk-serial-created +recording: authored +header: + class: sdk-serial-created + pin: true