test(agent-loop): cover scoped cancellation during Web shutdown
This commit is contained in:
1 parent
bff2d40dee
commit
1637954fe3
7 files changed
+220
-20
No files matched your search
+83
@@ -0,0 +1,83 @@
|
||||
/** Scope-owned cancellation during shutdown of the shipped Web profile. */
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const name = 'agent-shutdown-fixture'
|
||||
export const inject = ['agents', 'llm', 'appReady', 'appExit']
|
||||
|
||||
class Adapter extends LlmAdapter {
|
||||
async resolveModel(provider, model) {
|
||||
return { provider, id: model, name: model }
|
||||
}
|
||||
|
||||
async *stream() {
|
||||
throw new Error('The cancelled request must not reach the adapter.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold one turn until cancellation and report the state seen by scope cleanup. */
|
||||
export function apply(ctx, config) {
|
||||
ctx.llm.registerAdapter(['shutdown-fixture'], new Adapter())
|
||||
const exit = ctx.appExit
|
||||
ctx.effect(() => ctx.appReady.onReady(() => {
|
||||
void start(ctx, config).catch(error => { console.error(error); exit(1) })
|
||||
}))
|
||||
if (process.platform === 'win32') {
|
||||
const timer = setInterval(() => {
|
||||
if (!existsSync(config.interrupt)) return
|
||||
clearInterval(timer)
|
||||
process.emit('SIGTERM')
|
||||
}, 20)
|
||||
ctx.effect(() => () => { clearInterval(timer) })
|
||||
}
|
||||
}
|
||||
|
||||
async function start(ctx, config) {
|
||||
const { agent } = await ctx.agents.create({
|
||||
sessionId: 'shutdown-fixture',
|
||||
agentOptions: { provider: 'shutdown-fixture', model: 'shutdown-fixture' },
|
||||
meta: { cwd: config.root },
|
||||
})
|
||||
const controller = new AbortController()
|
||||
let abortHandled = 0
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
agent.cancel({ kind: 'parent' })
|
||||
agent.cancel({ kind: 'parent' })
|
||||
abortHandled++
|
||||
}, { once: true })
|
||||
agent.ctx.effect(() => () => {
|
||||
controller.abort()
|
||||
controller.abort()
|
||||
const events = agent.session.snapshotEvents()
|
||||
writeFileSync(config.result, JSON.stringify({
|
||||
abortHandled,
|
||||
status: agent.status,
|
||||
nextStep: agent.inbox.nextStep,
|
||||
nextTurn: agent.inbox.nextTurn,
|
||||
lastEvent: events.at(-1),
|
||||
cancelled: events.filter(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled'),
|
||||
}))
|
||||
})
|
||||
const message = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
if (config.running) {
|
||||
const entered = Promise.withResolvers()
|
||||
agent.ctx.on('agent/request', async ({ signal }, next) => {
|
||||
const aborted = Promise.withResolvers()
|
||||
signal.addEventListener('abort', () => { aborted.resolve() }, { once: true })
|
||||
entered.resolve()
|
||||
await aborted.promise
|
||||
await setImmediate()
|
||||
return next()
|
||||
})
|
||||
agent.followup(message('active'))
|
||||
await entered.promise
|
||||
}
|
||||
agent.send(message('pending step'), 'next-step', false)
|
||||
agent.send(message('pending turn'), 'next-turn', false)
|
||||
if (config.cancelFirst) {
|
||||
controller.abort()
|
||||
await agent.whenIdle()
|
||||
}
|
||||
console.log('agent-shutdown: ready')
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Keyless SIGTERM regression for Agent cancellation during Web teardown. */
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
it.each([
|
||||
{ state: 'idle', cancelFirst: false },
|
||||
{ state: 'running', cancelFirst: false },
|
||||
{ state: 'idle', cancelFirst: true },
|
||||
{ state: 'running', cancelFirst: true },
|
||||
])('preserves Agent cleanup during Web shutdown ($state, cancel first: $cancelFirst)', { retry: 0 }, async ({ state, cancelFirst }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-web-agent-shutdown-'))
|
||||
try {
|
||||
const patch = join(root, 'shutdown.patch.yml')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const interrupt = join(root, 'interrupt')
|
||||
await writeFile(patch, [
|
||||
'- id: hmr',
|
||||
' disabled: true',
|
||||
'- id: session-title-llm',
|
||||
' disabled: true',
|
||||
'- insert:',
|
||||
` - name: ${JSON.stringify(new URL('./fixtures/agent-shutdown.mjs', import.meta.url).href)}`,
|
||||
` config: ${JSON.stringify({ root, result: resultPath, interrupt, running: state === 'running', cancelFirst })}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../src/bin.ts', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
configArgs: ['--profile', 'web', '--patch', patch, '--no-open', '--port', '0'],
|
||||
env: {
|
||||
DSH_HOME: join(root, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(root, '.agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
})
|
||||
const child = execa(launch.command, launch.args, {
|
||||
cwd: root, env: launch.env, stdin: 'pipe',
|
||||
timeout: 90_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
const lines = createInterface({ input: child.stdout })
|
||||
try {
|
||||
const ready = Promise.withResolvers<undefined>()
|
||||
lines.on('line', (line) => {
|
||||
if (line === 'agent-shutdown: ready') ready.resolve(undefined)
|
||||
})
|
||||
await Promise.race([
|
||||
ready.promise,
|
||||
child.then((result) => { throw new Error(`Web exited before readiness: ${result.stderr}`) }),
|
||||
])
|
||||
if (process.platform === 'win32') await writeFile(interrupt, '')
|
||||
else child.kill('SIGTERM')
|
||||
const result = await child
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toMatch(/dsh: fatal|projection registration is not active/u)
|
||||
const evidence: unknown = JSON.parse(await readFile(resultPath, 'utf8'))
|
||||
expect(evidence).toMatchObject({
|
||||
abortHandled: 1,
|
||||
status: 'idle',
|
||||
nextStep: [],
|
||||
nextTurn: [],
|
||||
cancelled: [
|
||||
{ data: { target: 'next-step', removedCount: 1 } },
|
||||
{ data: { target: 'next-turn', removedCount: 1 } },
|
||||
],
|
||||
})
|
||||
if (state === 'running') {
|
||||
expect(evidence).toMatchObject({
|
||||
lastEvent: { type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: cancelFirst ? 'parent' : 'disposed' } } } },
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
lines.close()
|
||||
child.kill('SIGKILL')
|
||||
await child
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -39,11 +39,11 @@
|
||||
en: 2a8b93869eb5a2f9
|
||||
zh: ddab8e738d188a61
|
||||
/deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown:
|
||||
en: 1c8564a06f9409ba
|
||||
zh: 6ff14b442a191341
|
||||
en: d079cf181ece9267
|
||||
zh: 047f76fecfc5497c
|
||||
/deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration:
|
||||
en: da056b9f0dde4ab3
|
||||
zh: ba9ce3b19c17ca22
|
||||
en: 3c5721bc00a0bbf4
|
||||
zh: 711f832343ffbb75
|
||||
/deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow:
|
||||
en: 28939261ccc7defe
|
||||
zh: 9d157a321307ebd4
|
||||
@@ -105,8 +105,8 @@
|
||||
en: 83531f2a13cc1f0e
|
||||
zh: 81a780d637dd8c5d
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work:
|
||||
en: 93cbef12ed438ae7
|
||||
zh: 1b9213fc61eab201
|
||||
en: a57822fab5e6fdfd
|
||||
zh: 39f97b0c5040f7c8
|
||||
/deepseek-ai-dsh-agent-loop/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -109,11 +109,11 @@ The loop deep-freezes each derived message identity on its first request and reu
|
||||
|
||||
Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement.
|
||||
|
||||
Factory unload retains the inbox and turn-boundary projections until every Agent teardown and startup task settles, even when cleanup fails. Caller unload, factory unload, and handle disposal await the same teardown.
|
||||
Factory unload retains the inbox and turn-boundary projections through scope cleanup, including cancellation from scoped abort listeners, until every Agent teardown and startup task settles. Cleanup failures do not release those projections early. Caller unload, factory unload, and handle disposal await the same teardown.
|
||||
|
||||
### Persistence integration
|
||||
|
||||
The loop is the production acquisition point for session write handles. When `ctx.sessionPersistence` is mounted, `create`/`createAgent` call `persistence.create(header)` — storing the durable identity and taking write ownership before publication — and append the constructor seed through the handle; `resume` calls `persistence.open(id, 'write')` first (excluding a concurrent resume of the same id), reads the physically valid log through the handle, and appends `interruptedTurnClosers` for a log crashed mid-turn as an ordinary batch — semantic crash repair is the agent layer's job, not a storage entry point. Immediately before publication, `appendUnstoredSuffix` stores any events appended during the setup window (seed markers, delegation policy records), which never re-emit through `session/event`. Once published, the mounted backend routes the session's `session/event` batches, `session/flush` barriers, and `session/disposed` retirement into the active write handle by session id; the loop touches storage only through the handle it owns. The memoized teardown closes the handle — close drains any routed buffer — after the loop commits the session's closing events, provably releasing write ownership. Without a backend, sessions are memory-only and nothing else changes.
|
||||
The loop is the production acquisition point for session write handles. When `ctx.sessionPersistence` is mounted, `create`/`createAgent` call `persistence.create(header)` — storing the durable identity and taking write ownership before publication — and append the constructor seed through the handle; `resume` calls `persistence.open(id, 'write')` first (excluding a concurrent resume of the same id), reads the physically valid log through the handle, and appends `interruptedTurnClosers` for a log crashed mid-turn as an ordinary batch — semantic crash repair is the agent layer's job, not a storage entry point. Immediately before publication, `appendUnstoredSuffix` stores any events appended during the setup window (seed markers, delegation policy records), which never re-emit through `session/event`. Once published, the mounted backend routes the session's `session/event` batches, `session/flush` barriers, and `session/disposed` retirement into the active write handle by session id; the loop touches storage only through the handle it owns. With the backend still mounted, teardown closes the handle after the driver commits its closing events; close drains the routed buffer and releases write ownership. Without a backend, sessions are memory-only and nothing else changes.
|
||||
|
||||
### Turn and step flow
|
||||
|
||||
@@ -213,7 +213,7 @@ Recovery results append after the existing history and preserve its reusable pre
|
||||
These limits define when the loop needs special care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **Root shutdown persistence** — shutting down the root context with an active background child can leave its final `turn/end` in memory but absent from the persisted log. Agent teardown completing does not guarantee that this event was saved.
|
||||
- **Root shutdown persistence** — shutting down the root context with an active Agent can leave its final `turn/end` in memory but absent from the persisted log. Agent teardown completing does not guarantee that this event was saved.
|
||||
- **Previously closed inconsistent history** — failed-step recovery does not rewrite unanswered calls in already-closed historical turns.
|
||||
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona and tool composition are available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
|
||||
@@ -109,11 +109,11 @@ const handle = await ctx.agents.create({
|
||||
|
||||
创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。
|
||||
|
||||
工厂卸载时保留 inbox 投影与轮次边界投影,直到所有 Agent teardown 和启动任务结束,即使清理失败也如此。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。
|
||||
工厂卸载时在整个作用域清理期间保留 inbox 投影与轮次边界投影,包括作用域内 abort 监听器发起取消的时刻,直到所有 Agent teardown 和启动任务结束。清理失败不会使这些投影提前释放。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。
|
||||
|
||||
### 持久化集成
|
||||
|
||||
循环是会话写句柄在生产环境中的获取点。挂载 `ctx.sessionPersistence` 后,`create`/`createAgent` 调用 `persistence.create(header)`——在发布之前存储持久身份并取得写所有权——并通过句柄追加构造 seed;`resume` 先调用 `persistence.open(id, 'write')`(排除同 id 的并发恢复),通过句柄读取物理上有效的日志,并为在轮次中途崩溃的日志把 `interruptedTurnClosers` 作为普通批次追加——语义崩溃修复是 agent 层的职责,而非存储入口。发布前的最后一刻,`appendUnstoredSuffix` 存储 setup 窗口期间追加的事件(seed 标记、委派策略记录),它们绝不会经由 `session/event` 重新发出。发布之后,挂载的后端按会话 id 把该会话的 `session/event` 批次、`session/flush` 屏障与 `session/disposed` 退役路由进活跃写句柄;循环只通过它拥有的句柄触碰存储。记忆化的 teardown 在循环提交会话的收尾事件之后关闭句柄——close 会排空任何已路由的缓冲——可证明地释放写所有权。没有后端时,会话只存在于内存中,其余一切不变。
|
||||
循环是会话写句柄在生产环境中的获取点。挂载 `ctx.sessionPersistence` 后,`create`/`createAgent` 调用 `persistence.create(header)`——在发布之前存储持久身份并取得写所有权——并通过句柄追加构造 seed;`resume` 先调用 `persistence.open(id, 'write')`(排除同 id 的并发恢复),通过句柄读取物理上有效的日志,并为在轮次中途崩溃的日志把 `interruptedTurnClosers` 作为普通批次追加——语义崩溃修复是 agent 层的职责,而非存储入口。发布前的最后一刻,`appendUnstoredSuffix` 存储 setup 窗口期间追加的事件(seed 标记、委派策略记录),它们绝不会经由 `session/event` 重新发出。发布之后,挂载的后端按会话 id 把该会话的 `session/event` 批次、`session/flush` 屏障与 `session/disposed` 退役路由进活跃写句柄;循环只通过它拥有的句柄触碰存储。在后端仍保持挂载时,teardown 会在驱动器提交收尾事件后关闭句柄;close 排空已路由的缓冲并释放写所有权。没有后端时,会话只存在于内存中,其余一切不变。
|
||||
|
||||
### 轮次与步骤流程
|
||||
|
||||
@@ -213,7 +213,7 @@ const handle = await ctx.agents.create({
|
||||
这些限制说明循环何时需要特别留意。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占([原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md))。
|
||||
- **根上下文关停的持久化**:存在活跃后台子 Agent 时,关停根上下文可能使最终的 `turn/end` 仅留在内存中,未写入持久化日志。Agent teardown 完成不保证该事件已保存。
|
||||
- **根上下文关停的持久化**:存在活跃 Agent 时,关停根上下文可能使最终的 `turn/end` 仅留在内存中,未写入持久化日志。Agent teardown 完成不保证该事件已保存。
|
||||
- **此前已关闭的不一致历史**:失败步骤恢复不会改写已关闭历史轮次中尚无结果的调用。
|
||||
- **配置标签默认对应新会话**:省略 `sessionId` 时,每次启动都会创建新的 `${id}-session-<uuid>`;如需确切的恢复或创建行为,必须显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。
|
||||
- **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona 与工具组合。
|
||||
|
||||
@@ -572,10 +572,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
// The loop above committed its closing events synchronously into the
|
||||
// session; handle close drains them durably before releasing the write
|
||||
// path. The close drain can be the first operation that surfaces a
|
||||
// durability failure, so its error is retained, not logged away.
|
||||
// Close drains events already routed into the handle. The backend's
|
||||
// live-event listeners must stay mounted through driver shutdown to
|
||||
// receive all closing events. A failed final drain is retained here
|
||||
// rather than logged away.
|
||||
try {
|
||||
await handle?.close()
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
@@ -39,7 +40,16 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed
|
||||
let scopeDisposedBeforeWorkExit = false
|
||||
let projectionPresentAtWorkExit = false
|
||||
let scopeDisposed = false
|
||||
let abortHandled = 0
|
||||
const controller = new AbortController()
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
agent.cancel({ kind: 'parent' })
|
||||
agent.cancel({ kind: 'parent' })
|
||||
abortHandled++
|
||||
}, { once: true })
|
||||
agent.ctx.effect(() => () => {
|
||||
controller.abort()
|
||||
controller.abort()
|
||||
scopeDisposed = true
|
||||
scopeDisposedBeforeWorkExit = !workExited
|
||||
})
|
||||
@@ -63,6 +73,7 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed
|
||||
expect(scopeDisposedBeforeWorkExit).toBe(false)
|
||||
expect(projectionPresentAtWorkExit).toBe(true)
|
||||
expect(scopeDisposed).toBe(true)
|
||||
expect(abortHandled).toBe(1)
|
||||
expect(projections.stateOf(agent.session, 'inbox')).toBeUndefined()
|
||||
expect(projections.stateOf(agent.session, 'turnBoundary')).toBeUndefined()
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
@@ -99,10 +110,10 @@ it.each(['loop', 'root'] as const)('unloads %s while a background child is stopp
|
||||
const { agent: parent } = await ctx.agents.create({
|
||||
sessionId: parentId, agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const requestEntered = Promise.withResolvers<undefined>()
|
||||
const requestEntered = Promise.withResolvers<Agent>()
|
||||
ctx.on('agent/request', async (event, next) => {
|
||||
expect(event.agent).not.toBe(parent)
|
||||
requestEntered.resolve(undefined)
|
||||
requestEntered.resolve(event.agent)
|
||||
await release.promise
|
||||
return next()
|
||||
})
|
||||
@@ -112,12 +123,31 @@ it.each(['loop', 'root'] as const)('unloads %s while a background child is stopp
|
||||
arguments: { description: 'child', prompt: 'wait' },
|
||||
})
|
||||
expect(started.isError).toBe(false)
|
||||
await requestEntered.promise
|
||||
const child = await requestEntered.promise
|
||||
for (const target of ['next-step', 'next-turn'] as const) {
|
||||
child.send(createUserMessage({ content: [{ type: 'text', text: target }], source: { kind: 'user' } }), target, false)
|
||||
}
|
||||
const disposal = owner === 'loop' ? loop.dispose() : ctx.fiber.dispose()
|
||||
// Teardown cancels the in-flight child while it is still inside agent/request.
|
||||
await childCancelObserved.promise
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(child.status).toBe('idle')
|
||||
const events = child.session.snapshotEvents()
|
||||
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } } })
|
||||
expect(events.filter(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled'))
|
||||
.toMatchObject([
|
||||
{ data: { target: 'next-step', removedCount: 1 } },
|
||||
{ data: { target: 'next-turn', removedCount: 1 } },
|
||||
])
|
||||
if (owner === 'loop') {
|
||||
const verify = new Context()
|
||||
onTestFinished(async () => { await verify.fiber.dispose() })
|
||||
await verify.plugin(JsonlSessionPersistence, { root })
|
||||
const reader = await verify.sessionPersistence.open(child.id, 'read')
|
||||
expect((await reader.read()).events).toEqual(events)
|
||||
await reader.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('releases the Agent scope after cancel failure with scope failure %s', async (failScope) => {
|
||||
|
||||
Reference in new issue
Block a user