session-format-guard.snapshot.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. /**
  2. * Assembled-app regression for the session-format refusal surface: resuming a
  3. * log written by a "newer" harness (format version ahead, or an unknown
  4. * required event type) fails loud through the real Loader composition, and the
  5. * error the product user sees names the direction and the raw log path.
  6. * @module session-format-guard-snapshot
  7. */
  8. import { join, dirname } from 'node:path'
  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 SessionStore, {
  13. SESSION_FORMAT_VERSION,
  14. SessionId,
  15. type SessionEvent,
  16. type SessionHeader,
  17. } from '@deepseek-ai/dsh-session'
  18. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  19. import { describe, expect, it } from 'vitest'
  20. const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit')
  21. const replayFixture = join(fixtureDir, 'replay.jsonl')
  22. const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url))
  23. const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url))
  24. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  25. // The resumed-agent fixture in the shared config resumes exactly this id.
  26. const sessionId = SessionId('workspace-context-resume')
  27. /** Persist one session with the given header version and events, returning its log path. */
  28. async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
  29. const ctx = new Context()
  30. await ctx.plugin(SessionStore)
  31. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  32. const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd }
  33. try {
  34. await ctx.sessionPersistence.create(meta)
  35. await ctx.sessionPersistence.append(sessionId, events)
  36. const location = ctx.sessionPersistence.locate(meta)
  37. if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
  38. return location.path
  39. } finally {
  40. await ctx.fiber.dispose()
  41. }
  42. }
  43. function closedTurn(): SessionEvent[] {
  44. return [
  45. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  46. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  47. ]
  48. }
  49. describe('session format guard through the assembled app', () => {
  50. it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => {
  51. let sessionPath = ''
  52. const result = await runLoaderSmoke({
  53. label: 'newer-format resume refusal',
  54. tempDirPrefix: 'dsh-format-guard-version-',
  55. binScript,
  56. libBinScript: binScript,
  57. configPath,
  58. binArgs: [configPath, 'Try to resume.'],
  59. tsconfigPath,
  60. env: { DSH_SNAPSHOT_FILE: replayFixture },
  61. expectedExitCode: 1,
  62. prepare: async (runCwd) => {
  63. sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
  64. },
  65. })
  66. expect(result.stderr).toContain(
  67. `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`,
  68. )
  69. // macOS reports the temp dir via the /private symlink parent; assert the
  70. // stable path suffix instead of the realpath-dependent prefix.
  71. expect(result.stderr).toContain('(raw log: ')
  72. expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
  73. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  74. it('refuses to resume a log with an unknown required event type', async () => {
  75. let sessionPath = ''
  76. const result = await runLoaderSmoke({
  77. label: 'unknown-event resume refusal',
  78. tempDirPrefix: 'dsh-format-guard-event-',
  79. binScript,
  80. libBinScript: binScript,
  81. configPath,
  82. binArgs: [configPath, 'Try to resume.'],
  83. tsconfigPath,
  84. env: { DSH_SNAPSHOT_FILE: replayFixture },
  85. expectedExitCode: 1,
  86. prepare: async (runCwd) => {
  87. sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [
  88. ...closedTurn(),
  89. { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent,
  90. ])
  91. },
  92. })
  93. expect(result.stderr).toContain(
  94. `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`,
  95. )
  96. // macOS reports the temp dir via the /private symlink parent; assert the
  97. // stable path suffix instead of the realpath-dependent prefix.
  98. expect(result.stderr).toContain('(raw log: ')
  99. expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
  100. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  101. })