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()
|
||||
|
||||
Reference in New Issue
Block a user