refactor(agent): address inbox mutations by message id

This commit is contained in:
_Kerman
2026-08-04 13:10:22 +08:00
parent a90a1645aa
commit 0ac95437b4
20 changed files with 76 additions and 47 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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
2026-07-31-claimed-pre-step-inbox-lifecycle.md: a44f0cb906c9dd7a98b20a2f34fce902ac68a6d2
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 32fc435d3acef4d3cbd5edd57961cb098c1a1637
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 06bc875722d9dfea118934e9e025b4ef60b54e65
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: a2f61aaf5cd971dd4b7a210532db495ca70a8a14

View File

@@ -16,7 +16,7 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet
`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no turn or step and leaves the claimed batch removed. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` seam.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, `update`, `remove`, and `splice` commit normalized splices. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. These live events add no placement, outcome, or batch fields.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. These live events add no placement, outcome, or batch fields.
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change.

View File

@@ -16,7 +16,7 @@ Status: implemented
`PreStepDecision``{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开轮次或步骤并让已领取批次保持已删除。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare``agent/prompt-submit``agent/step` seam。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append``prepend``update``remove``splice` 提交规范化 splice。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。这些实时事件不增加 placement、outcome 或批次字段。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append``prepend``splice` 接受 target`replace(messageId, newMessage)``remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。这些实时事件不增加 placement、outcome 或批次字段。
两类事件表面服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted``claimed``discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced`UI 编辑与移除经 `Inbox.splice()` 或其他 Inbox 变更方法进入,从而让同一投影记录所有变化。

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 .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
2026-07-17-one-send-one-turn.md: 8f851088d191915cca2637a8962d2368eaa919b2
2026-07-17-one-send-one-turn.zh.md: 6abe748cf505cf4c006a35801575989d972e61ce
2026-07-17-one-send-one-turn.md: 6d2e9f57caf73754ea8c8d9e711126de2e7c885c
2026-07-17-one-send-one-turn.zh.md: 1d2e74bbcf9478020cf0dfcf7ebdbb2349a86022

View File

@@ -16,7 +16,7 @@ This grouping changes behavior, not just the number of model calls. One ordinary
The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined.
Before inserting a message, `send()` checks the agent state and accepts an already identified, deeply frozen value. The durable splice and `agent/inbox/inserted { message }` retain its `MessageId`; the pending message remains addressable through `Inbox.update()` and `Inbox.remove()` until the driver claims or discards it. The [claimed pre-step inbox decision](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns the current lifecycle.
Before inserting a message, `send()` checks the agent state and accepts an already identified, deeply frozen value. The durable splice and `agent/inbox/inserted { message }` retain its `MessageId`; the pending message remains addressable through `Inbox.replace()` and `Inbox.remove()` until the driver claims or discards it. The [claimed pre-step inbox decision](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns the current lifecycle.
If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn.

View File

@@ -16,7 +16,7 @@ Status: implemented
规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。
消息插入之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的值。持久 splice 与 `agent/inbox/inserted { message }` 会保留其 `MessageId`;在驱动器领取或丢弃该消息之前,可以通过 `Inbox.update()``Inbox.remove()` 寻址。当前生命周期由[已领取 pre-step inbox 决策](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md)规定。
消息插入之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的值。持久 splice 与 `agent/inbox/inserted { message }` 会保留其 `MessageId`;在驱动器领取或丢弃该消息之前,可以通过 `Inbox.replace()``Inbox.remove()` 寻址。当前生命周期由[已领取 pre-step inbox 决策](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md)规定。
如果消息 A、B 都进入处理B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose资源释放`turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。

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 docs/core-data-structures/core.md
core.md: 40176b0ebb1d6f2a483d75d201d23604441842b3
core.zh.md: 45e03ff4b1f367b3d2410b7b18fa828e54179175
core.md: f289fa88de42b578b5bb533f339ce1faa0caeb36
core.zh.md: 004b87e430c39abcf398239b4d0e52c02b9365e6

View File

@@ -485,7 +485,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
type InboxTarget = 'next-turn' | 'next-step'
```
Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `update`, `remove`, `clear`, and `splice` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. Ordinary removals and `clear()` are cancellations. `claim(target)` atomically removes the proposed step batch through pure deletion splices; the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, and `splice` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` atomically removes the proposed step batch through pure deletion splices; the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
```ts type-equiv
/** Options for {@link Agent.cancel}. */

View File

@@ -493,7 +493,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
type InboxTarget = 'next-turn' | 'next-step'
```
每个待处理入队项就是其 `UserMessage``MessageId` 是唯一标识。`Inbox.append`、`prepend`、`update`、`remove`、`clear` 与 `splice` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 原子移除拟进入步骤的批次;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
每个待处理入队项就是其 `UserMessage``MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 原子移除拟进入步骤的批次;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
```ts type-equiv
/** Options for {@link Agent.cancel}. */

View File

@@ -163,20 +163,20 @@ export function apply(ctx: Context, config: Config): void {
})
)
if (desired === undefined || alreadySupplied) {
for (const message of pending) agent.inbox.remove('next-step', message.id)
for (const message of pending) agent.inbox.remove(message.id)
return
}
const reusable = pending.find(message => sameContextPayload(message, desired))
if (reusable !== undefined) {
for (const message of pending) {
if (message !== reusable) agent.inbox.remove('next-step', message.id)
if (message !== reusable) agent.inbox.remove(message.id)
}
return
}
const replaced = pending[0]
if (replaced === undefined) agent.inbox.prepend('next-step', desired)
else agent.inbox.update('next-step', replaced.id, desired)
for (const message of pending.slice(1)) agent.inbox.remove('next-step', message.id)
else agent.inbox.replace(replaced.id, desired)
for (const message of pending.slice(1)) agent.inbox.remove(message.id)
}
const composeAndSync = async (

View File

@@ -2009,7 +2009,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Inbox',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean;\n remove(target: InboxTarget, messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
},
{
name: 'InboxNotifications',

View File

@@ -70,7 +70,7 @@ describe('Agent', () => {
source: { kind: 'plugin', plugin: 'test' },
})
agent.inject(context)
agent.inbox.remove('next-step', context.id)
agent.inbox.remove(context.id)
const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
agent.followup(prompt)
await agent.whenIdle()

View File

@@ -194,7 +194,7 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'go')
// The wake microtask has not run yet: remove the only pending message so
// the admission batch is empty.
agent.inbox.remove('next-turn', agent.inbox.nextTurn[0]!.id)
agent.inbox.remove(agent.inbox.nextTurn[0]!.id)
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)

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/core/agent/README.md
README.md: e1f1b121787645930fa9c41b3d0d5ee9880ef4ad
README.zh.md: becc0ae299269e9982d6a63d7a2df966b999cfa9
README.md: e47356370b23abfd8790a5210a6e11d4e7505627
README.zh.md: a2629732a04051e6b39642c190a2e3a687f850d9

View File

@@ -62,7 +62,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, `clear`, and `splice` mutate them; ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, and `splice` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.

View File

@@ -62,7 +62,7 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
每个插件面向的 handle
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``update``remove``clear``splice` 用于变更队列;普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``replace``remove``clear``splice` 用于变更队列;`replace(messageId, newMessage)``remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。

View File

@@ -95,30 +95,30 @@ export class Inbox {
}
/**
* Replace one pending message in place and durably record the mutation.
* @param target - pending list containing the message.
* @param messageId - identity of the message to replace.
* Replace one pending message in place, possibly changing its identity. A
* successful replacement publishes the old message as discarded and the new
* message as inserted.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
* @throws if the replacement duplicates another pending message identity.
*/
update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index < 0) return false
this.splice(target, index, 1, [newMessage])
replace(messageId: MessageId, newMessage: UserMessage): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message and durably record its cancellation.
* @param target - pending list containing the message.
* @param messageId - identity of the message to remove.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(target: InboxTarget, messageId: MessageId): boolean {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index < 0) return false
this.splice(target, index, 1, [])
remove(messageId: MessageId): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
@@ -142,6 +142,15 @@ export class Inbox {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
for (const target of ['next-turn', 'next-step'] as const) {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index >= 0) return { target, index }
}
return undefined
}
/** Commit one normalized mutation and publish its live notifications. */
private mutate(
target: InboxTarget,

View File

@@ -49,25 +49,45 @@ describe('Inbox', () => {
.toThrow('invalid persisted inbox splice at session seq 0')
})
it('updates a pending message by identity and reports a missing identity', () => {
const session = new Session(SessionId('update-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
it('replaces a pending message by identity across both lists', () => {
const session = new Session(SessionId('replace-inbox'))
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
inserted: message => void inserted.push(message),
discarded: message => void discarded.push(message),
})
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const replacement = freezeMessage({
...original,
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
inbox.append('next-turn', original)
inbox.append('next-step', nextStep)
expect(inbox.update('next-turn', createUserMessage({
expect(inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(inbox.update('next-turn', original.id, replacement)).toBe(true)
expect(inbox.replace(original.id, replacement)).toBe(true)
expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(inbox.nextTurn).toEqual([replacement])
expect(inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
@@ -85,7 +105,7 @@ describe('Inbox', () => {
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove('next-turn', second.id)).toBe(false)
expect(inbox.remove(second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})

View File

@@ -556,7 +556,7 @@ describe('goal replay validation', () => {
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
inbox.append('next-step', message)
expect(inbox.remove('next-step', message.id)).toBe(true)
expect(inbox.remove(message.id)).toBe(true)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
})

View File

@@ -1836,9 +1836,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}))
}
if (action.kind === 'edit') {
agent.inbox.update(target, itemId, freezeMessage({ ...message, content: action.content }))
agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
} else {
agent.inbox.remove(target, itemId)
agent.inbox.remove(itemId)
if (action.kind === 'steer') agent.steer(message)
}
return Promise.resolve(ok(request, { accepted: true as const }))