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, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, } 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 ToolRegistry, { 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' import TuiPromptService from '../src/prompt.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 /** Override the fake agent's next-step capability independently of status. */ acceptsNextStep?: boolean 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 configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null formatCwd?: TuiRuntime['formatCwd'] gitBranch?: TuiRuntime['gitBranch'] /** 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 resolveModelInfo?: ( provider: string, model: string, ) => Promise> } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ sessionPersistence?: { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ mountSessionQuery?: boolean } export interface TuiHarness void> { ctx: Context session: Session agent: FakeAgent terminal: TerminalType exit: Exit controller: ReturnType } /** * 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 void>( terminal: TerminalType, exit: Exit, options: TuiHarnessOptions = {}, ): Promise> { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) await ctx.plugin(TuiPromptService) 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) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) for (const tool of Object.values(options.tools ?? {})) ctx.tools.register(tool) } 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 }))) }, async resolveModelInfo(provider: string, model: string) { const advertised = catalog.models.find(candidate => candidate.provider === provider && candidate.id === model) const capabilities = await (catalog.resolveModelInfo?.(provider, model) ?? Promise.resolve({ context: { contextWindow: options.contextWindow ?? 128_000 }, })) return { provider, id: model, name: advertised?.name ?? model, ...advertised?.description === undefined ? {} : { description: advertised.description }, ...capabilities, } }, } 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) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), inspect: persistence.load === undefined ? (id: ReturnType) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => 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', get acceptsNextStep() { return options.acceptsNextStep ?? this.status === 'running' }, 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) }, whenIdle() { return Promise.resolve() }, } ctx.agents.register(agent) const controller = createTuiChat(ctx, Object.assign({ ...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' }, sessionId, theme: { 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 }), gitBranch: options.gitBranch ?? (() => 'tui-staging'), }) return { ctx, session, agent, terminal, exit, controller } } /** Dispose the mounted TUI before its owning Cordis context. */ export async function disposeTuiTestHarness( setup: Pick void>, 'controller' | 'ctx'>, ): Promise { 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' }) }