diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f2e38078da..acd413d961 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 18247f9630..d33640e9ba 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -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] : []] -}[SessionEventType] -``` - -Each array member keeps its event type paired with its payload and requires surface intent only for message-producing events. - ### 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] : []] +}[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[]; /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index a103ac85b9..1c9a931fd9 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -363,17 +363,6 @@ type SurfaceIntent = { `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] : []] -}[SessionEventType] -``` - -数组中的每个成员将事件类型与对应载荷配对,且仅对产生消息的事件要求消息视图意图。 - ### 插件拥有的消息投影 @@ -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] : []] +}[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[]; /** diff --git a/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.i18n.yaml new file mode 100644 index 0000000000..cc9434e9c3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.md new file mode 100644 index 0000000000..db74ea4789 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.md @@ -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. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.zh.md new file mode 100644 index 0000000000..c71289c7f7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/session-append-overloads/guide.zh.md @@ -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 }]])` 调用替代实现,验证前者返回一个事件,后者返回数组。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index f984f5326a..c573d40f60 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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 { diff --git a/packages/core/session/tests/append.spec.ts b/packages/core/session/tests/append.spec.ts index aa48deb523..2bd8063e63 100644 --- a/packages/core/session/tests/append.spec.ts +++ b/packages/core/session/tests/append.spec.ts @@ -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() })