refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
|
||||
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
|
||||
README.md: 3b9153c42885dfb29821ecf452811c1bb15446f2
|
||||
README.zh.md: 1d6f7bcedcd4fa8b9e3e87df137ba59f8f535666
|
||||
|
||||
@@ -20,7 +20,7 @@ Before model output, session events, tool presenters, questions, configuration,
|
||||
|
||||
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation; both enter the inbox and pass through `agent/pre-step` after the loop claims their batch.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
|
||||
|
||||
在 token 边界输入 `@` 会搜索会话工作目录下的文件和目录。没有路径的模糊查询使用可复用的有界工作区索引;包含 `/` 的查询直接列出该目录,选择文件夹后会保持补全开启以继续深入。含空白的路径会插入为 `@"path with spaces"`。选择文件只会插入其路径和一个尾随空格:TUI 不会读取文件、附加隐藏上下文,也不会把路径替换为引用对象。注册模型侧 `read` 工具后,TUI 会添加一条固定系统提示词指令,要求模型在需要显式路径内容时读取该路径。
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`;二者都会进入 inbox,并在循环领取相应批次后经过 `agent/pre-step`。
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
|
||||
|
||||
@@ -1234,8 +1234,7 @@ export function createTuiChat(
|
||||
return
|
||||
}
|
||||
if (agent.status === 'running') {
|
||||
// Steering is never subject to prompt admission; an attached snapshot
|
||||
// drains beside it at the same step boundary through the outbox.
|
||||
// The attached snapshot is claimed beside steering at the same step boundary.
|
||||
if (attachedContext !== undefined) {
|
||||
agent.inject(attachedContext)
|
||||
}
|
||||
@@ -1249,48 +1248,41 @@ export function createTuiChat(
|
||||
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
|
||||
return
|
||||
}
|
||||
// Idle: the snapshot rides the prompt's admission transaction so a
|
||||
// blocking hook discards both together.
|
||||
// Idle: the snapshot rides the prompt's pre-step decision so rejection
|
||||
// discards both together.
|
||||
let cleanedUp = false
|
||||
const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
|
||||
const acceptedId = message.id
|
||||
const discarded = new Set<MessageId>()
|
||||
const cleanup = (): void => {
|
||||
// Every completion path detaches both listeners. Keep this
|
||||
// idempotent so later cleanup paths cannot double-release them.
|
||||
/* v8 ignore next -- unreachable idempotence guard, see above */
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
detachSubmit()
|
||||
detachSplice()
|
||||
detachPreStep()
|
||||
detachDiscarded()
|
||||
}
|
||||
// Prepended so this wrapper is outermost: it observes the exact accepted
|
||||
// message identity whether a downstream hook allows or blocks, then detaches.
|
||||
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
|
||||
// Prepended so this wrapper is outermost: it observes the exact claimed
|
||||
// message identity whether downstream enters or rejects, then detaches.
|
||||
const detachPreStep = ctx.on('agent/pre-step', async (subject, submitted, _context, next) => {
|
||||
if (subject !== agent || !submitted.some(item => item.id === message.id)) return next()
|
||||
cleanup()
|
||||
const decision = await next()
|
||||
if (decision.kind !== 'allow') return decision
|
||||
return { ...decision, messages: [...decision.messages, attachedContext] }
|
||||
if (decision.kind !== 'enter') return decision
|
||||
return {
|
||||
...decision,
|
||||
messages: [...decision.messages, attachedContext],
|
||||
}
|
||||
}, { prepend: true })
|
||||
// Installed before followup(): an inbox observer can synchronously cancel
|
||||
// Installed before followup(): an inbox observer can synchronously discard
|
||||
// the inserted message before followup() returns.
|
||||
const detachSplice = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|
||||
|| event.data.target !== 'next-turn' || event.data.outcome !== 'canceled') return
|
||||
const removed = agent.inbox.nextTurn.slice(
|
||||
event.data.start,
|
||||
event.data.start + (event.data.removedCount ?? 0),
|
||||
)
|
||||
for (const item of removed) discarded.add(item.id)
|
||||
if (discarded.has(acceptedId)) cleanup()
|
||||
const detachDiscarded = ctx.on('agent/inbox/discarded', (subject, { message: discarded }) => {
|
||||
if (subject === agent && discarded.id === message.id) cleanup()
|
||||
})
|
||||
// followup() accepts any typed input and contains listener failures;
|
||||
// this guards a future synchronous throw so the wrapper cannot leak.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
try {
|
||||
agent.followup(message)
|
||||
if (discarded.has(acceptedId)) cleanup()
|
||||
} catch (error: unknown) {
|
||||
cleanup()
|
||||
throw error
|
||||
@@ -1449,7 +1441,7 @@ export function createTuiChat(
|
||||
if (disposed) return
|
||||
editor.addToHistory(text)
|
||||
if (editor.getText() === value) editor.setText('')
|
||||
// The snapshot travels with the prompt so a blocking admission hook
|
||||
// The snapshot travels with the prompt so a rejecting pre-step hook
|
||||
// discards them together — see dispatchMessage's attached-context path.
|
||||
dispatchMessage(prepared.content, prepared.additionalContext)
|
||||
}, (error: unknown) => {
|
||||
@@ -1503,15 +1495,6 @@ export function createTuiChat(
|
||||
|
||||
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
|
||||
const removed = agent.inbox.nextStep.slice(
|
||||
event.data.start,
|
||||
event.data.start + (event.data.removedCount ?? 0),
|
||||
)
|
||||
let changed = false
|
||||
for (const message of removed) changed = pendingSteering.delete(message.id) || changed
|
||||
if (changed) refreshStatus()
|
||||
}
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
|
||||
@@ -1526,6 +1509,15 @@ export function createTuiChat(
|
||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||
requestRender()
|
||||
})
|
||||
const removePendingSteering = (subject: Agent, message: UserMessage): void => {
|
||||
if (subject === agent && pendingSteering.delete(message.id)) refreshStatus()
|
||||
}
|
||||
const disposeInboxClaimed = ctx.on('agent/inbox/claimed', (subject, { message }) => {
|
||||
removePendingSteering(subject, message)
|
||||
})
|
||||
const disposeInboxDiscarded = ctx.on('agent/inbox/discarded', (subject, { message }) => {
|
||||
removePendingSteering(subject, message)
|
||||
})
|
||||
const disposeStatus = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent) return
|
||||
// Leaving 'running' ends the turn's status line; clear any badge so the
|
||||
@@ -1566,6 +1558,8 @@ export function createTuiChat(
|
||||
for (const value of promptValues) value.dispose()
|
||||
stopBannerReveal()
|
||||
disposeSessionEvents()
|
||||
disposeInboxClaimed()
|
||||
disposeInboxDiscarded()
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
disposeAgent()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-l
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, {
|
||||
agentEvents,
|
||||
Inbox,
|
||||
type Agent,
|
||||
type AgentCancelCause,
|
||||
@@ -187,11 +188,15 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const injected: ContentBlock[][] = []
|
||||
const injectedOptions: UserMessage[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const inbox = new Inbox(session, {
|
||||
inserted: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/inserted', { message }) },
|
||||
discarded: (message) =>{ agentEvents(ctx, agent).emit('agent/inbox/discarded', { message }) },
|
||||
})
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: options.agentOptions ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
inbox: new Inbox(session),
|
||||
inbox,
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
sent,
|
||||
|
||||
@@ -24,7 +24,7 @@ class SnapshotAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
// The snapshot rides the prompt's admission: the loop appends the
|
||||
// The snapshot rides the prompt's pre-step result: the loop appends the
|
||||
// prompt first, then its additional contexts (the branch-wide ordering
|
||||
// for plugin-sourced context).
|
||||
const [prompt, context] = options.messages.slice(-2)
|
||||
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
} from './harness.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
function preStepContext(signal = new AbortController().signal) {
|
||||
return { turn: 1, step: 1, signal }
|
||||
}
|
||||
|
||||
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
|
||||
schema: { type: 'null' },
|
||||
render: () => [],
|
||||
@@ -1700,7 +1704,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const id = result.agent.steeredIds.shift()
|
||||
if (id !== undefined) {
|
||||
const index = result.agent.inbox.nextStep.findIndex(message => message.id === id)
|
||||
if (index >= 0) result.agent.inbox.splice('next-step', index, 1, [], 'admitted')
|
||||
if (index >= 0) result.agent.inbox.splice('next-step', index, 1, [])
|
||||
}
|
||||
result.session.append('steering/message', {
|
||||
turn: 1,
|
||||
@@ -2600,28 +2604,28 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
|
||||
// Idle: the snapshot rides the prompt's admission (additionalContexts on
|
||||
// the allow decision), not a separate pre-admission inject.
|
||||
// Idle: the snapshot rides the prompt's pre-step message decision, not a
|
||||
// separate inject.
|
||||
expect(result.agent.injected).toHaveLength(0)
|
||||
const submitted = result.agent.sentMessages[0]!
|
||||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [submitted],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
|
||||
'agent/pre-step', [submitted],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
|
||||
)
|
||||
expect(decision.kind).toBe('allow')
|
||||
expect(decision.kind === 'allow'
|
||||
expect(decision.kind).toBe('enter')
|
||||
expect(decision.kind === 'enter'
|
||||
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
|
||||
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
|
||||
|
||||
// The one-shot wrapper detached itself at admission: replaying the
|
||||
// The one-shot wrapper detached itself at pre-step: replaying the
|
||||
// waterfall attaches nothing a second time.
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [submitted],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
|
||||
'agent/pre-step', [submitted],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.messages).toEqual([submitted])
|
||||
expect(replay.kind === 'enter' && replay.messages).toEqual([submitted])
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
||||
result.agent.status = 'running'
|
||||
@@ -2629,12 +2633,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
|
||||
// Steering bypasses admission, so its snapshot still arrives via inject.
|
||||
// Running steering queues its snapshot through inject at the same boundary.
|
||||
expect(result.agent.injected).toHaveLength(1)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
|
||||
it('releases the reference pre-step wrapper on the ordinary enter path', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
@@ -2656,16 +2660,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await send()
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
|
||||
// Each wrapper releases on its own identified message's allowed admission.
|
||||
// Running each prompt's admission waterfall detaches its wrapper.
|
||||
// Each wrapper releases on its own identified message's pre-step decision.
|
||||
for (const sent of result.agent.sentMessages) {
|
||||
await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [sent],
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
|
||||
'agent/pre-step', [sent],
|
||||
preStepContext(), () => Promise.resolve({ kind: 'enter' as const, messages: [sent] }),
|
||||
)
|
||||
}
|
||||
// Both wrappers now gone: a discard naming either prompt's content finds
|
||||
// no armed listener, and an unrelated admission is untouched. The leak
|
||||
// no armed listener, and an unrelated pre-step is untouched. The leak
|
||||
// regression: a listener installed after its cleanup already ran would
|
||||
// survive every future cleanup.
|
||||
const unrelatedMessage = createUserMessage({
|
||||
@@ -2673,19 +2676,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [unrelatedMessage],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
|
||||
'agent/pre-step', [unrelatedMessage],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [unrelatedMessage] }),
|
||||
)
|
||||
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
|
||||
expect(unrelated.kind === 'enter' && unrelated.messages).toEqual([unrelatedMessage])
|
||||
// Replaying either sent prompt attaches nothing: the one-shot wrappers
|
||||
// are gone, not merely spent.
|
||||
for (const sent of result.agent.sentMessages) {
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [sent],
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
|
||||
'agent/pre-step', [sent],
|
||||
preStepContext(), () => Promise.resolve({ kind: 'enter' as const, messages: [sent] }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.messages).toEqual([sent])
|
||||
expect(replay.kind === 'enter' && replay.messages).toEqual([sent])
|
||||
}
|
||||
await dispose(result)
|
||||
})
|
||||
@@ -2707,7 +2710,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.agent.sent.push(input.content)
|
||||
result.agent.sentMessages.push(input)
|
||||
result.agent.inbox.splice('next-turn', 0, 0, [input])
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [])
|
||||
}
|
||||
|
||||
result.terminal.send('@sync-source')
|
||||
@@ -2718,18 +2721,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
|
||||
// The synchronous discard released the listeners before followup()
|
||||
// returned the existing id: replaying the prompt's admission attaches no
|
||||
// returned the existing id: replaying the prompt's pre-step attaches no
|
||||
// stranded snapshot, and nothing leaks for the TUI lifetime.
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [result.agent.sentMessages[0]!],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
|
||||
'agent/pre-step', [result.agent.sentMessages[0]!],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [result.agent.sentMessages[0]!] }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.messages).toEqual([result.agent.sentMessages[0]!])
|
||||
expect(replay.kind === 'enter' && replay.messages).toEqual([result.agent.sentMessages[0]!])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
|
||||
it('discards the reference snapshot with its rejected or cancelled prompt', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
@@ -2739,11 +2742,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
appendUser(source, 'source background')
|
||||
},
|
||||
})
|
||||
// A downstream admission hook blocks the prompt: the attached snapshot
|
||||
// A downstream pre-step hook rejects the prompt: the attached snapshot
|
||||
// must be discarded with it, not stranded for the next prompt.
|
||||
let blockPrompts = true
|
||||
result.ctx.on('agent/prompt-submit', async (_agent, _message, _signal, next) =>
|
||||
blockPrompts ? { kind: 'block' as const, reason: 'policy', discardClaimed: true } : next())
|
||||
result.ctx.on('agent/pre-step', async (_agent, _message, _signal, next) =>
|
||||
blockPrompts ? { kind: 'reject' as const } : next())
|
||||
|
||||
result.terminal.send('@blocked-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||||
@@ -2753,13 +2756,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
|
||||
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [result.agent.sentMessages[0]!],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
|
||||
'agent/pre-step', [result.agent.sentMessages[0]!],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [result.agent.sentMessages[0]!] }),
|
||||
)
|
||||
expect(blocked.kind).toBe('block')
|
||||
expect(blocked.kind).toBe('reject')
|
||||
// Nothing entered history and nothing waits for a later prompt: a fresh
|
||||
// unrelated admission sees no leftover contexts.
|
||||
// unrelated pre-step sees no leftover contexts.
|
||||
expect(result.agent.injected).toHaveLength(0)
|
||||
blockPrompts = false
|
||||
const unrelatedMessage = createUserMessage({
|
||||
@@ -2767,14 +2770,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [unrelatedMessage],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
|
||||
'agent/pre-step', [unrelatedMessage],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [unrelatedMessage] }),
|
||||
)
|
||||
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
|
||||
expect(unrelated.kind === 'enter' && unrelated.messages).toEqual([unrelatedMessage])
|
||||
|
||||
// Second referenced prompt, this time dropped by a broad cancel before
|
||||
// any admission runs: the discard listener releases the wrapper.
|
||||
// any pre-step runs: the discard listener releases the wrapper.
|
||||
result.terminal.send('@blocked-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||||
result.terminal.send('\t')
|
||||
@@ -2787,28 +2790,28 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [differentMessage],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [differentMessage] }),
|
||||
'agent/pre-step', [differentMessage],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [differentMessage] }),
|
||||
)
|
||||
expect(passthrough.kind === 'allow' && passthrough.messages).toEqual([differentMessage])
|
||||
expect(passthrough.kind === 'enter' && passthrough.messages).toEqual([differentMessage])
|
||||
// Canceling the exact pending message releases its wrapper.
|
||||
const canceled = result.agent.sentMessages.at(-1)!
|
||||
result.agent.inbox.splice('next-turn', 0, 0, [canceled])
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [])
|
||||
const unrelatedDiscard = createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated discard' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
result.agent.inbox.splice('next-turn', 0, 0, [unrelatedDiscard])
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
|
||||
result.agent.inbox.splice('next-turn', 0, 1, [])
|
||||
await tick()
|
||||
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [canceled],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [canceled] }),
|
||||
'agent/pre-step', [canceled],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [canceled] }),
|
||||
)
|
||||
expect(afterDiscard.kind === 'allow' && afterDiscard.messages).toEqual([canceled])
|
||||
expect(afterDiscard.kind === 'enter' && afterDiscard.messages).toEqual([canceled])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -2961,11 +2964,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
]])
|
||||
const submitted = result.agent.sentMessages[0]!
|
||||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [submitted],
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
|
||||
'agent/pre-step', [submitted],
|
||||
preStepContext(),
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [submitted] }),
|
||||
)
|
||||
expect(decision.kind === 'allow'
|
||||
expect(decision.kind === 'enter'
|
||||
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
|
||||
.toMatchObject({ references: [{ sessionId: unsafeId }] })
|
||||
await dispose(result)
|
||||
@@ -5113,7 +5116,7 @@ describe('terminal mounting', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -5138,7 +5141,7 @@ describe('terminal mounting', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -5173,14 +5176,14 @@ describe('terminal mounting', () => {
|
||||
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession), status: 'idle', ctx,
|
||||
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
@@ -5211,7 +5214,7 @@ describe('terminal mounting', () => {
|
||||
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
@@ -5255,7 +5258,7 @@ describe('terminal mounting', () => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'running', ctx,
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx,
|
||||
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
@@ -212,7 +212,7 @@ export interface Config {
|
||||
/**
|
||||
* Approval service that applies session policy before answerers and logs every
|
||||
* ask/outcome pair to the requesting session. It exposes deterministic policy
|
||||
* changes to the model through prompt and pre-step notices.
|
||||
* changes to the model through prompt-submission notices.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -239,18 +239,21 @@ export class ApprovalService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// Visibility layer 2: the boundary narrator. agent/step runs before the
|
||||
// request history is derived, so the notice is
|
||||
// seen by THIS step's request: idle-time flip-flops coalesce at the
|
||||
// turn's first step (net-zero → nothing), and a mid-turn switch is
|
||||
// narrated no later than the next step. What each session was last told
|
||||
// is in-memory with a log-derived fallback (the folded header's system
|
||||
// text), so restarts lose nothing. Attribution is positional: an
|
||||
// override event after the log's last `request/header` was a runtime
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
ctx.on('agent/step', (agent) => {
|
||||
// Visibility layer 2: pre-step processing narrates a policy delta in the
|
||||
// exact request whose prompt is being finalized. The last request header
|
||||
// is authoritative for what the model was told, so a later listener that
|
||||
// rejects or throws cannot advance narration state. Attribution is
|
||||
// positional: an override event after the log's last `request/header` was
|
||||
// a runtime switch by the user; otherwise the configured default moved
|
||||
// under the session.
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent,
|
||||
_messages,
|
||||
_signal,
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
@@ -269,18 +272,23 @@ export class ApprovalService extends Service {
|
||||
// for POSITIONAL attribution; the default lives once, in the method.
|
||||
const current = this.effectivePolicy(session)
|
||||
const header = session.requestHeader()
|
||||
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
|
||||
narrated.set(session, current)
|
||||
const told = toldApprovalPolicy(header?.system)
|
||||
// Cold start (nothing ever told) narrates nothing — the section about
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
if (told === undefined || told === current) return decision
|
||||
const cause = overrideSource === 'delegation'
|
||||
? 'inherited from the delegating session'
|
||||
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}), { surfaceOp: 'append' })
|
||||
return {
|
||||
...decision,
|
||||
messages: [
|
||||
...decision.messages,
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -369,8 +369,26 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
|
||||
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
const submitPrompt = async (ctx: Context, agent: Agent): Promise<void> => {
|
||||
const signal = new AbortController().signal
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
if (decision.kind === 'enter') {
|
||||
for (const message of decision.messages) {
|
||||
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
const configured = ctx.get('approval')?.config.policy ?? 'ask'
|
||||
const current = effectiveApprovalPolicy(agent.session.events) ?? configured
|
||||
appendHeader(
|
||||
agent.session,
|
||||
current === 'never' ? `${NEVER_SENTENCE}\n${NEVER_MARKER}` : ASK_MARKER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const narrations = (session: Session): string[] => session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
@@ -487,18 +505,18 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-narr-1')
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual([])
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toHaveLength(1)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -509,10 +527,32 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-narr-2')
|
||||
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('retries narration when an outer pre-step listener throws before entry', async () => {
|
||||
const ctx = new Context()
|
||||
let fail = true
|
||||
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
|
||||
const decision = await next()
|
||||
if (fail) {
|
||||
fail = false
|
||||
throw new Error('outer failure')
|
||||
}
|
||||
return decision
|
||||
})
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-narr-retry')
|
||||
appendHeader(session, ASK_MARKER)
|
||||
setApprovalPolicy(session, 'never')
|
||||
|
||||
await expect(submitPrompt(ctx, agent)).rejects.toThrow('outer failure')
|
||||
await submitPrompt(ctx, agent)
|
||||
|
||||
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
})
|
||||
|
||||
it('attributes a constructor-seeded policy event to delegation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
@@ -520,7 +560,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
appendHeader(session, ASK_MARKER)
|
||||
session.append('approval/policy', { policy: 'never', source: 'delegation' })
|
||||
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
|
||||
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
|
||||
})
|
||||
@@ -530,7 +570,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session } = sessionAgent('sess-narr-3')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
@@ -541,7 +581,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -550,7 +590,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-narr-spoof-prose')
|
||||
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -559,7 +599,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session } = sessionAgent('sess-narr-unmarked-header')
|
||||
appendHeader(session, 'legacy persona-only header')
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -568,7 +608,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-narr-spoof-marker')
|
||||
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
await submitPrompt(ctx, agent)
|
||||
expect(narrations(session)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -584,7 +624,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
|
||||
appendHeader(live.session, `persona\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(live.session, 'never')
|
||||
await preStep(ctx, live.agent)
|
||||
await submitPrompt(ctx, live.agent)
|
||||
expect(narrations(live.session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
|
||||
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
|
||||
@@ -592,7 +632,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(await sectionFor()).toBeUndefined()
|
||||
await preStep(ctx, afterDispose.agent)
|
||||
await submitPrompt(ctx, afterDispose.agent)
|
||||
expect(narrations(afterDispose.session)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user