goal.snapshot.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import { readFile, writeFile } from 'node:fs/promises'
  2. import { dirname, join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import {
  5. normalizeSessionLog,
  6. normalizeStdout,
  7. runScenario,
  8. scrubRequestHeaders,
  9. type AgentUnderTest,
  10. type InputScript,
  11. type NormalizeContext,
  12. } from '@deepseek-ai/dsh-acp-snapshot'
  13. import { foldGoal } from '@deepseek-ai/dsh-goal'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  15. import { describe, expect, it } from 'vitest'
  16. // This lifecycle proof has goal-specific timestamp normalization and semantic
  17. // assertions, so it owns a separate snapshot root from the generic ACP suite.
  18. const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-session')
  19. const fixtureFile = join(scenarioDir, 'session.jsonl')
  20. const overrideFile = join(scenarioDir, 'replay.override.json')
  21. const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl')
  22. const sessionExpected = join(scenarioDir, 'session.expected.jsonl')
  23. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  24. const agent: AgentUnderTest = {
  25. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  26. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  27. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  28. }
  29. interface JsonObject {
  30. [key: string]: unknown
  31. }
  32. /** Parse non-empty records from one JSONL artifact. */
  33. function parseJsonl(content: string): JsonObject[] {
  34. return content.split('\n').filter(line => line.trim().length > 0)
  35. .map(line => JSON.parse(line) as JsonObject)
  36. }
  37. /** Zero durable goal timestamps inside metadata records and rendered XML JSON. */
  38. function normalizeGoalTimestamps(value: unknown): unknown {
  39. if (typeof value === 'string') {
  40. return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10')
  41. }
  42. if (Array.isArray(value)) return value.map(normalizeGoalTimestamps)
  43. if (value !== null && typeof value === 'object') {
  44. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  45. key,
  46. ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number'
  47. ? 0
  48. : normalizeGoalTimestamps(item),
  49. ]))
  50. }
  51. return value
  52. }
  53. /** Normalize one persisted goal log after the shared snapshot scrubbers. */
  54. function normalizeGoalLog(content: string, context: NormalizeContext): string {
  55. return parseJsonl(scrubRequestHeaders(normalizeSessionLog(content, context)))
  56. .map(record => JSON.stringify(normalizeGoalTimestamps(record)))
  57. .join('\n') + '\n'
  58. }
  59. describe('ACP same-session goal snapshot', () => {
  60. it('runs exact automatic rounds in the shipped application and persists cancellation', async () => {
  61. const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as InputScript
  62. const result = await runScenario(input, {
  63. agent,
  64. mode: 'replay',
  65. fixtureFile,
  66. overrideFile,
  67. configPath: agent.configPath,
  68. })
  69. expect(result.stderr).toBe('')
  70. expect(result.sessionLogs).toHaveLength(1)
  71. const log = result.sessionLogs[0]
  72. if (log === undefined) throw new Error('goal snapshot did not persist its ACP session')
  73. const records = parseJsonl(log.content)
  74. const events = records.slice(1) as unknown as SessionEvent[]
  75. const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
  76. expect(calls).toEqual(['create_goal', 'get_goal'])
  77. const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
  78. && event.data.source.round > 0
  79. ? [event.data.source.round]
  80. : [])
  81. expect(rounds).toEqual([1, 2])
  82. expect(foldGoal(events)).toMatchObject({
  83. goal: {
  84. objective: 'Finish the ACP goal-session snapshot proof',
  85. phase: 'paused',
  86. revision: 2,
  87. maxGoalRounds: 2,
  88. },
  89. roundsStarted: 2,
  90. })
  91. const context: NormalizeContext = {
  92. sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined),
  93. cwd: result.cwd,
  94. }
  95. const stdout = normalizeStdout(result.rawStdout, context)
  96. const session = normalizeGoalLog(log.content, context)
  97. if (refreshing) {
  98. await Promise.all([
  99. writeFile(stdoutExpected, stdout),
  100. writeFile(sessionExpected, session),
  101. ])
  102. }
  103. expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
  104. expect(session).toBe(await readFile(sessionExpected, 'utf8'))
  105. })
  106. })