workspace.test.ts 72 KB

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