diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 63ca338de7..6b40eff05d 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -283,7 +283,11 @@ export async function scopeHost(ctx: Context, services: string[]): Promise ctx.get(name) === undefined) await fiber.dispose() - throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`) + /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending + * fiber with zero absent services cannot occur (an all-present inject + * list runs the callback) */ + const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)' + throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`) } const host = hostCtx return { diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 85281a65be..e932ed80db 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -227,4 +227,9 @@ describe('scopeHost', () => { await expect(scopeHost(ctx, ['tools', 'systemPrompt'])) .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') }) + + it('names a single absent service in the singular', async () => { + const ctx = new Context() + await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') + }) }) diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 9c448bc2ac..99e6b1c113 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -100,6 +100,19 @@ describe('scoped tool providers and toolOrder × restriction', () => { expect(global.tools.map(t => t.name)).toEqual(['global_tool']) }) + it('disposing a scoped tool provider empties its layer without residue', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] })) + dispose() + const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + expect(after.tools.map(t => t.name)).toEqual([]) + // Re-registering through the same scope starts a fresh layer. + scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('again')] })) + const again = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + expect(again.tools.map(t => t.name)).toEqual(['again']) + }) + it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => { const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] }) // A provider mimicking the registry's restriction split: bash exists diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 027b5798de..d7c0f020d0 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -148,6 +148,7 @@ describe('restrict()', () => { expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/) }) }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 889a8afb5d..cd0202f1bb 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -203,6 +203,8 @@ export function startInProcessRun( try { unlink = request.parent.ctx.effect(() => () => handle.dispose()) } catch (error: unknown) { + // Fire-and-forget: start() must rethrow synchronously; the child's + // teardown (stop → unregister → detach) reaches quiescence on its own. void handle.dispose() throw error } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 9d8b4cb70b..158d08ce1f 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -184,6 +184,9 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut if (decision.kind === 'accept') captured = { value: staged.value } return decision } finally { + /* v8 ignore next -- defensive false branch: a concurrent re-stage + * would need a second capture call INSIDE the first's post-execute + * chain */ if (pending === staged) pending = undefined } }, { prepend: true }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3d676c3848..c05310d70a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -489,4 +489,45 @@ describe('in-process structured output', () => { expect(result.isError).toBe(true) expect(result.error?.code).toBe('UNKNOWN_TOOL') }) + + it('drops a stale stage from a short-circuited chain: a later call never promotes it (call-keyed commit)', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // An OUTER post-execute listener (registered after attach, prepend ⇒ + // outermost) that BLOCKS the first capture WITHOUT delegating: the commit + // listener never runs for c1, so its staged value would linger. + let blocks = 1 + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { + blocks -= 1 + return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] }) + } + return next() + }, { prepend: true }) + const result = await run.result + // The blocked capture must NOT surface as structured success… + expect(result.stopReason).toBe('error') + expect(result.structured).toBeUndefined() + // …and a LATER invalid call (its own body staged nothing) must not + // resurrect c1's orphaned value: drive the pipeline directly. + const invalid = await ctx.tools.execute({ + callId: 'c2' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 'not-a-number' }, + agent: child, + }) + expect(invalid.isError).toBe(true) + // A fresh valid call still captures ITS OWN value. + const valid = await ctx.tools.execute({ + callId: 'c3' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 9 }, + agent: child, + }) + expect(valid.isError).toBeFalsy() + await run.dispose() + }) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 0d3bc57d26..88f02070a6 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' @@ -377,4 +377,23 @@ describe('dsh-subagent-spawn', () => { expect(ctx.agents.list().length).toBe(before) }) }) + + it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => { + const { ctx } = await setup([]) + // A handle-owned parent we can dispose (config agents dispose with the loop fiber). + const parentHandle = ctx.agents.create({ + agentId: AgentId('doomed-parent'), + sessionId: SessionId('doomed-s'), + agentOptions: { model: 'mock' }, + }) + await parentHandle.dispose() + const before = ctx.agents.list().length + expect(() => ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'do X' }], + parent: parentHandle.agent, + })).toThrow(/inactive context/) + // The freshly created child's disposal was initiated before the rethrow + // (fire-and-forget — start() throws synchronously); quiescence follows. + await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) }) + }) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 90f3e0f931..9510df53ab 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -451,4 +451,37 @@ describe('dsh-tool-subagent', () => { expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) + + it('passes persona/toolFilter/maxDepth config through to the start request', async () => { + let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture2', + capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: (request) => { + seen = request + return { + id: AgentId('capture2-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { + provider: 'capture2', + persona: 'You are the child.', + toolFilter: { deny: ['subagent'] }, + maxDepth: 2, + }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.persona).toBe('You are the child.') + expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] }) + expect(seen?.maxDepth).toBe(2) + }) }) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 98e5d675fa..9bd796955d 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -797,6 +797,37 @@ describe('scoped-dispatch invariants', () => { .toThrow(/dispatched without a scope carrier/) }) + it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => { + const ctx = await scopedCtx() + // Real Session objects: the session-start tracker WeakSet-keys them. + const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent + const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent + // One dispatch per table row keeps every subject extractor covered: the + // matching carrier passes, the foreign-keyed one throws. + const rows: [string, unknown[]][] = [ + ['agent/created', [agent]], + ['agent/disposed', [agent]], + ['agent/status', [agent, 'idle']], + ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], + ['agent/session-start', [agent, 'startup']], + ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]], + ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], + ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], + ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], + ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], + ['agent/error', [agent, 1, 0, new Error('x')]], + ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], + ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ] + for (const [event, args] of rows) { + const subject = event.startsWith('tools/') ? agent : agent + expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) }, + `${event} with matching carrier`).not.toThrow() + expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) }, + `${event} with foreign carrier`).toThrow(/DIFFERENT subject/) + } + }) + it('rejects a carrier keyed to a different subject than the arguments name', async () => { const ctx = await scopedCtx() const agent = { id: 'a1' } as unknown as Agent @@ -843,4 +874,18 @@ describe('scoped-dispatch invariants', () => { session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) }).not.toThrow() }) + + it('marks sessions of agents that predate the plugin as started (HMR re-apply safety)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('pre-s')) + const agent = { id: 'pre', session } as unknown as Agent + ctx.root.provide('agents', { list: () => [agent] } as never) + // Invariants apply AFTER the agent exists: its ordering is unknowable, so + // a turn opening without an observed session-start must NOT false-positive. + await ctx.plugin(Invariants) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + }).not.toThrow() + }) })