fix inbox lifecycle downstream contracts

This commit is contained in:
_Kerman
2026-07-31 22:00:39 +08:00
parent 8e88b17c9f
commit afedf18ccf
219 changed files with 5660 additions and 4556 deletions

View File

@@ -502,13 +502,13 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
if (type === 'user/message') {
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
if (source?.kind === 'goal' && source.round === 0) {
// Goal fold: inserting a round-zero goal change durably advances the unit;
// later admission of the same message must not advance it again.
if (type === 'agent/inbox/spliced') {
const inserted = (event as unknown as { data: { inserted: UserMessage[] } }).data.inserted
if (inserted.some(message => goalChangeOf(message) !== undefined)) {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
return []
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
@@ -603,7 +603,7 @@ interface FxGoalProjection {
updatedAt: number
}
/** One durable goal change riding a round-zero goal-sourced user message. */
/** One durable goal change riding a round-zero goal-sourced inbox insertion. */
type FxGoalChange =
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
| {
@@ -616,6 +616,14 @@ type FxGoalChange =
updatedAt: number
}
/** Decode a fixture goal change from its durable inbox message. */
function goalChangeOf(message: UserMessage): FxGoalChange | undefined {
const source = message.source as unknown as { kind?: string; round?: number; change?: FxGoalChange }
if (source.kind !== 'goal' || source.round !== 0) return undefined
const change = source.change
return change?.kind === 'goal/change' ? change : undefined
}
/**
* Current goal projection over the full log (host parallel: the GoalService
* unit's last-wins fold of goal/change whole values; clear returns null).
@@ -624,16 +632,18 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i] as unknown as {
type: string
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
data?: { inserted?: UserMessage[] }
} | undefined
if (event === undefined || event.type !== 'user/message') continue
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
if (event === undefined || event.type !== 'agent/inbox/spliced') continue
const inserted = event.data?.inserted ?? []
for (let j = inserted.length - 1; j >= 0; j--) {
const message = inserted[j]
if (message === undefined) continue
const change = goalChangeOf(message)
if (change === undefined) continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
}
}
return null
}
@@ -869,20 +879,35 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
}
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
/** Append one goal/change as its round-zero goal-sourced inbox insertion (host GoalService parallel). */
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
const ref = change.operation === 'clear' ? change.cleared : change.goal
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
const log = logOf(id)
const pendingNextStep = log.reduce((count, event) => {
const inboxEvent = event as unknown as {
type: string
data: { target: string; removedCount?: number; inserted: UserMessage[] }
}
if (inboxEvent.type !== 'agent/inbox/spliced' || inboxEvent.data.target !== 'next-step') return count
return count - (inboxEvent.data.removedCount ?? 0) + inboxEvent.data.inserted.length
}, 0)
append(id, {
type: 'user/message', surfaceOp: 'append',
data: userMessage(
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
),
type: 'agent/inbox/spliced',
data: {
target: 'next-step',
start: pendingNextStep,
inserted: [
userMessage(
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
),
],
},
})
return backscanGoal(logOf(id)) as FxGoalProjection
return backscanGoal(log) as FxGoalProjection
}
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */

View File

@@ -862,6 +862,32 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
// complete → complete is an invalid transition.
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
const goalHistory = await client.sessions.history({ sessionId: id })
if (!goalHistory.result.ok) throw new Error('goal history failed')
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
type: string
data: {
target?: string
start?: number
source?: { kind?: string; round?: number }
inserted?: Array<{ source?: { kind?: string; round?: number; change?: { operation?: string } } }>
}
})
const goalSplices = goalEvents.filter(event => event.type === 'agent/inbox/spliced'
&& event.data.inserted?.some(message => message.source?.kind === 'goal' && message.source.round === 0) === true)
expect(goalSplices.map(event => ({ target: event.data.target, start: event.data.start }))).toEqual([
{ target: 'next-step', start: 0 },
{ target: 'next-step', start: 1 },
{ target: 'next-step', start: 2 },
{ target: 'next-step', start: 3 },
{ target: 'next-step', start: 4 },
{ target: 'next-step', start: 5 },
])
expect(goalSplices.map(event => event.data.inserted?.[0]?.source?.change?.operation))
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
expect(goalEvents.some(event => event.type === 'user/message'
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

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/client/ui-goal/README.md
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
README.md: caeaef4db9b1bd82090f1897c1a470a835294a39
README.zh.md: 60dd661b640d04917ac93c9930e1a4755a5817ff

View File

@@ -8,11 +8,11 @@ The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar
## Model Experience
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
#### KV Cache effect
None beyond the goal mutation's own context event, which appends to the log tail like any other message.
None unless the queued goal context is admitted. An admitted context extends the history tail like any other message; an insertion discarded before admission does not affect the cache.
## Known Limitations and Deferred Work

View File

@@ -8,11 +8,11 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input
## Model Experience
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
#### KV Cache effect
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响
非已排队的 goal 上下文获准,否则没有影响。获准的上下文会像其他消息一样扩展历史尾部;准入前被丢弃的插入项不会影响缓存
## Known Limitations and Deferred Work