browser-plugin.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { Context, Service } from '@deepseek-ai/cordis'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  4. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  5. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  6. import type { TeamMemberView as TeamRosterMember, TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team/client'
  7. import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote'
  8. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  9. import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
  10. import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx'
  11. import { inject, mountAgentTeamUi } from '../src/client/mount.ts'
  12. import { apply as nodeApply } from '../src/index.ts'
  13. const SESSION = 'team-session' as SessionId
  14. const CHILD = 'team-child' as SessionId
  15. const TASK_ID = 'task-1' as TeamTaskId
  16. const REMOTE: TypertRemoteContribution = {
  17. package: '@deepseek-ai/dsh-experimental-agent-team',
  18. descriptors: [],
  19. }
  20. async function bench(options: {
  21. addressed?: boolean
  22. conflict?: boolean
  23. registrationFailure?: boolean
  24. remoteFailure?: 'view' | 'update'
  25. refreshGate?: Promise<void>
  26. } = {}) {
  27. const ctx = new Context()
  28. const calls: { method: string; args: unknown[] }[] = []
  29. const answer = <T>(method: string, value: T) => (...args: unknown[]) => {
  30. calls.push({ method, args })
  31. return Promise.resolve({ ok: true as const, value })
  32. }
  33. const task = {
  34. id: 'task-1',
  35. revision: 1, subject: 'Task', description: 'Description', status: 'pending' as const,
  36. blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
  37. }
  38. class RemoteService extends Service {
  39. readonly disposeMount = vi.fn(() => Promise.resolve())
  40. readonly mount = vi.fn((_contribution: unknown) => Promise.resolve(this.disposeMount))
  41. constructor(serviceCtx: Context) {
  42. super(serviceCtx, 'remote')
  43. }
  44. $mount(contribution: unknown): Promise<() => Promise<void>> {
  45. return this.mount(contribution)
  46. }
  47. }
  48. const remote = new RemoteService(ctx)
  49. const failure = {
  50. ok: false as const,
  51. error: new RemoteError('gateway/internal', 'offline', {}),
  52. }
  53. const view = {
  54. members: [{
  55. id: SESSION, name: 'lead', role: 'lead' as const, status: 'idle' as const, diagnostics: [],
  56. }], tasks: [task],
  57. }
  58. ctx.provide('remote.agentTeams', {
  59. view: (...args: unknown[]) => {
  60. calls.push({ method: 'agentTeams/view', args })
  61. return Promise.resolve(options.remoteFailure === 'view'
  62. ? failure
  63. : { ok: true as const, value: view })
  64. },
  65. createTask: answer('agentTeams/createTask', task),
  66. updateTask: (...args: unknown[]) => {
  67. calls.push({ method: 'agentTeams/updateTask', args })
  68. if (options.remoteFailure === 'update') return Promise.resolve(failure)
  69. return Promise.resolve(options.conflict
  70. ? {
  71. ok: true as const,
  72. value: {
  73. ok: false as const,
  74. error: {
  75. code: 'team-task-conflict' as const,
  76. message: 'stale',
  77. },
  78. },
  79. }
  80. : { ok: true as const, value: { ok: true as const, value: { ...task, revision: 2 } } })
  81. },
  82. })
  83. const navigation: unknown[] = []
  84. let current = options.addressed === true ? CHILD : SESSION
  85. ctx.provide('sessions', {
  86. list: { getSnapshot: () => ({ current }) },
  87. binding: (id: SessionId) => options.addressed === true && id === CHILD
  88. ? { session: { getSnapshot: () => ({
  89. subagent: {
  90. address: {
  91. parentSessionId: SESSION,
  92. childSessionId: CHILD,
  93. mode: 'continuable' as const,
  94. },
  95. },
  96. }) } }
  97. : undefined,
  98. refreshSubagents: (id: SessionId) => {
  99. navigation.push(['refresh', id])
  100. return options.refreshGate ?? Promise.resolve()
  101. },
  102. openSubagent: (address: unknown) => { navigation.push(['open', address]) },
  103. })
  104. ctx.provide('conversation', {})
  105. ctx.provide('locale', new LocaleRuntime(ctx))
  106. await ctx.plugin(SlotRegistry).await()
  107. const collapseHeader = ctx.slots.register({
  108. name: 'root',
  109. children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' } },
  110. } as never, () => null)
  111. if (options.registrationFailure === true) {
  112. vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot registration failed') })
  113. }
  114. const fiber = options.registrationFailure === true
  115. ? ctx.plugin({ apply() {} })
  116. : ctx.plugin({ inject: [...inject], apply: clientCtx => mountAgentTeamUi(clientCtx, REMOTE) })
  117. const activation: Promise<unknown> = options.registrationFailure === true
  118. ? mountAgentTeamUi(ctx, REMOTE).catch((error: unknown) => error)
  119. : fiber.await()
  120. if (options.registrationFailure !== true) {
  121. await activation
  122. } else {
  123. await fiber.await()
  124. }
  125. const entry = () => ctx.slots.entries('conversation.session.header.actions')
  126. .find(candidate => candidate.component === TeamAction)
  127. return {
  128. ctx,
  129. fiber,
  130. activation,
  131. calls,
  132. navigation,
  133. remote,
  134. entry,
  135. collapseHeader,
  136. select: (sessionId: SessionId) => { current = sessionId },
  137. }
  138. }
  139. describe('ui-team browser plugin', () => {
  140. it('registers one disposable header action with RPC-backed task operations', async () => {
  141. const b = await bench()
  142. expect(inject).toEqual(['sessions', 'remote', 'slots', 'locale'])
  143. expect(b.entry()).toMatchObject({
  144. options: { id: 'agent-team', order: 20 },
  145. locale: 'agent-team',
  146. })
  147. expect(b.remote.mount).toHaveBeenCalledOnce()
  148. expect(b.remote.mount).toHaveBeenCalledWith(REMOTE)
  149. const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
  150. expect((await actions.load(SESSION)).ok).toBe(true)
  151. expect((await actions.createTask(SESSION, {
  152. subject: 'Task', description: 'Description', blockedBy: [], writeScopes: [],
  153. })).ok).toBe(true)
  154. expect((await actions.updateTask(SESSION, {
  155. taskId: TASK_ID, expectedRevision: 1, action: 'complete',
  156. })).ok).toBe(true)
  157. expect((await actions.updateTask(SESSION, {
  158. taskId: TASK_ID, expectedRevision: 2, action: 'reassign', owner: 'worker',
  159. })).ok).toBe(true)
  160. expect(b.calls.map(call => call.method)).toEqual([
  161. 'agentTeams/view', 'agentTeams/createTask', 'agentTeams/updateTask', 'agentTeams/updateTask',
  162. ])
  163. expect(b.calls.at(-1)?.args[1]).toMatchObject({ owner: 'worker' })
  164. await actions.openTeammate(SESSION, {
  165. id: SESSION,
  166. name: 'lead',
  167. role: 'lead',
  168. status: 'idle',
  169. diagnostics: [],
  170. })
  171. expect(b.navigation).toEqual([])
  172. await b.fiber.dispose()
  173. expect(b.entry()).toBeUndefined()
  174. expect(b.remote.disposeMount).toHaveBeenCalledOnce()
  175. })
  176. it('unmounts the Remote contribution when later Client registration fails', async () => {
  177. const b = await bench({ registrationFailure: true })
  178. await expect(b.activation).resolves.toMatchObject({ message: 'slot registration failed' })
  179. expect(b.remote.mount).toHaveBeenCalledOnce()
  180. expect(b.remote.disposeMount).toHaveBeenCalledOnce()
  181. })
  182. it('returns the generated task business result without a Client transport wrapper', async () => {
  183. const b = await bench({ conflict: true })
  184. const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
  185. await expect(actions.updateTask(SESSION, {
  186. taskId: TASK_ID, expectedRevision: 1, action: 'delete',
  187. })).resolves.toEqual({
  188. ok: true,
  189. value: {
  190. ok: false,
  191. error: { code: 'team-task-conflict', message: 'stale' },
  192. },
  193. })
  194. })
  195. it('returns Remote carrier failures unchanged', async () => {
  196. const view = await bench({ remoteFailure: 'view' })
  197. const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)()
  198. await expect(viewActions.load(SESSION)).resolves.toMatchObject({
  199. ok: false,
  200. error: { code: 'gateway/internal', message: 'offline' },
  201. })
  202. const update = await bench({ remoteFailure: 'update' })
  203. const updateActions = (update.entry()!.inject as unknown as () => TeamActionInjected)()
  204. await expect(updateActions.updateTask(SESSION, {
  205. taskId: TASK_ID, expectedRevision: 1, action: 'delete',
  206. })).resolves.toMatchObject({
  207. ok: false,
  208. error: { code: 'gateway/internal', message: 'offline' },
  209. })
  210. })
  211. it('refreshes the descriptor catalog before opening a continuable teammate address', async () => {
  212. const b = await bench()
  213. const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
  214. const member: TeamRosterMember = {
  215. id: CHILD,
  216. name: 'worker',
  217. role: 'teammate',
  218. status: 'inactive',
  219. diagnostics: [],
  220. }
  221. await actions.openTeammate(SESSION, member)
  222. expect(b.navigation).toEqual([
  223. ['refresh', SESSION],
  224. ['open', {
  225. parentSessionId: SESSION,
  226. childSessionId: CHILD,
  227. mode: 'continuable',
  228. }],
  229. ])
  230. })
  231. it('routes Team actions from an addressed teammate conversation back through its Lead', async () => {
  232. const b = await bench({ addressed: true })
  233. const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
  234. await actions.load(CHILD)
  235. await actions.openTeammate(CHILD, {
  236. id: CHILD,
  237. name: 'worker',
  238. role: 'teammate',
  239. status: 'inactive',
  240. diagnostics: [],
  241. })
  242. expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] })
  243. expect(b.navigation).toEqual([
  244. ['refresh', SESSION],
  245. ['open', {
  246. parentSessionId: SESSION,
  247. childSessionId: CHILD,
  248. mode: 'continuable',
  249. }],
  250. ])
  251. })
  252. it('does not open a teammate after navigation switches during catalog refresh', async () => {
  253. const refresh = Promise.withResolvers<undefined>()
  254. const b = await bench({ refreshGate: refresh.promise })
  255. const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)()
  256. const opening = actions.openTeammate(SESSION, {
  257. id: CHILD,
  258. name: 'worker',
  259. role: 'teammate',
  260. status: 'inactive',
  261. diagnostics: [],
  262. })
  263. expect(b.navigation).toEqual([['refresh', SESSION]])
  264. b.select('other-session' as SessionId)
  265. refresh.resolve(undefined)
  266. await opening
  267. expect(b.navigation).toEqual([['refresh', SESSION]])
  268. })
  269. it('re-registers after the conversation header slot is collapsed and declared again', async () => {
  270. const b = await bench()
  271. expect(b.entry()).toBeDefined()
  272. b.collapseHeader()
  273. expect(b.entry()).toBeUndefined()
  274. b.ctx.slots.register({
  275. name: 'root',
  276. children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' } },
  277. } as never, () => null)
  278. await Promise.resolve()
  279. expect(b.entry()).toBeDefined()
  280. })
  281. it('keeps the node half inert', () => {
  282. expect(() => { nodeApply() }).not.toThrow()
  283. })
  284. })