fix(session): assign batch sequences after payload snapshots
This commit is contained in:
1 parent
92f6142abd
commit
9d18b68095
8 files changed
+155
-48
No files matched your search
@@ -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
@@ -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[];
|
||||
/**
|
||||
|
||||
@@ -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 }]])` 调用替代实现,验证前者返回一个事件,后者返回数组。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
Reference in new issue
Block a user