fix inbox lifecycle downstream contracts
This commit is contained in:
@@ -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). */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user