session-reference.snapshot.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { mkdir, writeFile } from 'node:fs/promises'
  2. import { dirname, join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { describe, expect, it } from 'vitest'
  5. import { Context } from 'cordis'
  6. import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry from '@deepseek-ai/dsh-tools'
  10. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  12. import CommandService from '@deepseek-ai/dsh-commands'
  13. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  14. import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
  15. import { createTuiChat } from '../src/index.ts'
  16. import { HeadlessTerminal } from './headless-terminal.ts'
  17. import { TestSessionQueryService } from './session-query.ts'
  18. const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
  19. const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
  20. class SnapshotAdapter extends LlmAdapter {
  21. readonly requests: GenerateOptions[] = []
  22. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  23. this.requests.push(options)
  24. const [context, prompt] = options.messages.slice(-2)
  25. if (context?.role !== 'user' || prompt?.role !== 'user'
  26. || prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
  27. throw new Error('session reference context did not precede the direct user message')
  28. }
  29. yield { type: 'block-start', index: 0, blockType: 'text' }
  30. yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
  31. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Combined reference request accepted.' } }
  32. yield { type: 'finish', reason: { kind: 'stop' } }
  33. }
  34. }
  35. function nextIdle(ctx: Context, agent: Agent): Promise<void> {
  36. return new Promise((resolve) => {
  37. const dispose = ctx.on('agent/status', (subject, status) => {
  38. if (subject !== agent || status !== 'idle') return
  39. dispose()
  40. resolve()
  41. })
  42. })
  43. }
  44. describe('TUI session-reference snapshot', () => {
  45. it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
  46. const ctx = new Context()
  47. await ctx.plugin(LlmService)
  48. await ctx.plugin(SessionStore)
  49. await ctx.plugin(SystemPrompt)
  50. await ctx.plugin(ToolRegistry)
  51. await ctx.plugin(AgentRegistry)
  52. await ctx.plugin(CommandService)
  53. await ctx.plugin(UserInteractionService)
  54. await ctx.plugin(AgentLoop, { agents: [] })
  55. await ctx.plugin(TestSessionQueryService)
  56. await ctx.plugin(SessionReferenceService)
  57. const adapter = new SnapshotAdapter()
  58. ctx.llm.registerAdapter(['mock'], adapter)
  59. const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
  60. const oldUser = source.append('user/message', {
  61. content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
  62. source: { kind: 'user' },
  63. }, { surfaceOp: 'append' })
  64. const oldAssistant = source.append('assistant/message', {
  65. turn: 1,
  66. step: 1,
  67. provenance: { provider: 'mock', model: 'mock' },
  68. content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
  69. }, { surfaceOp: 'append' })
  70. source.append('user/message', {
  71. content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
  72. source: { kind: 'plugin', plugin: 'compact' },
  73. }, {
  74. surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
  75. sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
  76. })
  77. source.append('user/message', {
  78. content: [{ type: 'text', text: 'Recent retained question.' }],
  79. source: { kind: 'user' },
  80. }, { surfaceOp: 'append' })
  81. const target = ctx.agentLoop.create(
  82. SessionId('target-session'),
  83. { provider: 'mock', model: 'mock' },
  84. { cwd: '/workspace/project' },
  85. )
  86. const terminal = new HeadlessTerminal(96, 24)
  87. const controller = createTuiChat(ctx, {
  88. sessionId: target.id,
  89. welcome: 'Session reference snapshot.',
  90. color: true,
  91. title: 'DSH session reference',
  92. }, { terminal, exit: () => {} })
  93. await terminal.waitForFrame(0)
  94. const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'Source session' })
  95. const idle = nextIdle(ctx, target)
  96. const frame = terminal.frames
  97. terminal.send(`Use ${mention}`)
  98. terminal.send('\r')
  99. await idle
  100. await terminal.waitForFrame(frame)
  101. const request = JSON.stringify(adapter.requests[0]?.messages)
  102. expect(request).toContain('untrusted, read-only snapshot')
  103. expect(request).toContain('Retained checkpoint.')
  104. expect(request).toContain('Recent retained question.')
  105. expect(request).not.toContain('SHADOWED OLD USER')
  106. expect(request).not.toContain('SHADOWED OLD ASSISTANT')
  107. const context = target.session.events.find(event =>
  108. event.type === 'user/message' && event.data.source.kind === 'session-reference')
  109. expect(context?.type === 'user/message' && context.data.source).toMatchObject({
  110. kind: 'session-reference',
  111. references: [{ sessionId: 'source-session', compacted: true }],
  112. })
  113. const user = target.session.events.find(event =>
  114. event.type === 'user/message' && event.data.source.kind === 'user')
  115. expect(user?.type === 'user/message' && user.data.content).toEqual([
  116. { type: 'text', text: 'Use @Source session' },
  117. ])
  118. const snapshot = await terminal.snapshot({ includeScrollback: true })
  119. if (REFRESHING) {
  120. await mkdir(dirname(EXPECTED), { recursive: true })
  121. await writeFile(EXPECTED, snapshot)
  122. }
  123. await expect(snapshot).toMatchFileSnapshot(EXPECTED)
  124. await controller.dispose()
  125. await ctx.fiber.dispose()
  126. await terminal.dispose()
  127. })
  128. })