composition.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import type { CordisDynamicPackageId, CordisDynamicPluginId } from '../src/types.ts'
  3. import { missingServices } from '../src/lifecycle.ts'
  4. import {
  5. AGENT_A, call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, mount,
  6. PROVIDER_CODE, REVERSE_TOOL_CODE, setup, text,
  7. running,
  8. } from './helpers.ts'
  9. /**
  10. * Cross-package composition through ordinary cordis provide/inject semantics:
  11. * one package's host half provides a service, another injects it, and definition
  12. * ids stay the lifecycle handles across stop and run again. Every assertion is
  13. * against the WORLD — the registry, the service store, real tool dispatch — not
  14. * a rendered summary (that is the tool package's job).
  15. */
  16. afterEach(() => {
  17. vi.restoreAllMocks()
  18. })
  19. function latestPackage(harness: Awaited<ReturnType<typeof setup>>, pluginId: CordisDynamicPluginId): CordisDynamicPackageId {
  20. const row = harness.runner.inventory().find(candidate => candidate.pluginId === pluginId)
  21. const packageId = row?.packages.at(-1)?.packageId
  22. if (packageId === undefined) throw new Error(`missing package for ${pluginId}`)
  23. return packageId
  24. }
  25. describe('cross-package provide/inject', () => {
  26. it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
  27. const harness = await setup()
  28. await mount(harness, PROVIDER_CODE)
  29. await mount(harness, CONSUMER_CODE)
  30. // The vm-realm service value is callable across packages, and the result
  31. // normalizes into the host realm like any dynamic tool result.
  32. const greeted = await call(harness.ctx, 'greet', { name: 'harness' })
  33. expect(greeted.isError).toBe(false)
  34. expect(text(greeted)).toBe('hi harness')
  35. })
  36. it('consumer first: runs but stays parked on the missing service, then activates when the provider runs', async () => {
  37. const harness = await setup()
  38. const consumer = await mount(harness, CONSUMER_CODE)
  39. // A settled-but-pending host half is a successful run in legal cordis
  40. // semantics; the fiber names what it waits for.
  41. const [row] = harness.runner.snapshot(AGENT_A)
  42. expect(row?.activeRun?.fiber).toBeDefined()
  43. expect(missingServices(harness.ctx, row?.activeRun?.fiber as never)).toEqual(['greeter'])
  44. expect(harness.ctx.tools.get('greet')).toBeUndefined()
  45. expect(running(harness.runner, AGENT_A)).toEqual([{ id: consumer, running: true }])
  46. await mount(harness, PROVIDER_CODE)
  47. expect(harness.ctx.tools.get('greet')).toBeDefined()
  48. expect(text(await call(harness.ctx, 'greet', { name: 'late' }))).toBe('hi late')
  49. })
  50. it('stopping the provider sends the consumer back to pending and unwinds its registrations', async () => {
  51. const harness = await setup()
  52. const provider = await mount(harness, PROVIDER_CODE)
  53. await mount(harness, CONSUMER_CODE)
  54. expect(harness.ctx.tools.get('greet')).toBeDefined()
  55. await expect(harness.runner.stop(AGENT_A, provider)).resolves.toEqual({ ok: true })
  56. expect(harness.ctx.tools.get('greet')).toBeUndefined()
  57. expect(harness.ctx.get('greeter')).toBeUndefined()
  58. })
  59. it('running the provider again re-runs the consumer through a fresh guard (tool back)', async () => {
  60. const harness = await setup()
  61. const provider = await mount(harness, PROVIDER_CODE)
  62. await mount(harness, CONSUMER_CODE)
  63. await harness.runner.stop(AGENT_A, provider)
  64. expect(harness.ctx.tools.get('greet')).toBeUndefined()
  65. // The same definition, a new dispatch: the consumer's apply re-runs through
  66. // a new façade rather than needing its own re-definition.
  67. await expect(harness.runner.run(
  68. AGENT_A, provider, latestPackage(harness, provider), 'run',
  69. )).resolves.toMatchObject({ ok: true })
  70. expect(harness.ctx.tools.get('greet')).toBeDefined()
  71. expect(text(await call(harness.ctx, 'greet', { name: 'again' }))).toBe('hi again')
  72. })
  73. it('a duplicate provide fails loud and leaves the second package not running', async () => {
  74. const harness = await setup()
  75. await mount(harness, PROVIDER_CODE)
  76. await expect(mount(harness, PROVIDER_CODE)).rejects.toThrow('has been registered')
  77. const rows = harness.runner.snapshot(AGENT_A)
  78. expect(rows.map(row => row.activeRun !== undefined)).toEqual([true, false])
  79. // The service still belongs to the first package's fiber.
  80. expect(harness.ctx.get('greeter')).toBeDefined()
  81. })
  82. it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
  83. const harness = await setup()
  84. await mount(harness, `
  85. return {
  86. name: 'answer-provider',
  87. apply(ctx) {
  88. ctx.provide('answer', 42)
  89. ctx.provide('nothing', null)
  90. },
  91. }
  92. `)
  93. await mount(harness, `
  94. return {
  95. name: 'answer-consumer',
  96. inject: ['answer', 'nothing', 'tools'],
  97. apply(ctx) {
  98. harness.registerTool(ctx, harness.defineTool({
  99. name: 'answer',
  100. description: 'Read the provided primitive services.',
  101. parameters: {},
  102. ${CONTENT_OUTPUT_CODE}
  103. async execute() {
  104. return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
  105. },
  106. }))
  107. },
  108. }
  109. `)
  110. expect(text(await call(harness.ctx, 'answer', {}))).toBe('42/42/null')
  111. })
  112. it('stopping the consumer leaves the provider and its service intact', async () => {
  113. const harness = await setup()
  114. await mount(harness, PROVIDER_CODE)
  115. const consumer = await mount(harness, CONSUMER_CODE)
  116. await harness.runner.stop(AGENT_A, consumer)
  117. expect(harness.ctx.tools.get('greet')).toBeUndefined()
  118. expect(harness.ctx.get('greeter')).toBeDefined()
  119. })
  120. })
  121. describe('stop reaches quiescence', () => {
  122. it('the host half\'s listeners have stopped by the time stop returns', async () => {
  123. const harness = await setup()
  124. const log = vi.spyOn(console, 'log').mockImplementation(() => {})
  125. const id = await mount(harness, LISTENER_CODE)
  126. harness.ctx.tools.register(dummyTool('trigger_before'))
  127. expect(log).toHaveBeenCalledTimes(1)
  128. await expect(harness.runner.stop(AGENT_A, id)).resolves.toEqual({ ok: true })
  129. // Immediately after the awaited stop, the listener must be gone — no grace
  130. // period, no eventual consistency.
  131. harness.ctx.tools.register(dummyTool('trigger_after'))
  132. expect(log).toHaveBeenCalledTimes(1)
  133. })
  134. it('unregisters a self-made tool on stop, and registers it again on the next run', async () => {
  135. const harness = await setup()
  136. const id = await mount(harness, REVERSE_TOOL_CODE)
  137. expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
  138. await harness.runner.stop(AGENT_A, id)
  139. expect(harness.ctx.tools.get('reverse_text')).toBeUndefined()
  140. await harness.runner.run(AGENT_A, id, latestPackage(harness, id), 'run')
  141. expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
  142. })
  143. it('names the replace recipe when a run collides with a live registration', async () => {
  144. const harness = await setup()
  145. await mount(harness, REVERSE_TOOL_CODE)
  146. // A second package registering the same tool name collides; the teaching
  147. // error points at the stop-then-run recipe rather than a bare conflict.
  148. await expect(mount(harness, REVERSE_TOOL_CODE)).rejects.toThrow('first stop that package through its runner or the Cordis panel')
  149. })
  150. })