stdio-agent.spec.ts 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. import { describe, it, expect } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import { AgentId } from '@deepseek-ai/dsh-agent'
  5. import * as stdioAgent from '../src/index.ts'
  6. /**
  7. * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it
  8. * composes the console logger, the agent-core spine (pre-creating the `main`
  9. * agent from the app config), the JSONL backend, and the readline UI in one
  10. * `ctx.plugin`. The forwarded `model`/`systemPrompt` reach the pre-created
  11. * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends.
  12. *
  13. * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
  14. * plugin the in-process tier cannot import); the REAL Loader-path guard (export
  15. * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless
  16. * echo smoke in `examples/echo-agent`. Here we assert the composition + config
  17. * forwarding the unit tier can reach.
  18. */
  19. async function mount(config: stdioAgent.Config): Promise<Context> {
  20. const ctx = new Context()
  21. await ctx.plugin(stdioAgent, config)
  22. // The app mounts its children inside apply() (not awaited there); let their
  23. // fibers settle so the spine services + the pre-created agent are ready.
  24. await new Promise(resolve => setTimeout(resolve, 80))
  25. return ctx
  26. }
  27. describe('dsh-stdio-agent app', () => {
  28. it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
  29. const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
  30. // The spine services (brought up by the agent-core bundle) are all present.
  31. expect(ctx.get('agents')).toBeDefined()
  32. expect(ctx.get('agentLoop')).toBeDefined()
  33. expect(ctx.get('sessionPersistence')).toBeDefined()
  34. // The pre-created `main` agent the UI drives.
  35. expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
  36. await ctx.fiber.dispose()
  37. })
  38. it('defaults persistenceRoot and welcome when omitted', async () => {
  39. // Direct apply (NOT via ctx.plugin, which validates+defaults the config
  40. // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
  41. // apply()'s last two lines are the ones that fire — covering a
  42. // schema-bypassing direct-mount caller.
  43. const ctx = new Context()
  44. stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
  45. await new Promise(resolve => setTimeout(resolve, 80))
  46. expect(ctx.get('sessionPersistence')).toBeDefined()
  47. expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
  48. await ctx.fiber.dispose()
  49. })
  50. it('forwards resumeSessionId onto the pre-created agent when set', async () => {
  51. // A resume id defers agent creation until persistence loads; with no backing
  52. // session the resume is contained + logged, so no `main` agent registers —
  53. // the branch that maps resumeSessionId through is what this covers.
  54. const ctx = await mount({
  55. model: 'mock',
  56. systemPrompt: 'hi',
  57. persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
  58. resumeSessionId: 'no-such-session',
  59. })
  60. expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
  61. await ctx.fiber.dispose()
  62. })
  63. it('exposes its name and Config schema', () => {
  64. expect(stdioAgent.name).toBe('stdio-agent')
  65. expect(stdioAgent.Config).toBeDefined()
  66. })
  67. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
  68. // Postmortem 0001 guard: a stray `export default apply` makes the Loader's
  69. // `unwrapExports` (`exports.default ?? exports`) collapse the module to the
  70. // bare `apply` function, DROPPING the named `name`/`Config`. This package has
  71. // no `inject` export, so that collapse would NOT crash at load (the keyless
  72. // echo smoke would still boot the tree) — it would silently lose its config
  73. // schema. So guard the shape directly here: assert no `default` export, and
  74. // that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding
  75. // `export default` to src/index.ts fails this test.
  76. expect('default' in stdioAgent).toBe(false)
  77. expect(typeof stdioAgent.apply).toBe('function')
  78. const loader = Object.create(Loader.prototype) as Loader
  79. const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
  80. expect(unwrapped).toBe(stdioAgent)
  81. expect(unwrapped.name).toBe('stdio-agent')
  82. expect(unwrapped.Config).toBeDefined()
  83. expect(typeof unwrapped.apply).toBe('function')
  84. })
  85. })