mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
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<string, ToolDefinition>
|
|
configureContext?: (ctx: Context) => Promise<void>
|
|
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<LlmModelInfo[]>
|
|
resolveModelInfo?: (
|
|
provider: string,
|
|
model: string,
|
|
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
|
|
}
|
|
/** 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)
|
|
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<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',
|
|
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<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' })
|
|
}
|