harness.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { Context } from 'cordis'
  2. import type { Terminal } from '@earendil-works/pi-tui'
  3. import AgentRegistry, {
  4. AgentMessageId,
  5. type Agent,
  6. type AgentCancelCause,
  7. type AgentOptions,
  8. type AgentStatus,
  9. type SendOptions,
  10. } from '@deepseek-ai/dsh-agent'
  11. import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
  12. import CommandService from '@deepseek-ai/dsh-commands'
  13. import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
  14. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  15. import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
  16. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  17. import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
  18. import { TestSessionQueryService } from './session-query.ts'
  19. interface FakeAgent extends Agent {
  20. status: AgentStatus
  21. sent: ContentBlock[][]
  22. sentOptions: (SendOptions | undefined)[]
  23. steered: ContentBlock[][]
  24. steeredIds: AgentMessageId[]
  25. steeredOptions: UserMessageData[]
  26. injected: ContentBlock[][]
  27. injectedOptions: UserMessageData[]
  28. cancelled: AgentCancelCause[]
  29. }
  30. export interface TuiHarnessOptions {
  31. status?: AgentStatus
  32. config?: Config
  33. /** Leave the session event log empty instead of seeding one turn and step. */
  34. omitInitialLifecycle?: boolean
  35. /** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */
  36. omitWelcome?: boolean
  37. tools?: Record<string, ToolDefinition>
  38. configureContext?: (ctx: Context) => Promise<void>
  39. beforeMount?: (session: Session) => void
  40. cwd?: string | null
  41. formatCwd?: TuiRuntime['formatCwd']
  42. /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
  43. agentOptions?: AgentOptions
  44. contextWindow?: number
  45. contextTokens?: number
  46. now?: () => number
  47. catalog?: {
  48. providers: LlmProviderInfo[]
  49. models: LlmModelInfo[]
  50. listModels?: (provider: string) => Promise<LlmModelInfo[]>
  51. resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
  52. }
  53. /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
  54. sessionPersistence?: {
  55. list(): Promise<SessionHeader[]>
  56. load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
  57. }
  58. handoffResume?: TuiRuntime['handoffResume']
  59. /** Set false to exercise the optional session-query degradation path. */
  60. mountSessionQuery?: boolean
  61. }
  62. export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
  63. ctx: Context
  64. session: Session
  65. agent: FakeAgent
  66. terminal: TerminalType
  67. exit: Exit
  68. controller: ReturnType<typeof createTuiChat>
  69. }
  70. /**
  71. * Compose the production TUI around an in-memory session and controllable agent.
  72. * @param terminal - Terminal boundary driven by the test.
  73. * @param exit - Process-exit observer.
  74. * @param options - Initial session, agent, tool, and TUI configuration.
  75. * @returns The mounted TUI and every boundary the test may drive or inspect.
  76. */
  77. export async function createTuiTestHarness<TerminalType extends Terminal, Exit extends (code: number) => void>(
  78. terminal: TerminalType,
  79. exit: Exit,
  80. options: TuiHarnessOptions = {},
  81. ): Promise<TuiHarness<TerminalType, Exit>> {
  82. const ctx = new Context()
  83. await ctx.plugin(SessionStore)
  84. await ctx.plugin(AgentRegistry)
  85. await ctx.plugin(CommandService)
  86. await ctx.plugin(UserInteractionService)
  87. const catalog = options.catalog ?? {
  88. providers: [{ id: 'deepseek', name: 'DeepSeek' }],
  89. models: [
  90. { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
  91. { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
  92. ],
  93. }
  94. ctx.provide('tokenMeter', {
  95. measure() {
  96. return { totalTokens: options.contextTokens ?? 0 }
  97. },
  98. } as never)
  99. if (options.configureContext === undefined) {
  100. const tools = options.tools ?? {}
  101. ctx.provide('tools', {
  102. get(name: string) {
  103. return tools[name]
  104. },
  105. } as never)
  106. } else {
  107. await options.configureContext(ctx)
  108. }
  109. // A configureContext may mount the real LlmService; only fill the
  110. // advisory-catalog stub when none was provided.
  111. if (ctx.get('llm') === undefined) {
  112. ctx.provide('llm', {
  113. listProviders() {
  114. return catalog.providers.map(provider => ({ ...provider }))
  115. },
  116. listModels(provider: string) {
  117. return catalog.listModels?.(provider)
  118. ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
  119. },
  120. resolveModelContext(provider: string, model: string) {
  121. return catalog.resolveModelContext?.(provider, model)
  122. ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
  123. },
  124. } as never)
  125. }
  126. if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
  127. if (options.sessionPersistence !== undefined) {
  128. const persistence = options.sessionPersistence
  129. ctx.provide('sessionPersistence', {
  130. ...persistence,
  131. locate: () => undefined,
  132. create: () => Promise.resolve(),
  133. append: () => Promise.resolve(),
  134. load: persistence.load === undefined
  135. ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
  136. : (id: ReturnType<typeof SessionId>) => persistence.load!(id),
  137. inspect: persistence.load === undefined
  138. ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
  139. : (id: ReturnType<typeof SessionId>) => persistence.load!(id),
  140. } as never)
  141. }
  142. if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
  143. await ctx.plugin(TestSessionQueryService)
  144. }
  145. const sessionId = SessionId('main-session')
  146. const session = ctx.sessions.create(
  147. sessionId,
  148. options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
  149. )
  150. if (options.omitInitialLifecycle !== true) {
  151. session.append('turn/start', {
  152. turn: 1,
  153. trigger: { kind: 'message', source: { kind: 'user' } },
  154. })
  155. session.append('step/start', { turn: 1, step: 1 })
  156. }
  157. options.beforeMount?.(session)
  158. const sent: ContentBlock[][] = []
  159. const steered: ContentBlock[][] = []
  160. const steeredIds: AgentMessageId[] = []
  161. const sentOptions: (SendOptions | undefined)[] = []
  162. const steeredOptions: UserMessageData[] = []
  163. const injected: ContentBlock[][] = []
  164. const injectedOptions: UserMessageData[] = []
  165. const cancelled: AgentCancelCause[] = []
  166. const agent: FakeAgent = {
  167. id: sessionId,
  168. options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
  169. session,
  170. status: options.status ?? 'idle',
  171. ctx,
  172. sent,
  173. sentOptions,
  174. steered,
  175. steeredIds,
  176. steeredOptions,
  177. injected,
  178. injectedOptions,
  179. cancelled,
  180. send(input, options) {
  181. sent.push(input.content)
  182. sentOptions.push(options)
  183. return AgentMessageId('stub')
  184. },
  185. followup(input) {
  186. sent.push(input.content)
  187. sentOptions.push(undefined)
  188. return AgentMessageId('stub')
  189. },
  190. steer(input) {
  191. steered.push(input.content)
  192. steeredOptions.push(input)
  193. const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
  194. steeredIds.push(id)
  195. return id
  196. },
  197. inject(input) {
  198. injected.push(input.content)
  199. injectedOptions.push(input)
  200. return AgentMessageId('stub')
  201. },
  202. cancel(cause) {
  203. cancelled.push(cause)
  204. },
  205. retry() {},
  206. whenIdle() {
  207. return Promise.resolve()
  208. },
  209. }
  210. ctx.agents.register(agent)
  211. const controller = createTuiChat(ctx, Object.assign({
  212. ...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
  213. sessionId,
  214. color: false,
  215. }, options.config), {
  216. terminal,
  217. exit,
  218. // Default to the real clock (runtime.now falls back to Date.now) so the
  219. // elapsed-status suites can drive time via timers or Date.now spies; a
  220. // test pins the clock only by passing `now` explicitly.
  221. ...(options.now === undefined ? {} : { now: options.now }),
  222. ...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
  223. ...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
  224. })
  225. return { ctx, session, agent, terminal, exit, controller }
  226. }
  227. /** Dispose the mounted TUI before its owning Cordis context. */
  228. export async function disposeTuiTestHarness(
  229. setup: Pick<TuiHarness<Terminal, (code: number) => void>, 'controller' | 'ctx'>,
  230. ): Promise<void> {
  231. await setup.controller.dispose()
  232. await setup.ctx.fiber.dispose()
  233. }
  234. /** Append a production-shaped user message to the active session surface. */
  235. export function appendUser(session: Session, text: string): void {
  236. session.append('user/message', {
  237. content: [{ type: 'text', text }],
  238. source: { kind: 'user' },
  239. }, { surfaceOp: 'append' })
  240. }
  241. /** Append a production-shaped assistant message to the active session surface. */
  242. export function appendAssistant(
  243. session: Session,
  244. content: ContentBlock[],
  245. usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
  246. position: { turn: number; step: number } = { turn: 1, step: 1 },
  247. ): void {
  248. session.append('assistant/message', {
  249. ...position,
  250. provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
  251. content,
  252. ...usage === undefined ? {} : { usage },
  253. }, { surfaceOp: 'append' })
  254. }