goal.snapshot.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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 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 wrapupDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-wrapup')
  24. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  25. const agent: AgentUnderTest = {
  26. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  27. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  28. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  29. }
  30. interface JsonObject {
  31. [key: string]: unknown
  32. }
  33. /** Parse non-empty records from one JSONL artifact. */
  34. function parseJsonl(content: string): JsonObject[] {
  35. return content.split('\n').filter(line => line.trim().length > 0)
  36. .map(line => JSON.parse(line) as JsonObject)
  37. }
  38. /** Zero durable goal timestamps inside metadata records and rendered XML JSON. */
  39. function normalizeGoalTimestamps(value: unknown): unknown {
  40. if (typeof value === 'string') {
  41. return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10')
  42. }
  43. if (Array.isArray(value)) return value.map(normalizeGoalTimestamps)
  44. if (value !== null && typeof value === 'object') {
  45. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  46. key,
  47. ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number'
  48. ? 0
  49. : normalizeGoalTimestamps(item),
  50. ]))
  51. }
  52. return value
  53. }
  54. /** Normalize one persisted goal log after the shared snapshot scrubbers. */
  55. function normalizeGoalLog(content: string, context: NormalizeContext): string {
  56. return parseJsonl(scrubRequestHeaders(normalizeSessionLog(content, context)))
  57. .map(record => JSON.stringify(normalizeGoalTimestamps(record)))
  58. .join('\n') + '\n'
  59. }
  60. describe('same-session goal snapshot through the ACP automation driver', () => {
  61. it('runs exact automatic rounds in the shipped application and persists cancellation', async () => {
  62. const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as InputScript
  63. const result = await runScenario(input, {
  64. agent,
  65. mode: 'replay',
  66. fixtureFile,
  67. overrideFile,
  68. configPath: agent.configPath,
  69. })
  70. expect(result.stderr).toBe('')
  71. expect(result.sessionLogs).toHaveLength(1)
  72. const log = result.sessionLogs[0]
  73. if (log === undefined) throw new Error('goal snapshot did not persist its session')
  74. const records = parseJsonl(log.content)
  75. const events = records.slice(1) as unknown as SessionEvent[]
  76. const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
  77. expect(calls).toEqual(['create_goal', 'get_goal'])
  78. const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
  79. && event.data.source.round > 0
  80. ? [event.data.source.round]
  81. : [])
  82. expect(rounds).toEqual([1, 2])
  83. expect(foldGoal(events)).toMatchObject({
  84. goal: {
  85. objective: 'Finish the ACP goal-session snapshot proof',
  86. phase: 'paused',
  87. revision: 2,
  88. maxGoalRounds: 2,
  89. },
  90. roundsStarted: 2,
  91. })
  92. const context: NormalizeContext = {
  93. sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined),
  94. cwd: result.cwd,
  95. }
  96. const stdout = normalizeStdout(result.rawStdout, context)
  97. const session = normalizeGoalLog(log.content, context)
  98. if (refreshing) {
  99. await Promise.all([
  100. writeFile(stdoutExpected, stdout),
  101. writeFile(sessionExpected, session),
  102. ])
  103. }
  104. expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
  105. expect(session).toBe(await readFile(sessionExpected, 'utf8'))
  106. })
  107. it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => {
  108. const input = JSON.parse(await readFile(join(wrapupDir, 'input.json'), 'utf8')) as InputScript
  109. const result = await runScenario(input, {
  110. agent,
  111. mode: 'replay',
  112. fixtureFile: join(wrapupDir, 'session.jsonl'),
  113. overrideFile: join(wrapupDir, 'replay.override.json'),
  114. configPath: agent.configPath,
  115. })
  116. expect(result.stderr).toBe('')
  117. expect(result.sessionLogs).toHaveLength(1)
  118. const log = result.sessionLogs[0]
  119. if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session')
  120. const records = parseJsonl(log.content)
  121. const events = records.slice(1) as unknown as SessionEvent[]
  122. const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
  123. expect(calls).toEqual(['create_goal', 'update_goal'])
  124. expect(foldGoal(events)).toMatchObject({
  125. goal: {
  126. objective: 'Finish the ACP goal wrap-up snapshot proof',
  127. phase: 'complete',
  128. revision: 2,
  129. },
  130. roundsStarted: 1,
  131. })
  132. // The wrap-up instruction is one plugin-sourced context injected after the
  133. // terminal tool result, and the model still answers inside the same turn.
  134. const wrapups = events.filter(event => event.type === 'user/message'
  135. && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal')
  136. expect(wrapups).toHaveLength(1)
  137. const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0]
  138. expect(JSON.stringify(wrapupText)).toContain('<goal_complete>')
  139. const closing = events.filter(event => event.type === 'assistant/message')
  140. .flatMap(event => event.data.message.content)
  141. .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP'))
  142. expect(closing).toHaveLength(1)
  143. const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2)
  144. expect(roundTurnEnds).toHaveLength(1)
  145. expect(roundTurnEnds[0]?.data).toMatchObject({ turn: 2, reason: { kind: 'completed' } })
  146. const context: NormalizeContext = {
  147. sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined),
  148. cwd: result.cwd,
  149. }
  150. const stdout = normalizeStdout(result.rawStdout, context)
  151. const session = normalizeGoalLog(log.content, context)
  152. const wrapupStdoutExpected = join(wrapupDir, 'stdout.expected.jsonl')
  153. const wrapupSessionExpected = join(wrapupDir, 'session.expected.jsonl')
  154. if (refreshing) {
  155. await Promise.all([
  156. writeFile(wrapupStdoutExpected, stdout),
  157. writeFile(wrapupSessionExpected, session),
  158. ])
  159. }
  160. expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8'))
  161. expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8'))
  162. })
  163. })