workspace.test.ts 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { WebSocketServer, type WebSocket } from 'ws'
  8. import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
  9. import { WorkspaceStore } from '../../src/server/workspaceStore'
  10. import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
  11. import { WorkspaceNodes } from '../../src/server/workspaceGateway'
  12. import { UploadStore } from '../../src/server/uploads'
  13. import type {
  14. WorkspaceConversation,
  15. WorkspaceRun,
  16. WorkspaceMessage,
  17. WorkspaceInteraction,
  18. } from '../../src/shared/workspace'
  19. let home: string,
  20. store: WorkspaceStore,
  21. uploads: UploadStore,
  22. runtime: WorkspaceRuntime,
  23. server: WebSocketServer
  24. let requests: Array<{ method: string; params: Record<string, any> }>,
  25. reply: (socket: WebSocket, params: Record<string, any>) => void
  26. let nodes: WorkspaceNodes
  27. let rejectPrompt: string | undefined
  28. let rejectedInterrupts: number
  29. let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
  30. let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
  31. const owner = 'first-user',
  32. other = 'second-user'
  33. beforeEach(async () => {
  34. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-test-'))
  35. store = new WorkspaceStore(home)
  36. uploads = new UploadStore(home)
  37. requests = []
  38. recoveryHistory = []
  39. storedByRuntime = new Map(); recoveryByStored = new Map()
  40. rejectPrompt = undefined
  41. rejectedInterrupts = 0
  42. server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  43. await new Promise<void>((resolve) => server.once('listening', resolve))
  44. const address = server.address() as { port: number }
  45. const target = {
  46. url: new URL(`http://127.0.0.1:${address.port}`),
  47. client: { directAgent: undefined },
  48. session: {
  49. webSocketCredential: async () => ({ name: 'ticket', value: 'test' }),
  50. request: async (path: string, options?: {search?: URLSearchParams}) => ({
  51. status: 200,
  52. body: Buffer.from(JSON.stringify({ messages: (() => { const all = recoveryByStored.get(decodeURIComponent(path.split('/')[3]!)) ?? recoveryHistory; const offset = Number(options?.search?.get('offset') ?? 0); return all.slice(Math.max(0,all.length-offset-500), all.length-offset) })() })),
  53. }),
  54. },
  55. }
  56. nodes = { target: () => target } as unknown as WorkspaceNodes
  57. runtime = new WorkspaceRuntime(store, nodes, uploads)
  58. reply = (socket, p) =>
  59. setTimeout(
  60. () =>
  61. socket.send(
  62. JSON.stringify({
  63. method: 'event',
  64. params: {
  65. type: 'message.complete',
  66. session_id: p.session_id,
  67. payload: { text: '完成', status: 'complete' },
  68. },
  69. }),
  70. ),
  71. 5,
  72. )
  73. server.on('connection', (socket) => {
  74. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready' } }))
  75. socket.on('message', (data) => {
  76. const frame = JSON.parse(String(data))
  77. requests.push(frame)
  78. const respond = (result: unknown) => socket.send(JSON.stringify({ id: frame.id, result }))
  79. if (frame.method === 'session.create' || frame.method === 'session.resume') {
  80. const runtimeId = randomUUID(), storedId = frame.method === 'session.resume' ? frame.params.session_id : randomUUID()
  81. storedByRuntime.set(runtimeId, storedId)
  82. respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile } })
  83. }
  84. else if (frame.method === 'prompt.submit') {
  85. if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
  86. respond({ status: 'streaming' })
  87. reply(socket, frame.params)
  88. } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
  89. else if (frame.method === 'session.interrupt' && rejectedInterrupts-- > 0) socket.send(JSON.stringify({id:frame.id,error:{code:500,message:'暂时不能中断'}}))
  90. else respond({ ok: true, status: 'interrupted' })
  91. })
  92. })
  93. })
  94. afterEach(async () => {
  95. runtime.close()
  96. await new Promise((resolve) => setTimeout(resolve, 10))
  97. for (const client of server.clients) client.terminate()
  98. await new Promise<void>((resolve) => server.close(() => resolve()))
  99. uploads.close()
  100. store.close()
  101. rmSync(home, { recursive: true, force: true })
  102. })
  103. function agent(name: string, user = owner) {
  104. return store.createAgent(user, { name, profile: 'default', instructions: `规则:你是${name}` })
  105. }
  106. function direct(id: string, user = owner) {
  107. return store
  108. .list<WorkspaceConversation>(user, 'conversation')
  109. .find((c) => c.kind === 'direct' && c.memberIds[0] === id)!
  110. }
  111. function defaultTask(conversationId: string, user = owner) {
  112. return store.tasks(user, conversationId)[0]!
  113. }
  114. async function finished(id: string) {
  115. await vi.waitFor(() =>
  116. expect(store.require<WorkspaceRun>(owner, 'run', id).status).toBe('complete'),
  117. )
  118. return store.require<WorkspaceRun>(owner, 'run', id)
  119. }
  120. describe('Web-owned workspace', () => {
  121. it('isolates messages, Hermes sessions, bindings, and context between concurrent group tasks', async () => {
  122. const administrator = store.createAgent(owner, { name: '多任务远程管理员', profile: 'remote-profile', nodeId: 'remote-node' }),
  123. member = agent('多任务成员'),
  124. conversation = store.createGroup(owner, {
  125. name: '多任务群聊',
  126. memberIds: [administrator.id, member.id],
  127. administratorId: administrator.id,
  128. }),
  129. firstTask = defaultTask(conversation.id),
  130. secondTask = store.createTask(owner, conversation.id, { title: '显式标题' })
  131. const pending: Array<{ socket: WebSocket; params: Record<string, any> }> = []
  132. reply = (socket, params) => {
  133. pending.push({ socket, params })
  134. if (pending.length !== 2) return
  135. for (const item of pending) {
  136. const text = item.params.text.includes('第一项工作') ? '第一项结果' : '第二项结果'
  137. setTimeout(() => item.socket.send(JSON.stringify({
  138. method: 'event',
  139. params: { type: 'message.complete', session_id: item.params.session_id, payload: { text, status: 'complete' } },
  140. })), 5)
  141. }
  142. }
  143. const firstRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '第一项工作' }),
  144. secondRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '第二项工作' })
  145. await Promise.all([finished(firstRun.id), finished(secondRun.id)])
  146. const firstMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, firstTask.id),
  147. secondMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id)
  148. expect(firstMessages.map(message => message.content)).toEqual(['第一项工作', '第一项结果'])
  149. expect(secondMessages.map(message => message.content)).toEqual(['第二项工作', '第二项结果'])
  150. expect(firstMessages.every(message => message.conversationTaskId === firstTask.id)).toBe(true)
  151. expect(secondMessages.every(message => message.conversationTaskId === secondTask.id)).toBe(true)
  152. const prompts = requests.filter(request => request.method === 'prompt.submit').map(request => String(request.params.text))
  153. expect(prompts.find(prompt => prompt.includes('第一项工作'))).not.toContain('第二项工作')
  154. expect(prompts.find(prompt => prompt.includes('第二项工作'))).not.toContain('第一项工作')
  155. const bindings = store.list<any>(owner, 'binding')
  156. expect(bindings.map(binding => binding.conversationTaskId).sort()).toEqual([firstTask.id, secondTask.id].sort())
  157. expect(bindings.every(binding => binding.nodeId === 'remote-node' && binding.profile === 'remote-profile')).toBe(true)
  158. expect(new Set(bindings.map(binding => binding.storedId)).size).toBe(2)
  159. expect(store.get<any>(owner, 'context', firstTask.id)).toMatchObject({ conversationTaskId: firstTask.id, percent: 40 })
  160. expect(store.get<any>(owner, 'context', secondTask.id)).toMatchObject({ conversationTaskId: secondTask.id, percent: 40 })
  161. const firstSummary = store.require<any>(owner, 'conversation-task', firstTask.id)
  162. expect(firstSummary).toMatchObject({ title: '第一项工作', titleSource: 'automatic', messageCount: 2, unreadCount: 2 })
  163. expect(store.require<any>(owner, 'conversation-task', secondTask.id)).toMatchObject({ title: '显式标题', titleSource: 'user', messageCount: 2 })
  164. expect(store.markTaskRead(owner, conversation.id, firstTask.id, firstSummary.lastSeq).unreadCount).toBe(0)
  165. store.deleteTask(owner, conversation.id, firstTask.id)
  166. expect(store.list<WorkspaceMessage>(owner, 'message').some(message => message.conversationTaskId === firstTask.id)).toBe(false)
  167. expect(store.list<any>(owner, 'binding').some(binding => binding.conversationTaskId === firstTask.id)).toBe(false)
  168. expect(store.get(owner, 'context', firstTask.id)).toBeUndefined()
  169. expect(store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id).map(message => message.content))
  170. .toEqual(['第二项工作', '第二项结果'])
  171. })
  172. it('limits a group to four active conversation tasks while retaining the global scheduler limit', async () => {
  173. const administrator = agent('并发任务管理员'),
  174. member = agent('并发任务成员'),
  175. conversation = store.createGroup(owner, {
  176. name: '并发任务群聊',
  177. memberIds: [administrator.id, member.id],
  178. administratorId: administrator.id,
  179. }),
  180. tasks = [defaultTask(conversation.id), ...Array.from({ length: 4 }, (_, index) => store.createTask(owner, conversation.id, { title: `任务 ${index + 2}` }))]
  181. reply = () => {}
  182. for (const [index, task] of tasks.slice(0, 4).entries())
  183. runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: task.id, content: `执行 ${index + 1}` })
  184. expect(store.activeTaskCount(owner, conversation.id)).toBe(4)
  185. expect(() => runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: tasks[4]!.id, content: '第五项' }))
  186. .toThrow('最多同时运行 4 个任务')
  187. await Promise.all(tasks.slice(0, 4).map(task => runtime.stopTask(owner, conversation.id, task.id)))
  188. })
  189. it('keeps same-named upstream interactions scoped to their conversation task', async () => {
  190. const administrator = agent('交互隔离管理员'),
  191. member = agent('交互隔离成员'),
  192. conversation = store.createGroup(owner, {
  193. name: '交互隔离群聊',
  194. memberIds: [administrator.id, member.id],
  195. administratorId: administrator.id,
  196. }),
  197. firstTask = defaultTask(conversation.id),
  198. secondTask = store.createTask(owner, conversation.id, {})
  199. reply = (socket, params) => setTimeout(() => socket.send(JSON.stringify({
  200. method: 'event',
  201. params: {
  202. type: 'approval.request',
  203. session_id: params.session_id,
  204. payload: { request_id: 'same-upstream-id', message: '允许执行吗?' },
  205. },
  206. })), 5)
  207. runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
  208. runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '任务乙' })
  209. await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction').filter(interaction => !interaction.resolved)).toHaveLength(2))
  210. const interactions = store.list<WorkspaceInteraction>(owner, 'interaction'),
  211. first = interactions.find(interaction => interaction.conversationTaskId === firstTask.id)!,
  212. second = interactions.find(interaction => interaction.conversationTaskId === secondTask.id)!
  213. expect(store.require<any>(owner, 'conversation-task', firstTask.id).activeRunStatus).toBe('waiting')
  214. expect(store.require<any>(owner, 'conversation-task', secondTask.id).activeRunStatus).toBe('waiting')
  215. await runtime.respond(owner, first.id, 'once')
  216. expect(store.require<WorkspaceInteraction>(owner, 'interaction', first.id).resolved).toBe(true)
  217. expect(store.require<WorkspaceInteraction>(owner, 'interaction', second.id).resolved).toBe(false)
  218. await Promise.all([runtime.stopTask(owner, conversation.id, firstTask.id), runtime.stopTask(owner, conversation.id, secondTask.id)])
  219. })
  220. it('persists a visible failure when a fixed member loses its source node',async()=>{
  221. const a=agent('不可用成员'),c=direct(a.id)
  222. runtime.close()
  223. runtime=new WorkspaceRuntime(store,{target:()=>{throw new Error('基础节点不可用')}} as unknown as WorkspaceNodes,uploads)
  224. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行任务'})
  225. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  226. expect(store.messages(owner,c.id).at(-1)).toMatchObject({role:'system',status:'failed',content:'执行失败:基础节点不可用'})
  227. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
  228. })
  229. it('interprets a reply against its launch-time roster when names or mode change mid-turn', async () => {
  230. const a = agent('管理员'),
  231. b = agent('执行者')
  232. const c = store.createGroup(owner, {
  233. name: '配置更新',
  234. memberIds: [a.id, b.id],
  235. administratorId: a.id,
  236. maxReplyRounds: 3,
  237. })
  238. let count = 0
  239. reply = (socket, p) => {
  240. let text = '已完成'
  241. if (count++ === 0) {
  242. store.updateAgent(owner, b.id, { name: '新名字', instructions: '新规则' })
  243. store.updateConversation(owner, c.id, { mode: 'free', administratorId: b.id })
  244. text = '请继续 @执行者'
  245. }
  246. setTimeout(
  247. () =>
  248. socket.send(
  249. JSON.stringify({
  250. method: 'event',
  251. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  252. }),
  253. ),
  254. 5,
  255. )
  256. }
  257. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' }).id)
  258. expect(
  259. store
  260. .messages(owner, c.id)
  261. .filter((m) => m.role === 'assistant')
  262. .map((m) => m.agentId),
  263. ).toEqual([a.id, b.id])
  264. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  265. '新规则',
  266. )
  267. })
  268. it('rejects unscoped and foreign-session completion events', async () => {
  269. const a = agent('会话隔离'),
  270. c = direct(a.id)
  271. reply = (socket, p) => {
  272. for (const session_id of [undefined, 'another-session'])
  273. socket.send(
  274. JSON.stringify({
  275. method: 'event',
  276. params: { type: 'message.complete', session_id, payload: { text: '别人的结果' } },
  277. }),
  278. )
  279. setTimeout(
  280. () =>
  281. socket.send(
  282. JSON.stringify({
  283. method: 'event',
  284. params: {
  285. type: 'message.complete',
  286. session_id: p.session_id,
  287. payload: { text: '本轮结果' },
  288. },
  289. }),
  290. ),
  291. 15,
  292. )
  293. }
  294. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '执行' })
  295. await finished(run.id)
  296. expect(store.messages(owner, c.id).at(-1)?.content).toBe('本轮结果')
  297. expect(store.get<{ percent: number }>(owner, 'context', c.id)?.percent).toBe(40)
  298. })
  299. it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
  300. const a = agent('恢复测试'),
  301. c = direct(a.id)
  302. reply = (socket, p) => {
  303. recoveryHistory = [
  304. { role: 'user', content: p.text },
  305. { role: 'assistant', content: '已经执行完成' },
  306. ]
  307. socket.terminate()
  308. }
  309. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改一次' })
  310. await vi.waitFor(() =>
  311. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
  312. )
  313. runtime.close()
  314. runtime = new WorkspaceRuntime(store, nodes, uploads)
  315. await runtime.reconcile(owner, run.id)
  316. await finished(run.id)
  317. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  318. expect(store.messages(owner, c.id).map((m) => m.content)).toEqual(['修改一次', '已经执行完成'])
  319. })
  320. it('routes approvals and clarifications to the owning run and ignores optional push failures', async () => {
  321. const a = agent('交互测试'),
  322. c = direct(a.id)
  323. runtime.onNotify = () => {
  324. throw new Error('push storage unavailable')
  325. }
  326. let kind = 'approval'
  327. server.on('connection', (socket) =>
  328. socket.on('message', (data) => {
  329. const f = JSON.parse(String(data))
  330. if (['approval.respond', 'clarify.respond'].includes(f.method))
  331. socket.send(
  332. JSON.stringify({
  333. method: 'event',
  334. params: {
  335. type: 'message.complete',
  336. session_id: f.params.session_id,
  337. payload: { text: '确认后完成' },
  338. },
  339. }),
  340. )
  341. }),
  342. )
  343. reply = (socket, p) =>
  344. socket.send(
  345. JSON.stringify({
  346. method: 'event',
  347. params: {
  348. type: `${kind}.request`,
  349. session_id: p.session_id,
  350. payload: { request_id: 'upstream-interaction', message: '请确认' },
  351. },
  352. }),
  353. )
  354. for (kind of ['approval', 'clarify']) {
  355. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续' })
  356. await vi.waitFor(() =>
  357. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting'),
  358. )
  359. const interaction = store
  360. .list<WorkspaceInteraction>(owner, 'interaction')
  361. .find((i) => !i.resolved)!
  362. await expect(runtime.respond(other, interaction.id, 'once')).rejects.toThrow('记录不存在')
  363. await runtime.respond(owner, interaction.id, kind === 'approval' ? 'once' : '补充说明')
  364. await finished(run.id)
  365. expect(
  366. store.require<WorkspaceInteraction>(owner, 'interaction', interaction.id).resolved,
  367. ).toBe(true)
  368. }
  369. expect(
  370. requests
  371. .filter((r) => r.method === 'approval.respond' || r.method === 'clarify.respond')
  372. .map((r) => r.params.request_id),
  373. ).toEqual(['upstream-interaction', 'upstream-interaction'])
  374. })
  375. it('creates one direct chat without importing a native profile session', () => {
  376. const a = agent('编辑')
  377. expect(direct(a.id).kind).toBe('direct')
  378. expect(store.list(other, 'agent')).toEqual([])
  379. expect(() => store.require(other, 'conversation', direct(a.id).id)).toThrow('记录不存在')
  380. expect(requests).toEqual([])
  381. })
  382. it('protects the current administrator and validates member ownership', () => {
  383. const a = agent('甲'),
  384. b = agent('乙'),
  385. foreign = agent('外部', other)
  386. const c = store.createGroup(owner, {
  387. name: '团队',
  388. memberIds: [a.id, b.id],
  389. administratorId: a.id,
  390. })
  391. expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id] })).toThrow('当前管理员不能移除')
  392. expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id], administratorId: b.id })).toThrow('当前管理员不能移除')
  393. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, foreign.id] })).toThrow('记录不存在')
  394. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, a.id] })).toThrow('群成员或管理员无效')
  395. expect(() =>
  396. store.createGroup(owner, {
  397. name: '越权',
  398. memberIds: [a.id, foreign.id],
  399. administratorId: a.id,
  400. }),
  401. ).toThrow()
  402. store.updateAgent(owner, b.id, { archived: true })
  403. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).memberIds).toEqual([
  404. a.id,
  405. b.id,
  406. ])
  407. expect(() =>
  408. store.createGroup(owner, { name: '新群', memberIds: [a.id, b.id], administratorId: a.id }),
  409. ).toThrow('已归档')
  410. expect(store.updateConversation(owner, c.id, { memberIds: [a.id] }).memberIds).toEqual([a.id])
  411. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, b.id] })).toThrow('已归档')
  412. })
  413. it('adds and removes members, cleans automatic replies, and permits removal after administrator transfer', async () => {
  414. const a = agent('管理员'), b = agent('旧成员'), added = agent('新成员')
  415. const c = store.createGroup(owner, { name: '成员维护', memberIds: [a.id, b.id], administratorId: a.id, autoReplyIds: [b.id] })
  416. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '保留这条历史' }).id)
  417. const before = store.require<WorkspaceConversation>(owner, 'conversation', c.id)
  418. const history = store.messages(owner, c.id)
  419. const changed = store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
  420. expect(changed.memberIds).toEqual([a.id, added.id])
  421. expect(changed.autoReplyIds).toEqual([])
  422. expect(changed.lastMessageAt).toBe(before.lastMessageAt)
  423. expect(store.messages(owner, c.id)).toEqual(history)
  424. expect(store.events(owner, 0).at(-1)).toMatchObject({ type: 'conversation.changed', data: { memberIds: [a.id, added.id] } })
  425. store.updateConversation(owner, c.id, { administratorId: added.id })
  426. expect(store.updateConversation(owner, c.id, { memberIds: [added.id] }).memberIds).toEqual([added.id])
  427. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id] })).toThrow('当前管理员不能移除')
  428. })
  429. it('skips removed queued members and accepts a newly added member on the next request', async () => {
  430. const a = agent('管理员'), b = agent('待回复'), added = agent('新成员')
  431. const c = store.createGroup(owner, { name: '队列维护', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', autoReplyIds: [a.id, b.id] })
  432. reply = (socket, p) => {
  433. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: '请@待回复继续' } } }))
  434. }
  435. const queued = runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' })
  436. store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
  437. await finished(queued.id)
  438. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id])
  439. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '请@新成员继续' }).id)
  440. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id, added.id, a.id])
  441. })
  442. it('finishes an in-flight removed member reply without scheduling it again', async () => {
  443. const a = agent('管理员'), b = agent('正在回复')
  444. const c = store.createGroup(owner, { name: '回复中移除', memberIds: [a.id, b.id], administratorId: a.id })
  445. let turns = 0
  446. reply = (socket, p) => {
  447. if (turns++ === 1) store.updateConversation(owner, c.id, { memberIds: [a.id] })
  448. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: turns === 1 ? '请@正在回复继续' : '已完成' } } }))
  449. }
  450. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '@正在回复 开始' }).id)
  451. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id, b.id, a.id])
  452. expect(() => runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续', mentionIds: [b.id] })).toThrow('只能 @ 群内成员')
  453. })
  454. it('dispatches a request exactly once across retries and rejects payload reuse', async () => {
  455. const a = agent('编辑'),
  456. c = direct(a.id),
  457. requestId = randomUUID(),
  458. payload = { requestId, content: '检查代码' }
  459. const run = runtime.send(owner, c.id, payload)
  460. expect(runtime.send(owner, c.id, payload).id).toBe(run.id)
  461. expect(() => runtime.send(owner, c.id, { ...payload, content: '另一件事' })).toThrow('请求编号')
  462. await finished(run.id)
  463. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  464. expect(store.messages(owner, c.id).map((m) => m.role)).toEqual(['user', 'assistant'])
  465. })
  466. it('keeps template responsibilities inside their group and follows Agent identity after rename', async () => {
  467. const a = agent('现有甲'), b = agent('现有乙')
  468. const before = store.require<WorkspaceAgent>(owner, 'agent', a.id)
  469. const c = store.createGroup(owner, { name: '模板群', memberIds: [a.id, b.id], administratorId: a.id, memberRoles: {
  470. [a.id]: { name: '调研负责人', description: '汇总可信结论' },
  471. [b.id]: { name: '事实核验', description: '交叉验证来源' },
  472. } })
  473. expect(store.require<WorkspaceAgent>(owner, 'agent', a.id)).toEqual(before)
  474. store.updateAgent(owner, a.id, { name: '改名后的甲' })
  475. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始调研' }).id)
  476. const groupPrompt = requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text
  477. expect(groupPrompt).toContain('@改名后的甲:调研负责人;汇总可信结论')
  478. expect(groupPrompt).toContain('@现有乙:事实核验;交叉验证来源')
  479. await finished(runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '单聊' }).id)
  480. expect(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text).not.toContain('汇总可信结论')
  481. const changed = store.updateConversation(owner, c.id, { memberIds: [a.id] })
  482. expect(changed.memberRoles).toEqual({ [a.id]: { name: '调研负责人', description: '汇总可信结论' } })
  483. expect(() => store.updateConversation(owner, c.id, { memberRoles: { [b.id]: { name: '已移除', description: '' } } })).toThrow('群成员或管理员无效')
  484. })
  485. it('isolates roles over the same profile and applies edited rules on the next turn', async () => {
  486. const a = agent('编辑'),
  487. b = agent('审查'),
  488. ca = direct(a.id),
  489. cb = direct(b.id)
  490. const first = runtime.send(owner, ca.id, { requestId: randomUUID(), content: '编辑任务' })
  491. const second = runtime.send(owner, cb.id, { requestId: randomUUID(), content: '审查任务' })
  492. await Promise.all([finished(first.id), finished(second.id)])
  493. expect(requests.filter((r) => r.method === 'session.create')).toHaveLength(2)
  494. expect(store.messages(owner, ca.id).some((m) => m.content === '审查任务')).toBe(false)
  495. store.updateAgent(owner, a.id, { instructions: '最新规则:先给证据' })
  496. await finished(runtime.send(owner, ca.id, { requestId: randomUUID(), content: '继续' }).id)
  497. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  498. '最新规则:先给证据',
  499. )
  500. expect(requests.some((r) => r.method === 'profiles.configure')).toBe(false)
  501. expect(
  502. requests
  503. .filter((r) => r.method === 'session.create')
  504. .every((r) => r.params.hidden && r.params.source === 'yaoyao_workspace'),
  505. ).toBe(true)
  506. })
  507. it('executes bounded administrator delegation and keeps group sessions separate from direct chat', async () => {
  508. const a = agent('管理员'),
  509. b = agent('开发者'),
  510. c = store.createGroup(owner, {
  511. name: '开发',
  512. memberIds: [a.id, b.id],
  513. administratorId: a.id,
  514. maxReplyRounds: 3,
  515. })
  516. let turns = 0
  517. reply = (socket, p) => {
  518. const text = turns++ === 0 ? '我已整理需求,现在请@开发者继续实现。' : '任务完成'
  519. setTimeout(
  520. () =>
  521. socket.send(
  522. JSON.stringify({
  523. method: 'event',
  524. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  525. }),
  526. ),
  527. 5,
  528. )
  529. }
  530. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '实现功能' }).id)
  531. expect(
  532. store
  533. .messages(owner, c.id)
  534. .filter((m) => m.role === 'assistant')
  535. .map((m) => m.agentId),
  536. ).toEqual([a.id, b.id, a.id])
  537. expect(store.messages(owner, direct(a.id).id)).toHaveLength(0)
  538. expect(turns).toBe(3)
  539. })
  540. it('does not trigger delegation from quoted text or code blocks', () => {
  541. const a = agent('甲'),
  542. b = agent('乙')
  543. expect(mentionedAgents('> @乙\n```\n@甲\n```\n`@all`', [a, b])).toEqual([])
  544. expect(mentionedAgents('请处理 @乙。', [a, b])).toEqual([b.id])
  545. })
  546. it('keeps uncertain submissions blocked instead of executing tools twice', async () => {
  547. const a = agent('执行者'),
  548. c = direct(a.id)
  549. reply = (socket) => socket.terminate()
  550. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改文件' })
  551. await vi.waitFor(() =>
  552. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
  553. )
  554. const queued = runtime.send(owner, c.id, { requestId: randomUUID(), content: '后续请求' })
  555. expect(queued.status).toBe('queued')
  556. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  557. await runtime.stopConversation(owner, c.id)
  558. expect(
  559. store.require<WorkspaceConversation>(owner, 'conversation', c.id).activeRunId,
  560. ).toBeUndefined()
  561. })
  562. it('publishes durable ordered events only after commit and rolls back failed transactions', () => {
  563. const a = agent('甲'),
  564. c = direct(a.id),
  565. cursor = store.cursor(owner)
  566. expect(() =>
  567. store.atomic(() => {
  568. store.event(owner, 'test', {})
  569. throw new Error('rollback')
  570. }),
  571. ).toThrow('rollback')
  572. expect(store.cursor(owner)).toBe(cursor)
  573. store.updateAgent(owner, a.id, { name: '乙' })
  574. expect(store.events(owner, cursor).map((e) => e.type)).toEqual([
  575. 'agent.changed',
  576. 'conversation.changed',
  577. ])
  578. expect(store.events(other, 0)).toEqual([])
  579. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).name).toBe('乙')
  580. })
  581. })
  582. it('pins a direct chat without injecting or clearing optional profile fields', () => {
  583. const avatar = encodeAgentAvatar({...defaultAgentIdentity('reviewer'),shape:'triangle',color:'#00b9ac',expression:'curious'})
  584. const agent = store.createAgent(owner, { name: '审查员', profile: 'default', avatar })
  585. const chat = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  586. expect(store.updateConversation(owner, chat.id, { pinned: true }).pinned).toBe(true)
  587. expect(store.updateConversation(owner, chat.id, { pinned: false }).pinned).toBe(false)
  588. expect(store.require<WorkspaceConversation>(owner, 'conversation', chat.id).avatar).toBe(avatar)
  589. expect(store.updateAgent(owner, agent.id, { instructions: '请检查边界' }).avatar).toBe(avatar)
  590. expect(decodeAgentAvatar(store.updateAgent(owner, agent.id, { avatar: '' }).avatar)).toMatchObject({shape:'circle',color:'#00c875'})
  591. })
  592. it('accepts the existing cross-client mascot and team avatar formats', () => {
  593. const a = store.createAgent(owner, { name: '设计', profile: 'default', avatar: 'yaoyao-mascot:v1:square:377fe6:friendly' })
  594. const b = store.createAgent(owner, { name: '开发', profile: 'default' })
  595. const group = store.createGroup(owner, { name: '产品团队', memberIds: [a.id, b.id], administratorId: a.id, avatar: 'builtin:team-animal:fox' })
  596. expect(group.avatar).toBe('builtin:team-animal:fox')
  597. expect(() => store.updateAgent(owner, a.id, { avatar: 'javascript:alert(1)' })).toThrow()
  598. })
  599. it('assigns a random v2 avatar only when a new Agent omits its avatar', () => {
  600. const random = vi.spyOn(Math, 'random').mockReturnValue(0)
  601. const created = store.createAgent(owner, { name: '随机头像', profile: 'default' })
  602. expect(decodeAgentAvatar(created.avatar)).toMatchObject({ shape: 'circle', color: '#000000', expression: 'idle' })
  603. expect(created.avatar).not.toBe(encodeAgentAvatar(defaultAgentIdentity(created.id, created.name)))
  604. random.mockRestore()
  605. })
  606. it('keeps the last message time independent of pinning and metadata edits', () => {
  607. const agent=store.createAgent(owner,{name:'时间验收',profile:'default'})
  608. const c=store.list<WorkspaceConversation>(owner,'conversation')[0]!
  609. const message: WorkspaceMessage={id:randomUUID(),conversationId:c.id,seq:0,role:'user',content:'历史消息',reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()-600_000}
  610. store.saveMessage(owner,message)
  611. const pinned=store.updateConversation(owner,c.id,{pinned:true})
  612. expect(store.conversationSummary(owner,pinned).lastMessageAt).toBe(message.createdAt)
  613. store.updateAgent(owner,agent.id,{name:'改名之后'})
  614. expect(store.conversationSummary(owner,store.require(owner,'conversation',c.id)).lastMessageAt).toBe(message.createdAt)
  615. // Existing records without the summary field still use their transcript time.
  616. const previous={...pinned}; delete previous.lastMessageAt
  617. expect(store.conversationSummary(owner,previous).lastMessageAt).toBe(message.createdAt)
  618. })
  619. it('publishes only the executing member and clears avatar activity when stopped', async () => {
  620. const a=store.createAgent(owner,{name:'管理员头像',profile:'default'})
  621. const b=store.createAgent(owner,{name:'执行者头像',profile:'default'})
  622. const c=store.createGroup(owner,{name:'头像状态',memberIds:[a.id,b.id],administratorId:a.id})
  623. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'approval-avatar',message:'需要确认'}}}))
  624. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'处理任务',mentionIds:[b.id]})
  625. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('waiting'))
  626. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id)).toMatchObject({activeAgentId:a.id,activeRunStatus:'waiting'})
  627. await runtime.stop(owner,run.id)
  628. const stopped=store.require<WorkspaceConversation>(owner,'conversation',c.id)
  629. expect(stopped.activeAgentId).toBeUndefined()
  630. expect(stopped.activeRunStatus).toBeUndefined()
  631. })
  632. it('stores the expanded avatar shapes for cross-client role settings', () => {
  633. const agent=store.createAgent(owner,{name:'扩展头像验收',profile:'default'})
  634. for(const shape of ['ellipse','capsule','hexagon','cloud','droplet'] as const) {
  635. const avatar=encodeAgentAvatar({...defaultAgentIdentity('bot'),shape,color:'#f52ba5',expression:'curious'})
  636. expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
  637. }
  638. })
  639. it('automatically reconciles lost completion without submitting the prompt again', async () => {
  640. const a=agent('自动恢复'),c=direct(a.id)
  641. reply=(socket,p)=>{recoveryHistory=[{role:'user',content:p.text},{role:'assistant',content:'已完成一次'}];socket.terminate()}
  642. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行一次'})
  643. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete'),{timeout:5000})
  644. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  645. expect(store.messages(owner,c.id).at(-1)).toMatchObject({content:'已完成一次',status:'complete'})
  646. })
  647. it('reports a rejected prompt as failed instead of leaving it uncertain', async () => {
  648. rejectPrompt='本轮请求被明确拒绝'
  649. const a=agent('拒绝测试'),c=direct(a.id)
  650. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行'})
  651. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  652. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
  653. expect(store.messages(owner,c.id).some(m=>m.status==='uncertain')).toBe(false)
  654. })
  655. it('keeps an unconfirmed turn separate from a later reply and retries history without resubmitting', async () => {
  656. const a = agent('恢复边界'), c = direct(a.id)
  657. let prompt = ''
  658. reply = (socket, p) => {
  659. prompt = p.text
  660. recoveryHistory = [{ role: 'user', content: prompt }, { role: 'user', content: '另一个请求' }, { role: 'assistant', content: '另一轮回复' }]
  661. socket.terminate()
  662. }
  663. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '只执行一次' })
  664. await vi.waitFor(() => expect(requests.filter(r => r.method === 'session.resume').length).toBeGreaterThanOrEqual(1), { timeout: 3000 })
  665. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain')
  666. expect(store.messages(owner, c.id).at(-1)?.content).not.toBe('另一轮回复')
  667. recoveryHistory = [{ role: 'user', content: prompt }, { role: 'assistant', content: '本轮结果' }]
  668. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete'), { timeout: 4000 })
  669. expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  670. expect(store.messages(owner, c.id).at(-1)).toMatchObject({ content: '本轮结果', status: 'complete' })
  671. })
  672. it('uses one generated avatar for the agent and its direct conversation',()=>{
  673. const a=agent('默认头像'),c=direct(a.id)
  674. expect(decodeAgentAvatar(a.avatar)).toMatchObject({version:2,avatarMode:'mascot'})
  675. expect(store.agentSummary(a).avatar).toBe(store.conversationSummary(owner,c).avatar)
  676. const changed=store.updateAgent(owner,a.id,{name:'换个名称'})
  677. expect(store.agentSummary(changed).avatar).toBe(store.agentSummary(a).avatar)
  678. })
  679. it('recognizes mentions inside Chinese sentences and ignores quoted code and emails',()=>{
  680. const a=agent('瑶儿'),b=agent('竹儿'),c=agent('瑶儿助手'),d=agent('Ann')
  681. const roster=[a,b,c,d]
  682. expect(mentionedAgents('先整理,再请@瑶儿帮忙,最后由(@竹儿)复核。',roster)).toEqual([a.id,b.id])
  683. expect(mentionedAgents('请**@瑶儿助手**继续',roster)).toEqual([c.id])
  684. expect(mentionedAgents('mail@Ann.com https://site/@竹儿 `@瑶儿` <quoted_message>@瑶儿</quoted_message>',roster)).toEqual([])
  685. expect(mentionedAgents('@Anna',roster)).toEqual([])
  686. })
  687. function completeReply(socket: WebSocket, p: Record<string, any>, text: string, status = 'complete') {
  688. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text, status, ...(status === 'failed' ? { error: text } : {}) } } }))
  689. }
  690. const speaker = (p: Record<string, any>) => /^你是 (.*?)。/.exec(p.text)?.[1]
  691. it('starts with the administrator, executes a batch concurrently, and reviews failures once without spending a round', async () => {
  692. const a = agent('管理员'), b = agent('成员乙'), c = agent('成员丙')
  693. const group = store.createGroup(owner, { name: '并行验收', memberIds: [a.id,b.id,c.id], administratorId: a.id, maxReplyRounds: 2 })
  694. const pending = new Map<string, { socket: WebSocket; p: Record<string, any> }>()
  695. let admin = 0, review = ''
  696. reply = (socket,p) => {
  697. if (speaker(p) === a.name) { if (admin++ === 0) completeReply(socket,p,'请@成员乙检查接口,@成员丙检查数据'); else { review=p.text; completeReply(socket,p,'汇总完成') } }
  698. else pending.set(speaker(p)!, {socket,p})
  699. }
  700. const root = runtime.send(owner,group.id,{requestId:randomUUID(),content:'@成员乙 请处理'})
  701. await vi.waitFor(()=>expect(pending.size).toBe(2))
  702. expect(admin).toBe(1)
  703. expect(store.require<WorkspaceConversation>(owner,'conversation',group.id).activeAgentStates).toEqual({[b.id]:'running',[c.id]:'running'})
  704. completeReply(pending.get(c.name)!.socket,pending.get(c.name)!.p,'数据检查失败','failed')
  705. await vi.waitFor(()=>expect(store.messages(owner,group.id).some(m=>m.status==='failed')).toBe(true))
  706. expect(admin).toBe(1)
  707. completeReply(pending.get(b.name)!.socket,pending.get(b.name)!.p,'接口正常')
  708. await finished(root.id)
  709. expect(admin).toBe(2)
  710. expect(review).toContain('数据检查失败')
  711. expect(review).toContain('接口正常')
  712. expect(store.require<WorkspaceRun>(owner,'run',root.id).round).toBe(1)
  713. })
  714. it('allows successive dependent delegations and does not charge administrator reviews against the limit', async () => {
  715. const a=agent('负责人'),b=agent('先做'),c=agent('后做')
  716. const g=store.createGroup(owner,{name:'串行依赖',memberIds:[a.id,b.id,c.id],administratorId:a.id,maxReplyRounds:3})
  717. const order:string[]=[];let admin=0
  718. reply=(socket,p)=>{const name=speaker(p)!;order.push(name);completeReply(socket,p,name===a.name ? ['@先做 第一步','@后做 第二步','完成'][admin++]! : '结果已提交')}
  719. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'执行'}).id)
  720. expect(order).toEqual([a.name,b.name,a.name,c.name,a.name])
  721. })
  722. it('continues free discussion automatically and hides irrelevant automatic replies', async () => {
  723. const a=agent('公开管理员'),b=agent('自动成员')
  724. const g=store.createGroup(owner,{name:'自由讨论',memberIds:[a.id,b.id],administratorId:a.id,mode:'free',autoReplyIds:[b.id],maxReplyRounds:3})
  725. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name?'我来回答':'[[YAOYAO_NO_REPLY_V1]]')
  726. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'一个问题'})
  727. await finished(root.id)
  728. expect(requests.filter(r=>r.method==='prompt.submit'&&speaker(r.params)===b.name)).toHaveLength(2)
  729. expect(store.messages(owner,g.id).map(m=>m.content)).toEqual(['一个问题','我来回答'])
  730. expect(store.conversationSummary(owner,store.require(owner,'conversation',g.id)).unreadCount).toBe(2)
  731. expect(store.hiddenMessageIds(owner,g.id)).toHaveLength(2)
  732. })
  733. it('requires a public administrator reply when the model tries to remain silent', async () => {
  734. const a=agent('必须答复'),b=agent('旁观者'),g=store.createGroup(owner,{name:'公开答复',memberIds:[a.id,b.id],administratorId:a.id})
  735. reply=(socket,p)=>completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  736. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'帮助我'}).id)
  737. const answer=store.messages(owner,g.id).at(-1)!
  738. expect(answer.content).toContain('请补充具体目标')
  739. expect(answer.content).not.toContain('YAOYAO_')
  740. })
  741. it('queues later user requests durably without leaking them into the preceding collaboration', async () => {
  742. const a=agent('排队管理员'),b=agent('排队执行者'),g=store.createGroup(owner,{name:'消息排队',memberIds:[a.id,b.id],administratorId:a.id})
  743. let first: {socket:WebSocket;p:Record<string,any>}|undefined
  744. let admin=0;const prompts:string[]=[]
  745. reply=(socket,p)=>{prompts.push(p.text);if(speaker(p)===a.name&&admin++===0)first={socket,p};else completeReply(socket,p,'已完成')}
  746. const one=runtime.send(owner,g.id,{requestId:randomUUID(),content:'第一条任务'})
  747. await vi.waitFor(()=>expect(first).toBeDefined())
  748. const two=runtime.send(owner,g.id,{requestId:randomUUID(),content:'之后才处理的秘密标记'})
  749. expect(two.status).toBe('queued')
  750. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeRunId).toBe(one.id)
  751. completeReply(first!.socket,first!.p,'请@排队执行者继续')
  752. await finished(one.id);await finished(two.id)
  753. expect(prompts).toHaveLength(4)
  754. expect(prompts.slice(0,3).every(p=>!p.includes('之后才处理的秘密标记'))).toBe(true)
  755. expect(prompts[3]).toContain('之后才处理的秘密标记')
  756. })
  757. it('stops one member while keeping its sibling running and returns the interrupted result for review', async () => {
  758. const a=agent('停止管理员'),b=agent('停止乙'),c=agent('继续丙'),g=store.createGroup(owner,{name:'独立停止',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  759. const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>();let admin=0,review=''
  760. reply=(socket,p)=>{if(speaker(p)===a.name){if(admin++===0)completeReply(socket,p,'@停止乙 @继续丙');else {review=p.text;completeReply(socket,p,'已汇总')}}else pending.set(speaker(p)!,{socket,p})}
  761. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  762. await vi.waitFor(()=>expect(pending.size).toBe(2))
  763. await runtime.stopAgent(owner,g.id,b.id)
  764. expect(admin).toBe(1)
  765. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeAgentStates?.[c.id]).toBe('running')
  766. completeReply(pending.get(c.name)!.socket,pending.get(c.name)!.p,'丙完成')
  767. await finished(root.id)
  768. expect(review).toContain('interrupted')
  769. expect(review).toContain('丙完成')
  770. })
  771. it('recovers a partially completed parallel batch after restart without resubmitting members or duplicating review', async () => {
  772. const a=agent('恢复管理员'),b=agent('恢复乙'),c=agent('恢复丙'),g=store.createGroup(owner,{name:'批次恢复',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  773. let admin=0;const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>()
  774. reply=(socket,p)=>{if(speaker(p)===a.name)completeReply(socket,p,admin++===0?'@恢复乙 @恢复丙':'复核完成');else pending.set(speaker(p)!,{socket,p})}
  775. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'并行执行'})
  776. await vi.waitFor(()=>expect(pending.size).toBe(2))
  777. completeReply(pending.get(b.name)!.socket,pending.get(b.name)!.p,'乙已完成')
  778. const last=pending.get(c.name)!
  779. recoveryByStored.set(storedByRuntime.get(last.p.session_id)!,[{role:'user',content:last.p.text},{role:'assistant',content:'丙已完成'}])
  780. last.socket.terminate()
  781. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain'))
  782. runtime.close();runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  783. await finished(root.id)
  784. expect(admin).toBe(2)
  785. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(4)
  786. expect(store.list<any>(owner,'turn').filter(t=>t.reviewOf)).toHaveLength(1)
  787. })
  788. it('persists the next delegation when restarted between reply completion and cascade planning', async () => {
  789. const a=agent('记录管理员'),b=agent('记录成员'),g=store.createGroup(owner,{name:'计划恢复',memberIds:[a.id,b.id],administratorId:a.id})
  790. let admin=0
  791. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name&&admin++===0?'@记录成员 执行':'完成')
  792. runtime.onMessage=async()=>{runtime.close()}
  793. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'执行一次'})
  794. await vi.waitFor(()=>expect(store.list<any>(owner,'turn')[0]?.status).toBe('complete'))
  795. runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  796. await finished(root.id)
  797. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  798. })
  799. it('bounds long context while preserving the task beginning and ending', async () => {
  800. const a=agent('长上下文'),b=agent('辅助'),g=store.createGroup(owner,{name:'上下文边界',memberIds:[a.id,b.id],administratorId:a.id})
  801. const text='任务开始标记'+ '文'.repeat(60_000)+'任务结束标记'
  802. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:text}).id)
  803. const prompt=requests.find(r=>r.method==='prompt.submit')!.params.text
  804. expect(prompt).toContain('任务开始标记');expect(prompt).toContain('任务结束标记')
  805. expect(prompt).toContain('保留首尾片段');expect(prompt.length).toBeLessThan(32_000)
  806. })
  807. it('enforces three tasks per group and four globally while preserving queued work', async () => {
  808. const agents=['并发甲','并发乙','并发丙','并发丁','其他群'].map(n=>agent(n))
  809. const group=store.createGroup(owner,{name:'并发限制',memberIds:agents.slice(0,4).map(a=>a.id),administratorId:agents[0]!.id,mode:'free',autoReplyIds:agents.slice(0,4).map(a=>a.id),maxReplyRounds:1})
  810. const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>()
  811. reply=(socket,p)=>pending.set(speaker(p)!,{socket,p})
  812. const one=runtime.send(owner,group.id,{requestId:randomUUID(),content:'并发测试'})
  813. const two=runtime.send(owner,direct(agents[4]!.id).id,{requestId:randomUUID(),content:'另一群'})
  814. await vi.waitFor(()=>expect(pending.size).toBe(4))
  815. expect(Object.keys(store.require<WorkspaceConversation>(owner,'conversation',group.id).activeAgentStates!)).toHaveLength(3)
  816. expect(store.list<any>(owner,'turn').filter(t=>t.status==='running')).toHaveLength(4)
  817. const released=[...pending.values()].find(v=>speaker(v.p)!==agents[4]!.name)!
  818. completeReply(released.socket,released.p,'完成')
  819. await vi.waitFor(()=>expect(pending.size).toBe(5))
  820. expect(store.list<any>(owner,'turn').filter(t=>t.status==='running').length).toBeLessThanOrEqual(4)
  821. for(const v of pending.values()) if(v!==released) completeReply(v.socket,v.p,'完成')
  822. await finished(one.id);await finished(two.id)
  823. })
  824. it('supports unlimited rounds beyond the finite limit and still permits stopping', async () => {
  825. const a=agent('不限甲'),b=agent('不限乙'),g=store.createGroup(owner,{name:'不限轮次',memberIds:[a.id,b.id],administratorId:a.id,mode:'free',autoReplyIds:[a.id],maxReplyRounds:-1})
  826. let count=0
  827. reply=(socket,p)=>{
  828. if(count++===0){const work=store.list<any>(owner,'turn').find(t=>t.status==='running')!;work.depth=100;store.put(owner,'turn',work.id,work);completeReply(socket,p,'继续讨论')}
  829. else completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  830. }
  831. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'@不限乙 开始'})
  832. await finished(root.id)
  833. expect(count).toBe(2)
  834. expect(store.require<WorkspaceRun>(owner,'run',root.id).round).toBe(101)
  835. expect(()=>store.updateConversation(owner,g.id,{maxReplyRounds:0})).toThrow()
  836. })
  837. it('keeps parallel approval cards scoped to their member and deduplicates replayed requests and answers', async () => {
  838. const a=agent('审批管理员'),b=agent('审批乙'),c=agent('审批丙'),g=store.createGroup(owner,{name:'并发审批',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  839. let admin=0
  840. server.on('connection',socket=>socket.on('message',raw=>{const f=JSON.parse(String(raw));if(f.method==='approval.respond')completeReply(socket,f.params,'已确认')}))
  841. reply=(socket,p)=>{
  842. if(speaker(p)===a.name){completeReply(socket,p,admin++===0?'@审批乙 @审批丙':'汇总');return}
  843. const frame=JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'same-request',message:'是否继续'}}})
  844. socket.send(frame);socket.send(frame)
  845. }
  846. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  847. await vi.waitFor(()=>expect(store.list<WorkspaceInteraction>(owner,'interaction').filter(i=>!i.resolved)).toHaveLength(2))
  848. const interactions=store.list<WorkspaceInteraction>(owner,'interaction')
  849. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeAgentStates).toEqual({[b.id]:'waiting',[c.id]:'waiting'})
  850. await runtime.respond(owner,interactions[0]!.id,'once')
  851. await runtime.respond(owner,interactions[0]!.id,'once')
  852. await expect(runtime.respond(owner,interactions[0]!.id,'deny')).rejects.toThrow('其他答复')
  853. expect(admin).toBe(1)
  854. await runtime.respond(owner,interactions[1]!.id,'once')
  855. await finished(root.id)
  856. expect(requests.filter(r=>r.method==='approval.respond')).toHaveLength(2)
  857. expect(admin).toBe(2)
  858. })
  859. it('retries a rolled-back cascade plan without losing or duplicating delegation', async () => {
  860. const a=agent('事务管理员'),b=agent('事务成员'),g=store.createGroup(owner,{name:'调度事务',memberIds:[a.id,b.id],administratorId:a.id})
  861. const original=store.put.bind(store);let failed=false,admin=0
  862. store.put=(user,kind,id,value)=>{if(!failed&&kind==='turn'&&(value as any).agentId===b.id){failed=true;throw new Error('模拟一次写入失败')}original(user,kind,id,value)}
  863. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name&&admin++===0?'@事务成员 继续':'完成')
  864. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  865. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('complete'),{timeout:4000})
  866. expect(failed).toBe(true)
  867. expect(store.list<any>(owner,'turn').filter(t=>t.agentId===b.id)).toHaveLength(1)
  868. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  869. })
  870. it('upgrades an admitted v0.3.7 execution without submitting its prompt again', async () => {
  871. const a=agent('升级执行'),c=direct(a.id)
  872. reply=(socket,p)=>{recoveryHistory=[{role:'user',content:p.text},{role:'assistant',content:'升级前已完成'}];socket.terminate()}
  873. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'只执行一次'})
  874. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain'))
  875. runtime.close()
  876. const old=store.list<any>(owner,'turn')[0]!
  877. for(const task of store.list<any>(owner,'turn'))store.remove(owner,'turn',task.id)
  878. const legacy=store.require<any>(owner,'run',root.id)
  879. Object.assign(legacy,{queue:[a.id],next:[],hostReturn:false,currentMessageId:old.currentMessageId,turnConfiguration:old.turnConfiguration})
  880. store.put(owner,'run',root.id,legacy)
  881. const binding=store.get<any>(owner,'binding',`${c.id}:${a.id}`)!
  882. delete binding.taskId;store.put(owner,'binding',binding.id,binding)
  883. runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  884. await finished(root.id)
  885. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  886. expect(store.messages(owner,c.id).at(-1)?.content).toBe('升级前已完成')
  887. })
  888. it('does not read unrelated or orphaned historical runs when projecting a group prompt', async () => {
  889. const a=agent('上下文隔离'),b=agent('组内成员'),g=store.createGroup(owner,{name:'干净群',memberIds:[a.id,b.id],administratorId:a.id})
  890. store.put(owner,'run','historical-orphan',{id:'historical-orphan',conversationId:'unrelated-conversation',messageId:'missing-history',mentionIds:[],status:'complete',round:0,createdAt:1,updatedAt:1})
  891. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'当前群任务'}).id)
  892. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  893. })
  894. it('pages backwards to find a long-running turn marker and never mistakes a tool call for a final answer', async () => {
  895. const a=agent('分页恢复'),c=direct(a.id)
  896. reply=(socket,p)=>{
  897. recoveryHistory=[{role:'user',content:p.text},...Array.from({length:600},()=>({role:'tool',content:'工具结果'})),{role:'assistant',content:'最终结果'}]
  898. socket.terminate()
  899. }
  900. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'长时间执行'}).id)
  901. expect(store.messages(owner,c.id).at(-1)?.content).toBe('最终结果')
  902. let submitted=''
  903. reply=(socket,p)=>{submitted=p.text;recoveryHistory=[{role:'user',content:submitted},{role:'assistant',content:'准备调用工具',tool_calls:[{id:'tool'}]},{role:'tool',content:'工具输出'}];socket.terminate()}
  904. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不要把过程当结论'})
  905. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain'))
  906. await runtime.reconcile(owner,root.id)
  907. await new Promise(resolve=>setTimeout(resolve,100))
  908. expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain')
  909. recoveryHistory.push({role:'assistant',content:'真正完成'})
  910. await runtime.reconcile(owner,root.id)
  911. await finished(root.id)
  912. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2)
  913. })
  914. it('keeps explicit replies visible and emits one notice when the configured cascade limit is reached', async () => {
  915. const a=agent('轮数管理员'),b=agent('轮数成员'),g=store.createGroup(owner,{name:'有限轮次',memberIds:[a.id,b.id],administratorId:a.id,maxReplyRounds:2})
  916. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name?'@轮数成员 继续':'已完成本步')
  917. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  918. await finished(root.id)
  919. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  920. expect(store.messages(owner,g.id).filter(m=>m.content.includes('轮数上限'))).toHaveLength(1)
  921. store.updateConversation(owner,g.id,{mode:'free',autoReplyIds:[]})
  922. reply=(socket,p)=>completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  923. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'@轮数成员 显示这个字符串'}).id)
  924. expect(store.messages(owner,g.id).at(-1)?.content).toBe('[[YAOYAO_NO_REPLY_V1]]')
  925. })
  926. it('retries a failed stop of a live task without falsely completing it or resubmitting work', async () => {
  927. const a=agent('停止恢复'),c=direct(a.id)
  928. reply=()=>{}
  929. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待停止'})
  930. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  931. rejectedInterrupts=1
  932. await runtime.stop(owner,root.id)
  933. expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain')
  934. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('interrupted'),{timeout:3000})
  935. expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(2)
  936. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  937. })
  938. it('marks a stopped administrator as interrupted without sending a false completion notification', async () => {
  939. const a=agent('管理员停止'),b=agent('普通成员'),g=store.createGroup(owner,{name:'停止管理员',memberIds:[a.id,b.id],administratorId:a.id})
  940. reply=()=>{}
  941. const notifications: string[]=[]
  942. runtime.onNotify=(_owner,_c,run)=>notifications.push(run.status)
  943. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'等待'})
  944. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  945. await runtime.stopAgent(owner,g.id,a.id)
  946. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('interrupted'))
  947. expect(notifications).toEqual([])
  948. })
  949. it('randomizes old default avatars once while preserving photos, custom v2 settings and direct snapshots',()=>{
  950. const a=store.createAgent(owner,{name:'旧头像',profile:'default',instructions:'保留规则'})
  951. const photo='data:image/png;base64,aGVsbG8='
  952. const b=store.createAgent(owner,{name:'照片',profile:'default',avatar:photo})
  953. store.put(owner,'agent',a.id,{...a,avatar:'yaoyao-mascot:v1:triangle:ff0000:friendly'})
  954. store.put(owner,'agent',b.id,{...b,avatar:photo})
  955. store.db.prepare("DELETE FROM workspace_migrations WHERE id IN ('avatar-v2','avatar-random-default-v1')").run()
  956. const random=vi.spyOn(Math,'random').mockReturnValue(0)
  957. const migrated=new WorkspaceStore(home)
  958. random.mockRestore()
  959. const next=migrated.require<any>(owner,'agent',a.id)
  960. expect(next.instructions).toBe('保留规则')
  961. expect(decodeAgentAvatar(next.avatar)).toMatchObject({avatarMode:'mascot'})
  962. expect(next.avatar).not.toBe(encodeAgentAvatar(defaultAgentIdentity(a.id,a.name)))
  963. const image=migrated.require<any>(owner,'agent',b.id)
  964. expect(decodeAgentAvatar(image.avatar)).toMatchObject({avatarMode:'image',imageDataURL:photo,imageCrop:'rounded'})
  965. for(const c of migrated.list<WorkspaceConversation>(owner,'conversation')) expect(c.avatar).toBe(migrated.require<any>(owner,'agent',c.memberIds[0]!).avatar)
  966. const customized=encodeAgentAvatar({...defaultAgentIdentity('bot'),bodyId:'star',expression:'proud',color:'#1488ff'})
  967. migrated.updateAgent(owner,a.id,{avatar:customized})
  968. migrated.close()
  969. const reopened=new WorkspaceStore(home)
  970. expect(reopened.require<any>(owner,'agent',a.id).avatar).toBe(customized)
  971. expect(reopened.db.prepare("SELECT count(*) AS count FROM workspace_migrations WHERE id='avatar-random-default-v1'").get()!.count).toBe(1)
  972. reopened.close()
  973. })
  974. // A member can have a queued follow-up while their current task needs approval.
  975. it('keeps active avatar state ahead of queued work and publishes only recent outcomes', () => {
  976. const a=agent('状态优先级'), c=direct(a.id), now=Date.now()
  977. const run: WorkspaceRun={id:'avatar-run',conversationId:c.id,messageId:'unused',mentionIds:[],status:'complete',round:1,createdAt:now,updatedAt:now}
  978. for (const [id,status] of [['current','waiting'],['next','queued']] as const)
  979. store.put(owner,'turn',id,{id,conversationId:c.id,agentId:a.id,status,planned:true,updatedAt:now})
  980. store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'failed',updatedAt:now})
  981. store.saveRun(owner,run)
  982. let summary=store.require<WorkspaceConversation>(owner,'conversation',c.id)
  983. expect(summary.activeAgentStates).toEqual({[a.id]:'waiting'})
  984. expect(summary.avatarSignals?.[a.id]).toMatchObject({id:'result',state:'failure'})
  985. store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'complete',updatedAt:now-3000})
  986. store.saveRun(owner,run)
  987. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).avatarSignals).toEqual({})
  988. })