workspace.test.ts 49 KB

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