fix(session): reject retired tool results in V4 Team checkpoints

Central V4 event admission already rejects tool-result content in Team
queue records, but projection checkpoints use the Team state schema directly.
After restoring the baseline Team parser during extraction, the production
SessionProjectionRegistry.restore() path accepted a decoded native V4
checkpoint containing the retired wrapper without reading an event.

Remove only that V4-incompatible schema arm. Keep the existing unknown-JSON
parser unchanged; its independent own-key preservation fix remains in the
standalone master PR, where recursive V3 tool results are still valid.

Exercise the real checkpoint restoration recipe with a valid text state and
an invalid retired-result state, plus queued-event projection. The new
checkpoint rejection failed before this change; central event admission and
the complete Team owner suite pass afterward with 100% projection coverage.
No Session schema, format version or projection state version is changed:
the rejected value is already outside the native V4 content vocabulary.
This commit is contained in:
Tianyi Cui committed 2026-09-19 15:43:10 +08:00
1 parent 88897a16c6
commit 4d9c4e7a12
5 files changed
+34 -12

No files matched your search

@@ -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/experimental/agent-team/README.md
README.md: 6e2b55018971ec96802de0e5be042d49b45c674a
README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5
README.md: 31ea956d2e39dfe25410929be2a574b4c3f07a7d
README.zh.md: 03344285ceaee965c4154f939f22b592c833840b
@@ -145,6 +145,8 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision
Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append.
Native V4 Team event and checkpoint admission reject retired `tool-result` content before it can enter mailbox state. Historical conversion belongs to the Session-format migration; the Team projection does not convert old wrappers.
### Disposal
Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`.
@@ -145,6 +145,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息
Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。
原生 V4 的 Team 事件及检查点准入会拒绝退役的 `tool-result` 内容,防止它进入邮箱状态。历史转换由 Session 格式迁移负责,Team 投影不转换旧包装。
### Dispose
dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。
@@ -40,8 +40,8 @@ const imageAttachmentSchema = z.object({
name: z.string().optional(),
}).strict()
// ContentBlockMap is merge-extensible. Validate every core variant exactly,
// while retaining JSON-decoded plugin variants under an unknown type tag.
// Validate the listed variants; retired tool-result tags cannot enter the
// merge-extensible fallback for JSON-decoded plugin content.
const contentBlockSchema: z.ZodType<ContentBlock> = z.lazy(() => z.union([
z.object({ type: z.literal('text'), text: z.string() }).strict(),
z.object({ type: z.literal('reasoning'), text: z.string() }).strict(),
@@ -52,12 +52,6 @@ const contentBlockSchema: z.ZodType<ContentBlock> = z.lazy(() => z.union([
name: z.string(),
arguments: z.string(),
}).strict(),
z.object({
type: z.literal('tool-result'),
toolCallId: z.string().min(1),
content: z.array(contentBlockSchema),
isError: z.boolean().optional(),
}).strict(),
z.object({ type: z.string().min(1) }).loose().refine(
block => !coreContentBlockTypes.has(block.type),
{ message: 'known content block types must match their declared fields' },
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { teamProjectionDefinition } from '../src/projection.ts'
import type { TeamProjectionState, TeamState } from '../src/projection.ts'
import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts'
@@ -82,6 +84,28 @@ function message(overrides: Partial<TeamMessageSnapshot> = {}): TeamMessageSnaps
}
describe('Agent Teams projection events', () => {
it('rejects retired tool-result content when restoring a native V4 Team checkpoint', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition))
const session = ctx.sessions.create(ROOT)
const restore = (val: unknown) => ctx.sessionProjections.restore({
agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val },
}, [], SessionLogOffset(0), session.header, SessionLogOffset(0))
const valid = { ...project(ROOT, []), messages: [message()] }
expect(restore(JSON.parse(JSON.stringify(valid))).checkpoint['agentTeam']?.val).toEqual(valid)
const retiredMessage = message({ content: [{
type: 'tool-result', toolCallId: 'retired', content: [{ type: 'text', text: 'old result' }],
}] as unknown as TeamMessageSnapshot['content'] })
const retired = { ...valid, messages: [retiredMessage] }
expect(() => restore(JSON.parse(JSON.stringify(retired)))).toThrow()
expect(() => projectTeam(ROOT, [event('team/message/queued', {
version: 2, teamId: TEAM, message: retiredMessage,
}, SessionSeq(0))])).toThrow(/team\/message\/queued payload is invalid/)
})
it('projects current-team records independently from inherited records', () => {
const records: SessionEvent[] = [
event('team/member', { version: 2, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)),