|
|
@@ -22,9 +22,15 @@ import SubagentRuntime, {
|
|
|
SUBAGENT_DESCRIPTOR_VERSION,
|
|
|
} from '../src/index.ts'
|
|
|
import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
|
|
|
+import type { SubagentPromptRequestId } from '../src/control-types.ts'
|
|
|
import * as SubagentInvariant from '../src/invariant.ts'
|
|
|
import { TestSessionQuery } from './test-session-query.ts'
|
|
|
import { loadStoredSession } from './persistence-helpers.ts'
|
|
|
+import {
|
|
|
+ continuationActivations,
|
|
|
+ continuationManager,
|
|
|
+ dropContinuationActivation,
|
|
|
+} from './continuation-internals.ts'
|
|
|
|
|
|
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
|
|
|
|
|
@@ -125,6 +131,11 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean {
|
|
|
&& event.data.content.some(block => block.type === 'text' && block.text === text))
|
|
|
}
|
|
|
|
|
|
+function hasAssistantText(events: readonly SessionEvent[], text: string): boolean {
|
|
|
+ return events.some(event => event.type === 'assistant/message'
|
|
|
+ && event.data.message.content.some(block => block.type === 'text' && block.text === text))
|
|
|
+}
|
|
|
+
|
|
|
/** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */
|
|
|
function userTexts(events: readonly SessionEvent[]): string[] {
|
|
|
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
|
|
|
@@ -142,19 +153,24 @@ function queuePrompt(
|
|
|
content: ContentBlock[],
|
|
|
signal: AbortSignal = testSignal,
|
|
|
) {
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations?: {
|
|
|
- queuePrompt(
|
|
|
- parent: Agent,
|
|
|
- childId: SessionId,
|
|
|
- content: ContentBlock[],
|
|
|
- source: { kind: 'user' },
|
|
|
- signal: AbortSignal,
|
|
|
- ): Promise<string>
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- if (manager === undefined) throw new Error('expected a bound continuation manager')
|
|
|
- return manager.queuePrompt(parent, childId, content, { kind: 'user' }, signal)
|
|
|
+ return continuationManager(ctx).queuePrompt(parent, childId, content, { kind: 'user' }, signal)
|
|
|
+}
|
|
|
+
|
|
|
+function humanPrompt(
|
|
|
+ ctx: Context,
|
|
|
+ parent: Agent,
|
|
|
+ childId: SessionId,
|
|
|
+ text: string,
|
|
|
+ delivery: 'queue' | 'steer',
|
|
|
+) {
|
|
|
+ return ctx.subagents.prompt({
|
|
|
+ requestId: `request-${text}` as SubagentPromptRequestId,
|
|
|
+ parentSessionId: parent.id,
|
|
|
+ childSessionId: childId,
|
|
|
+ mode: 'continuable',
|
|
|
+ delivery,
|
|
|
+ content: message(text),
|
|
|
+ }, testSignal)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -162,11 +178,7 @@ function queuePrompt(
|
|
|
* adding the irreversible operation to the public service contract.
|
|
|
*/
|
|
|
function drainManager(ctx: Context): Promise<void> {
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations?: { drain(): Promise<void> }
|
|
|
- }).continuations
|
|
|
- if (manager === undefined) throw new Error('expected a bound continuation manager')
|
|
|
- return manager.drain()
|
|
|
+ return continuationManager(ctx).drain()
|
|
|
}
|
|
|
|
|
|
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
|
|
|
@@ -176,6 +188,46 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
|
|
|
}, { timeout: 5_000 })
|
|
|
}
|
|
|
|
|
|
+/** Wait until the settlement watcher has checked the child's current idle state. */
|
|
|
+async function passSettlementCheck(ctx: Context, childId: SessionId): Promise<void> {
|
|
|
+ const manager = childLocks(ctx)
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const entered = Promise.withResolvers<undefined>()
|
|
|
+ const barrier = manager.locks.run(childId, async () => {
|
|
|
+ entered.resolve(undefined)
|
|
|
+ await release.promise
|
|
|
+ })
|
|
|
+ await entered.promise
|
|
|
+ release.resolve(undefined)
|
|
|
+ await barrier
|
|
|
+ await manager.locks.run(childId, () => Promise.resolve())
|
|
|
+}
|
|
|
+
|
|
|
+/** The Activation registry's package-private lock, which orders every child decision. */
|
|
|
+function childLocks(ctx: Context) {
|
|
|
+ return continuationActivations(ctx)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Occupy one child's lock so a settlement watcher that already observed
|
|
|
+ * quiescence waits behind the caller, which is the window where later Agent
|
|
|
+ * activity or Inbox changes race disposal.
|
|
|
+ * @returns the release callback and the held lock's completion.
|
|
|
+ */
|
|
|
+async function holdChildLock(
|
|
|
+ ctx: Context,
|
|
|
+ childId: SessionId,
|
|
|
+): Promise<{ release: () => void; held: Promise<void> }> {
|
|
|
+ const entered = Promise.withResolvers<undefined>()
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const held = childLocks(ctx).locks.run(childId, async () => {
|
|
|
+ entered.resolve(undefined)
|
|
|
+ await release.promise
|
|
|
+ })
|
|
|
+ await entered.promise
|
|
|
+ return { release: () => { release.resolve(undefined) }, held }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Keep the top-level test parent out of a scripted model corpus. Every child
|
|
|
* settlement wakes its parent, so a suite that scripts only child responses
|
|
|
@@ -917,6 +969,46 @@ describe('direct-child Queue residency routing', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+describe('continuable human steering delivery', () => {
|
|
|
+ it('places resident steering in nextStep with its durable identity and source', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('first'), gate: release.promise },
|
|
|
+ { chunks: textResponse('steered') },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+
|
|
|
+ const receipt = await humanPrompt(ctx, parent, started.childId, 'resident steer', 'steer')
|
|
|
+ expect(child.inbox.nextStep).toContainEqual(expect.objectContaining({
|
|
|
+ id: receipt.messageId,
|
|
|
+ content: message('resident steer'),
|
|
|
+ source: { kind: 'user', rpcId: 'request-resident steer' },
|
|
|
+ }))
|
|
|
+
|
|
|
+ release.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('cold-resumes steering into nextStep instead of inventing another queue', async () => {
|
|
|
+ const { ctx, parent } = await setup([textResponse('first'), textResponse('steered')])
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+
|
|
|
+ const receipt = await humanPrompt(ctx, parent, started.childId, 'cold steer', 'steer')
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
+ expect(loaded.events.some(event => event.type === 'agent/inbox/spliced'
|
|
|
+ && event.data.target === 'next-step'
|
|
|
+ && event.data.inserted.some(message => message.id === receipt.messageId))).toBe(true)
|
|
|
+ expect(hasUserText(loaded.events, 'cold steer')).toBe(true)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('continuable child ownership', () => {
|
|
|
it('keeps a parent Activation waiting until its child completes disposal', async () => {
|
|
|
const releaseGrandchild = Promise.withResolvers<undefined>()
|
|
|
@@ -956,6 +1048,187 @@ describe('continuable child ownership', () => {
|
|
|
})
|
|
|
|
|
|
describe('continuable durability and teardown', () => {
|
|
|
+ it('rechecks direct Agent inbox work accepted during the final flush', async () => {
|
|
|
+ const releaseFirstTurn = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('first answer'), gate: releaseFirstTurn.promise },
|
|
|
+ { chunks: textResponse('late answer') },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const flushing = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFlush = Promise.withResolvers<undefined>()
|
|
|
+ let childFlushes = 0
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session.header.parentSession === undefined) return
|
|
|
+ childFlushes++
|
|
|
+ if (childFlushes !== 1) return
|
|
|
+ flushing.resolve(undefined)
|
|
|
+ await releaseFlush.promise
|
|
|
+ })
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const cancelSpy = vi.spyOn(child, 'cancel')
|
|
|
+ releaseFirstTurn.resolve(undefined)
|
|
|
+ await flushing.promise
|
|
|
+ expect(cancelSpy).not.toHaveBeenCalled()
|
|
|
+ child.followup(createUserMessage({ content: message('accepted during flush'), source: { kind: 'user' } }))
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(adapter.requests).toHaveLength(2)
|
|
|
+ expect(hasAssistantText(child.session.snapshotEvents(), 'late answer')).toBe(true)
|
|
|
+ })
|
|
|
+ await child.whenIdle()
|
|
|
+ expect(cancelSpy).not.toHaveBeenCalled()
|
|
|
+ releaseFlush.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ expect(childFlushes).toBe(2)
|
|
|
+ const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
+ expect(hasUserText(loaded.events, 'accepted during flush')).toBe(true)
|
|
|
+ expect(hasAssistantText(loaded.events, 'late answer')).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retries after Session-only work completes during the final flush', async () => {
|
|
|
+ const releaseFirstTurn = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('answer'), gate: releaseFirstTurn.promise },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const flushing = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFlush = Promise.withResolvers<undefined>()
|
|
|
+ let childFlushes = 0
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session.header.parentSession === undefined) return
|
|
|
+ childFlushes++
|
|
|
+ if (childFlushes !== 1) return
|
|
|
+ flushing.resolve(undefined)
|
|
|
+ await releaseFlush.promise
|
|
|
+ })
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ releaseFirstTurn.resolve(undefined)
|
|
|
+ await flushing.promise
|
|
|
+ child.session.append('user/message', createUserMessage({
|
|
|
+ content: message('detached result'),
|
|
|
+ source: { kind: 'user' },
|
|
|
+ }), { surfaceOp: 'append' })
|
|
|
+ releaseFlush.resolve(undefined)
|
|
|
+
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ expect(childFlushes).toBe(2)
|
|
|
+ const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
+ expect(hasUserText(loaded.events, 'detached result')).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a child acquired during the final flush before settling', async () => {
|
|
|
+ const releaseFirstTurn = Promise.withResolvers<undefined>()
|
|
|
+ const releaseGrandchild = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('child answer'), gate: releaseFirstTurn.promise },
|
|
|
+ { chunks: textResponse('grandchild answer'), gate: releaseGrandchild.promise },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const flushing = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFlush = Promise.withResolvers<undefined>()
|
|
|
+ let heldFinalFlush = false
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session.header.parentSession !== parent.id || heldFinalFlush) return
|
|
|
+ heldFinalFlush = true
|
|
|
+ flushing.resolve(undefined)
|
|
|
+ await releaseFlush.promise
|
|
|
+ })
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ releaseFirstTurn.resolve(undefined)
|
|
|
+ await flushing.promise
|
|
|
+ const grandchild = await ctx.subagents.startContinuable(startSpec(child))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
|
|
+
|
|
|
+ releaseFlush.resolve(undefined)
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(ctx.agents.get(started.childId)).toBe(child)
|
|
|
+
|
|
|
+ releaseGrandchild.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, grandchild.childId)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('lets explicit disposal win while the natural final flush is pending', async () => {
|
|
|
+ const releaseFirstTurn = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('answer'), gate: releaseFirstTurn.promise },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const flushing = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFlush = Promise.withResolvers<undefined>()
|
|
|
+ let heldFinalFlush = false
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session.header.parentSession !== parent.id || heldFinalFlush) return
|
|
|
+ heldFinalFlush = true
|
|
|
+ flushing.resolve(undefined)
|
|
|
+ await releaseFlush.promise
|
|
|
+ })
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ releaseFirstTurn.resolve(undefined)
|
|
|
+ await flushing.promise
|
|
|
+ const drained = drainManager(ctx)
|
|
|
+ await drained
|
|
|
+
|
|
|
+ releaseFlush.resolve(undefined)
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(ctx.agents.get(started.childId)).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rechecks maintenance that claims the Agent during the final flush', async () => {
|
|
|
+ const releaseFirstTurn = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: releaseFirstTurn.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ parkParent(ctx, parent)
|
|
|
+ const flushing = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFlush = Promise.withResolvers<undefined>()
|
|
|
+ let heldFinalFlush = false
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session.header.parentSession === undefined || heldFinalFlush) return
|
|
|
+ heldFinalFlush = true
|
|
|
+ flushing.resolve(undefined)
|
|
|
+ await releaseFlush.promise
|
|
|
+ })
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const cancelSpy = vi.spyOn(child, 'cancel')
|
|
|
+ releaseFirstTurn.resolve(undefined)
|
|
|
+ await flushing.promise
|
|
|
+ expect(cancelSpy).not.toHaveBeenCalled()
|
|
|
+ const releaseMaintenance = Promise.withResolvers<undefined>()
|
|
|
+ let maintenanceSignal: AbortSignal | undefined
|
|
|
+ const maintenance = child.runMaintenance(async (signal) => {
|
|
|
+ maintenanceSignal = signal
|
|
|
+ await releaseMaintenance.promise
|
|
|
+ })
|
|
|
+ const runMaintenance = child.runMaintenance.bind(child)
|
|
|
+ const settlementClaimAttempted = Promise.withResolvers<undefined>()
|
|
|
+ vi.spyOn(child, 'runMaintenance').mockImplementation((task) => {
|
|
|
+ settlementClaimAttempted.resolve(undefined)
|
|
|
+ return runMaintenance(task)
|
|
|
+ })
|
|
|
+
|
|
|
+ releaseFlush.resolve(undefined)
|
|
|
+ await settlementClaimAttempted.promise
|
|
|
+ expect(maintenanceSignal?.aborted).toBe(false)
|
|
|
+ expect(ctx.agents.get(started.childId)).toBe(child)
|
|
|
+
|
|
|
+ releaseMaintenance.resolve(undefined)
|
|
|
+ await maintenance
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
it('settles despite the persistence backend being disposed mid-run', async () => {
|
|
|
const releaseResponse = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([
|
|
|
@@ -1008,10 +1281,7 @@ describe('continuable durability and teardown', () => {
|
|
|
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
+ const activation = continuationActivations(ctx).get(started.childId)!
|
|
|
const realDispose = activation.handle.dispose.bind(activation.handle)
|
|
|
activation.handle.dispose = async () => {
|
|
|
await realDispose()
|
|
|
@@ -1198,10 +1468,7 @@ describe('continuable durability and teardown', () => {
|
|
|
const { ctx, parent } = await setupWith(adapter)
|
|
|
const target = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(target.childId)!
|
|
|
+ const activation = continuationActivations(ctx).get(target.childId)!
|
|
|
const realDispose = activation.handle.dispose.bind(activation.handle)
|
|
|
activation.handle.dispose = async () => {
|
|
|
await realDispose()
|
|
|
@@ -1279,10 +1546,7 @@ describe('continuable durability and teardown', () => {
|
|
|
|
|
|
it('awaits and rolls back an admitted materialization below a scoped root', async () => {
|
|
|
const { ctx, parent } = await setup([])
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { ownerCtx: Context }
|
|
|
- }).continuations
|
|
|
- const agents = manager.ownerCtx.agents
|
|
|
+ const agents = continuationActivations(ctx).ownerCtx.agents
|
|
|
const create = agents.create.bind(agents)
|
|
|
const published = Promise.withResolvers<SessionId>()
|
|
|
const releaseMaterialization = Promise.withResolvers<undefined>()
|
|
|
@@ -1330,10 +1594,7 @@ describe('continuable durability and teardown', () => {
|
|
|
const { ctx, parent } = await setupWith(adapter)
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
+ const activation = continuationActivations(ctx).get(started.childId)!
|
|
|
const realDispose = activation.handle.dispose.bind(activation.handle)
|
|
|
activation.handle.dispose = async () => {
|
|
|
await realDispose()
|
|
|
@@ -1456,10 +1717,7 @@ describe('continuable review regressions', () => {
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent))
|
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { ownerCtx: Context }
|
|
|
- }).continuations
|
|
|
- const ownerAgents = manager.ownerCtx.agents
|
|
|
+ const ownerAgents = continuationActivations(ctx).ownerCtx.agents
|
|
|
const originalResume = ownerAgents.resume.bind(ownerAgents)
|
|
|
const resumed = Promise.withResolvers<undefined>()
|
|
|
const releaseResume = Promise.withResolvers<undefined>()
|
|
|
@@ -1496,19 +1754,13 @@ describe('continuable review regressions', () => {
|
|
|
await replacement.dispose()
|
|
|
})
|
|
|
|
|
|
- it('clears the accepted reservation when Agent.followup throws', async () => {
|
|
|
+ it('accepts a later delivery after Agent.followup throws', async () => {
|
|
|
const hold = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
|
|
|
const { ctx, parent } = await setupWith(adapter)
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
const child = ctx.agents.get(started.childId)!
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: {
|
|
|
- activations: Map<SessionId, { accepted: Set<MessageId> }>
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
const realFollowup = child.followup.bind(child)
|
|
|
child.followup = () => {
|
|
|
throw new Error('synthetic inbox failure')
|
|
|
@@ -1516,9 +1768,10 @@ describe('continuable review regressions', () => {
|
|
|
|
|
|
await expect(queuePrompt(ctx, parent, started.childId, message('throws')))
|
|
|
.rejects.toThrow(/synthetic inbox failure/)
|
|
|
- expect(activation.accepted.size).toBe(0)
|
|
|
|
|
|
child.followup = realFollowup
|
|
|
+ const accepted = await queuePrompt(ctx, parent, started.childId, message('accepted later'))
|
|
|
+ expect(child.inbox.nextTurn.some(candidate => candidate.id === accepted)).toBe(true)
|
|
|
const drained = drainManager(ctx)
|
|
|
hold.resolve(undefined)
|
|
|
await drained
|
|
|
@@ -1652,11 +1905,8 @@ describe('continuable review regressions', () => {
|
|
|
ctx.on('subagent/end', (info) => { ends.push(info) })
|
|
|
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
const activation = await vi.waitFor(() => {
|
|
|
- const found = manager.activations.get(started.childId)
|
|
|
+ const found = continuationActivations(ctx).get(started.childId)
|
|
|
expect(found).toBeDefined()
|
|
|
return found!
|
|
|
})
|
|
|
@@ -1680,12 +1930,7 @@ describe('continuable review regressions', () => {
|
|
|
ctx.on('subagent/end', info => void ends.push(info))
|
|
|
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: {
|
|
|
- activations: Map<SessionId, { observer: { capture: (child: Agent) => void } }>
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
+ const activation = continuationActivations(ctx).get(started.childId)!
|
|
|
activation.observer.capture = () => { throw new Error('capture failed') }
|
|
|
|
|
|
const drained = drainManager(ctx)
|
|
|
@@ -1695,20 +1940,30 @@ describe('continuable review regressions', () => {
|
|
|
expect(ends[0]!.stopReason).toBe('error')
|
|
|
})
|
|
|
|
|
|
+ it('releases a naturally settled Activation when terminal capture fails', async () => {
|
|
|
+ const hold = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const ends: SubagentRunEndInfo[] = []
|
|
|
+ ctx.on('subagent/end', info => void ends.push(info))
|
|
|
+
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ continuationActivations(ctx).get(started.childId)!.observer.capture = () => {
|
|
|
+ throw new Error('capture failed')
|
|
|
+ }
|
|
|
+
|
|
|
+ hold.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
|
|
+ expect(ends[0]!.stopReason).toBe('error')
|
|
|
+ })
|
|
|
+
|
|
|
it('preserves independent pre-disposal and handle-disposal failures', async () => {
|
|
|
const hold = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
|
|
|
const { ctx, parent } = await setupWith(adapter)
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: {
|
|
|
- activations: Map<SessionId, {
|
|
|
- handle: { dispose: () => Promise<void> }
|
|
|
- observer: { capture: (child: Agent) => void }
|
|
|
- }>
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
+ const activation = continuationActivations(ctx).get(started.childId)!
|
|
|
const realDispose = activation.handle.dispose.bind(activation.handle)
|
|
|
activation.observer.capture = () => { throw new Error('capture failed') }
|
|
|
activation.handle.dispose = async () => {
|
|
|
@@ -1771,6 +2026,138 @@ describe('continuable review regressions', () => {
|
|
|
expect(hasUserText(loaded.events, 'discarded')).toBe(false)
|
|
|
})
|
|
|
|
|
|
+ it('settles after removing the last message from an idle parked Inbox', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const messageId = await queuePrompt(ctx, parent, started.childId, message('queued'))
|
|
|
+ ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(child.inbox.remove(messageId)).toBe(true)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a maintenance task that claimed the idle phase after whenIdle resolved', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ // Held while the child still runs, so the watcher observes idle and then
|
|
|
+ // waits here with its settlement decision already outstanding.
|
|
|
+ const lock = await holdChildLock(ctx, started.childId)
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ const finishMaintenance = Promise.withResolvers<undefined>()
|
|
|
+ let maintenanceSignal: AbortSignal | undefined
|
|
|
+ const maintenance = child.runMaintenance(async (signal) => {
|
|
|
+ maintenanceSignal = signal
|
|
|
+ await finishMaintenance.promise
|
|
|
+ })
|
|
|
+ lock.release()
|
|
|
+ await lock.held
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(maintenanceSignal?.aborted).toBe(false)
|
|
|
+ expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
|
|
|
+ finishMaintenance.resolve(undefined)
|
|
|
+ await maintenance
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('settles when maintenance finishes after losing the idle phase', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const lock = await holdChildLock(ctx, started.childId)
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ const finishMaintenance = Promise.withResolvers<undefined>()
|
|
|
+ const maintenance = child.runMaintenance(async () => { await finishMaintenance.promise })
|
|
|
+ lock.release()
|
|
|
+ // Let the queued settlement check observe maintenance, then finish it
|
|
|
+ // before that check's caller receives the false result.
|
|
|
+ queueMicrotask(() => {
|
|
|
+ queueMicrotask(() => { finishMaintenance.resolve(undefined) })
|
|
|
+ })
|
|
|
+ await lock.held
|
|
|
+ await maintenance
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ { label: 'plugin', source: { kind: 'plugin' as const, plugin: 'tool-jobs' } },
|
|
|
+ { label: 'non-plugin', source: { kind: 'team-message', teamId: 't-1' } as never },
|
|
|
+ ])('keeps an idle child resident while its Inbox holds $label injected context', async ({ source }) => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const lock = await holdChildLock(ctx, started.childId)
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ const context = createUserMessage({ content: message('parked context'), source })
|
|
|
+ child.inject(context)
|
|
|
+ expect(child.inbox.nextStep).toHaveLength(1)
|
|
|
+ lock.release()
|
|
|
+ await lock.held
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
|
|
|
+ expect(child.inbox.remove(context.id)).toBe(true)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps an idle child resident while plugin-sourced steering stays unclaimed', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ // A cordis-host-runner failure report: `steer()` from a plugin still wakes
|
|
|
+ // a driver, so residency must survive until that turn claims the message.
|
|
|
+ const steered = createUserMessage({
|
|
|
+ content: message('Cordis Host handler failed'),
|
|
|
+ source: { kind: 'plugin', plugin: 'cordis-host-runner' },
|
|
|
+ })
|
|
|
+ child.steer(steered)
|
|
|
+ ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
|
|
|
+ expect(child.inbox.remove(steered.id)).toBe(true)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps an idle child resident while an interrupted turn leaves human steering parked', async () => {
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ await humanPrompt(ctx, parent, started.childId, 'steered', 'steer')
|
|
|
+ ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
|
|
|
+ release.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ const parked = child.inbox.nextStep[0]!
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
+ expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
|
|
|
+ expect(child.inbox.remove(parked.id)).toBe(true)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
it('settles after a delivery discarded inside its own admission window', async () => {
|
|
|
const releaseFirst = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
|
|
|
@@ -1779,8 +2166,8 @@ describe('continuable review regressions', () => {
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
const child = ctx.agents.get(started.childId)!
|
|
|
|
|
|
- // Cancel from the synchronous enqueue observer: the discard fires after the
|
|
|
- // id is recorded but before `queuePrompt()` returns.
|
|
|
+ // Cancel from the synchronous enqueue observer, before `queuePrompt()`
|
|
|
+ // returns from Agent.followup().
|
|
|
const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
|
|
|
if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
|
|
|
child.cancel({ kind: 'user' })
|
|
|
@@ -1790,29 +2177,21 @@ describe('continuable review regressions', () => {
|
|
|
off()
|
|
|
|
|
|
releaseFirst.resolve(undefined)
|
|
|
- // Retaining the discarded id would pin residency at `running` forever, so
|
|
|
- // reaching no-Activation without an explicit drain is the assertion.
|
|
|
+ // The discarded delivery leaves no phantom activity that pins residency.
|
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
expect(hasUserText(loaded.events, 'doomed')).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('releases older ids discarded during a later admission window', async () => {
|
|
|
+ it('settles after a later delivery discards older queued work', async () => {
|
|
|
const releaseFirst = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
|
|
|
const { ctx, parent } = await setupWith(adapter)
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
const child = ctx.agents.get(started.childId)!
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: {
|
|
|
- activations: Map<SessionId, { accepted: Set<MessageId> }>
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- const activation = manager.activations.get(started.childId)!
|
|
|
|
|
|
await queuePrompt(ctx, parent, started.childId, message('queued'))
|
|
|
- expect(activation.accepted.size).toBe(1)
|
|
|
const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
|
|
|
if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
|
|
|
child.cancel({ kind: 'user' })
|
|
|
@@ -1821,9 +2200,11 @@ describe('continuable review regressions', () => {
|
|
|
await queuePrompt(ctx, parent, started.childId, message('doomed'))
|
|
|
off()
|
|
|
|
|
|
- expect(activation.accepted.size).toBe(0)
|
|
|
releaseFirst.resolve(undefined)
|
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
+ const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
+ expect(hasUserText(loaded.events, 'queued')).toBe(false)
|
|
|
+ expect(hasUserText(loaded.events, 'doomed')).toBe(false)
|
|
|
})
|
|
|
|
|
|
it('reports a prompt a pre-step rejection discarded as refusal', async () => {
|
|
|
@@ -2250,11 +2631,8 @@ describe('continuable settlement delivery', () => {
|
|
|
it('withholds an outcome the harness could not durably release', async () => {
|
|
|
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
|
|
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose(): Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
const activation = await vi.waitFor(() => {
|
|
|
- const live = manager.activations.get(started.childId)
|
|
|
+ const live = continuationActivations(ctx).get(started.childId)
|
|
|
expect(live).toBeDefined()
|
|
|
return live!
|
|
|
})
|
|
|
@@ -2342,11 +2720,8 @@ describe('continuable settlement delivery', () => {
|
|
|
const second = await ctx.subagents.startContinuable(startSpec(middle))
|
|
|
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
|
|
|
|
|
- // `Agent.status` folds maintenance into `idle`, and a waking send behind it
|
|
|
- // only arms a deferred wake. The first child's release moves the middle
|
|
|
- // Activation's settlement watcher onto its quiescence race; the second one
|
|
|
- // then arrives at exactly the point where an unaccounted delivery would be
|
|
|
- // judged quiet, settled, and cancelled — clearing the inbox it sits in.
|
|
|
+ // `whenIdle()` follows maintenance and the deferred wake it releases, so
|
|
|
+ // neither settlement notice can be mistaken for completed idle work.
|
|
|
const maintaining = Promise.withResolvers<undefined>()
|
|
|
const maintenance = middle.runMaintenance(async () => { await maintaining.promise })
|
|
|
releaseFirst.resolve(undefined)
|
|
|
@@ -2380,13 +2755,10 @@ describe('continuable settlement delivery', () => {
|
|
|
const inner = await ctx.subagents.startContinuable(startSpec(middle))
|
|
|
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
|
|
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { ownedChildren: Set<SessionId> }> }
|
|
|
- }).continuations
|
|
|
let ownedAtDelivery: SessionId[] | undefined
|
|
|
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
|
|
if (agent !== middle || message.source.kind !== 'subagent-settled') return
|
|
|
- ownedAtDelivery = [...manager.activations.get(middle.id)!.ownedChildren]
|
|
|
+ ownedAtDelivery = [...continuationActivations(ctx).get(middle.id)!.ownedChildren]
|
|
|
})
|
|
|
|
|
|
releaseChild.resolve(undefined)
|
|
|
@@ -2633,10 +3005,7 @@ describe('continuable errors', () => {
|
|
|
})
|
|
|
// Drop the Activation without disposing the Agent, leaving the id live but
|
|
|
// unmanaged. Materialization must not adopt it.
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, unknown> }
|
|
|
- }).continuations
|
|
|
- manager.activations.delete(started.childId)
|
|
|
+ dropContinuationActivation(ctx, started.childId)
|
|
|
|
|
|
await expect(queuePrompt(ctx, parent, started.childId, message('hello')))
|
|
|
.rejects.toThrow(SubagentError)
|
|
|
@@ -2696,10 +3065,7 @@ describe('continuable errors', () => {
|
|
|
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
|
|
|
// Make the grandchild's own handle disposal reject: scope teardown failure
|
|
|
// propagates, unlike a contained `agent/disposed` listener throw.
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
|
|
- }).continuations
|
|
|
- const branch = manager.activations.get(grandchild.childId)!
|
|
|
+ const branch = continuationActivations(ctx).get(grandchild.childId)!
|
|
|
const realDispose = branch.handle.dispose.bind(branch.handle)
|
|
|
branch.handle.dispose = async () => {
|
|
|
await realDispose()
|
|
|
@@ -2730,11 +3096,8 @@ describe('continuable errors', () => {
|
|
|
})
|
|
|
// The would-be parent's disposal is already open at the entry hold, so the
|
|
|
// establishment rejects before any grandchild resource exists.
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: { activations: Map<SessionId, { disposal: Promise<void> | undefined }> }
|
|
|
- }).continuations
|
|
|
const before = new Set(ctx.agents.list().map(agent => agent.id))
|
|
|
- manager.activations.get(outer.childId)!.disposal = Promise.resolve()
|
|
|
+ void continuationActivations(ctx).get(outer.childId)!.inbox.close(() => Promise.resolve())
|
|
|
|
|
|
await expect(ctx.subagents.startContinuable(startSpec(child)))
|
|
|
.rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
|
|
|
@@ -2757,13 +3120,8 @@ describe('continuable errors', () => {
|
|
|
expect(found).toBeDefined()
|
|
|
return found!
|
|
|
})
|
|
|
- const manager = (ctx.subagents as unknown as {
|
|
|
- continuations: {
|
|
|
- activations: Map<SessionId, { disposal: Promise<void> | undefined }>
|
|
|
- ownerCtx: Context
|
|
|
- }
|
|
|
- }).continuations
|
|
|
- const ownerAgents = manager.ownerCtx.agents
|
|
|
+ const activations = continuationActivations(ctx)
|
|
|
+ const ownerAgents = activations.ownerCtx.agents
|
|
|
const before = new Set(ctx.agents.list().map(agent => agent.id))
|
|
|
// Open the would-be parent's disposal only once the grandchild's Agent is
|
|
|
// being created: the entry hold has already passed, so the post-transfer
|
|
|
@@ -2771,7 +3129,7 @@ describe('continuable errors', () => {
|
|
|
// Activation and no live Agent left behind.
|
|
|
const originalCreate = ownerAgents.create.bind(ownerAgents)
|
|
|
const createSpy = vi.spyOn(ownerAgents, 'create').mockImplementation((options) => {
|
|
|
- manager.activations.get(outer.childId)!.disposal = Promise.resolve()
|
|
|
+ void activations.get(outer.childId)!.inbox.close(() => Promise.resolve())
|
|
|
createSpy.mockRestore()
|
|
|
return originalCreate(options)
|
|
|
})
|
|
|
@@ -2901,7 +3259,7 @@ describe('continuable errors', () => {
|
|
|
})
|
|
|
|
|
|
describe('SubagentRuntime.interrupt', () => {
|
|
|
- it('aborts the current turn durably, parks accepted follow-ups, and resumes them only on a waking send', async () => {
|
|
|
+ it('aborts the current turn durably, parks accepted follow-ups, and settles after direct Agent followup', async () => {
|
|
|
const releaseFirst = Promise.withResolvers<undefined>()
|
|
|
const adapter = new GatedAdapter([
|
|
|
{ chunks: textResponse('first'), gate: releaseFirst.promise },
|
|
|
@@ -2924,6 +3282,7 @@ describe('SubagentRuntime.interrupt', () => {
|
|
|
// Cancellation is cooperative: the held model call observes it on release.
|
|
|
releaseFirst.resolve(undefined)
|
|
|
await child.whenIdle()
|
|
|
+ await passSettlementCheck(ctx, started.childId)
|
|
|
// Parked, not resumed: no second model request follows the abort, the
|
|
|
// accepted follow-ups stay pending, and the same Activation stays resident.
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
|
@@ -2931,9 +3290,9 @@ describe('SubagentRuntime.interrupt', () => {
|
|
|
expect(child.status).toBe('idle')
|
|
|
expect(ctx.agents.get(started.childId)).toBe(child)
|
|
|
|
|
|
- // Only an explicit waking send restores the driver; the parked items then
|
|
|
- // run before it in the existing FIFO order.
|
|
|
- await queuePrompt(ctx, parent, started.childId, message('waking D'))
|
|
|
+ // A host can wake a resident child through Agent directly; the parked items
|
|
|
+ // still run before the new message in the existing FIFO order.
|
|
|
+ child.followup(createUserMessage({ content: message('waking D'), source: { kind: 'user' } }))
|
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
|
|
|
expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D'])
|