| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- import { Context } from 'cordis'
- import type { Terminal } from '@earendil-works/pi-tui'
- import AgentRegistry, {
- AgentMessageId,
- type Agent,
- type AgentCancelCause,
- type AgentOptions,
- type AgentStatus,
- type SendOptions,
- } from '@deepseek-ai/dsh-agent'
- import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
- import CommandService from '@deepseek-ai/dsh-commands'
- import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
- import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
- import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
- import { TestSessionQueryService } from './session-query.ts'
- interface FakeAgent extends Agent {
- status: AgentStatus
- sent: ContentBlock[][]
- sentOptions: (SendOptions | undefined)[]
- steered: ContentBlock[][]
- steeredIds: AgentMessageId[]
- steeredOptions: UserMessageData[]
- injected: ContentBlock[][]
- injectedOptions: UserMessageData[]
- cancelled: AgentCancelCause[]
- }
- export interface TuiHarnessOptions {
- status?: AgentStatus
- config?: Config
- /** Leave the session event log empty instead of seeding one turn and step. */
- omitInitialLifecycle?: boolean
- /** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */
- omitWelcome?: boolean
- tools?: Record<string, ToolDefinition>
- configureContext?: (ctx: Context) => Promise<void>
- beforeMount?: (session: Session) => void
- cwd?: string | null
- formatCwd?: TuiRuntime['formatCwd']
- /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
- agentOptions?: AgentOptions
- contextWindow?: number
- contextTokens?: number
- now?: () => number
- catalog?: {
- providers: LlmProviderInfo[]
- models: LlmModelInfo[]
- listModels?: (provider: string) => Promise<LlmModelInfo[]>
- resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
- }
- /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
- sessionPersistence?: {
- list(): Promise<SessionHeader[]>
- load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
- }
- handoffResume?: TuiRuntime['handoffResume']
- /** Set false to exercise the optional session-query degradation path. */
- mountSessionQuery?: boolean
- }
- export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
- ctx: Context
- session: Session
- agent: FakeAgent
- terminal: TerminalType
- exit: Exit
- controller: ReturnType<typeof createTuiChat>
- }
- /**
- * Compose the production TUI around an in-memory session and controllable agent.
- * @param terminal - Terminal boundary driven by the test.
- * @param exit - Process-exit observer.
- * @param options - Initial session, agent, tool, and TUI configuration.
- * @returns The mounted TUI and every boundary the test may drive or inspect.
- */
- export async function createTuiTestHarness<TerminalType extends Terminal, Exit extends (code: number) => void>(
- terminal: TerminalType,
- exit: Exit,
- options: TuiHarnessOptions = {},
- ): Promise<TuiHarness<TerminalType, Exit>> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(CommandService)
- await ctx.plugin(UserInteractionService)
- const catalog = options.catalog ?? {
- providers: [{ id: 'deepseek', name: 'DeepSeek' }],
- models: [
- { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
- { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
- ],
- }
- ctx.provide('tokenMeter', {
- measure() {
- return { totalTokens: options.contextTokens ?? 0 }
- },
- } as never)
- if (options.configureContext === undefined) {
- const tools = options.tools ?? {}
- ctx.provide('tools', {
- get(name: string) {
- return tools[name]
- },
- } as never)
- } else {
- await options.configureContext(ctx)
- }
- // A configureContext may mount the real LlmService; only fill the
- // advisory-catalog stub when none was provided.
- if (ctx.get('llm') === undefined) {
- ctx.provide('llm', {
- listProviders() {
- return catalog.providers.map(provider => ({ ...provider }))
- },
- listModels(provider: string) {
- return catalog.listModels?.(provider)
- ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
- },
- resolveModelContext(provider: string, model: string) {
- return catalog.resolveModelContext?.(provider, model)
- ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
- },
- } as never)
- }
- if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
- if (options.sessionPersistence !== undefined) {
- const persistence = options.sessionPersistence
- ctx.provide('sessionPersistence', {
- ...persistence,
- locate: () => undefined,
- create: () => Promise.resolve(),
- append: () => Promise.resolve(),
- load: persistence.load === undefined
- ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
- : (id: ReturnType<typeof SessionId>) => persistence.load!(id),
- inspect: persistence.load === undefined
- ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
- : (id: ReturnType<typeof SessionId>) => persistence.load!(id),
- } as never)
- }
- if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
- await ctx.plugin(TestSessionQueryService)
- }
- const sessionId = SessionId('main-session')
- const session = ctx.sessions.create(
- sessionId,
- options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
- )
- if (options.omitInitialLifecycle !== true) {
- session.append('turn/start', {
- turn: 1,
- trigger: { kind: 'message', source: { kind: 'user' } },
- })
- session.append('step/start', { turn: 1, step: 1 })
- }
- options.beforeMount?.(session)
- const sent: ContentBlock[][] = []
- const steered: ContentBlock[][] = []
- const steeredIds: AgentMessageId[] = []
- const sentOptions: (SendOptions | undefined)[] = []
- const steeredOptions: UserMessageData[] = []
- const injected: ContentBlock[][] = []
- const injectedOptions: UserMessageData[] = []
- const cancelled: AgentCancelCause[] = []
- const agent: FakeAgent = {
- id: sessionId,
- options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
- session,
- status: options.status ?? 'idle',
- ctx,
- sent,
- sentOptions,
- steered,
- steeredIds,
- steeredOptions,
- injected,
- injectedOptions,
- cancelled,
- send(input, options) {
- sent.push(input.content)
- sentOptions.push(options)
- return AgentMessageId('stub')
- },
- followup(input) {
- sent.push(input.content)
- sentOptions.push(undefined)
- return AgentMessageId('stub')
- },
- steer(input) {
- steered.push(input.content)
- steeredOptions.push(input)
- const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
- steeredIds.push(id)
- return id
- },
- inject(input) {
- injected.push(input.content)
- injectedOptions.push(input)
- return AgentMessageId('stub')
- },
- cancel(cause) {
- cancelled.push(cause)
- },
- retry() {},
- whenIdle() {
- return Promise.resolve()
- },
- }
- ctx.agents.register(agent)
- const controller = createTuiChat(ctx, Object.assign({
- ...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
- sessionId,
- color: false,
- }, options.config), {
- terminal,
- exit,
- // Default to the real clock (runtime.now falls back to Date.now) so the
- // elapsed-status suites can drive time via timers or Date.now spies; a
- // test pins the clock only by passing `now` explicitly.
- ...(options.now === undefined ? {} : { now: options.now }),
- ...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
- ...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
- })
- return { ctx, session, agent, terminal, exit, controller }
- }
- /** Dispose the mounted TUI before its owning Cordis context. */
- export async function disposeTuiTestHarness(
- setup: Pick<TuiHarness<Terminal, (code: number) => void>, 'controller' | 'ctx'>,
- ): Promise<void> {
- await setup.controller.dispose()
- await setup.ctx.fiber.dispose()
- }
- /** Append a production-shaped user message to the active session surface. */
- export function appendUser(session: Session, text: string): void {
- session.append('user/message', {
- content: [{ type: 'text', text }],
- source: { kind: 'user' },
- }, { surfaceOp: 'append' })
- }
- /** Append a production-shaped assistant message to the active session surface. */
- export function appendAssistant(
- session: Session,
- content: ContentBlock[],
- usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
- position: { turn: number; step: number } = { turn: 1, step: 1 },
- ): void {
- session.append('assistant/message', {
- ...position,
- provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
- content,
- ...usage === undefined ? {} : { usage },
- }, { surfaceOp: 'append' })
- }
|