refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 在空闲时退出。

View File

@@ -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()

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()