| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- import { describe, it, expect } from 'vitest'
- import { mkdtemp } from 'node:fs/promises'
- import { join } from 'node:path'
- import { tmpdir } from 'node:os'
- import { Context } from 'cordis'
- import Loader from '@cordisjs/plugin-loader'
- import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
- import type { Message } from '@deepseek-ai/dsh-llm'
- import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
- import * as stdioAgent from '../src/index.ts'
- /**
- * Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
- * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
- * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
- * survive namespace collapse while silently losing its schema.
- */
- async function mount(config: stdioAgent.Config): Promise<Context> {
- const ctx = new Context()
- await ctx.plugin(stdioAgent, config)
- // The app mounts its children inside apply() (not awaited there); let their
- // fibers settle so the spine services + the pre-created agent are ready.
- await new Promise(resolve => setTimeout(resolve, 80))
- return ctx
- }
- async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
- const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
- return {
- local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
- ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
- }
- }
- async function composePrefix(ctx: Context): Promise<Message[]> {
- const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
- const empty: Message[] = []
- return await agentEvents(ctx, agent).waterfall(
- 'agent/session-prefix', empty, new AbortController().signal,
- () => Promise.resolve(empty),
- )
- }
- async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
- const oldDshHome = process.env.DSH_HOME
- const oldAgentsHome = process.env.DSH_AGENTS_HOME
- const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
- process.env.DSH_HOME = join(home, '.dsh')
- process.env.DSH_AGENTS_HOME = join(home, '.agents')
- try {
- return await run()
- } finally {
- if (oldDshHome === undefined) {
- delete process.env.DSH_HOME
- } else {
- process.env.DSH_HOME = oldDshHome
- }
- if (oldAgentsHome === undefined) {
- delete process.env.DSH_AGENTS_HOME
- } else {
- process.env.DSH_AGENTS_HOME = oldAgentsHome
- }
- }
- }
- describe('dsh-stdio-agent app', () => {
- it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
- const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
- // The spine services (brought up by the agent-core bundle) are all present.
- expect(ctx.get('agents')).toBeDefined()
- expect(ctx.get('agentLoop')).toBeDefined()
- expect(ctx.get('sessionPersistence')).toBeDefined()
- expect(ctx.get('userInteraction')).toBeDefined()
- expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
- // The pre-created `main` agent the UI drives.
- const agent = ctx.get('agents')?.get(AgentId('main'))
- expect(agent).toBeDefined()
- expect(agent?.session.header.cwd).toBe(process.cwd())
- await ctx.fiber.dispose()
- })
- it('defaults persistenceRoot and welcome when omitted', async () => {
- // Direct apply (NOT via ctx.plugin, which validates+defaults the config
- // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
- // apply()'s last two lines are the ones that fire — covering a
- // schema-bypassing direct-mount caller.
- const ctx = new Context()
- // No persona: covers the omitted-persona forwarding branch too.
- stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
- await new Promise(resolve => setTimeout(resolve, 80))
- expect(ctx.get('sessionPersistence')).toBeDefined()
- expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
- await ctx.fiber.dispose()
- })
- it('uses default skill config when apply is called directly without skills', async () => {
- await withIsolatedSkillHomes(async () => {
- const ctx = new Context()
- stdioAgent.apply(ctx, { model: 'mock' })
- await new Promise(resolve => setTimeout(resolve, 80))
- expect(ctx.skills).toBeDefined()
- expect(await ctx.skills.list()).toEqual([])
- await ctx.fiber.dispose()
- })
- })
- it('forwards resumeSessionId onto the pre-created agent when set', async () => {
- // A resume id defers agent creation until persistence loads; with no backing
- // session the resume is contained + logged, so no `main` agent registers —
- // the branch that maps resumeSessionId through is what this covers.
- const ctx = await mount({
- model: 'mock',
- persona: 'hi',
- persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
- resumeSessionId: 'no-such-session',
- skills: await isolatedSkillsConfig(),
- })
- expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('forwards skill config into agent-core', async () => {
- const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
- ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
- expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
- await ctx.fiber.dispose()
- })
- it('exposes its name and Config schema', () => {
- expect(stdioAgent.name).toBe('stdio-agent')
- expect(stdioAgent.Config).toBeDefined()
- })
- it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
- const ctx = await mount({
- model: 'mock',
- toolOrder: ['zulu', TOOL_ORDER_REST],
- persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
- })
- // The bundle's own bash tools pend on the absent `ctx.bash` executor in
- // this providerless mount, so register two plain tools to order.
- for (const name of ['alpha', 'zulu']) {
- ctx.get('tools')!.register({
- name,
- description: name,
- parameters: {},
- execute: async () => [],
- })
- }
- const assembly = await ctx.get('systemPrompt')!.assemble()
- expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
- await ctx.fiber.dispose()
- })
- it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
- // A default export would make `unwrapExports` collapse this inject-less namespace and silently
- // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
- expect('default' in stdioAgent).toBe(false)
- expect(typeof stdioAgent.apply).toBe('function')
- const loader = Object.create(Loader.prototype) as Loader
- const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
- expect(unwrapped).toBe(stdioAgent)
- expect(unwrapped.name).toBe('stdio-agent')
- expect(unwrapped.Config).toBeDefined()
- expect(typeof unwrapped.apply).toBe('function')
- })
- })
|