diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 35bf5d9eed..22828a103c 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -367,12 +367,18 @@ function identityOf( } } -/** Whether a stored record's bound identity names the caller's lifecycle. */ +/** + * Whether a stored record's bound identity names the caller's lifecycle. + * Absent lineage fields (records admitted via `compatibleVersions` predate + * them) read as the unseeded lineage: exact for an unseeded caller, and a + * seeded caller's expectation then fails the match, discarding the record to + * a cold rebuild. + */ function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean { return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd - && stored.isSeeded === expected.isSeeded - && stored.inheritedEventCount === expected.inheritedEventCount + && (stored.isSeeded ?? false) === expected.isSeeded + && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount } export default SessionProjectionCache diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 35351917a5..d368764244 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -38,12 +38,19 @@ export const checkpointRow = z.object({ * old record pass every watermark check and seed state folded from an * unrelated log. Reads validate this against the live header (listing) or * the stored header (cold read) before accepting any record. + * + * The lineage fields are optional because records admitted through + * `compatibleVersions` predate them. The reader (`identityMatches`) + * interprets their absence as the unseeded lineage — exact for an unseeded + * session, while a seeded expectation fails the match and the record is + * discarded to a cold rebuild. Current-version writes always store both + * fields. */ export const checkpointIdentity = z.object({ createdAt: z.number().int().nonnegative(), cwd: z.string().optional(), - isSeeded: z.boolean(), - inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset), + isSeeded: z.boolean().optional(), + inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset).optional(), }) /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */ @@ -68,11 +75,27 @@ export type CheckpointRecord = z.infer * bumps per session: after a bump, a stale session document is discarded on * open (cache semantics — a stale or unreadable cache costs a longer tail * replay, never a wrong value) while the rest of the domain stays usable, - * instead of rejecting the whole medium. + * instead of rejecting the whole medium. The `compatibleVersions` entries + * are declared because those records differ from the current version only + * by the absent optional lineage fields, so upgraded homes keep serving + * their cached listing projections instead of dropping every title until + * each session is reopened; the per-record version map lives in the + * read-compat Agent Note + * (.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md). + * The per-row `ver` guard and the identity match still discard anything the + * current fold semantics cannot vouch for. + * + * `invalidRecords: 'backup-and-skip'`: a stored record that fails the schema + * anyway is disposable derived data, so it must never cost the boot — the + * domain layer moves the document aside as `.json.bak.`, logs + * the concrete validation failure, and serves the session as uncached (a + * cold read rebuilds and rewrites it). */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', version: 5, + compatibleVersions: [3, 4], + invalidRecords: 'backup-and-skip', layout: 'per-record', tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index bfdca18567..4a5ce73d28 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -42,9 +42,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/marks2': Map 'cache-test/count': number 'cache-test/secret': string + 'cache-test/marks3': MarksState } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } + 'cache-test/marks3': { marks: string[] } } } @@ -71,6 +73,18 @@ const marksUnit = (stateVersion = 1) => ({ stateVersion, }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> +const marks3Unit = { + key: 'cache-test/marks3', + stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), + init: () => null, + apply: state => state, + wire: { + viewSchema: z.object({ marks: z.array(z.string()) }), + view: state => state ?? { marks: [] }, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/marks3', MarksState> + const secretUnit = { key: 'cache-test/secret', stateSchema: z.string(), @@ -376,6 +390,26 @@ describe('SessionProjectionCache listing read', () => { .toBeUndefined() }) + it('carries ONE cut across multiple served rows: the lowest watermark wins', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + // Equal watermarks: whichever row is visited second cannot lower the cut, + // so the one-cut fold sees both a lowering and a non-lowering row in + // every iteration order. + await seedRecord(root, 'multi-row', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['a'] } }, + 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(marks3Unit) + const block = cache.cachedSnapshot(headerOf(SessionId('multi-row')), SessionLogOffset(0)) + expect(block?.values).toEqual({ + 'cache-test/marks': { marks: ['a'] }, + 'cache-test/marks3': { marks: ['b'] }, + }) + expect(block?.asOfSeq).toBe(4) + }) + it('returns undefined when the stored record is version-mismatched', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) @@ -394,6 +428,30 @@ describe('SessionProjectionCache listing read', () => { .toBeUndefined() }) + it('serves a pre-lineage record (accepted old version) to an unseeded caller only', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + // A document stamped with an accepted older version whose identity + // predates the lineage fields: absent lineage reads as unseeded. + const path = recordPath(root, SessionId('pre-lineage')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify({ + version: 4, + record: { + identity: { createdAt: 0 }, + rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['kept'] } } }, + }, + })) + const { cache } = await harness({ root }) + const id = SessionId('pre-lineage') + // Unseeded caller: the absent lineage is exactly its identity — served. + expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))) + .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['kept'] } } }) + // Seeded caller: the lineage-less record cannot vouch for the cut — refused. + expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(2))) + .toBeUndefined() + }) + it('returns undefined when every stored row is version-mismatched', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts new file mode 100644 index 0000000000..a85bcef5c6 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -0,0 +1,240 @@ +/** + * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds + * real `session_projcache` media produced by released builds through their + * web apps (see scripts/releasefix): the v3 whole-unit file, a v4 per-record + * document, a current v5 document, and the v5-stamped lineage-less document + * that the formerly unguarded legacy bootstrap wrote over v3 records. Each + * must recover through the real storage stack — the domain opens and the + * listing read serves the archived title — and a record that fails schema + * validation anyway is backed up and skipped instead of failing the boot. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import Storage from '@deepseek-ai/dsh-storage' +import { + apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName, +} from '@deepseek-ai/dsh-storage-json' +import { + apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName, +} from '@deepseek-ai/dsh-storage-domain' +import SessionProjectionCache from '../src/index.ts' +import { projectionCacheDomainSpec } from '../src/spec.ts' + +// Declarations must match the shipped title unit's exactly (the repo-wide +// compile face sees both). +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + title: string | null + } + interface SessionProjectionMap { + title: string | null + } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + 'fixtures-test/set-title': { title: string } + } + + interface OutOfBandSessionEventMap { + 'fixtures-test/set-title': true + } +} + +// Mirrors the shipped title unit's storage face: stateVersion 1, bare-string +// state (the fixture rows carry exactly this shape in every archived +// version), folding a test event so the rewrite path has fresh data. +const titleUnit = { + key: 'title', + stateSchema: z.string().nullable(), + init: () => null, + apply: (state, event) => (event.type === 'fixtures-test/set-title' ? event.data.title : state), + wire: { viewSchema: z.string().nullable(), view: state => state }, + stateVersion: 1, +} satisfies ProjectionDefinition<'title', string | null> + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)) + +/** One archived per-record document (`{version, record}`). */ +interface FixtureDoc { + version: number + record: { + identity: { createdAt: number; cwd?: string } + rows: Record + } +} + +async function fixtureJson(name: string): Promise { + return JSON.parse(await readFile(join(FIXTURES, name), 'utf8')) as T +} + +/** Header for the session a fixture record is bound to (identity witness). */ +function headerFor(id: SessionId, identity: FixtureDoc['record']['identity']): SessionHeader { + return { + version: 0, + id, + createdAt: identity.createdAt, + isSeeded: false, + ...identity.cwd === undefined ? {} : { cwd: identity.cwd }, + } +} + +const contexts: Context[] = [] +const roots: string[] = [] + +async function harness(root: string) { + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) + await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleUnit) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + return { ctx, cache: ctx.sessionProjectionCache } +} + +/** Lay one per-record fixture document into a fresh backend root. */ +async function placeDoc(root: string, id: string, name: string): Promise { + const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`) + await mkdir(dirname(path), { recursive: true }) + await cp(join(FIXTURES, name), path) + return fixtureJson(name) +} + +/** + * Drive a live write over a recovered session id and assert the archived + * document is replaced by a current-version one: v5 stamp, lineage present, + * and the freshly folded title — the write path never keeps the old format. + */ +async function assertRewrite(ctx: Context, root: string, id: SessionId): Promise { + const session = ctx.sessions.create(id) + session.append('fixtures-test/set-title', { title: '重写标题' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`) + await vi.waitFor(async () => { + const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc + expect(doc.version).toBe(projectionCacheDomainSpec.version) + expect(doc.record.identity).toMatchObject({ isSeeded: false, inheritedEventCount: 0 }) + expect(doc.record.rows['title']?.val).toBe('重写标题') + }, { timeout: 5_000 }) +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) +}) + +describe('archived version recovery', () => { + it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`)) + type SingleUnit = { + unit: { version: number } + tables: { sessions: Record } + } + const archive = await fixtureJson('v3-single-unit.json') + expect(archive.unit.version).toBe(3) // the fixture IS the old format + const [sid, record] = Object.entries(archive.tables.sessions)[0]! + + const { ctx, cache } = await harness(root) + const snapshot = cache.cachedSnapshot(headerFor(SessionId(sid), record.identity), SessionLogOffset(0), ['title']) + expect(snapshot?.values.title).toBe(record.rows['title']!.val) + + // The one-time bootstrap materialized a current-version document. + const migrated = JSON.parse( + await readFile(join(root, projectionCacheDomainSpec.name, 'sessions', `${sid}.json`), 'utf8'), + ) as { version: number } + expect(migrated.version).toBe(projectionCacheDomainSpec.version) + + await assertRewrite(ctx, root, SessionId(sid)) + }) + + for (const [fixture, storedVersion] of [ + ['v4-session-doc.json', 4], + ['v5-session-doc.json', 5], + ['v5-lineageless-doc.json', 5], + ] as const) { + it(`serves the archived title from ${fixture}, then rewrites it current`, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + const id = SessionId('fixture-session') + const doc = await placeDoc(root, id, fixture) + expect(doc.version).toBe(storedVersion) + + const { ctx, cache } = await harness(root) + const snapshot = cache.cachedSnapshot(headerFor(id, doc.record.identity), SessionLogOffset(0), ['title']) + expect(snapshot?.values.title).toBe(doc.record.rows['title']!.val) + + await assertRewrite(ctx, root, id) + }) + } + + it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + const id = SessionId('fixture-seeded') + const doc = await placeDoc(root, id, 'v5-lineageless-doc.json') + + const { cache } = await harness(root) + const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true } + expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined() + }) + + it('backs up and skips a record that fails schema validation instead of failing the boot', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + roots.push(root) + const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + // Current-version stamp, hopeless record content: no compat rung can save it. + await writeFile(join(sessionsDir, 'broken.json'), JSON.stringify({ + version: projectionCacheDomainSpec.version, + record: { identity: { createdAt: 'not-a-number' }, rows: 'not-an-object' }, + })) + const good = await placeDoc(root, SessionId('survivor'), 'v5-session-doc.json') + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) + await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleUnit) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + // The boot survives the broken record — this line rejecting IS the fixed bug. + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + + // Concrete console diagnostics: which record, where it went, and why. + expect(error).toHaveBeenCalledWith(expect.stringContaining("record 'broken'")) + expect(error).toHaveBeenCalledWith(expect.stringContaining('.json.bak.')) + + // The document was moved aside as .json.bak., bytes intact. + const entries = await readdir(sessionsDir) + expect(entries).not.toContain('broken.json') + const backup = entries.find(name => /^broken\.json\.bak\.\d{12}$/.test(name)) + expect(backup).toBeDefined() + expect(JSON.parse(await readFile(join(sessionsDir, backup!), 'utf8'))) + .toMatchObject({ record: { rows: 'not-an-object' } }) + + // The broken record reads as absent; its neighbors still serve. + const cache = ctx.sessionProjectionCache + expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }), SessionLogOffset(0))) + .toBeUndefined() + expect(cache.cachedSnapshot( + headerFor(SessionId('survivor'), good.record.identity), + SessionLogOffset(0), + ['title'], + )?.values.title).toBe(good.record.rows['title']!.val) + }) +}) diff --git a/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json b/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json new file mode 100644 index 0000000000..4b36243611 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json @@ -0,0 +1,136 @@ +{ + "unit": { + "name": "session_projcache", + "version": 3 + }, + "global": null, + "tables": { + "sessions": { + "session-1374fa81-15da-44ca-be12-b4c6fe8076a3": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp" + }, + "rows": { + "sessionStats": { + "ver": 1, + "seq": 54, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 2169, + "toolMs": 0, + "ttftMs": 1872, + "ttftSteps": 2, + "decodeMs": 297, + "decodeTokens": 20, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "title": { + "ver": 1, + "seq": 54, + "val": "验收标题-rc2" + }, + "goal": { + "ver": 4, + "seq": 54, + "val": null + }, + "tokenUsage": { + "ver": 1, + "seq": 54, + "val": { + "totals": { + "uncachedInputTokens": 6083, + "outputTokens": 20, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 112, + "outputTokens": 4, + "cacheReadTokens": 7808, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 54, + "val": { + "surfaceTokens": 178, + "contextWindow": 1000000, + "pressureTokens": 7920, + "sampledSurfaceTokens": 168 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 54, + "val": { + "systemTokens": 1620, + "toolsTokens": 6475, + "messageTokens": 178 + } + }, + "subagentTiming": { + "ver": 2, + "seq": 54, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 54, + "val": {} + }, + "permissions": { + "ver": 1, + "seq": 54, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask" + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 54, + "val": { + "blank": false, + "lastPromptAt": 1788286867410 + } + }, + "imageLimits": { + "ver": 1, + "seq": 54, + "val": null + }, + "todos": { + "ver": 2, + "seq": 54, + "val": null + }, + "plan": { + "ver": 2, + "seq": 54, + "val": { + "active": false, + "wanted": null, + "running": null + } + } + } + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json b/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json new file mode 100644 index 0000000000..56d6868405 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json @@ -0,0 +1,212 @@ +{ + "version": 4, + "record": { + "identity": { + "createdAt": 1788286912530, + "cwd": "/tmp" + }, + "rows": { + "title": { + "ver": 1, + "seq": 68, + "val": "验收标题-alpha3" + }, + "titleInput": { + "ver": 3, + "seq": 68, + "val": { + "first": { + "seq": 7, + "text": "请只回复一个词:pong" + }, + "count": 2, + "lastSeq": 58 + } + }, + "llmRetry": { + "ver": 1, + "seq": 68, + "val": {} + }, + "sandboxMode": { + "ver": 1, + "seq": 68, + "val": "workspace-write" + }, + "goal": { + "ver": 6, + "seq": 68, + "val": { + "current": null, + "seenGoalIds": [], + "failure": null + } + }, + "tokenUsage": { + "ver": 2, + "seq": 68, + "val": { + "totals": { + "uncachedInputTokens": 8192, + "outputTokens": 35, + "cacheReadTokens": 8064, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 86, + "outputTokens": 4, + "cacheReadTokens": 8064, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 68, + "val": { + "surfaceTokens": 193, + "contextWindow": 1000000, + "pressureTokens": 8150, + "sampledSurfaceTokens": 183 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 68, + "val": { + "systemTokens": 1760, + "toolsTokens": 6579, + "messageTokens": 193 + } + }, + "turnBoundary": { + "ver": 2, + "seq": 68, + "val": { + "openTurnStartSeq": null, + "lastStepStartSeq": 57, + "lastStepBoundary": { + "kind": "end", + "seq": 67 + }, + "lastTurn": 2 + } + }, + "sessionStats": { + "ver": 1, + "seq": 68, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 3486, + "toolMs": 0, + "ttftMs": 3199, + "ttftSteps": 2, + "decodeMs": 287, + "decodeTokens": 35, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "turnOutline": { + "ver": 2, + "seq": 68, + "val": { + "turns": [ + { + "turn": 1, + "seq": 4, + "prompt": "请只回复一个词:pong", + "response": "pong" + }, + { + "turn": 2, + "seq": 55, + "prompt": "请只回复一个词:pong2", + "response": "pong2" + } + ], + "draft": "" + } + }, + "agentPreset": { + "ver": 1, + "seq": 68, + "val": "standard" + }, + "subagentTiming": { + "ver": 2, + "seq": 68, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 68, + "val": {} + }, + "permissions": { + "ver": 2, + "seq": 68, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask", + "seeded": false + } + }, + "modelSelection": { + "ver": 2, + "seq": 68, + "val": { + "lastUsed": { + "provider": "deepseek-official", + "model": "deepseek-v4-flash", + "reasoningEffort": "high" + }, + "pending": null + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 68, + "val": { + "blank": false, + "lastPromptAt": 1788286917834 + } + }, + "imageLimits": { + "ver": 1, + "seq": 68, + "val": null + }, + "todos": { + "ver": 2, + "seq": 68, + "val": null + }, + "plan": { + "ver": 3, + "seq": 68, + "val": { + "active": false, + "wanted": null, + "running": null, + "activeAtLastHeader": false + } + }, + "subagentModelSelectionPolicy": { + "ver": 1, + "seq": 68, + "val": null + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json b/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json new file mode 100644 index 0000000000..f887e980e2 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json @@ -0,0 +1,128 @@ +{ + "version": 5, + "record": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp" + }, + "rows": { + "sessionStats": { + "ver": 1, + "seq": 54, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 2169, + "toolMs": 0, + "ttftMs": 1872, + "ttftSteps": 2, + "decodeMs": 297, + "decodeTokens": 20, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "title": { + "ver": 1, + "seq": 54, + "val": "\u9a8c\u6536\u6807\u9898-rc2" + }, + "goal": { + "ver": 4, + "seq": 54, + "val": null + }, + "tokenUsage": { + "ver": 1, + "seq": 54, + "val": { + "totals": { + "uncachedInputTokens": 6083, + "outputTokens": 20, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 112, + "outputTokens": 4, + "cacheReadTokens": 7808, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 54, + "val": { + "surfaceTokens": 178, + "contextWindow": 1000000, + "pressureTokens": 7920, + "sampledSurfaceTokens": 168 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 54, + "val": { + "systemTokens": 1620, + "toolsTokens": 6475, + "messageTokens": 178 + } + }, + "subagentTiming": { + "ver": 2, + "seq": 54, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 54, + "val": {} + }, + "permissions": { + "ver": 1, + "seq": 54, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask" + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 54, + "val": { + "blank": false, + "lastPromptAt": 1788286867410 + } + }, + "imageLimits": { + "ver": 1, + "seq": 54, + "val": null + }, + "todos": { + "ver": 2, + "seq": 54, + "val": null + }, + "plan": { + "ver": 2, + "seq": 54, + "val": { + "active": false, + "wanted": null, + "running": null + } + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json b/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json new file mode 100644 index 0000000000..9b822870a8 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json @@ -0,0 +1,220 @@ +{ + "version": 5, + "record": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp", + "isSeeded": false, + "inheritedEventCount": 0 + }, + "rows": { + "title": { + "ver": 1, + "seq": 71, + "val": "验收标题-rc2" + }, + "titleInput": { + "ver": 3, + "seq": 71, + "val": { + "first": { + "seq": 7, + "text": "请只回复一个词:pong" + }, + "count": 3, + "lastSeq": 60 + } + }, + "llmRetry": { + "ver": 1, + "seq": 71, + "val": {} + }, + "sandboxMode": { + "ver": 1, + "seq": 71, + "val": "workspace-write" + }, + "goal": { + "ver": 6, + "seq": 71, + "val": { + "current": null, + "seenGoalIds": [], + "failure": null + } + }, + "tokenUsage": { + "ver": 2, + "seq": 71, + "val": { + "totals": { + "uncachedInputTokens": 14219, + "outputTokens": 24, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 3, + "step": 1, + "buckets": { + "uncachedInputTokens": 8136, + "outputTokens": 4, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 71, + "val": { + "surfaceTokens": 200, + "contextWindow": 1000000, + "pressureTokens": 8136, + "sampledSurfaceTokens": 190 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 71, + "val": { + "systemTokens": 1729, + "toolsTokens": 6611, + "messageTokens": 200 + } + }, + "sessionStats": { + "ver": 1, + "seq": 71, + "val": { + "turns": 3, + "steps": 3, + "llmMs": 4368, + "toolMs": 0, + "ttftMs": 4030, + "ttftSteps": 3, + "decodeMs": 338, + "decodeTokens": 24, + "lastTurn": 3, + "openStep": null, + "pendingCalls": {} + } + }, + "agentPreset": { + "ver": 1, + "seq": 71, + "val": "standard" + }, + "subagentTiming": { + "ver": 2, + "seq": 71, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 71, + "val": {} + }, + "turnBoundary": { + "ver": 2, + "seq": 71, + "val": { + "openTurnStartSeq": null, + "lastStepStartSeq": 59, + "lastStepBoundary": { + "kind": "end", + "seq": 70 + }, + "lastTurn": 3 + } + }, + "turnOutline": { + "ver": 2, + "seq": 71, + "val": { + "turns": [ + { + "turn": 1, + "seq": 4, + "prompt": "请只回复一个词:pong", + "response": "pong" + }, + { + "turn": 2, + "seq": 40, + "prompt": "请只回复一个词:pong2", + "response": "pong2" + }, + { + "turn": 3, + "seq": 57, + "prompt": "请只回复一个词:pong3", + "response": "pong3" + } + ], + "draft": "" + } + }, + "permissions": { + "ver": 2, + "seq": 71, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask", + "seeded": true + } + }, + "modelSelection": { + "ver": 2, + "seq": 71, + "val": { + "lastUsed": { + "provider": "deepseek-official", + "model": "deepseek-v4-flash", + "reasoningEffort": "high" + }, + "pending": null + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 71, + "val": { + "blank": false, + "lastPromptAt": 1788287660106 + } + }, + "imageLimits": { + "ver": 1, + "seq": 71, + "val": null + }, + "todos": { + "ver": 2, + "seq": 71, + "val": null + }, + "plan": { + "ver": 3, + "seq": 71, + "val": { + "active": false, + "wanted": null, + "running": null, + "activeAtLastHeader": false + } + }, + "subagentModelSelectionPolicy": { + "ver": 1, + "seq": 71, + "val": null + } + } + } +}