workspace.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  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 recoveryHistory: Array<{ role: string; content: string }>
  27. const owner = 'first-user',
  28. other = 'second-user'
  29. beforeEach(async () => {
  30. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-test-'))
  31. store = new WorkspaceStore(home)
  32. uploads = new UploadStore(home)
  33. requests = []
  34. recoveryHistory = []
  35. server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  36. await new Promise<void>((resolve) => server.once('listening', resolve))
  37. const address = server.address() as { port: number }
  38. const target = {
  39. url: new URL(`http://127.0.0.1:${address.port}`),
  40. client: { directAgent: undefined },
  41. session: {
  42. webSocketCredential: async () => ({ name: 'ticket', value: 'test' }),
  43. request: async () => ({
  44. status: 200,
  45. body: Buffer.from(JSON.stringify({ messages: recoveryHistory })),
  46. }),
  47. },
  48. }
  49. nodes = { target: () => target } as unknown as WorkspaceNodes
  50. runtime = new WorkspaceRuntime(store, nodes, uploads)
  51. reply = (socket, p) =>
  52. setTimeout(
  53. () =>
  54. socket.send(
  55. JSON.stringify({
  56. method: 'event',
  57. params: {
  58. type: 'message.complete',
  59. session_id: p.session_id,
  60. payload: { text: '完成', status: 'complete' },
  61. },
  62. }),
  63. ),
  64. 5,
  65. )
  66. server.on('connection', (socket) => {
  67. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready' } }))
  68. socket.on('message', (data) => {
  69. const frame = JSON.parse(String(data))
  70. requests.push(frame)
  71. const respond = (result: unknown) => socket.send(JSON.stringify({ id: frame.id, result }))
  72. if (frame.method === 'session.create')
  73. respond({
  74. session_id: randomUUID(),
  75. stored_session_id: randomUUID(),
  76. info: { profile_name: frame.params.profile },
  77. running: false,
  78. })
  79. else if (frame.method === 'session.resume')
  80. respond({
  81. session_id: randomUUID(),
  82. stored_session_id: frame.params.session_id,
  83. running: false,
  84. info: { profile_name: frame.params.profile },
  85. })
  86. else if (frame.method === 'prompt.submit') {
  87. respond({ status: 'streaming' })
  88. reply(socket, frame.params)
  89. } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
  90. else respond({ ok: true, status: 'interrupted' })
  91. })
  92. })
  93. })
  94. afterEach(async () => {
  95. runtime.close()
  96. await new Promise((resolve) => setTimeout(resolve, 10))
  97. for (const client of server.clients) client.terminate()
  98. await new Promise<void>((resolve) => server.close(() => resolve()))
  99. uploads.close()
  100. store.close()
  101. rmSync(home, { recursive: true, force: true })
  102. })
  103. function agent(name: string, user = owner) {
  104. return store.createAgent(user, { name, profile: 'default', instructions: `规则:你是${name}` })
  105. }
  106. function direct(id: string, user = owner) {
  107. return store
  108. .list<WorkspaceConversation>(user, 'conversation')
  109. .find((c) => c.kind === 'direct' && c.memberIds[0] === id)!
  110. }
  111. async function finished(id: string) {
  112. await vi.waitFor(() =>
  113. expect(store.require<WorkspaceRun>(owner, 'run', id).status).toBe('complete'),
  114. )
  115. return store.require<WorkspaceRun>(owner, 'run', id)
  116. }
  117. describe('Web-owned workspace', () => {
  118. it('persists a visible failure when a fixed member loses its source node',async()=>{
  119. const a=agent('不可用成员'),c=direct(a.id)
  120. runtime.close()
  121. runtime=new WorkspaceRuntime(store,{target:()=>{throw new Error('基础节点不可用')}} as unknown as WorkspaceNodes,uploads)
  122. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行任务'})
  123. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  124. expect(store.messages(owner,c.id).at(-1)).toMatchObject({role:'system',status:'failed',content:'执行失败:基础节点不可用'})
  125. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
  126. })
  127. it('interprets a reply against its launch-time roster when names or mode change mid-turn', async () => {
  128. const a = agent('管理员'),
  129. b = agent('执行者')
  130. const c = store.createGroup(owner, {
  131. name: '配置更新',
  132. memberIds: [a.id, b.id],
  133. administratorId: a.id,
  134. maxReplyRounds: 3,
  135. })
  136. let count = 0
  137. reply = (socket, p) => {
  138. let text = '已完成'
  139. if (count++ === 0) {
  140. store.updateAgent(owner, b.id, { name: '新名字', instructions: '新规则' })
  141. store.updateConversation(owner, c.id, { mode: 'free', administratorId: b.id })
  142. text = '请继续 @执行者'
  143. }
  144. setTimeout(
  145. () =>
  146. socket.send(
  147. JSON.stringify({
  148. method: 'event',
  149. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  150. }),
  151. ),
  152. 5,
  153. )
  154. }
  155. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' }).id)
  156. expect(
  157. store
  158. .messages(owner, c.id)
  159. .filter((m) => m.role === 'assistant')
  160. .map((m) => m.agentId),
  161. ).toEqual([a.id, b.id])
  162. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  163. '新规则',
  164. )
  165. })
  166. it('rejects unscoped and foreign-session completion events', async () => {
  167. const a = agent('会话隔离'),
  168. c = direct(a.id)
  169. reply = (socket, p) => {
  170. for (const session_id of [undefined, 'another-session'])
  171. socket.send(
  172. JSON.stringify({
  173. method: 'event',
  174. params: { type: 'message.complete', session_id, payload: { text: '别人的结果' } },
  175. }),
  176. )
  177. setTimeout(
  178. () =>
  179. socket.send(
  180. JSON.stringify({
  181. method: 'event',
  182. params: {
  183. type: 'message.complete',
  184. session_id: p.session_id,
  185. payload: { text: '本轮结果' },
  186. },
  187. }),
  188. ),
  189. 15,
  190. )
  191. }
  192. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '执行' })
  193. await finished(run.id)
  194. expect(store.messages(owner, c.id).at(-1)?.content).toBe('本轮结果')
  195. expect(store.get<{ percent: number }>(owner, 'context', c.id)?.percent).toBe(40)
  196. })
  197. it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
  198. const a = agent('恢复测试'),
  199. c = direct(a.id)
  200. reply = (socket, p) => {
  201. recoveryHistory = [
  202. { role: 'user', content: p.text },
  203. { role: 'assistant', content: '已经执行完成' },
  204. ]
  205. socket.terminate()
  206. }
  207. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改一次' })
  208. await vi.waitFor(() =>
  209. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
  210. )
  211. runtime.close()
  212. runtime = new WorkspaceRuntime(store, nodes, uploads)
  213. await runtime.reconcile(owner, run.id)
  214. await finished(run.id)
  215. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  216. expect(store.messages(owner, c.id).map((m) => m.content)).toEqual(['修改一次', '已经执行完成'])
  217. })
  218. it('routes approvals and clarifications to the owning run and ignores optional push failures', async () => {
  219. const a = agent('交互测试'),
  220. c = direct(a.id)
  221. runtime.onNotify = () => {
  222. throw new Error('push storage unavailable')
  223. }
  224. let kind = 'approval'
  225. server.on('connection', (socket) =>
  226. socket.on('message', (data) => {
  227. const f = JSON.parse(String(data))
  228. if (['approval.respond', 'clarify.respond'].includes(f.method))
  229. socket.send(
  230. JSON.stringify({
  231. method: 'event',
  232. params: {
  233. type: 'message.complete',
  234. session_id: f.params.session_id,
  235. payload: { text: '确认后完成' },
  236. },
  237. }),
  238. )
  239. }),
  240. )
  241. reply = (socket, p) =>
  242. socket.send(
  243. JSON.stringify({
  244. method: 'event',
  245. params: {
  246. type: `${kind}.request`,
  247. session_id: p.session_id,
  248. payload: { request_id: 'upstream-interaction', message: '请确认' },
  249. },
  250. }),
  251. )
  252. for (kind of ['approval', 'clarify']) {
  253. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续' })
  254. await vi.waitFor(() =>
  255. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting'),
  256. )
  257. const interaction = store
  258. .list<WorkspaceInteraction>(owner, 'interaction')
  259. .find((i) => !i.resolved)!
  260. await expect(runtime.respond(other, interaction.id, 'once')).rejects.toThrow('记录不存在')
  261. await runtime.respond(owner, interaction.id, kind === 'approval' ? 'once' : '补充说明')
  262. await finished(run.id)
  263. expect(
  264. store.require<WorkspaceInteraction>(owner, 'interaction', interaction.id).resolved,
  265. ).toBe(true)
  266. }
  267. expect(
  268. requests
  269. .filter((r) => r.method === 'approval.respond' || r.method === 'clarify.respond')
  270. .map((r) => r.params.request_id),
  271. ).toEqual(['upstream-interaction', 'upstream-interaction'])
  272. })
  273. it('creates one direct chat without importing a native profile session', () => {
  274. const a = agent('编辑')
  275. expect(direct(a.id).kind).toBe('direct')
  276. expect(store.list(other, 'agent')).toEqual([])
  277. expect(() => store.require(other, 'conversation', direct(a.id).id)).toThrow('记录不存在')
  278. expect(requests).toEqual([])
  279. })
  280. it('keeps membership immutable and validates member ownership', () => {
  281. const a = agent('甲'),
  282. b = agent('乙'),
  283. foreign = agent('外部', other)
  284. const c = store.createGroup(owner, {
  285. name: '团队',
  286. memberIds: [a.id, b.id],
  287. administratorId: a.id,
  288. })
  289. expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id] })).toThrow()
  290. expect(() =>
  291. store.createGroup(owner, {
  292. name: '越权',
  293. memberIds: [a.id, foreign.id],
  294. administratorId: a.id,
  295. }),
  296. ).toThrow()
  297. store.updateAgent(owner, b.id, { archived: true })
  298. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).memberIds).toEqual([
  299. a.id,
  300. b.id,
  301. ])
  302. expect(() =>
  303. store.createGroup(owner, { name: '新群', memberIds: [a.id, b.id], administratorId: a.id }),
  304. ).toThrow('已归档')
  305. })
  306. it('dispatches a request exactly once across retries and rejects payload reuse', async () => {
  307. const a = agent('编辑'),
  308. c = direct(a.id),
  309. requestId = randomUUID(),
  310. payload = { requestId, content: '检查代码' }
  311. const run = runtime.send(owner, c.id, payload)
  312. expect(runtime.send(owner, c.id, payload).id).toBe(run.id)
  313. expect(() => runtime.send(owner, c.id, { ...payload, content: '另一件事' })).toThrow('请求编号')
  314. await finished(run.id)
  315. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  316. expect(store.messages(owner, c.id).map((m) => m.role)).toEqual(['user', 'assistant'])
  317. })
  318. it('isolates roles over the same profile and applies edited rules on the next turn', async () => {
  319. const a = agent('编辑'),
  320. b = agent('审查'),
  321. ca = direct(a.id),
  322. cb = direct(b.id)
  323. const first = runtime.send(owner, ca.id, { requestId: randomUUID(), content: '编辑任务' })
  324. const second = runtime.send(owner, cb.id, { requestId: randomUUID(), content: '审查任务' })
  325. await Promise.all([finished(first.id), finished(second.id)])
  326. expect(requests.filter((r) => r.method === 'session.create')).toHaveLength(2)
  327. expect(store.messages(owner, ca.id).some((m) => m.content === '审查任务')).toBe(false)
  328. store.updateAgent(owner, a.id, { instructions: '最新规则:先给证据' })
  329. await finished(runtime.send(owner, ca.id, { requestId: randomUUID(), content: '继续' }).id)
  330. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  331. '最新规则:先给证据',
  332. )
  333. expect(requests.some((r) => r.method === 'profiles.configure')).toBe(false)
  334. expect(
  335. requests
  336. .filter((r) => r.method === 'session.create')
  337. .every((r) => r.params.hidden && r.params.source === 'yaoyao_workspace'),
  338. ).toBe(true)
  339. })
  340. it('executes bounded administrator delegation and keeps group sessions separate from direct chat', async () => {
  341. const a = agent('管理员'),
  342. b = agent('开发者'),
  343. c = store.createGroup(owner, {
  344. name: '开发',
  345. memberIds: [a.id, b.id],
  346. administratorId: a.id,
  347. maxReplyRounds: 3,
  348. })
  349. let turns = 0
  350. reply = (socket, p) => {
  351. const text = turns++ === 0 ? '请协作 @开发者' : '任务完成'
  352. setTimeout(
  353. () =>
  354. socket.send(
  355. JSON.stringify({
  356. method: 'event',
  357. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  358. }),
  359. ),
  360. 5,
  361. )
  362. }
  363. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '实现功能' }).id)
  364. expect(
  365. store
  366. .messages(owner, c.id)
  367. .filter((m) => m.role === 'assistant')
  368. .map((m) => m.agentId),
  369. ).toEqual([a.id, b.id, a.id])
  370. expect(store.messages(owner, direct(a.id).id)).toHaveLength(0)
  371. expect(turns).toBe(3)
  372. })
  373. it('does not trigger delegation from quoted text or code blocks', () => {
  374. const a = agent('甲'),
  375. b = agent('乙')
  376. expect(mentionedAgents('> @乙\n```\n@甲\n```\n`@all`', [a, b])).toEqual([])
  377. expect(mentionedAgents('请处理 @乙。', [a, b])).toEqual([b.id])
  378. })
  379. it('keeps uncertain submissions blocked instead of executing tools twice', async () => {
  380. const a = agent('执行者'),
  381. c = direct(a.id)
  382. reply = (socket) => socket.terminate()
  383. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改文件' })
  384. await vi.waitFor(() =>
  385. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
  386. )
  387. expect(() => runtime.send(owner, c.id, { requestId: randomUUID(), content: '重试' })).toThrow(
  388. '当前回复',
  389. )
  390. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  391. await runtime.stop(owner, run.id)
  392. expect(
  393. store.require<WorkspaceConversation>(owner, 'conversation', c.id).activeRunId,
  394. ).toBeUndefined()
  395. })
  396. it('publishes durable ordered events only after commit and rolls back failed transactions', () => {
  397. const a = agent('甲'),
  398. c = direct(a.id),
  399. cursor = store.cursor(owner)
  400. expect(() =>
  401. store.atomic(() => {
  402. store.event(owner, 'test', {})
  403. throw new Error('rollback')
  404. }),
  405. ).toThrow('rollback')
  406. expect(store.cursor(owner)).toBe(cursor)
  407. store.updateAgent(owner, a.id, { name: '乙' })
  408. expect(store.events(owner, cursor).map((e) => e.type)).toEqual([
  409. 'agent.changed',
  410. 'conversation.changed',
  411. ])
  412. expect(store.events(other, 0)).toEqual([])
  413. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).name).toBe('乙')
  414. })
  415. })
  416. it('pins a direct chat without injecting or clearing optional profile fields', () => {
  417. const avatar = 'yaoyao-mascot:v1:triangle:0ea5c6:curious'
  418. const agent = store.createAgent(owner, { name: '审查员', profile: 'default', avatar })
  419. const chat = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  420. expect(store.updateConversation(owner, chat.id, { pinned: true }).pinned).toBe(true)
  421. expect(store.updateConversation(owner, chat.id, { pinned: false }).pinned).toBe(false)
  422. expect(store.require<WorkspaceConversation>(owner, 'conversation', chat.id).avatar).toBe(avatar)
  423. expect(store.updateAgent(owner, agent.id, { instructions: '请检查边界' }).avatar).toBe(avatar)
  424. expect(store.updateAgent(owner, agent.id, { avatar: '' }).avatar).toBe('')
  425. })
  426. it('accepts the existing cross-client mascot and team avatar formats', () => {
  427. const a = store.createAgent(owner, { name: '设计', profile: 'default', avatar: 'yaoyao-mascot:v1:square:377fe6:friendly' })
  428. const b = store.createAgent(owner, { name: '开发', profile: 'default' })
  429. const group = store.createGroup(owner, { name: '产品团队', memberIds: [a.id, b.id], administratorId: a.id, avatar: 'builtin:team-animal:fox' })
  430. expect(group.avatar).toBe('builtin:team-animal:fox')
  431. expect(() => store.updateAgent(owner, a.id, { avatar: 'javascript:alert(1)' })).toThrow()
  432. })
  433. it('keeps the last message time independent of pinning and metadata edits', () => {
  434. const agent=store.createAgent(owner,{name:'时间验收',profile:'default'})
  435. const c=store.list<WorkspaceConversation>(owner,'conversation')[0]!
  436. const message: WorkspaceMessage={id:randomUUID(),conversationId:c.id,seq:0,role:'user',content:'历史消息',reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()-600_000}
  437. store.saveMessage(owner,message)
  438. const pinned=store.updateConversation(owner,c.id,{pinned:true})
  439. expect(store.conversationSummary(owner,pinned).lastMessageAt).toBe(message.createdAt)
  440. store.updateAgent(owner,agent.id,{name:'改名之后'})
  441. expect(store.conversationSummary(owner,store.require(owner,'conversation',c.id)).lastMessageAt).toBe(message.createdAt)
  442. // Existing records without the summary field still use their transcript time.
  443. const previous={...pinned}; delete previous.lastMessageAt
  444. expect(store.conversationSummary(owner,previous).lastMessageAt).toBe(message.createdAt)
  445. })
  446. it('publishes only the executing member and clears avatar activity when stopped', async () => {
  447. const a=store.createAgent(owner,{name:'管理员头像',profile:'default'})
  448. const b=store.createAgent(owner,{name:'执行者头像',profile:'default'})
  449. const c=store.createGroup(owner,{name:'头像状态',memberIds:[a.id,b.id],administratorId:a.id})
  450. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'approval-avatar',message:'需要确认'}}}))
  451. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'处理任务',mentionIds:[b.id]})
  452. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('waiting'))
  453. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id)).toMatchObject({activeAgentId:b.id,activeRunStatus:'waiting'})
  454. await runtime.stop(owner,run.id)
  455. const stopped=store.require<WorkspaceConversation>(owner,'conversation',c.id)
  456. expect(stopped.activeAgentId).toBeUndefined()
  457. expect(stopped.activeRunStatus).toBeUndefined()
  458. })
  459. it('stores the expanded avatar shapes for cross-client role settings', () => {
  460. const agent=store.createAgent(owner,{name:'扩展头像验收',profile:'default'})
  461. for(const shape of ['ellipse','capsule','hexagon','cloud','droplet']) {
  462. const avatar=`yaoyao-mascot:v1:${shape}:ff2dab:curious`
  463. expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
  464. }
  465. })