workspace.test.ts 27 KB

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