fix(session): assign batch sequences after payload snapshots

This commit is contained in:
liukx0205 authored and Kexin Liu committed 2026-11-07 17:02:19 +08:00
1 parent 92f6142abd
commit 9d18b68095
8 files changed
+155 -48

No files matched your search

+7 -7
View File
@@ -27,11 +27,8 @@
en: 8fcc967516a13692
zh: d3978d3b8d02c566
/sessions/surface-types/surfaceintent-the-parameter-to-session-append:
en: ebdf0684eccf4f86
zh: e5f545c060bfb824
/sessions/surface-types/sessioneventinput-one-proposed-event:
en: 58f1d60b60d7fe99
zh: a162d6d0cb15c6a1
en: c65cc976acad9005
zh: 3efd10779881cba5
/sessions/surface-types/plugin-owned-message-projections:
en: f1f73607d650620f
zh: b3ef72f19fb5ee3d
@@ -42,8 +39,11 @@
en: 13820e0611b2e141
zh: 2aafb1e5e3dc42f6
/sessions/session-public-api:
en: 04e9982948d9560d
zh: 221961cadebc6fb1
en: 2dbf1890d8c38701
zh: 9948971a092b3243
/sessions/session-public-api/sessioneventinput-one-proposed-event:
en: 90c3989856a301dc
zh: f9ce633998fea8dc
/sessions/derived-history-derivemessages-and-deriveeventmessage:
en: 45d1a9ee6d610ac5
zh: 79c061252fde4ceb
+16 -15
View File
@@ -361,17 +361,6 @@ Required for `SurfaceEventType` events — every message-producing event must de
`assistant/message` cannot carry `sourceEventSeqs`; its `stream` owns exact provider evidence. Other surface events omit the field when they cite no earlier event and use a complete non-empty list when they do.
### `SessionEventInput` — one proposed event
```ts type-equiv
/** One positional event call proposed for append, without assigned sequence or time. */
type SessionEventInput = {
[K in SessionEventType]: readonly [type: K, data: SessionEventMap[K], ...opts: K extends SurfaceEventType ? [opts: SurfaceIntent<K>] : []]
}[SessionEventType]
```
Each array member keeps its event type paired with its payload and requires surface intent only for message-producing events.
<a id="plugin-owned-message-projections"></a>
### Plugin-owned message projections
@@ -458,6 +447,17 @@ interface SurfaceFoldResult {
## `Session` public API
### `SessionEventInput` — one proposed event
```ts type-equiv
/** One positional event call proposed for append, without assigned sequence or time. */
type SessionEventInput = {
[K in SessionEventType]: readonly [type: K, data: SessionEventMap[K], ...opts: K extends SurfaceEventType ? [opts: SurfaceIntent<K>] : []]
}[SessionEventType]
```
Each array member keeps its event type paired with its payload and requires surface intent only for message-producing events.
The body-stripped declaration keeps the plain class's detached factory, state accessors, append method, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore).
```ts public-api
@@ -586,11 +586,12 @@ declare class Session {
get seq(): SessionLogOffset;
/**
* Append consecutive events together after validating every candidate and dispatch.
* Observers read the complete committed log and final message surface. An empty
* array appends nothing. Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order.
* Observers read the complete committed log and final message surface.
* Listener registration changes during publication take effect on the next append.
* Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order. An empty array records no events.
* @returns immutable committed events in the same order.
* @throws before changing the log if any candidate or synchronous dispatch validation fails.
* @throws if the Session cannot accept writes, including an empty append, or any candidate or synchronous dispatch validation fails.
*/
append(inputs: readonly SessionEventInput[]): readonly SessionEvent[];
/**
+16 -15
View File
@@ -363,17 +363,6 @@ type SurfaceIntent<T extends SurfaceEventType = SurfaceEventType> = {
`assistant/message` 不能携带 `sourceEventSeqs`;它的 `stream` 拥有精确 provider 证据。其他 surface event 不引用较早 event 时省略该字段,需要引用时使用完整非空 list。
### `SessionEventInput`:一个候选事件
```ts type-equiv
/** One positional event call proposed for append, without assigned sequence or time. */
type SessionEventInput = {
[K in SessionEventType]: readonly [type: K, data: SessionEventMap[K], ...opts: K extends SurfaceEventType ? [opts: SurfaceIntent<K>] : []]
}[SessionEventType]
```
数组中的每个成员将事件类型与对应载荷配对,且仅对产生消息的事件要求消息视图意图。
<a id="plugin-owned-message-projections"></a>
### 插件拥有的消息投影
@@ -460,6 +449,17 @@ interface SurfaceFoldResult {
## `Session` 公共 API
### `SessionEventInput`:一个候选事件
```ts type-equiv
/** One positional event call proposed for append, without assigned sequence or time. */
type SessionEventInput = {
[K in SessionEventType]: readonly [type: K, data: SessionEventMap[K], ...opts: K extends SurfaceEventType ? [opts: SurfaceIntent<K>] : []]
}[SessionEventType]
```
数组中的每个成员将事件类型与对应载荷配对,且仅对产生消息的事件要求消息视图意图。
去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、append 方法和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。
```ts public-api
@@ -588,11 +588,12 @@ declare class Session {
get seq(): SessionLogOffset;
/**
* Append consecutive events together after validating every candidate and dispatch.
* Observers read the complete committed log and final message surface. An empty
* array appends nothing. Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order.
* Observers read the complete committed log and final message surface.
* Listener registration changes during publication take effect on the next append.
* Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order. An empty array records no events.
* @returns immutable committed events in the same order.
* @throws before changing the log if any candidate or synchronous dispatch validation fails.
* @throws if the Session cannot accept writes, including an empty append, or any candidate or synchronous dispatch validation fails.
*/
append(inputs: readonly SessionEventInput[]): readonly SessionEvent[];
/**
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.md
/:
en: 0c7dd958bce09e16
zh: 3b3c85a2152294ec
/update-replacements-for-session-append:
en: 5cc8a72cf2203e52
zh: 22976024cf5453eb
/update-replacements-for-session-append/change:
en: 51fbd9ed1cd50cdf
zh: 103d3ac9b631414e
/update-replacements-for-session-append/migration:
en: 19c39d4582b09f41
zh: 4345d25a52dec57e
@@ -0,0 +1,17 @@
---
kind: upgrade-guide
description: "Code that replaces Session.append must implement both the single-event and array overloads."
---
# Update replacements for `Session.append`
English | [中文](guide.zh.md)
## Change
In v0.2.2-rc.1, `Session.append` had a single call signature. It now accepts either one typed event call or an array of event tuples. Ordinary single-event calls still compile. Test doubles and wrappers assigned to `Session['append']` must support both overloads.
## Migration
1. For a wrapper around `Session.append`, preserve both overloads by wrapping the original function with a `Proxy` and forwarding from its `apply` trap with `Reflect.apply`. For a test double that only accepts one event, type the single-event helper separately and call it from the relevant test.
2. Typecheck assignments to `Session['append']`. Call replacements with both `append('turn/start', { turn: 1 })` and `append([['turn/start', { turn: 1 }]])` and verify the first returns one event and the second an array.
@@ -0,0 +1,17 @@
---
kind: upgrade-guide
description: "替换 Session.append 的代码必须实现单事件和数组两种重载。"
---
# 更新 `Session.append` 的替代实现
[English](guide.md) | 中文
## 变更
在 v0.2.2-rc.1 中,`Session.append` 只有一个调用签名。现在它可接收一个带类型的事件调用,也可接收事件元组数组。普通单事件调用仍可编译。赋值给 `Session['append']` 的测试替身和包装函数必须支持两种重载。
## 迁移
1. 包装 `Session.append` 时,用 `Proxy` 包装原函数,在 `apply` 拦截器内用 `Reflect.apply` 转发,以保留两种重载。若测试替身只接收单个事件,给该辅助函数单独声明类型并在相应测试中调用。
2. 检查赋值给 `Session['append']` 的代码能通过类型检查。分别以 `append('turn/start', { turn: 1 })` 和 `append([['turn/start', { turn: 1 }]])` 调用替代实现,验证前者返回一个事件,后者返回数组。
+15 -10
View File
@@ -763,11 +763,12 @@ export class Session {
/**
* Append consecutive events together after validating every candidate and dispatch.
* Observers read the complete committed log and final message surface. An empty
* array appends nothing. Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order.
* Observers read the complete committed log and final message surface.
* Listener registration changes during publication take effect on the next append.
* Persistence listeners retain their own durability rules.
* @param inputs - event payloads and surface intents in commit order. An empty array records no events.
* @returns immutable committed events in the same order.
* @throws before changing the log if any candidate or synchronous dispatch validation fails.
* @throws if the Session cannot accept writes, including an empty append, or any candidate or synchronous dispatch validation fails.
*/
append(inputs: readonly SessionEventInput[]): readonly SessionEvent[]
@@ -861,8 +862,7 @@ export class Session {
#appendEvents(inputs: readonly SessionEventInput[]): readonly SessionEvent[] {
this.publicationEntry()
if (inputs.length === 0) return []
const startSeq = this.log.length
const events = Array.from({ length: inputs.length }, (_, index) => {
const snapshots = Array.from({ length: inputs.length }, (_, index) => {
const input = inputs.at(index)
if (input === undefined) throw new Error(`session batch has an empty member at index ${index}`)
const [type, data, surfaceOpts] = input
@@ -871,14 +871,19 @@ export class Session {
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
})
if (snapshot === undefined) throw new Error('session batch carries non-JSON-serializable data')
const event = {
...snapshot, seq: SessionSeq(startSeq + index), time: Date.now(),
if (snapshot === undefined) {
throw new Error(`session batch member ${index} ("${type}") carries non-JSON-serializable data`)
}
return snapshot
})
// Payload getters may append to or detach the Session.
const entry = this.publicationEntry()
const startSeq = this.log.length
const events = snapshots.map((snapshot, index) => {
const event = { ...snapshot, seq: SessionSeq(startSeq + index), time: Date.now() }
assertSessionEventEnvelope(event, startSeq + index)
return deepFreeze(event)
})
const entry = this.publicationEntry()
const acceptSurface = this.surfaceManager.prepareBatch(events)
if (entry !== undefined) entry.appending = true
try {
+51 -1
View File
@@ -36,6 +36,16 @@ describe('multiple Session appends', () => {
expect(events[0]).toMatchObject({ type: 'turn/start', seq: 0, data: { turn: 1 } })
})
it('accepts both call forms through an append wrapper', () => {
const session = Session.create(SessionId('wrapped-append'))
const append = new Proxy(session.append, {
apply: (target, receiver, args) => Reflect.apply(target, receiver, args),
})
session.append = append
expect(session.append('turn/start', { turn: 1 }).seq).toBe(0)
expect(session.append([['step/start', { turn: 1, step: 1 }]])).toHaveLength(1)
})
it('publishes each member after the complete log and surface are committed', async () => {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
@@ -73,10 +83,50 @@ describe('multiple Session appends', () => {
expect(() => session.append([
['turn/start', { turn: 1 }],
['turn/start', { turn: Number.NaN }],
])).toThrow(/non-JSON/)
])).toThrow('session batch member 1 ("turn/start") carries non-JSON-serializable data')
expect(session.seq).toBe(0)
})
it('assigns consecutive sequences after a candidate getter appends an event', () => {
const session = Session.create(SessionId('getter-append'))
const events = session.append([
['turn/start', {
get turn() {
session.append('request/context', { provider: 'mock', model: 'mock' })
return 1
},
}],
['step/start', { turn: 1, step: 1 }],
])
expect(events.map(event => event.seq)).toEqual([1, 2])
expect(session.snapshotEvents().map(event => event.type)).toEqual(['request/context', 'turn/start', 'step/start'])
})
it('contains a reentrant append while publishing the remaining members', async () => {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('reentrant-batch'))
const observed: number[] = []
const warnings: string[] = []
ctx.logger.warn = (warning: unknown) => { warnings.push(String(warning)) }
ctx.on('session/event', (current, event) => {
if (current === session && event.seq === 0) {
session.append('request/context', { provider: 'mock', model: 'mock' })
}
})
ctx.on('session/event', (current, event) => {
if (current === session) observed.push(event.seq)
})
session.append([
['turn/start', { turn: 1 }],
['step/start', { turn: 1, step: 1 }],
])
expect(session.seq).toBe(2)
expect(observed).toEqual([0, 1])
expect(warnings).toEqual([expect.stringContaining('session append cannot reenter')])
})
it('detaches only after every member has been observed', async () => {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })