harness.ts 11 KB

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