session-format-guard.expected.e2e.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. /**
  2. * Assembled-app regressions for Session-format lifecycle behavior: released v0
  3. * migrates before resume without changing its source, while a future format or
  4. * unknown required event fails loud through the real Loader composition.
  5. * @module session-format-guard-snapshot
  6. */
  7. import { join, dirname } from 'node:path'
  8. import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
  9. import { fileURLToPath } from 'node:url'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  12. import {
  13. SESSION_FORMAT_VERSION,
  14. SessionId,
  15. SessionSeq,
  16. type SessionEvent,
  17. type SessionHeader,
  18. } from '@deepseek-ai/dsh-session'
  19. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  20. import {
  21. generationLogFilename,
  22. generationLogPath,
  23. logPath,
  24. } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts'
  25. import { describe, expect, it } from 'vitest'
  26. const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'expected/workspace-context-resume/offline-edit')
  27. const replayFixture = join(fixtureDir, 'replay.jsonl')
  28. const configPath = fileURLToPath(new URL('../workspace-context-resume-snapshot.patch.yml', import.meta.url))
  29. const binScript = fileURLToPath(new URL('../../../../../../packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts', import.meta.url))
  30. const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url))
  31. // The resumed-agent fixture in the shared config resumes exactly this id.
  32. const sessionId = SessionId('workspace-context-resume')
  33. const fixtureEnv = {
  34. DSH_SNAPSHOT_FILE: replayFixture,
  35. DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID: 'resumed-agent',
  36. }
  37. /** Persist one session with the given header version and events, returning its log path. */
  38. async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
  39. if (version !== SESSION_FORMAT_VERSION) {
  40. const path = generationLogPath(root, cwd, sessionId, version, 'none')
  41. await mkdir(dirname(path), { recursive: true })
  42. await writeFile(path, [
  43. JSON.stringify({ type: 'session', version, id: sessionId, createdAt: 1, cwd, delegationDepth: 0 }),
  44. ...events.map(event => JSON.stringify(event)),
  45. '',
  46. ].join('\n'))
  47. return path
  48. }
  49. const ctx = new Context()
  50. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  51. const meta: SessionHeader = {
  52. version: SESSION_FORMAT_VERSION,
  53. id: sessionId,
  54. createdAt: 1,
  55. cwd,
  56. isSeeded: false,
  57. }
  58. try {
  59. const handle = await ctx.sessionPersistence.create(meta)
  60. await handle.append(events)
  61. await handle.close()
  62. return logPath(root, meta.cwd, meta.id, 'none')
  63. } finally {
  64. await ctx.fiber.dispose()
  65. }
  66. }
  67. function closedTurn(): SessionEvent[] {
  68. return [
  69. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  70. { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  71. ]
  72. }
  73. describe('session format guard through the assembled app', () => {
  74. it('migrates v0 before resume, preserves its source, and appends only to the current generation', async () => {
  75. let sourcePath = ''
  76. let source = Buffer.alloc(0)
  77. let sourceIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined
  78. await runLoaderSmoke({
  79. label: 'v0 migration before resume',
  80. tempDirPrefix: 'dsh-format-migrate-v0-',
  81. binScript,
  82. libBinScript: binScript,
  83. configPath,
  84. binArgs: [configPath, 'Continue the migrated session.'],
  85. tsconfigPath,
  86. env: fixtureEnv,
  87. prepare: async (runCwd) => {
  88. sourcePath = await seedSession(join(runCwd, '.sessions'), runCwd, 0, closedTurn())
  89. source = await readFile(sourcePath)
  90. const identity = await stat(sourcePath, { bigint: true })
  91. sourceIdentity = { dev: identity.dev, ino: identity.ino }
  92. },
  93. inspect: async () => {
  94. const currentPath = join(
  95. dirname(sourcePath),
  96. generationLogFilename(SESSION_FORMAT_VERSION, 'none'),
  97. )
  98. const current = await readFile(currentPath, 'utf8')
  99. const sourceAfter = await stat(sourcePath, { bigint: true })
  100. const currentIdentity = await stat(currentPath, { bigint: true })
  101. expect(await readFile(sourcePath)).toEqual(source)
  102. expect({ dev: sourceAfter.dev, ino: sourceAfter.ino }).toEqual(sourceIdentity)
  103. expect({ dev: currentIdentity.dev, ino: currentIdentity.ino }).not.toEqual(sourceIdentity)
  104. expect(JSON.parse(current.split('\n')[0] as string)).toMatchObject({
  105. version: SESSION_FORMAT_VERSION,
  106. })
  107. expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1)
  108. // `session.lock` is the write handle's kernel lock file, published
  109. // with the first materializing write and kept across release.
  110. expect((await readdir(dirname(sourcePath))).sort())
  111. .toEqual(['session.jsonl', 'session.lock', generationLogFilename(SESSION_FORMAT_VERSION, 'none')])
  112. },
  113. })
  114. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  115. it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => {
  116. let sessionPath = ''
  117. const result = await runLoaderSmoke({
  118. label: 'newer-format resume refusal',
  119. tempDirPrefix: 'dsh-format-guard-version-',
  120. binScript,
  121. libBinScript: binScript,
  122. configPath,
  123. binArgs: [configPath, 'Try to resume.'],
  124. tsconfigPath,
  125. env: fixtureEnv,
  126. expectedExitCode: 1,
  127. prepare: async (runCwd) => {
  128. sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
  129. },
  130. })
  131. expect(result.stderr).toContain(
  132. `session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`,
  133. )
  134. // macOS reports the temp dir via the /private symlink parent; assert the
  135. // stable path suffix instead of the realpath-dependent prefix.
  136. expect(result.stderr).toContain('(raw log: ')
  137. expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
  138. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  139. it('refuses unaudited V2 queued content before resume without publishing a successor', async () => {
  140. let sourcePath = ''
  141. let source = Buffer.alloc(0)
  142. let sourceIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined
  143. const result = await runLoaderSmoke({
  144. label: 'unaudited V2 content resume refusal',
  145. tempDirPrefix: 'dsh-format-guard-content-',
  146. binScript,
  147. libBinScript: binScript,
  148. configPath,
  149. binArgs: [configPath, 'Try to resume.'],
  150. tsconfigPath,
  151. env: fixtureEnv,
  152. expectedExitCode: 1,
  153. prepare: async (runCwd) => {
  154. sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none')
  155. await mkdir(dirname(sourcePath), { recursive: true })
  156. const rows = [
  157. { type: 'session', version: 2, id: sessionId, createdAt: 1, cwd: runCwd, isSeeded: false, delegationDepth: 0 },
  158. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  159. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  160. { type: 'team/message/queued', seq: 2, time: 3, data: {
  161. version: 1, teamId: 'team', message: {
  162. id: 'queued', senderId: 'sender', senderName: 'Sender', targetId: 'target', delivery: 'quiet',
  163. content: [{ type: 'future-message-block', localSeq: 1 }],
  164. },
  165. } },
  166. ]
  167. source = Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n')
  168. await writeFile(sourcePath, source)
  169. const identity = await stat(sourcePath, { bigint: true })
  170. sourceIdentity = { dev: identity.dev, ino: identity.ino }
  171. },
  172. inspect: async () => {
  173. expect(await readFile(sourcePath)).toEqual(source)
  174. const identity = await stat(sourcePath, { bigint: true })
  175. expect({ dev: identity.dev, ino: identity.ino }).toEqual(sourceIdentity)
  176. expect((await readdir(dirname(sourcePath))).filter(name => name !== 'session.lock'))
  177. .toEqual(['session.v2.jsonl'])
  178. },
  179. })
  180. expect(result.stderr).toContain(
  181. 'format v2 team/message/queued at seq 2 data.message.content[0]: cannot safely transform unclassified message content kind "future-message-block"',
  182. )
  183. expect(result.stderr).toContain('source v2 artifact remains unchanged')
  184. expect(result.stderr).toContain(sourcePath.slice(sourcePath.indexOf('/.sessions/')))
  185. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  186. it('refuses to resume a log with an unknown required event type', async () => {
  187. let sessionPath = ''
  188. const result = await runLoaderSmoke({
  189. label: 'unknown-event resume refusal',
  190. tempDirPrefix: 'dsh-format-guard-event-',
  191. binScript,
  192. libBinScript: binScript,
  193. configPath,
  194. binArgs: [configPath, 'Try to resume.'],
  195. tsconfigPath,
  196. env: fixtureEnv,
  197. expectedExitCode: 1,
  198. prepare: async (runCwd) => {
  199. sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [
  200. ...closedTurn(),
  201. { type: 'future/event', seq: SessionSeq(2), time: 3, data: { payload: 1 } } as unknown as SessionEvent,
  202. ])
  203. },
  204. })
  205. expect(result.stderr).toContain(
  206. `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`,
  207. )
  208. // macOS reports the temp dir via the /private symlink parent; assert the
  209. // stable path suffix instead of the realpath-dependent prefix.
  210. expect(result.stderr).toContain('(raw log: ')
  211. expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
  212. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  213. })