workspace.test.ts 156 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358
  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, createHash } from 'node:crypto'
  7. import { WebSocketServer, type WebSocket } from 'ws'
  8. import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
  9. import {readHostTools,saveHostTools} from '../../src/server/hostToolSettings'
  10. import {DesktopEnvironments} from '../../src/server/desktopEnvironments'
  11. import {HttpError} from '../../src/server/errors'
  12. import * as sessionStop from '../../src/server/sessionStop'
  13. import {ExecutionSettings} from '../../src/server/executionSettings'
  14. import {WorkspaceAssets} from '../../src/server/workspaceAssets'
  15. import { WorkspaceStore } from '../../src/server/workspaceStore'
  16. import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
  17. import type { WorkspacePlugins } from '../../src/server/botPlugins/workspacePlugins'
  18. import type { Work } from '../../src/server/workspaceScheduler'
  19. import { REPEATED_RELAY_NOTICE } from '../../src/server/workspaceRelay'
  20. import { WorkspaceNodes, WorkspaceGateway, type GatewayTarget } from '../../src/server/workspaceGateway'
  21. import { UploadStore } from '../../src/server/uploads'
  22. import type {
  23. WorkspaceConversation,
  24. WorkspaceRun,
  25. WorkspaceMessage,
  26. WorkspaceInteraction,
  27. } from '../../src/shared/workspace'
  28. let home: string,
  29. store: WorkspaceStore,
  30. uploads: UploadStore,
  31. runtime: WorkspaceRuntime,
  32. server: WebSocketServer
  33. let requests: Array<{ method: string; params: Record<string, any> }>,
  34. reply: (socket: WebSocket, params: Record<string, any>) => void
  35. let nodes: WorkspaceNodes
  36. let configuredCwds: Map<string, string>
  37. let rejectPrompt: string | undefined
  38. let observeGatewayRequest: ((frame: { method: string; params: Record<string, any> }) => void) | undefined
  39. let rejectedModelConfig: Record<string, unknown> | undefined
  40. let rejectedInterrupts: number
  41. let nativeStillWorking:boolean
  42. let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
  43. let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
  44. let resumeExistingRuntime: boolean, profileMtime: number | undefined
  45. let modelInfo: Map<string, { model: string; provider: string }>
  46. const owner = 'first-user',
  47. other = 'second-user'
  48. beforeEach(async () => {
  49. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-test-'))
  50. store = new WorkspaceStore(home)
  51. uploads = new UploadStore(home)
  52. requests = []
  53. configuredCwds = new Map()
  54. recoveryHistory = []
  55. storedByRuntime = new Map(); recoveryByStored = new Map()
  56. resumeExistingRuntime = false; profileMtime = 1; modelInfo = new Map()
  57. rejectPrompt = undefined
  58. observeGatewayRequest = undefined
  59. rejectedModelConfig = undefined
  60. rejectedInterrupts = 0
  61. nativeStillWorking=false
  62. server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  63. await new Promise<void>((resolve) => server.once('listening', resolve))
  64. const address = server.address() as { port: number }
  65. const target = {
  66. url: new URL(`http://127.0.0.1:${address.port}`),
  67. client: { directAgent: undefined },
  68. session: {
  69. webSocketCredential: async () => ({ name: 'ticket', value: 'test' }),
  70. request: async (path: string, options?: {search?: URLSearchParams}) => ({
  71. status: 200,
  72. body: Buffer.from(JSON.stringify(path === '/api/config'
  73. ? { terminal: { cwd: configuredCwds.get(options?.search?.get('profile') ?? '') } }
  74. : { 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) })() })),
  75. }),
  76. },
  77. }
  78. nodes = { requireSource: () => {}, target: () => target } as unknown as WorkspaceNodes
  79. runtime = new WorkspaceRuntime(store, nodes, uploads)
  80. reply = (socket, p) =>
  81. setTimeout(
  82. () =>
  83. socket.send(
  84. JSON.stringify({
  85. method: 'event',
  86. params: {
  87. type: 'message.complete',
  88. session_id: p.session_id,
  89. payload: { text: '完成', status: 'complete' },
  90. },
  91. }),
  92. ),
  93. 5,
  94. )
  95. server.on('connection', (socket) => {
  96. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready' } }))
  97. socket.on('message', (data) => {
  98. const frame = JSON.parse(String(data))
  99. requests.push(frame)
  100. observeGatewayRequest?.(frame)
  101. const respond = (result: unknown) => socket.send(JSON.stringify({ id: frame.id, result }))
  102. if (frame.method === 'session.create' || frame.method === 'session.resume') {
  103. const previousRuntime = resumeExistingRuntime && frame.method === 'session.resume'
  104. ? [...storedByRuntime].find(([, stored]) => stored === frame.params.session_id)?.[0] : undefined
  105. const runtimeId = previousRuntime ?? randomUUID(), storedId = frame.method === 'session.resume' ? frame.params.session_id : randomUUID()
  106. storedByRuntime.set(runtimeId, storedId)
  107. respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile, ...modelInfo.get(runtimeId) } })
  108. }
  109. else if (frame.method === 'session.cwd.set') respond({ cwd: frame.params.cwd })
  110. else if (frame.method === 'config.get') respond({ mtime: profileMtime })
  111. else if (frame.method === 'config.set') {
  112. if (!rejectedModelConfig && frame.params.key === 'model') {
  113. const [model, route] = frame.params.value.split(' --provider ')
  114. modelInfo.set(frame.params.session_id, { model, provider: route.split(' --session')[0] })
  115. }
  116. respond(rejectedModelConfig ?? { value: frame.params.key === 'model' ? frame.params.value.split(' --provider ')[0] : frame.params.value, scope:'session' })
  117. }
  118. else if (frame.method === 'prompt.submit') {
  119. if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
  120. respond({ status: 'streaming' })
  121. reply(socket, frame.params)
  122. } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
  123. else if (frame.method === 'session.active_list') respond({sessions:[{id:frame.params.current_session_id,status:nativeStillWorking?'working':'idle'}]})
  124. else if (frame.method === 'session.interrupt' && rejectedInterrupts-- > 0) socket.send(JSON.stringify({id:frame.id,error:{code:500,message:'暂时不能中断'}}))
  125. else respond({ ok: true, status: 'interrupted' })
  126. })
  127. })
  128. })
  129. afterEach(async () => {
  130. runtime.close()
  131. await new Promise((resolve) => setTimeout(resolve, 10))
  132. for (const client of server.clients) client.terminate()
  133. await new Promise<void>((resolve) => server.close(() => resolve()))
  134. uploads.close()
  135. store.close()
  136. rmSync(home, { recursive: true, force: true })
  137. })
  138. function agent(name: string, user = owner) {
  139. return store.createAgent(user, { name, profile: 'default', instructions: `规则:你是${name}` })
  140. }
  141. it('includes only the connected per-turn MCP inventory in the submitted Bot prompt', async () => {
  142. const bot = agent('MCP 检查助手'), conversation = direct(bot.id)
  143. const target = nodes.target(owner, 'local'), request = target.session.request.bind(target.session)
  144. vi.spyOn(target.session, 'request').mockImplementation(async (path, options) => path.startsWith('/api/plugins/yaoyao-bot-bridge/')
  145. ? { status: 200, body: Buffer.from(JSON.stringify({ ok: true, version: 1, ready: true, in_process: true, native_tools: true })), headers: new Headers() }
  146. : request(path, options))
  147. const services = vi.fn(() => [{ name: 'vaultwarden', transport: 'stdio' as const, toolCount: 59 }])
  148. const open = vi.fn(async () => ({ services, warnings: () => [], catalog: () => [], call: vi.fn(), dispose: async () => {} }))
  149. runtime.plugins = { selected: () => true, open } as unknown as WorkspacePlugins
  150. const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '查看 vaultwarden mcp 是否正常使用' })
  151. await vi.waitFor(() => expect(['complete', 'failed']).toContain(store.require<WorkspaceRun>(owner, 'run', run.id).status))
  152. const settled = store.require<WorkspaceRun>(owner, 'run', run.id)
  153. expect(settled.status, settled.error).toBe('complete')
  154. expect(open).toHaveBeenCalledOnce()
  155. expect(services).toHaveBeenCalledOnce()
  156. const prompt = requests.find(frame => frame.method === 'prompt.submit')!.params.text
  157. expect(prompt).toContain('"name":"vaultwarden","transport":"stdio","toolCount":59')
  158. expect(prompt).toContain('yaoyao_tools')
  159. expect(prompt).toContain('不能根据 hermes mcp list/test 的结果判断这些服务不存在')
  160. })
  161. it('executes a structured assignment on its actual worker and leaves acceptance to its coordinator', async () => {
  162. const lead = store.updateAgent(owner, agent('负责人').id, { canManageTeam: true })
  163. const worker = agent('研究成员')
  164. const source = direct(lead.id)
  165. const original = runtime.send(owner, source.id, { requestId: randomUUID(), content: '完成研究' })
  166. for (const work of store.list<any>(owner, 'turn').filter(w => w.runId === original.id))
  167. store.put(owner, 'turn', work.id, { ...work, status: 'complete', planned: true })
  168. original.status = 'complete';store.saveRun(owner, original)
  169. const team = store.createGroup(owner, { name: '研究团队', memberIds: [lead.id,worker.id], administratorId: lead.id })
  170. const goal = runtime.tasks.begin(owner, store.tasks(owner, team.id)[0]!, lead, '研究事实', { conversationId: source.id, runId: original.id, agentId: lead.id })
  171. const assignment = runtime.tasks.createAssignment(owner, lead.id, { requestId: randomUUID(), goalId: goal.id, agentId: worker.id, title: '核对事实', brief: '提交核对结果' })
  172. await vi.waitFor(() => expect(store.get<any>(owner,'assignment',assignment.id)?.status).toBe('review'), { timeout: 5000 })
  173. const submitted = requests.find(r => r.method === 'prompt.submit' && String(r.params.text).includes('核对事实'))!
  174. expect(submitted.params.text).toContain('你是 研究成员')
  175. expect(submitted.params.text).toContain('执行子任务')
  176. expect(store.get<any>(owner,'goal',goal.id)?.status).not.toBe('complete')
  177. })
  178. it('keeps structured goal coordination on the lead and does not relay mention text as duplicate work', async () => {
  179. const lead=store.updateAgent(owner,agent('交付负责人').id,{canManageTeam:true}),worker=agent('研究成员')
  180. const target=nodes.target(owner,'local'), request=target.session.request.bind(target.session)
  181. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>path.startsWith('/api/plugins/yaoyao-bot-bridge/')
  182. ? {status:200,body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,in_process:true,native_tools:true})),headers:new Headers()}
  183. : request(path,options))
  184. const team=store.createGroup(owner,{name:'自由协作交付',memberIds:[lead.id,worker.id],administratorId:lead.id,mode:'free',autoReplyIds:[worker.id]})
  185. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'@研究成员 请核对资料',status:'complete'}}}))
  186. const run=runtime.send(owner,team.id,{requestId:randomUUID(),content:'交付研究报告',mode:'goal'})
  187. await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status))
  188. const settled=store.require<WorkspaceRun>(owner,'run',run.id)
  189. expect(settled.status,settled.error).toBe('complete')
  190. const work=store.list<Work>(owner,'turn').filter(w=>w.runId===run.id)
  191. expect(work.map(w=>w.agentId)).toEqual([lead.id])
  192. expect(runtime.tasks.assignments(owner,run.goalId!)).toEqual([])
  193. expect(requests.filter(r=>r.method==='prompt.submit').every(r=>!String(r.params.text).startsWith('你是 研究成员'))).toBe(true)
  194. })
  195. function direct(id: string, user = owner) {
  196. return store
  197. .list<WorkspaceConversation>(user, 'conversation')
  198. .find((c) => c.kind === 'direct' && c.memberIds[0] === id)!
  199. }
  200. function defaultTask(conversationId: string, user = owner) {
  201. return store.tasks(user, conversationId)[0]!
  202. }
  203. async function finished(id: string) {
  204. await vi.waitFor(() =>
  205. expect(store.require<WorkspaceRun>(owner, 'run', id).status).toBe('complete'),
  206. )
  207. return store.require<WorkspaceRun>(owner, 'run', id)
  208. }
  209. describe('Web-owned workspace', () => {
  210. it('restores recent user and assistant context when moving a completed conversation to another runner',async()=>{
  211. const a=agent('移动成员'),c=direct(a.id)
  212. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'保存研究结论'}).id)
  213. const binding=store.list<any>(owner,'binding')[0]!
  214. store.put(owner,'binding',binding.id,{...binding,runnerId:'previous-runner'})
  215. const before=requests.length
  216. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'继续后续分析'}).id)
  217. const next=requests.slice(before)
  218. expect(next.some(r=>r.method==='session.resume')).toBe(false)
  219. const text=next.find(r=>r.method==='prompt.submit')!.params.text
  220. expect(text).toContain('执行节点已切换')
  221. expect(text).toContain('保存研究结论')
  222. expect(text).toContain('移动成员(complete):完成')
  223. expect(text).toContain('继续后续分析')
  224. })
  225. it('uses each base profile cwd and replaces historical cwd before the next Bot turn', async () => {
  226. configuredCwds.set('default', '/profile/first')
  227. configuredCwds.set('other', '/profile/other')
  228. const a = agent('主助手'), c = direct(a.id)
  229. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: 'first' }).id)
  230. expect(requests.find(r => r.method === 'session.create')?.params.cwd).toBe('/profile/first')
  231. expect(requests.find(r => r.method === 'session.cwd.set')?.params.cwd).toBe('/profile/first')
  232. configuredCwds.set('default', '/profile/changed')
  233. const before = requests.length
  234. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: 'second' }).id)
  235. // The prior turn's asynchronous usage refresh can arrive after completion.
  236. // This assertion concerns the next turn's execution RPCs, not telemetry.
  237. const next = requests.slice(before).filter(r => r.method !== 'session.usage')
  238. expect(next[0].method).toBe('session.resume')
  239. expect(next.find(r => r.method === 'session.cwd.set')?.params.cwd).toBe('/profile/changed')
  240. expect(next.findIndex(r => r.method === 'session.cwd.set')).toBeLessThan(next.findIndex(r => r.method === 'prompt.submit'))
  241. const b = store.createAgent(owner, { name: '其他 Profile', profile: 'other' })
  242. await finished(runtime.send(owner, direct(b.id).id, { requestId: randomUUID(), content: 'third' }).id)
  243. expect(requests.filter(r => r.method === 'session.create').at(-1)?.params.cwd).toBe('/profile/other')
  244. })
  245. it('flushes a paused delta before completion and cancels pending flush on finish', async () => {
  246. let upstream: { socket: WebSocket; params: Record<string, any> } | undefined
  247. reply = (socket, params) => {
  248. upstream = { socket, params }
  249. const event = (type: string, payload: Record<string, unknown>) => socket.send(JSON.stringify({
  250. method: 'event', params: { type, session_id: params.session_id, payload },
  251. }))
  252. event('message.start', {})
  253. event('message.delta', { text: '# 流式标题' })
  254. }
  255. const bot = agent('流式助手'), conversation = direct(bot.id)
  256. const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '验证暂停分片' })
  257. const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
  258. await vi.waitFor(() => expect(assistant()?.content).toBe('# 流式标题'))
  259. expect(assistant()?.status).toBe('streaming')
  260. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).not.toBe('complete')
  261. const { socket, params } = upstream!
  262. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.delta', session_id: params.session_id, payload: { text: '\n\n最后一段' } } }))
  263. completeReply(socket, params, '# 最终正文')
  264. await finished(run.id)
  265. await new Promise(resolve => setTimeout(resolve, 150))
  266. expect(assistant()?.content).toBe('# 最终正文')
  267. expect(assistant()?.status).toBe('complete')
  268. })
  269. it.each([false, true])('shows interim text immediately without duplicating streamed text (already_streamed=%s)', async alreadyStreamed => {
  270. let upstream!: { socket: WebSocket; params: Record<string, any> }
  271. reply = (socket, params) => {
  272. upstream = { socket, params }
  273. const event = (type: string, payload: Record<string, unknown>) => socket.send(JSON.stringify({ method: 'event', params: { type, session_id: params.session_id, payload } }))
  274. if (alreadyStreamed) event('message.delta', { text: '正在检查服务器。' })
  275. event('message.interim', { text: '正在检查服务器。', already_streamed: alreadyStreamed })
  276. }
  277. const bot = agent('进度助手'), conversation = direct(bot.id)
  278. const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '检查服务器' })
  279. const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
  280. await vi.waitFor(() => expect(assistant()?.content).toBe('正在检查服务器。\n\n'))
  281. expect(assistant()?.status).toBe('streaming')
  282. expect(assistant()?.visible).toBe(true)
  283. completeReply(upstream.socket, upstream.params, '检查完成。')
  284. await finished(run.id)
  285. expect(assistant()?.content).toBe('检查完成。')
  286. })
  287. it.each(['status', 'delta', 'complete'])('publishes compression progress separately from text and clears it on %s', async next => {
  288. let upstream!: { socket: WebSocket; params: Record<string, any> }
  289. reply = (socket, params) => {
  290. upstream = { socket, params }
  291. socket.send(JSON.stringify({ method: 'event', params: { type: 'status.update', session_id: params.session_id, payload: { kind: 'compacting', text: 'Internal context details' } } }))
  292. }
  293. const bot = agent('长会话助手'), conversation = direct(bot.id)
  294. const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '继续' })
  295. const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
  296. await vi.waitFor(() => expect(assistant()?.activity).toBe('compacting'))
  297. expect(assistant()?.content).toBe('')
  298. if (next !== 'complete') {
  299. upstream.socket.send(JSON.stringify({ method: 'event', params: {
  300. type: next === 'status' ? 'status.update' : 'message.delta', session_id: upstream.params.session_id,
  301. payload: next === 'status' ? { kind: 'lifecycle', text: 'Done' } : { text: '开始回复' },
  302. } }))
  303. await vi.waitFor(() => expect(assistant()?.activity).toBeUndefined())
  304. }
  305. completeReply(upstream.socket, upstream.params, '最终结果')
  306. await finished(run.id)
  307. expect(assistant()?.activity).toBeUndefined()
  308. expect(assistant()?.content).toBe('最终结果')
  309. })
  310. it('backfills legacy group history into the initial task instead of leaving a new empty task', () => {
  311. const legacyHome = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-task-migration-'))
  312. let seededStore: WorkspaceStore | undefined = new WorkspaceStore(legacyHome)
  313. let migratedStore: WorkspaceStore | undefined
  314. try {
  315. const administrator = seededStore.createAgent(owner, { name: '历史管理员', profile: 'default' }),
  316. member = seededStore.createAgent(owner, { name: '历史成员', profile: 'default' }),
  317. conversation = seededStore.createGroup(owner, {
  318. name: '公众号文案团队',
  319. memberIds: [administrator.id, member.id],
  320. administratorId: administrator.id,
  321. }),
  322. initialTask = seededStore.tasks(owner, conversation.id)[0]!,
  323. userAt = 1_788_000_000_000,
  324. assistantAt = userAt + 60_000
  325. seededStore.put(owner, 'message', 'legacy-user', {
  326. id: 'legacy-user', conversationId: conversation.id, seq: 1, role: 'user',
  327. content: '请继续完成公众号文案', reasoning: '', status: 'complete',
  328. attachments: [], tools: [], createdAt: userAt,
  329. } satisfies WorkspaceMessage)
  330. seededStore.put(owner, 'message', 'legacy-assistant', {
  331. id: 'legacy-assistant', conversationId: conversation.id, seq: 2, role: 'assistant',
  332. agentId: administrator.id, agentName: '历史管理员', content: '旧内容已经完成。',
  333. reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: assistantAt,
  334. } satisfies WorkspaceMessage)
  335. seededStore.put(owner, 'conversation', conversation.id, {
  336. ...conversation,
  337. readSeq: 1,
  338. lastSeq: 2,
  339. preview: '旧内容已经完成。',
  340. lastMessageAt: assistantAt,
  341. updatedAt: assistantAt,
  342. })
  343. seededStore.put(owner, 'run', 'legacy-run', {
  344. id: 'legacy-run', conversationId: conversation.id, messageId: 'legacy-user',
  345. mentionIds: [], status: 'complete', round: 1, createdAt: userAt, updatedAt: assistantAt,
  346. } satisfies WorkspaceRun)
  347. seededStore.put(owner, 'turn', 'legacy-turn', {
  348. id: 'legacy-turn', runId: 'legacy-run', conversationId: conversation.id,
  349. agentId: administrator.id, messageId: 'legacy-user', status: 'complete', createdAt: userAt,
  350. })
  351. seededStore.put(owner, 'interaction', 'legacy-interaction', {
  352. id: 'legacy-interaction', conversationId: conversation.id, runId: 'legacy-run',
  353. agentId: administrator.id, kind: 'clarification', message: '补充说明',
  354. choices: [], resolved: true,
  355. } satisfies WorkspaceInteraction)
  356. seededStore.put(owner, 'interaction-binding', 'legacy-interaction', {
  357. key: `${conversation.id}:${administrator.id}`,
  358. upstreamId: 'upstream-interaction', taskId: 'legacy-turn',
  359. })
  360. seededStore.put(owner, 'binding', `${conversation.id}:${administrator.id}`, {
  361. nodeId: 'local', profile: 'default', storedId: 'stored-legacy',
  362. runtimeId: 'runtime-legacy', aliases: [], runId: 'legacy-run',
  363. messageId: 'legacy-user', taskId: 'legacy-turn',
  364. })
  365. seededStore.put(owner, 'context', conversation.id, {
  366. usedTokens: 100, limitTokens: 1_000, observedAt: assistantAt,
  367. })
  368. seededStore.db.prepare('DELETE FROM workspace_migrations WHERE id=?')
  369. .run('conversation-task-history-v1')
  370. seededStore.close()
  371. seededStore = undefined
  372. migratedStore = new WorkspaceStore(legacyHome)
  373. const tasks = migratedStore.tasks(owner, conversation.id)
  374. expect(tasks).toHaveLength(1)
  375. expect(tasks[0]).toMatchObject({
  376. id: initialTask.id,
  377. title: '请继续完成公众号文案',
  378. titleSource: 'automatic',
  379. messageCount: 2,
  380. readSeq: 1,
  381. lastSeq: 2,
  382. unreadCount: 1,
  383. lastMessageAt: assistantAt,
  384. createdAt: userAt,
  385. updatedAt: assistantAt,
  386. })
  387. expect(
  388. migratedStore.messages(
  389. owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, initialTask.id,
  390. ).map(message => message.id),
  391. ).toEqual(['legacy-user', 'legacy-assistant'])
  392. expect(migratedStore.require<WorkspaceRun>(owner, 'run', 'legacy-run').conversationTaskId)
  393. .toBe(initialTask.id)
  394. expect(migratedStore.require<{ conversationTaskId: string }>(owner, 'turn', 'legacy-turn').conversationTaskId)
  395. .toBe(initialTask.id)
  396. expect(migratedStore.require<WorkspaceInteraction>(owner, 'interaction', 'legacy-interaction').conversationTaskId)
  397. .toBe(initialTask.id)
  398. expect(migratedStore.get(owner, 'binding', `${conversation.id}:${administrator.id}`)).toBeUndefined()
  399. expect(migratedStore.get(owner, 'binding', `${conversation.id}:${initialTask.id}:${administrator.id}`))
  400. .toMatchObject({ conversationTaskId: initialTask.id })
  401. expect(migratedStore.get(owner, 'context', conversation.id)).toBeUndefined()
  402. expect(migratedStore.get(owner, 'context', initialTask.id))
  403. .toMatchObject({ conversationTaskId: initialTask.id })
  404. } finally {
  405. seededStore?.close()
  406. migratedStore?.close()
  407. rmSync(legacyHome, { recursive: true, force: true })
  408. }
  409. })
  410. it('runs one Agent across group tasks concurrently while keeping messages, sessions and context isolated', async () => {
  411. const administrator = store.createAgent(owner, { name: '多任务远程管理员', profile: 'remote-profile', nodeId: 'remote-node' }),
  412. member = agent('多任务成员'),
  413. conversation = store.createGroup(owner, {
  414. name: '多任务群聊',
  415. memberIds: [administrator.id, member.id],
  416. administratorId: administrator.id,
  417. }),
  418. firstTask = defaultTask(conversation.id),
  419. secondTask = store.createTask(owner, conversation.id, { title: '显式标题' })
  420. const pending: Array<{ socket: WebSocket; params: Record<string, any> }> = []
  421. reply = (socket, params) => {
  422. pending.push({ socket, params })
  423. }
  424. const complete = (item: typeof pending[number]) => {
  425. const text = item.params.text.includes('第一项工作') ? '第一项结果' : '第二项结果'
  426. item.socket.send(JSON.stringify({method:'event',params:{type:'context.update',session_id:item.params.session_id,payload:{percent:40}}}))
  427. setTimeout(() => item.socket.send(JSON.stringify({
  428. method: 'event',
  429. params: { type: 'message.complete', session_id: item.params.session_id, payload: { text, status: 'complete' } },
  430. })), 5)
  431. }
  432. const firstRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '第一项工作' }),
  433. secondRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '第二项工作' })
  434. await vi.waitFor(()=>expect(pending).toHaveLength(2))
  435. expect(store.require<WorkspaceRun>(owner,'run',firstRun.id).status).toBe('running')
  436. expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('running')
  437. complete(pending.find(item => item.params.text.includes('第二项工作'))!)
  438. await finished(secondRun.id)
  439. expect(store.require<WorkspaceRun>(owner,'run',firstRun.id).status).toBe('running')
  440. complete(pending.find(item => item.params.text.includes('第一项工作'))!)
  441. await finished(firstRun.id)
  442. const firstMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, firstTask.id),
  443. secondMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id)
  444. expect(firstMessages.map(message => message.content)).toEqual(['第一项工作', '第一项结果'])
  445. expect(secondMessages.map(message => message.content)).toEqual(['第二项工作', '第二项结果'])
  446. expect(firstMessages.every(message => message.conversationTaskId === firstTask.id)).toBe(true)
  447. expect(secondMessages.every(message => message.conversationTaskId === secondTask.id)).toBe(true)
  448. const prompts = requests.filter(request => request.method === 'prompt.submit').map(request => String(request.params.text))
  449. expect(prompts.find(prompt => prompt.includes('第一项工作'))).not.toContain('第二项工作')
  450. expect(prompts.find(prompt => prompt.includes('第二项工作'))).not.toContain('第一项工作')
  451. const bindings = store.list<any>(owner, 'binding')
  452. expect(bindings.map(binding => binding.conversationTaskId).sort()).toEqual([firstTask.id, secondTask.id].sort())
  453. expect(bindings.every(binding => binding.nodeId === 'remote-node' && binding.profile === 'remote-profile')).toBe(true)
  454. expect(new Set(bindings.map(binding => binding.storedId)).size).toBe(2)
  455. expect(store.get<any>(owner, 'context', firstTask.id)).toMatchObject({ conversationTaskId: firstTask.id, percent: 40 })
  456. expect(store.get<any>(owner, 'context', secondTask.id)).toMatchObject({ conversationTaskId: secondTask.id, percent: 40 })
  457. const firstSummary = store.require<any>(owner, 'conversation-task', firstTask.id)
  458. expect(firstSummary).toMatchObject({ title: '第一项工作', titleSource: 'automatic', messageCount: 2, unread: true, unreadCount: 1 })
  459. expect(store.require<any>(owner, 'conversation-task', secondTask.id)).toMatchObject({ title: '显式标题', titleSource: 'user', messageCount: 2 })
  460. expect(store.markTaskRead(owner, conversation.id, firstTask.id, firstSummary.lastSeq).unreadCount).toBe(0)
  461. store.deleteTask(owner, conversation.id, firstTask.id)
  462. expect(store.list<WorkspaceMessage>(owner, 'message').some(message => message.conversationTaskId === firstTask.id)).toBe(false)
  463. expect(store.list<any>(owner, 'binding').some(binding => binding.conversationTaskId === firstTask.id)).toBe(false)
  464. expect(store.get(owner, 'context', firstTask.id)).toBeUndefined()
  465. expect(store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id).map(message => message.content))
  466. .toEqual(['第二项工作', '第二项结果'])
  467. })
  468. it('admits ten active conversation tasks for one Agent and rejects the eleventh without stopping them', async () => {
  469. const administrator = agent('并发任务管理员'),
  470. member = agent('并发任务成员'),
  471. conversation = store.createGroup(owner, {
  472. name: '并发任务群聊',
  473. memberIds: [administrator.id, member.id],
  474. administratorId: administrator.id,
  475. }),
  476. tasks = [defaultTask(conversation.id), ...Array.from({ length: 10 }, (_, index) => store.createTask(owner, conversation.id, { title: `任务 ${index + 2}` }))]
  477. reply = () => {}
  478. for (const [index, task] of tasks.slice(0, 10).entries())
  479. runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: task.id, content: `执行 ${index + 1}` })
  480. expect(store.activeTaskCount(owner, conversation.id)).toBe(10)
  481. await vi.waitFor(() => expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(10))
  482. expect(new Set(requests.filter(r => r.method === 'prompt.submit').map(r => r.params.session_id)).size).toBe(10)
  483. expect(() => runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: tasks[10]!.id, content: '第十一项' }))
  484. .toThrow('最多同时运行 10 个任务')
  485. expect(requests.filter(r => r.method === 'session.interrupt')).toHaveLength(0)
  486. await Promise.all(tasks.slice(0, 10).map(task => runtime.stopTask(owner, conversation.id, task.id)))
  487. })
  488. it('keeps same-named upstream interactions scoped to their conversation task', async () => {
  489. const administrator = agent('交互隔离管理员'),
  490. member = agent('交互隔离成员'),
  491. conversation = store.createGroup(owner, {
  492. name: '交互隔离群聊',
  493. memberIds: [administrator.id, member.id],
  494. administratorId: administrator.id,
  495. }),
  496. firstTask = defaultTask(conversation.id),
  497. secondTask = store.createTask(owner, conversation.id, {})
  498. const pending: Array<{socket:WebSocket;params:Record<string,any>}> = []
  499. reply = (socket, params) => { pending.push({socket,params});setTimeout(() => socket.send(JSON.stringify({
  500. method: 'event',
  501. params: {
  502. type: 'approval.request',
  503. session_id: params.session_id,
  504. payload: { request_id: 'same-upstream-id', message: '允许执行吗?' },
  505. },
  506. })), 5) }
  507. const firstRun=runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
  508. // Different tasks admitted in the same millisecond need not win a lane in
  509. // insertion order. Establish the active task before testing its isolation.
  510. await vi.waitFor(() => expect(pending).toHaveLength(1))
  511. const secondRun=runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '任务乙' })
  512. await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction').filter(interaction => !interaction.resolved)).toHaveLength(2))
  513. const first=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>i.conversationTaskId===firstTask.id)!
  514. expect(store.require<any>(owner, 'conversation-task', firstTask.id).activeRunStatus).toBe('waiting')
  515. expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('waiting')
  516. await runtime.respond(owner, first.id, 'once')
  517. expect(requests.filter(r => r.method === 'approval.respond').map(r => r.params.session_id)).toEqual([pending[0]!.params.session_id])
  518. pending[0]!.socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:pending[0]!.params.session_id,payload:{text:'任务甲完成',status:'complete'}}}))
  519. await finished(firstRun.id)
  520. await vi.waitFor(()=>expect(store.list<WorkspaceInteraction>(owner,'interaction')).toHaveLength(2))
  521. const second=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>i.conversationTaskId===secondTask.id)!
  522. expect(second.id).not.toBe(first.id)
  523. expect(store.require<any>(owner, 'conversation-task', secondTask.id).activeRunStatus).toBe('waiting')
  524. await runtime.respond(owner,first.id,'once')
  525. expect(store.require<WorkspaceInteraction>(owner, 'interaction', first.id).resolved).toBe(true)
  526. expect(store.require<WorkspaceInteraction>(owner, 'interaction', second.id).resolved).toBe(false)
  527. await Promise.all([runtime.stopTask(owner, conversation.id, firstTask.id), runtime.stopTask(owner, conversation.id, secondTask.id)])
  528. })
  529. it('persists a visible failure when a fixed member loses its source node',async()=>{
  530. const a=agent('不可用成员'),c=direct(a.id)
  531. runtime.close()
  532. runtime=new WorkspaceRuntime(store,{requireSource: () => {},target:()=>{throw new Error('基础节点不可用')}} as unknown as WorkspaceNodes,uploads)
  533. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行任务'})
  534. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  535. expect(store.messages(owner,c.id).at(-1)).toMatchObject({role:'system',status:'failed',content:'执行失败:基础节点不可用'})
  536. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
  537. })
  538. it('interprets a reply against its launch-time roster when names or mode change mid-turn', async () => {
  539. const a = agent('管理员'),
  540. b = agent('执行者')
  541. const c = store.createGroup(owner, {
  542. name: '配置更新',
  543. memberIds: [a.id, b.id],
  544. administratorId: a.id,
  545. maxReplyRounds: 3,
  546. })
  547. let count = 0
  548. reply = (socket, p) => {
  549. let text = '已完成'
  550. if (count++ === 0) {
  551. store.updateAgent(owner, b.id, { name: '新名字', instructions: '新规则' })
  552. store.updateConversation(owner, c.id, { mode: 'free', administratorId: b.id })
  553. text = '请继续 @执行者'
  554. }
  555. setTimeout(
  556. () =>
  557. socket.send(
  558. JSON.stringify({
  559. method: 'event',
  560. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  561. }),
  562. ),
  563. 5,
  564. )
  565. }
  566. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' }).id)
  567. expect(
  568. store
  569. .messages(owner, c.id)
  570. .filter((m) => m.role === 'assistant')
  571. .map((m) => m.agentId),
  572. ).toEqual([a.id, b.id])
  573. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  574. '新规则',
  575. )
  576. })
  577. it('rejects unscoped and foreign-session completion events', async () => {
  578. const a = agent('会话隔离'),
  579. c = direct(a.id)
  580. reply = (socket, p) => {
  581. for (const session_id of [undefined, 'another-session'])
  582. socket.send(
  583. JSON.stringify({
  584. method: 'event',
  585. params: { type: 'message.complete', session_id, payload: { text: '别人的结果' } },
  586. }),
  587. )
  588. setTimeout(
  589. () =>
  590. socket.send(
  591. JSON.stringify({
  592. method: 'event',
  593. params: {
  594. type: 'message.complete',
  595. session_id: p.session_id,
  596. payload: { text: '本轮结果' },
  597. },
  598. }),
  599. ),
  600. 15,
  601. )
  602. }
  603. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '执行' })
  604. await finished(run.id)
  605. expect(store.messages(owner, c.id).at(-1)?.content).toBe('本轮结果')
  606. expect(requests.some(request => request.method === 'session.usage')).toBe(false)
  607. expect(store.list<any>(owner, 'agent-token-day')).toEqual([expect.objectContaining({agentId:a.id,output:4,total:expect.any(Number)})])
  608. })
  609. it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
  610. modelBridge()
  611. const a = agent('恢复测试'),
  612. c = direct(a.id)
  613. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
  614. reply = (socket, p) => {
  615. recoveryHistory = [
  616. { role: 'user', content: p.text },
  617. { role: 'assistant', content: '已经执行完成' },
  618. ]
  619. socket.terminate()
  620. }
  621. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改一次' })
  622. await vi.waitFor(() =>
  623. expect(store.require<WorkspaceRun>(owner, 'run', run.id).error).toContain('正在恢复原执行'),
  624. )
  625. runtime.close()
  626. const configured = requests.filter(r => r.method === 'config.set').length
  627. expect(configured).toBe(3)
  628. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-b', reasoningEffort: 'none', fastMode: 'cold' } })
  629. runtime = new WorkspaceRuntime(store, nodes, uploads)
  630. await runtime.reconcile(owner, run.id)
  631. await finished(run.id)
  632. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  633. expect(requests.filter(r => r.method === 'config.set')).toHaveLength(configured)
  634. expect(store.messages(owner, c.id).map((m) => m.content)).toEqual(['修改一次', '已经执行完成'])
  635. })
  636. it('routes approvals and clarifications to the owning run and ignores optional push failures', async () => {
  637. const a = agent('交互测试'),
  638. c = direct(a.id)
  639. runtime.onNotify = () => {
  640. throw new Error('push storage unavailable')
  641. }
  642. let kind = 'approval'
  643. server.on('connection', (socket) =>
  644. socket.on('message', (data) => {
  645. const f = JSON.parse(String(data))
  646. if (['approval.respond', 'clarify.respond'].includes(f.method))
  647. socket.send(
  648. JSON.stringify({
  649. method: 'event',
  650. params: {
  651. type: 'message.complete',
  652. session_id: f.params.session_id,
  653. payload: { text: '确认后完成' },
  654. },
  655. }),
  656. )
  657. }),
  658. )
  659. reply = (socket, p) =>
  660. socket.send(
  661. JSON.stringify({
  662. method: 'event',
  663. params: {
  664. type: `${kind}.request`,
  665. session_id: p.session_id,
  666. payload: { request_id: 'upstream-interaction', message: '请确认' },
  667. },
  668. }),
  669. )
  670. for (kind of ['approval', 'clarify']) {
  671. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续' })
  672. await vi.waitFor(() =>
  673. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting'),
  674. )
  675. const interaction = store
  676. .list<WorkspaceInteraction>(owner, 'interaction')
  677. .find((i) => !i.resolved)!
  678. await expect(runtime.respond(other, interaction.id, 'once')).rejects.toThrow('记录不存在')
  679. await runtime.respond(owner, interaction.id, kind === 'approval' ? 'once' : '补充说明')
  680. await finished(run.id)
  681. expect(
  682. store.require<WorkspaceInteraction>(owner, 'interaction', interaction.id).resolved,
  683. ).toBe(true)
  684. }
  685. expect(
  686. requests
  687. .filter((r) => r.method === 'approval.respond' || r.method === 'clarify.respond')
  688. .map((r) => r.params.request_id),
  689. ).toEqual(['upstream-interaction', 'upstream-interaction'])
  690. })
  691. it('creates one direct chat without importing a native profile session', () => {
  692. const a = agent('编辑')
  693. expect(direct(a.id).kind).toBe('direct')
  694. expect(store.list(other, 'agent')).toEqual([])
  695. expect(() => store.require(other, 'conversation', direct(a.id).id)).toThrow('记录不存在')
  696. expect(requests).toEqual([])
  697. })
  698. it('protects the current administrator and validates member ownership', () => {
  699. const a = agent('甲'),
  700. b = agent('乙'),
  701. foreign = agent('外部', other)
  702. const c = store.createGroup(owner, {
  703. name: '团队',
  704. memberIds: [a.id, b.id],
  705. administratorId: a.id,
  706. })
  707. expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id] })).toThrow('当前管理员不能移除')
  708. expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id], administratorId: b.id })).toThrow('当前管理员不能移除')
  709. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, foreign.id] })).toThrow('记录不存在')
  710. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, a.id] })).toThrow('群成员或管理员无效')
  711. expect(() =>
  712. store.createGroup(owner, {
  713. name: '越权',
  714. memberIds: [a.id, foreign.id],
  715. administratorId: a.id,
  716. }),
  717. ).toThrow()
  718. store.updateAgent(owner, b.id, { archived: true })
  719. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).memberIds).toEqual([
  720. a.id,
  721. b.id,
  722. ])
  723. expect(() =>
  724. store.createGroup(owner, { name: '新群', memberIds: [a.id, b.id], administratorId: a.id }),
  725. ).toThrow('已归档')
  726. expect(store.updateConversation(owner, c.id, { memberIds: [a.id] }).memberIds).toEqual([a.id])
  727. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, b.id] })).toThrow('已归档')
  728. })
  729. it('adds and removes members, cleans automatic replies, and permits removal after administrator transfer', async () => {
  730. const a = agent('管理员'), b = agent('旧成员'), added = agent('新成员')
  731. const c = store.createGroup(owner, { name: '成员维护', memberIds: [a.id, b.id], administratorId: a.id, autoReplyIds: [b.id] })
  732. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '保留这条历史' }).id)
  733. const before = store.require<WorkspaceConversation>(owner, 'conversation', c.id)
  734. const history = store.messages(owner, c.id)
  735. const changed = store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
  736. expect(changed.memberIds).toEqual([a.id, added.id])
  737. expect(changed.autoReplyIds).toEqual([])
  738. expect(changed.lastMessageAt).toBe(before.lastMessageAt)
  739. expect(store.messages(owner, c.id)).toEqual(history)
  740. expect(store.events(owner, 0).at(-1)).toMatchObject({ type: 'conversation.changed', data: { memberIds: [a.id, added.id] } })
  741. store.updateConversation(owner, c.id, { administratorId: added.id })
  742. expect(store.updateConversation(owner, c.id, { memberIds: [added.id] }).memberIds).toEqual([added.id])
  743. expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id] })).toThrow('当前管理员不能移除')
  744. })
  745. it('skips removed queued members and accepts a newly added member on the next request', async () => {
  746. const a = agent('管理员'), b = agent('待回复'), added = agent('新成员')
  747. const c = store.createGroup(owner, { name: '队列维护', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', autoReplyIds: [a.id, b.id] })
  748. reply = (socket, p) => {
  749. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: '请@待回复继续' } } }))
  750. }
  751. const queued = runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' })
  752. store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
  753. await finished(queued.id)
  754. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id])
  755. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '请@新成员继续' }).id)
  756. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id, added.id, a.id])
  757. })
  758. it('finishes an in-flight removed member reply without scheduling it again', async () => {
  759. const a = agent('管理员'), b = agent('正在回复')
  760. const c = store.createGroup(owner, { name: '回复中移除', memberIds: [a.id, b.id], administratorId: a.id })
  761. let turns = 0
  762. reply = (socket, p) => {
  763. if (turns++ === 1) store.updateConversation(owner, c.id, { memberIds: [a.id] })
  764. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: turns === 1 ? '请@正在回复继续' : '已完成' } } }))
  765. }
  766. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '@正在回复 开始' }).id)
  767. expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id, b.id, a.id])
  768. expect(() => runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续', mentionIds: [b.id] })).toThrow('只能 @ 群内成员')
  769. })
  770. it('dispatches a request exactly once across retries and rejects payload reuse', async () => {
  771. const a = agent('编辑'),
  772. c = direct(a.id),
  773. requestId = randomUUID(),
  774. payload = { requestId, content: '检查代码' }
  775. const run = runtime.send(owner, c.id, payload)
  776. expect(runtime.send(owner, c.id, payload).id).toBe(run.id)
  777. expect(() => runtime.send(owner, c.id, { ...payload, content: '另一件事' })).toThrow('请求编号')
  778. await finished(run.id)
  779. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  780. expect(store.messages(owner, c.id).map((m) => m.role)).toEqual(['user', 'assistant'])
  781. })
  782. it('keeps template responsibilities inside their group and follows Agent identity after rename', async () => {
  783. const a = agent('现有甲'), b = agent('现有乙')
  784. const before = store.require<WorkspaceAgent>(owner, 'agent', a.id)
  785. const c = store.createGroup(owner, { name: '模板群', memberIds: [a.id, b.id], administratorId: a.id, memberRoles: {
  786. [a.id]: { name: '调研负责人', description: '汇总可信结论' },
  787. [b.id]: { name: '事实核验', description: '交叉验证来源' },
  788. } })
  789. expect(store.require<WorkspaceAgent>(owner, 'agent', a.id)).toEqual(before)
  790. store.updateAgent(owner, a.id, { name: '改名后的甲' })
  791. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始调研' }).id)
  792. const groupPrompt = requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text
  793. expect(groupPrompt).toContain('@改名后的甲:调研负责人;汇总可信结论')
  794. expect(groupPrompt).toContain('@现有乙:事实核验;交叉验证来源')
  795. await finished(runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '单聊' }).id)
  796. expect(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text).not.toContain('汇总可信结论')
  797. const changed = store.updateConversation(owner, c.id, { memberIds: [a.id] })
  798. expect(changed.memberRoles).toEqual({ [a.id]: { name: '调研负责人', description: '汇总可信结论' } })
  799. expect(() => store.updateConversation(owner, c.id, { memberRoles: { [b.id]: { name: '已移除', description: '' } } })).toThrow('群成员或管理员无效')
  800. })
  801. it('applies self-edited names and rules on the next turn without changing a shared Hermes Profile', async () => {
  802. const a = agent('原来的自己'), b = agent('同 Profile 的同伴')
  803. const target = nodes.target(owner,'local'), request = target.session.request.bind(target.session)
  804. const bindings = new Map<string,any>()
  805. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
  806. if (!path.startsWith('/api/plugins/yaoyao-bot-bridge/')) return request(path,options)
  807. if (path.endsWith('/bind')) bindings.set(String((options?.body as any).session_id),options?.body)
  808. return {status:200,body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,in_process:true,native_tools:true})),headers:new Headers()}
  809. })
  810. let edited = false, toolError: unknown
  811. reply = async(socket,params)=>{
  812. if (!edited) {
  813. edited = true
  814. try {
  815. const binding = bindings.get(params.session_id)
  816. const toolRequest = async(path:string,body:unknown)=>(await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)})).json()
  817. const catalog = await toolRequest('/tools/list',{})
  818. const invoke = (name:string,args:unknown,callId:string)=>toolRequest('/tools/call',{toolId:catalog.tools.find((tool:any)=>tool.name===name).id,arguments:args,callId})
  819. const current = await invoke('workspace_get_self_rules',{},'read-self')
  820. const updated = await invoke('workspace_update_self_rules',{requestId:randomUUID(),expectedRevision:current.structuredContent.agent.revision,name:'自己的新名字',instructions:'后续回答先给依据',job:'独立核验事实'},'update-self')
  821. expect(updated.structuredContent.agent).toMatchObject({id:a.id,revision:2})
  822. expect((await toolRequest('/tools/list',{})).tools.length).toBeGreaterThan(0)
  823. } catch (error) { toolError = error }
  824. }
  825. socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:params.session_id,payload:{text:'完成',status:'complete'}}}))
  826. }
  827. await finished(runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'修改自己的名字和规则'}).id)
  828. expect(toolError).toBeUndefined()
  829. const initial = requests.find(frame=>frame.method==='prompt.submit')!.params.text
  830. expect(initial).toContain('你是 原来的自己')
  831. expect(initial).not.toContain('后续回答先给依据')
  832. await finished(runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'继续'}).id)
  833. const next = requests.filter(frame=>frame.method==='prompt.submit').at(-1)!.params.text
  834. expect(next).toContain('你是 自己的新名字')
  835. expect(next).toContain('后续回答先给依据')
  836. expect(next).toContain('独立核验事实')
  837. expect(store.require(owner,'agent',b.id)).toEqual(b)
  838. expect(requests.some(frame=>frame.method==='profiles.configure'||frame.method==='config.set')).toBe(false)
  839. })
  840. it('isolates roles over the same profile and applies edited rules on the next turn', async () => {
  841. const a = agent('编辑'),
  842. b = agent('审查'),
  843. ca = direct(a.id),
  844. cb = direct(b.id)
  845. const first = runtime.send(owner, ca.id, { requestId: randomUUID(), content: '编辑任务' })
  846. const second = runtime.send(owner, cb.id, { requestId: randomUUID(), content: '审查任务' })
  847. await Promise.all([finished(first.id), finished(second.id)])
  848. expect(requests.filter((r) => r.method === 'session.create')).toHaveLength(2)
  849. expect(store.messages(owner, ca.id).some((m) => m.content === '审查任务')).toBe(false)
  850. store.updateAgent(owner, a.id, { instructions: '最新规则:先给证据' })
  851. await finished(runtime.send(owner, ca.id, { requestId: randomUUID(), content: '继续' }).id)
  852. expect(requests.filter((r) => r.method === 'prompt.submit').at(-1)!.params.text).toContain(
  853. '最新规则:先给证据',
  854. )
  855. expect(requests.some((r) => r.method === 'profiles.configure')).toBe(false)
  856. expect(
  857. requests
  858. .filter((r) => r.method === 'session.create')
  859. .every((r) => r.params.hidden && r.params.source === 'yaoyao_workspace'),
  860. ).toBe(true)
  861. })
  862. it('executes bounded administrator delegation and keeps group sessions separate from direct chat', async () => {
  863. const a = agent('管理员'),
  864. b = agent('开发者'),
  865. c = store.createGroup(owner, {
  866. name: '开发',
  867. memberIds: [a.id, b.id],
  868. administratorId: a.id,
  869. maxReplyRounds: 3,
  870. })
  871. let turns = 0
  872. reply = (socket, p) => {
  873. const text = turns++ === 0 ? '我已整理需求,现在请@开发者继续实现。' : '任务完成'
  874. setTimeout(
  875. () =>
  876. socket.send(
  877. JSON.stringify({
  878. method: 'event',
  879. params: { type: 'message.complete', session_id: p.session_id, payload: { text } },
  880. }),
  881. ),
  882. 5,
  883. )
  884. }
  885. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '实现功能' }).id)
  886. expect(
  887. store
  888. .messages(owner, c.id)
  889. .filter((m) => m.role === 'assistant')
  890. .map((m) => m.agentId),
  891. ).toEqual([a.id, b.id, a.id])
  892. expect(store.messages(owner, direct(a.id).id)).toHaveLength(0)
  893. expect(turns).toBe(3)
  894. })
  895. it('does not trigger delegation from quoted text or code blocks', () => {
  896. const a = agent('甲'),
  897. b = agent('乙')
  898. expect(mentionedAgents('> @乙\n```\n@甲\n```\n`@all`', [a, b])).toEqual([])
  899. expect(mentionedAgents('请处理 @乙。', [a, b])).toEqual([b.id])
  900. })
  901. it('keeps uncertain submissions blocked instead of executing tools twice', async () => {
  902. const a = agent('执行者'),
  903. c = direct(a.id)
  904. reply = (socket) => socket.terminate()
  905. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '修改文件' })
  906. await vi.waitFor(() =>
  907. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
  908. )
  909. const queued = runtime.send(owner, c.id, { requestId: randomUUID(), content: '后续请求' })
  910. expect(queued.status).toBe('queued')
  911. expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
  912. await runtime.stopConversation(owner, c.id)
  913. expect(
  914. store.require<WorkspaceConversation>(owner, 'conversation', c.id).activeRunId,
  915. ).toBeUndefined()
  916. })
  917. it('publishes durable ordered events only after commit and rolls back failed transactions', () => {
  918. const a = agent('甲'),
  919. c = direct(a.id),
  920. cursor = store.cursor(owner)
  921. expect(() =>
  922. store.atomic(() => {
  923. store.event(owner, 'test', {})
  924. throw new Error('rollback')
  925. }),
  926. ).toThrow('rollback')
  927. expect(store.cursor(owner)).toBe(cursor)
  928. store.updateAgent(owner, a.id, { name: '乙' })
  929. expect(store.events(owner, cursor).map((e) => e.type)).toEqual([
  930. 'agent.changed',
  931. 'conversation.changed',
  932. ])
  933. expect(store.events(other, 0)).toEqual([])
  934. expect(store.require<WorkspaceConversation>(owner, 'conversation', c.id).name).toBe('乙')
  935. })
  936. })
  937. it('pins a direct chat without injecting or clearing optional profile fields', () => {
  938. const avatar = encodeAgentAvatar({...defaultAgentIdentity('reviewer'),shape:'triangle',color:'#00b9ac',expression:'curious'})
  939. const agent = store.createAgent(owner, { name: '审查员', profile: 'default', avatar })
  940. const chat = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  941. expect(store.updateConversation(owner, chat.id, { pinned: true }).pinned).toBe(true)
  942. expect(store.updateConversation(owner, chat.id, { pinned: false }).pinned).toBe(false)
  943. expect(store.require<WorkspaceConversation>(owner, 'conversation', chat.id).avatar).toBe(avatar)
  944. expect(store.updateAgent(owner, agent.id, { instructions: '请检查边界' }).avatar).toBe(avatar)
  945. expect(decodeAgentAvatar(store.updateAgent(owner, agent.id, { avatar: '' }).avatar)).toMatchObject({shape:'circle',color:'#00c875'})
  946. })
  947. it('accepts the existing cross-client mascot and team avatar formats', () => {
  948. const a = store.createAgent(owner, { name: '设计', profile: 'default', avatar: 'yaoyao-mascot:v1:square:377fe6:friendly' })
  949. const b = store.createAgent(owner, { name: '开发', profile: 'default' })
  950. const group = store.createGroup(owner, { name: '产品团队', memberIds: [a.id, b.id], administratorId: a.id, avatar: 'builtin:team-animal:fox' })
  951. expect(group.avatar).toBe('builtin:team-animal:fox')
  952. expect(() => store.updateAgent(owner, a.id, { avatar: 'javascript:alert(1)' })).toThrow()
  953. })
  954. it('assigns a random v2 avatar only when a new Agent omits its avatar', () => {
  955. const random = vi.spyOn(Math, 'random').mockReturnValue(0)
  956. const created = store.createAgent(owner, { name: '随机头像', profile: 'default' })
  957. expect(decodeAgentAvatar(created.avatar)).toMatchObject({ shape: 'circle', color: '#000000', expression: 'idle' })
  958. expect(created.avatar).not.toBe(encodeAgentAvatar(defaultAgentIdentity(created.id, created.name)))
  959. random.mockRestore()
  960. })
  961. it('keeps the last message time independent of pinning and metadata edits', () => {
  962. const agent=store.createAgent(owner,{name:'时间验收',profile:'default'})
  963. const c=store.list<WorkspaceConversation>(owner,'conversation')[0]!
  964. const message: WorkspaceMessage={id:randomUUID(),conversationId:c.id,seq:0,role:'user',content:'历史消息',reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()-600_000}
  965. store.saveMessage(owner,message)
  966. const pinned=store.updateConversation(owner,c.id,{pinned:true})
  967. expect(store.conversationSummary(owner,pinned).lastMessageAt).toBe(message.createdAt)
  968. store.updateAgent(owner,agent.id,{name:'改名之后'})
  969. expect(store.conversationSummary(owner,store.require(owner,'conversation',c.id)).lastMessageAt).toBe(message.createdAt)
  970. // Existing records without the summary field still use their transcript time.
  971. const previous={...pinned}; delete previous.lastMessageAt
  972. expect(store.conversationSummary(owner,previous).lastMessageAt).toBe(message.createdAt)
  973. })
  974. it('publishes only the executing member and clears avatar activity when stopped', async () => {
  975. const a=store.createAgent(owner,{name:'管理员头像',profile:'default'})
  976. const b=store.createAgent(owner,{name:'执行者头像',profile:'default'})
  977. const c=store.createGroup(owner,{name:'头像状态',memberIds:[a.id,b.id],administratorId:a.id})
  978. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'approval-avatar',message:'需要确认'}}}))
  979. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'处理任务',mentionIds:[b.id]})
  980. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('waiting'))
  981. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id)).toMatchObject({activeAgentId:a.id,activeRunStatus:'waiting'})
  982. await runtime.stop(owner,run.id)
  983. const stopped=store.require<WorkspaceConversation>(owner,'conversation',c.id)
  984. expect(stopped.activeAgentId).toBeUndefined()
  985. expect(stopped.activeRunStatus).toBeUndefined()
  986. })
  987. it('stores the expanded avatar shapes for cross-client role settings', () => {
  988. const agent=store.createAgent(owner,{name:'扩展头像验收',profile:'default'})
  989. for(const shape of ['ellipse','capsule','hexagon','cloud','droplet'] as const) {
  990. const avatar=encodeAgentAvatar({...defaultAgentIdentity('bot'),shape,color:'#f52ba5',expression:'curious'})
  991. expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
  992. }
  993. })
  994. it('automatically reconciles lost completion without submitting the prompt again', async () => {
  995. const a=agent('自动恢复'),c=direct(a.id)
  996. reply=(socket,p)=>{recoveryHistory=[{role:'user',content:p.text},{role:'assistant',content:'已完成一次'}];socket.terminate()}
  997. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行一次'})
  998. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete'),{timeout:5000})
  999. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1000. expect(store.messages(owner,c.id).at(-1)).toMatchObject({content:'已完成一次',status:'complete'})
  1001. })
  1002. it('reports a rejected prompt as failed instead of leaving it uncertain', async () => {
  1003. rejectPrompt='本轮请求被明确拒绝'
  1004. const a=agent('拒绝测试'),c=direct(a.id)
  1005. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行'})
  1006. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  1007. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
  1008. expect(store.messages(owner,c.id).some(m=>m.status==='uncertain')).toBe(false)
  1009. })
  1010. it('keeps an unconfirmed turn separate from a later reply and retries history without resubmitting', async () => {
  1011. const a = agent('恢复边界'), c = direct(a.id)
  1012. let prompt = ''
  1013. reply = (socket, p) => {
  1014. prompt = p.text
  1015. recoveryHistory = [{ role: 'user', content: prompt }, { role: 'user', content: '另一个请求' }, { role: 'assistant', content: '另一轮回复' }]
  1016. socket.terminate()
  1017. }
  1018. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '只执行一次' })
  1019. await vi.waitFor(() => expect(requests.filter(r => r.method === 'session.resume').length).toBeGreaterThanOrEqual(1), { timeout: 3000 })
  1020. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'), { timeout: 3000 })
  1021. expect(store.messages(owner, c.id).at(-1)?.content).not.toBe('另一轮回复')
  1022. recoveryHistory = [{ role: 'user', content: prompt }, { role: 'assistant', content: '本轮结果' }]
  1023. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete'), { timeout: 4000 })
  1024. expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  1025. expect(store.messages(owner, c.id).at(-1)).toMatchObject({ content: '本轮结果', status: 'complete' })
  1026. })
  1027. it('uses one generated avatar for the agent and its direct conversation',()=>{
  1028. const a=agent('默认头像'),c=direct(a.id)
  1029. expect(decodeAgentAvatar(a.avatar)).toMatchObject({version:2,avatarMode:'mascot'})
  1030. expect(store.agentSummary(a).avatar).toBe(store.conversationSummary(owner,c).avatar)
  1031. const changed=store.updateAgent(owner,a.id,{name:'换个名称'})
  1032. expect(store.agentSummary(changed).avatar).toBe(store.agentSummary(a).avatar)
  1033. })
  1034. it('recognizes mentions inside Chinese sentences and ignores quoted code and emails',()=>{
  1035. const a=agent('瑶儿'),b=agent('竹儿'),c=agent('瑶儿助手'),d=agent('Ann')
  1036. const roster=[a,b,c,d]
  1037. expect(mentionedAgents('先整理,再请@瑶儿帮忙,最后由(@竹儿)复核。',roster)).toEqual([a.id,b.id])
  1038. expect(mentionedAgents('请**@瑶儿助手**继续',roster)).toEqual([c.id])
  1039. expect(mentionedAgents('mail@Ann.com https://site/@竹儿 `@瑶儿` <quoted_message>@瑶儿</quoted_message>',roster)).toEqual([])
  1040. expect(mentionedAgents('@Anna',roster)).toEqual([])
  1041. })
  1042. function completeReply(socket: WebSocket, p: Record<string, any>, text: string, status = 'complete') {
  1043. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text, status, ...(status === 'failed' ? { error: text } : {}) } } }))
  1044. }
  1045. const speaker = (p: Record<string, any>) => /你是 (.*?)。/.exec(p.text)?.[1]
  1046. describe('automatic relay termination', () => {
  1047. const receipt = '收到 @审核 终审维持通过。\n\n交付状态(不变):入草稿箱 Media ID `draft-123`,发布目录 `/work/final/`。\n等民哥群发指令。'
  1048. const review = '@竹儿 维持通过,等民哥群发指令。无新事实、无新派工,本轮不重复核验。'
  1049. const promptCount = () => requests.filter(r => r.method === 'prompt.submit').length
  1050. async function idle(runId: string, conversationId: string) {
  1051. await finished(runId)
  1052. const count = promptCount()
  1053. await new Promise(resolve => setTimeout(resolve, 80))
  1054. expect(promptCount()).toBe(count)
  1055. expect(store.list<Work>(owner, 'turn').filter(w => w.runId === runId && (!w.planned || !['complete', 'failed', 'interrupted'].includes(w.status)))).toEqual([])
  1056. const summary = store.conversationSummary(owner, store.require(owner, 'conversation', conversationId))
  1057. expect(summary.activeRunId).toBeUndefined()
  1058. expect(summary.queuedMessageCount ?? 0).toBe(0)
  1059. }
  1060. it('finishes the observed host receipt cycle in exactly three calls and accepts a new user message', async () => {
  1061. const a = agent('竹儿'), b = agent('审核')
  1062. const g = store.createGroup(owner, { name: '收尾验收', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
  1063. let admin = 0
  1064. reply = (socket, p) => completeReply(socket, p, speaker(p) === a.name ? admin++ === 0 ? '@审核 请审核文案' : receipt : review)
  1065. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '请完成文案' })
  1066. await idle(root.id, g.id)
  1067. expect(promptCount()).toBe(3)
  1068. expect(store.messages(owner, g.id).map(m => m.content)).toEqual(['请完成文案', '@审核 请审核文案', review, receipt])
  1069. expect(store.list<Work>(owner, 'turn').find(w => w.reviewOf)?.relay?.suppressed[b.id]).toBe('acknowledgement')
  1070. const next = runtime.send(owner, g.id, { requestId: randomUUID(), content: '好的。' })
  1071. await idle(next.id, g.id)
  1072. expect(promptCount()).toBe(4)
  1073. })
  1074. it.each(['感谢 @审核,等待用户指令。', '收到,等待用户指令。'])('does not turn a free-mode receipt into an automatic broadcast: %s', content => {
  1075. const a = agent('竹儿'), b = agent('审核')
  1076. const g = store.createGroup(owner, { name: '自动收尾', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', autoReplyIds: [b.id], maxReplyRounds: -1 })
  1077. reply = (socket, p) => completeReply(socket, p, content)
  1078. return (async () => {
  1079. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@竹儿 请处理' })
  1080. await idle(root.id, g.id)
  1081. expect(promptCount()).toBe(1)
  1082. const next = runtime.send(owner, g.id, { requestId: randomUUID(), content: '感谢 @审核' })
  1083. await idle(next.id, g.id)
  1084. expect(speaker(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params)).toBe(b.name)
  1085. })()
  1086. })
  1087. it.each([
  1088. ['host', 2, 5], ['host', 3, 7], ['free', 2, 4], ['free', 3, 5],
  1089. ] as const)('bounds unchanged %s cooperation with %i members at %i calls', async (mode, size, expected) => {
  1090. const members = [agent('甲'), agent('乙'), ...(size === 3 ? [agent('丙')] : [])]
  1091. const g = store.createGroup(owner, { name: '重复往返', memberIds: members.map(a => a.id), administratorId: members[0]!.id, mode, maxReplyRounds: -1 })
  1092. let count = 0
  1093. reply = (socket, p) => {
  1094. const index = members.findIndex(a => a.name === speaker(p)), whitespace = count++ % 2 ? ' ' : ' '
  1095. const text = mode === 'host' ? index === 0 ? members.slice(1).map(a => `@${a.name}${whitespace}再检查一次`).join(',') : '检查结果相同'
  1096. : `@${members[(index + 1) % members.length]!.name}${whitespace}再检查一次`
  1097. completeReply(socket, p, count > 20 ? '完成' : text)
  1098. completeReply(socket, p, count > 20 ? '完成' : text) // Duplicate completion must not double-plan.
  1099. }
  1100. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 开始' })
  1101. await idle(root.id, g.id)
  1102. expect(count).toBe(expected)
  1103. expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
  1104. expect(store.list<Work>(owner, 'turn').some(w => Object.values(w.relay?.suppressed ?? {}).includes('repeated'))).toBe(true)
  1105. })
  1106. it.each(['result', 'tool', 'attachment', 'interaction'] as const)('allows repeated instructions with new %s evidence beyond thirteen calls', async kind => {
  1107. const a = agent('负责人'), b = agent('审核')
  1108. const g = store.createGroup(owner, { name: '持续推进', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
  1109. let admin = 0, worker = 0
  1110. reply = (socket, p) => {
  1111. if (speaker(p) === a.name) completeReply(socket, p, admin++ < 7 ? '感谢 @审核,请重新检查第二段' : '完成')
  1112. else { worker++; completeReply(socket, p, kind === 'result' ? `第 ${worker} 版结果` : '本次检查结果') }
  1113. }
  1114. runtime.onMessage = async (_owner, message) => {
  1115. if (message.agentId !== b.id) return
  1116. if (kind === 'tool') message.tools = [{ id: randomUUID(), name: 'verify', status: 'tool.complete', result: { revision: worker } }]
  1117. if (kind === 'attachment') message.attachments = [{ id: randomUUID(), name: 'revision.txt', mimeType: 'text/plain', size: 10, createdAt: Date.now() }]
  1118. if (kind === 'interaction') {
  1119. const id = randomUUID()
  1120. store.put(owner, 'interaction', id, { id, runId: message.runId, conversationId: g.id, conversationTaskId: message.conversationTaskId, agentId: b.id, kind: 'clarification', message: '继续重试?', answer: '继续', responseState: 'sent', resolved: true })
  1121. store.put(owner, 'interaction-binding', id, { taskId: message.taskId })
  1122. }
  1123. store.saveMessage(owner, message)
  1124. }
  1125. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '反复改进' })
  1126. await idle(root.id, g.id)
  1127. expect(promptCount()).toBe(15)
  1128. expect(store.messages(owner, g.id).some(m => m.content === REPEATED_RELAY_NOTICE)).toBe(false)
  1129. })
  1130. it('ends only the repeating free-mode branch while an independent member finishes', async () => {
  1131. const a = agent('甲'), b = agent('乙'), c = agent('独立成员')
  1132. const g = store.createGroup(owner, { name: '分支隔离', memberIds: [a.id, b.id, c.id], administratorId: a.id, mode: 'free', maxReplyRounds: -1 })
  1133. let pending: { socket: WebSocket; p: Record<string, any> } | undefined, loops = 0
  1134. reply = (socket, p) => {
  1135. if (speaker(p) === c.name) { pending = { socket, p }; return }
  1136. completeReply(socket, p, ++loops > 15 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : '@甲 再检查')
  1137. }
  1138. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 @独立成员 请分别处理' })
  1139. await vi.waitFor(() => expect(store.messages(owner, g.id).some(m => m.content === REPEATED_RELAY_NOTICE)).toBe(true))
  1140. expect(store.require<WorkspaceRun>(owner, 'run', root.id).status).toBe('running')
  1141. expect(store.list<Work>(owner, 'turn').filter(w => w.cancelRequested)).toEqual([])
  1142. expect(loops).toBe(4)
  1143. completeReply(pending!.socket, pending!.p, '完成')
  1144. await idle(root.id, g.id)
  1145. expect(store.messages(owner, g.id).find(m => m.agentId === c.id)).toMatchObject({ content: '完成', status: 'complete' })
  1146. })
  1147. it('checks each target independently when a new automatic member joins the same repeated reply', async () => {
  1148. const a = agent('甲'), b = agent('乙'), c = agent('丙')
  1149. const g = store.createGroup(owner, { name: '目标隔离', memberIds: [a.id, b.id, c.id], administratorId: a.id, mode: 'free', autoReplyIds: [a.id], maxReplyRounds: -1 })
  1150. let calls = 0, admin = 0
  1151. reply = (socket, p) => { calls++; completeReply(socket, p, calls > 15 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : speaker(p) === b.name ? '继续检查' : '完成') }
  1152. runtime.onMessage = async (_owner, message) => {
  1153. if (message.agentId === b.id && ++admin === 2) store.updateConversation(owner, g.id, { autoReplyIds: [a.id, c.id] })
  1154. }
  1155. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 开始' })
  1156. await idle(root.id, g.id)
  1157. expect(calls).toBe(5)
  1158. const blocked = store.list<Work>(owner, 'turn').find(w => w.relay?.suppressed[a.id] === 'repeated')!
  1159. expect(blocked.relay?.suppressed[c.id]).toBeUndefined()
  1160. expect(store.list<Work>(owner, 'turn').find(w => w.batchId === blocked.id && w.agentId === c.id)?.status).toBe('complete')
  1161. })
  1162. it('starts fresh for identical new requests in the same task and a different task', async () => {
  1163. const a = agent('甲'), b = agent('乙')
  1164. const g = store.createGroup(owner, { name: '任务隔离', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', maxReplyRounds: -1 })
  1165. let calls = 0
  1166. reply = (socket, p) => { calls++; completeReply(socket, p, calls > 20 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : '@甲 再检查') }
  1167. const originalTask = defaultTask(g.id), secondTask = store.createTask(owner, g.id, { title: '另一个任务' })
  1168. for (const [index, task] of [originalTask, originalTask, secondTask].entries()) {
  1169. const root = runtime.send(owner, g.id, { requestId: randomUUID(), taskId: task.id, content: '@甲 开始' })
  1170. await idle(root.id, g.id)
  1171. expect(calls).toBe((index + 1) * 4)
  1172. expect(store.messages(owner, g.id, Number.MAX_SAFE_INTEGER, 100, false, task.id).filter(m => m.runId === root.id && m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
  1173. }
  1174. })
  1175. it.each([false, true])('recovers repeat suppression across restart before planning (legacy=%s)', async legacy => {
  1176. const a = agent('负责人'), b = agent('成员')
  1177. const g = store.createGroup(owner, { name: '循环恢复', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
  1178. let calls = 0
  1179. reply = (socket, p) => { calls++; completeReply(socket, p, calls > 15 ? '完成' : speaker(p) === a.name ? '@成员 再检查' : '结果相同') }
  1180. runtime.onMessage = async () => { if (calls === 5) runtime.close() }
  1181. const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '开始' })
  1182. await vi.waitFor(() => expect(store.list<Work>(owner, 'turn').filter(w => w.status === 'complete')).toHaveLength(5))
  1183. if (legacy) for (const work of store.list<Work>(owner, 'turn')) { delete work.relay; store.put(owner, 'turn', work.id, work) }
  1184. runtime = new WorkspaceRuntime(store, nodes, uploads); runtime.start()
  1185. await idle(root.id, g.id)
  1186. expect(calls).toBe(5)
  1187. expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
  1188. runtime.close(); runtime = new WorkspaceRuntime(store, nodes, uploads); runtime.start()
  1189. await idle(root.id, g.id)
  1190. expect(calls).toBe(5)
  1191. expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
  1192. })
  1193. })
  1194. it('starts with the administrator, executes a batch concurrently, and reviews failures once without spending a round', async () => {
  1195. const a = agent('管理员'), b = agent('成员乙'), c = agent('成员丙')
  1196. const group = store.createGroup(owner, { name: '并行验收', memberIds: [a.id,b.id,c.id], administratorId: a.id, maxReplyRounds: 2 })
  1197. const pending = new Map<string, { socket: WebSocket; p: Record<string, any> }>()
  1198. let admin = 0, review = ''
  1199. reply = (socket,p) => {
  1200. if (speaker(p) === a.name) { if (admin++ === 0) completeReply(socket,p,'请@成员乙检查接口,@成员丙检查数据'); else { review=p.text; completeReply(socket,p,'汇总完成') } }
  1201. else pending.set(speaker(p)!, {socket,p})
  1202. }
  1203. const root = runtime.send(owner,group.id,{requestId:randomUUID(),content:'@成员乙 请处理'})
  1204. await vi.waitFor(()=>expect(pending.size).toBe(2))
  1205. expect(admin).toBe(1)
  1206. expect(store.require<WorkspaceConversation>(owner,'conversation',group.id).activeAgentStates).toEqual({[b.id]:'running',[c.id]:'running'})
  1207. completeReply(pending.get(c.name)!.socket,pending.get(c.name)!.p,'数据检查失败','failed')
  1208. await vi.waitFor(()=>expect(store.messages(owner,group.id).some(m=>m.status==='failed')).toBe(true))
  1209. expect(admin).toBe(1)
  1210. completeReply(pending.get(b.name)!.socket,pending.get(b.name)!.p,'接口正常')
  1211. await finished(root.id)
  1212. expect(admin).toBe(2)
  1213. expect(review).toContain('数据检查失败')
  1214. expect(review).toContain('接口正常')
  1215. expect(store.require<WorkspaceRun>(owner,'run',root.id).round).toBe(1)
  1216. })
  1217. it('allows successive dependent delegations and does not charge administrator reviews against the limit', async () => {
  1218. const a=agent('负责人'),b=agent('先做'),c=agent('后做')
  1219. const g=store.createGroup(owner,{name:'串行依赖',memberIds:[a.id,b.id,c.id],administratorId:a.id,maxReplyRounds:3})
  1220. const order:string[]=[];let admin=0
  1221. reply=(socket,p)=>{const name=speaker(p)!;order.push(name);completeReply(socket,p,name===a.name ? ['@先做 第一步','@后做 第二步','完成'][admin++]! : '结果已提交')}
  1222. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'执行'}).id)
  1223. expect(order).toEqual([a.name,b.name,a.name,c.name,a.name])
  1224. })
  1225. it('continues free discussion automatically and hides irrelevant automatic replies', async () => {
  1226. const a=agent('公开管理员'),b=agent('自动成员')
  1227. const g=store.createGroup(owner,{name:'自由讨论',memberIds:[a.id,b.id],administratorId:a.id,mode:'free',autoReplyIds:[b.id],maxReplyRounds:3})
  1228. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name?'我来回答':'[[YAOYAO_NO_REPLY_V1]]')
  1229. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'一个问题'})
  1230. await finished(root.id)
  1231. expect(requests.filter(r=>r.method==='prompt.submit'&&speaker(r.params)===b.name)).toHaveLength(2)
  1232. expect(store.messages(owner,g.id).map(m=>m.content)).toEqual(['一个问题','我来回答'])
  1233. expect(store.conversationSummary(owner,store.require(owner,'conversation',g.id))).toMatchObject({unread:true, unreadCount:1})
  1234. expect(store.hiddenMessageIds(owner,g.id)).toHaveLength(2)
  1235. })
  1236. it('requires a public administrator reply when the model tries to remain silent', async () => {
  1237. const a=agent('必须答复'),b=agent('旁观者'),g=store.createGroup(owner,{name:'公开答复',memberIds:[a.id,b.id],administratorId:a.id})
  1238. reply=(socket,p)=>completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  1239. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'帮助我'}).id)
  1240. const answer=store.messages(owner,g.id).at(-1)!
  1241. expect(answer.content).toContain('请补充具体目标')
  1242. expect(answer.content).not.toContain('YAOYAO_')
  1243. })
  1244. it('queues later user requests durably without leaking them into the preceding collaboration', async () => {
  1245. const a=agent('排队管理员'),b=agent('排队执行者'),g=store.createGroup(owner,{name:'消息排队',memberIds:[a.id,b.id],administratorId:a.id})
  1246. let first: {socket:WebSocket;p:Record<string,any>}|undefined
  1247. let admin=0;const prompts:string[]=[]
  1248. reply=(socket,p)=>{prompts.push(p.text);if(speaker(p)===a.name&&admin++===0)first={socket,p};else completeReply(socket,p,'已完成')}
  1249. const one=runtime.send(owner,g.id,{requestId:randomUUID(),content:'第一条任务'})
  1250. await vi.waitFor(()=>expect(first).toBeDefined())
  1251. const two=runtime.send(owner,g.id,{requestId:randomUUID(),content:'之后才处理的秘密标记'})
  1252. expect(two.status).toBe('queued')
  1253. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeRunId).toBe(one.id)
  1254. completeReply(first!.socket,first!.p,'请@排队执行者继续')
  1255. await finished(one.id);await finished(two.id)
  1256. expect(prompts).toHaveLength(4)
  1257. expect(prompts.slice(0,3).every(p=>!p.includes('之后才处理的秘密标记'))).toBe(true)
  1258. expect(prompts[3]).toContain('之后才处理的秘密标记')
  1259. })
  1260. it('stops one member while keeping its sibling running and returns the interrupted result for review', async () => {
  1261. const a=agent('停止管理员'),b=agent('停止乙'),c=agent('继续丙'),g=store.createGroup(owner,{name:'独立停止',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  1262. const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>();let admin=0,review=''
  1263. 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})}
  1264. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  1265. await vi.waitFor(()=>expect(pending.size).toBe(2))
  1266. await runtime.stopAgent(owner,g.id,b.id)
  1267. expect(admin).toBe(1)
  1268. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeAgentStates?.[c.id]).toBe('running')
  1269. completeReply(pending.get(c.name)!.socket,pending.get(c.name)!.p,'丙完成')
  1270. await finished(root.id)
  1271. expect(review).toContain('interrupted')
  1272. expect(review).toContain('丙完成')
  1273. })
  1274. it('recovers a partially completed parallel batch after restart without resubmitting members or duplicating review', async () => {
  1275. const a=agent('恢复管理员'),b=agent('恢复乙'),c=agent('恢复丙'),g=store.createGroup(owner,{name:'批次恢复',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  1276. let admin=0;const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>()
  1277. reply=(socket,p)=>{if(speaker(p)===a.name)completeReply(socket,p,admin++===0?'@恢复乙 @恢复丙':'复核完成');else pending.set(speaker(p)!,{socket,p})}
  1278. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'并行执行'})
  1279. await vi.waitFor(()=>expect(pending.size).toBe(2))
  1280. completeReply(pending.get(b.name)!.socket,pending.get(b.name)!.p,'乙已完成')
  1281. const last=pending.get(c.name)!
  1282. recoveryByStored.set(storedByRuntime.get(last.p.session_id)!,[{role:'user',content:last.p.text},{role:'assistant',content:'丙已完成'}])
  1283. last.socket.terminate()
  1284. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).error).toContain('正在恢复原执行'))
  1285. runtime.close();runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  1286. await finished(root.id)
  1287. expect(admin).toBe(2)
  1288. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(4)
  1289. expect(store.list<any>(owner,'turn').filter(t=>t.reviewOf)).toHaveLength(1)
  1290. })
  1291. it('persists the next delegation when restarted between reply completion and cascade planning', async () => {
  1292. const a=agent('记录管理员'),b=agent('记录成员'),g=store.createGroup(owner,{name:'计划恢复',memberIds:[a.id,b.id],administratorId:a.id})
  1293. let admin=0
  1294. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name&&admin++===0?'@记录成员 执行':'完成')
  1295. runtime.onMessage=async()=>{runtime.close()}
  1296. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'执行一次'})
  1297. await vi.waitFor(()=>expect(store.list<any>(owner,'turn')[0]?.status).toBe('complete'))
  1298. runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  1299. await finished(root.id)
  1300. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  1301. })
  1302. it('bounds long context while preserving the task beginning and ending', async () => {
  1303. const a=agent('长上下文'),b=agent('辅助'),g=store.createGroup(owner,{name:'上下文边界',memberIds:[a.id,b.id],administratorId:a.id})
  1304. const text='任务开始标记'+ '文'.repeat(60_000)+'任务结束标记'
  1305. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:text}).id)
  1306. const prompt=requests.find(r=>r.method==='prompt.submit')!.params.text
  1307. expect(prompt).toContain('任务开始标记');expect(prompt).toContain('任务结束标记')
  1308. expect(prompt).toContain('保留首尾片段');expect(prompt.length).toBeLessThan(32_000)
  1309. })
  1310. it('serializes a session and stops only that task while the same Agent keeps replying elsewhere', async () => {
  1311. const a=agent('同一机器人'), b=agent('旁观成员')
  1312. const c=store.createGroup(owner,{name:'会话隔离',memberIds:[a.id,b.id],administratorId:a.id})
  1313. const first=defaultTask(c.id), second=store.createTask(owner,c.id,{})
  1314. const pending:Array<{socket:WebSocket;p:Record<string,any>}>=[]
  1315. reply=(socket,p)=>pending.push({socket,p})
  1316. const one=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:first.id,content:'第一话题开始'})
  1317. await vi.waitFor(()=>expect(pending).toHaveLength(1))
  1318. const queued=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:first.id,content:'第一话题追问'})
  1319. const other=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:second.id,content:'第二话题独立执行'})
  1320. await vi.waitFor(()=>expect(pending).toHaveLength(2))
  1321. expect(store.require<WorkspaceRun>(owner,'run',queued.id).status).toBe('queued')
  1322. completeReply(pending[0]!.socket,pending[0]!.p,'第一话题首轮结果')
  1323. await finished(one.id)
  1324. await vi.waitFor(()=>expect(pending).toHaveLength(3))
  1325. const firstBinding=store.get<any>(owner,'binding',`${c.id}:${first.id}:${a.id}`)!
  1326. expect(requests.filter(r=>r.method==='session.resume').map(r=>r.params.session_id)).toEqual([firstBinding.storedId])
  1327. expect(pending[2]!.p.text).not.toContain('第二话题独立执行')
  1328. await runtime.stopTask(owner,c.id,first.id)
  1329. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='session.interrupt').map(r=>r.params.session_id)).toEqual([pending[2]!.p.session_id]))
  1330. expect(store.require<WorkspaceRun>(owner,'run',other.id).status).toBe('running')
  1331. expect(store.require<any>(owner,'conversation-task',second.id).activeRunStatus).toBe('running')
  1332. completeReply(pending[1]!.socket,pending[1]!.p,'第二话题独立完成')
  1333. await finished(other.id)
  1334. expect(store.messages(owner,c.id,Number.MAX_SAFE_INTEGER,100,false,second.id).map(m=>m.content)).toEqual(['第二话题独立执行','第二话题独立完成'])
  1335. expect(store.require<WorkspaceRun>(owner,'run',queued.id).status).toBe('interrupted')
  1336. })
  1337. it('recovers simultaneous sessions for one Agent after restart without replaying either prompt', async () => {
  1338. const a=agent('恢复机器人'),b=agent('恢复旁观者')
  1339. const c=store.createGroup(owner,{name:'并发恢复',memberIds:[a.id,b.id],administratorId:a.id})
  1340. const tasks=[defaultTask(c.id),store.createTask(owner,c.id,{})]
  1341. reply=(_socket,p)=>recoveryByStored.set(storedByRuntime.get(p.session_id)!,[
  1342. {role:'user',content:p.text},{role:'assistant',content:p.text.includes('恢复甲')?'甲的最终结果':'乙的最终结果'},
  1343. ])
  1344. const runs=tasks.map((task,i)=>runtime.send(owner,c.id,{requestId:randomUUID(),taskId:task.id,content:i===0?'恢复甲':'恢复乙'}))
  1345. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2))
  1346. runtime.close();runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  1347. await Promise.all(runs.map(r=>finished(r.id)))
  1348. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2)
  1349. expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
  1350. expect(tasks.map(task=>store.messages(owner,c.id,Number.MAX_SAFE_INTEGER,100,false,task.id).at(-1)?.content)).toEqual(['甲的最终结果','乙的最终结果'])
  1351. })
  1352. it('enforces three turns per topic and ten globally while preserving queued work', async () => {
  1353. const agents=Array.from({length:12},(_,i)=>agent(`并发成员${i}`))
  1354. 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})
  1355. const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>()
  1356. reply=(socket,p)=>pending.set(speaker(p)!,{socket,p})
  1357. const one=runtime.send(owner,group.id,{requestId:randomUUID(),content:'并发测试'})
  1358. await vi.waitFor(()=>expect(pending.size).toBe(3))
  1359. const others=agents.slice(4).map(a=>runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'另一群'}))
  1360. await vi.waitFor(()=>expect(pending.size).toBe(10))
  1361. expect(Object.keys(store.require<WorkspaceConversation>(owner,'conversation',group.id).activeAgentStates!)).toHaveLength(3)
  1362. expect(store.list<any>(owner,'turn').filter(t=>t.status==='running')).toHaveLength(10)
  1363. expect(store.list<any>(owner,'turn').filter(t=>t.status==='queued')).toHaveLength(2)
  1364. expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
  1365. const released=[...pending.values()]
  1366. for(const v of released) completeReply(v.socket,v.p,'完成')
  1367. await vi.waitFor(()=>expect(pending.size).toBe(12))
  1368. expect(store.list<any>(owner,'turn').filter(t=>t.status==='running').length).toBeLessThanOrEqual(10)
  1369. for(const v of pending.values()) if(!released.includes(v)) completeReply(v.socket,v.p,'完成')
  1370. await finished(one.id);await Promise.all(others.map(r=>finished(r.id)))
  1371. })
  1372. it('supports unlimited rounds beyond the finite limit and still permits stopping', async () => {
  1373. 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})
  1374. let count=0
  1375. reply=(socket,p)=>{
  1376. 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,'继续讨论')}
  1377. else completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  1378. }
  1379. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'@不限乙 开始'})
  1380. await finished(root.id)
  1381. expect(count).toBe(2)
  1382. expect(store.require<WorkspaceRun>(owner,'run',root.id).round).toBe(101)
  1383. expect(()=>store.updateConversation(owner,g.id,{maxReplyRounds:0})).toThrow()
  1384. })
  1385. it('applies one global approval policy to all Bots, ignoring legacy overrides and duplicate requests', async () => {
  1386. const bots=[agent('旧询问'),agent('旧允许'),agent('旧拒绝')]
  1387. bots.forEach((bot,index)=>store.put(owner,'agent',bot.id,{...bot,approvalPolicy:['ask','allow','deny'][index]}))
  1388. server.on('connection',socket=>socket.on('message',raw=>{const frame=JSON.parse(String(raw));if(frame.method==='approval.respond')completeReply(socket,frame.params,'审批已处理')}))
  1389. reply=(socket,params)=>{const frame=JSON.stringify({method:'event',params:{type:'approval.request',session_id:params.session_id,payload:{request_id:'same-id',message:'是否执行'}}});socket.send(frame);socket.send(frame)}
  1390. for(const policy of ['allow','deny'] as const){
  1391. saveHostTools(home,{approvalPolicy:policy})
  1392. const runs=bots.map(bot=>runtime.send(owner,direct(bot.id).id,{requestId:randomUUID(),content:'执行任务'}))
  1393. await Promise.all(runs.map(run=>finished(run.id)))
  1394. expect(requests.filter(f=>f.method==='approval.respond').slice(-3).map(f=>f.params.choice)).toEqual(Array(3).fill(policy==='allow'?'once':'deny'))
  1395. }
  1396. expect(requests.filter(f=>f.method==='approval.respond')).toHaveLength(6)
  1397. })
  1398. it('treats a legacy auto reply as one approval without altering global or other Bot policy', async () => {
  1399. const bot = agent('选择自动允许'), peer = agent('同一基础机器人')
  1400. reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'first-approval', message: '生成图片' } } }))
  1401. server.on('connection', socket => socket.on('message', raw => {
  1402. const frame = JSON.parse(String(raw))
  1403. if (frame.method !== 'approval.respond') return
  1404. if (frame.params.request_id === 'first-approval') socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: frame.params.session_id, payload: { request_id: 'next-approval', message: '保存图片' } } }))
  1405. else completeReply(socket, frame.params, '自动完成')
  1406. }))
  1407. const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '生成图片' })
  1408. await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction')).toHaveLength(1))
  1409. const request = store.list<WorkspaceInteraction>(owner, 'interaction')[0]!
  1410. await expect(runtime.respond(other, request.id, 'auto')).rejects.toThrow('记录不存在')
  1411. expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('ask')
  1412. await runtime.respond(owner, request.id, 'auto')
  1413. await vi.waitFor(()=>expect(store.list<WorkspaceInteraction>(owner,'interaction').filter(i=>!i.resolved)).toHaveLength(1))
  1414. const next=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>!i.resolved)!
  1415. expect(readHostTools(home).approvalPolicy).toBe('ask')
  1416. await runtime.respond(owner,next.id,'once')
  1417. await finished(run.id)
  1418. expect(requests.filter(frame => frame.method === 'approval.respond').map(frame => frame.params.choice)).toEqual(['once', 'once'])
  1419. expect(store.agentSummary(store.require<any>(owner, 'agent', bot.id)).approvalPolicy).toBe('ask')
  1420. expect(store.require<any>(owner, 'agent', peer.id).approvalPolicy).toBe('ask')
  1421. store.updateAgent(owner, bot.id, { approvalPolicy: 'ask' })
  1422. await runtime.respond(owner, request.id, 'auto')
  1423. expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('ask')
  1424. expect(requests.filter(frame => frame.method === 'approval.respond')).toHaveLength(2)
  1425. })
  1426. it('preserves current team grants for approval-only edits without reviving revoked grants', () => {
  1427. const bot = agent('有效工具授权')
  1428. store.put(owner, 'turn', 'valid-grant', { id: 'valid-grant', agentId: bot.id, status: 'waiting', teamManagementRevision: bot.revision })
  1429. store.put(owner, 'turn', 'revoked-grant', { id: 'revoked-grant', agentId: bot.id, status: 'running', teamManagementRevision: bot.revision - 1 })
  1430. const updated = store.updateAgent(owner, bot.id, { approvalPolicy: 'allow' })
  1431. expect(store.require<any>(owner, 'turn', 'valid-grant').teamManagementRevision).toBe(updated.revision)
  1432. expect(store.require<any>(owner, 'turn', 'revoked-grant').teamManagementRevision).toBe(bot.revision - 1)
  1433. store.updateAgent(owner, bot.id, { instructions: '新规则' })
  1434. expect(store.require<any>(owner, 'turn', 'valid-grant').teamManagementRevision).toBe(updated.revision)
  1435. })
  1436. it('keeps an explicit automatic preference but reports an unconfirmed reply without retrying it', async () => {
  1437. const bot = agent('自动设置与答复状态')
  1438. reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'uncertain-auto', message: '需要确认' } } }))
  1439. runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续' })
  1440. await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction')).toHaveLength(1))
  1441. const interaction = store.list<WorkspaceInteraction>(owner, 'interaction')[0]!
  1442. const rpc = WorkspaceGateway.prototype.rpc
  1443. const spy = vi.spyOn(WorkspaceGateway.prototype, 'rpc').mockImplementation(function (this: WorkspaceGateway, method, params) {
  1444. return method === 'approval.respond' ? Promise.reject(new Error('connection lost')) : rpc.call(this, method, params)
  1445. })
  1446. try {
  1447. await expect(runtime.respond(owner, interaction.id, 'auto')).rejects.toMatchObject({ code: 'approval_auto_response_failed' })
  1448. expect(store.agentSummary(store.require<any>(owner, 'agent', bot.id)).approvalPolicy).toBe('ask')
  1449. expect(store.require<any>(owner, 'interaction', interaction.id)).toMatchObject({ resolved: false, responseState: 'uncertain', answer: 'auto' })
  1450. await expect(runtime.respond(owner, interaction.id, 'auto')).rejects.toMatchObject({ code: 'interaction_uncertain' })
  1451. expect(spy.mock.calls.filter(call => call[0] === 'approval.respond')).toHaveLength(1)
  1452. } finally { spy.mockRestore() }
  1453. })
  1454. it('leaves clarifications and already pending approvals for the user after a policy change', async () => {
  1455. const bot = agent('更改审批')
  1456. let kind = 'approval'
  1457. server.on('connection', socket => socket.on('message', raw => {
  1458. const frame = JSON.parse(String(raw))
  1459. if (['approval.respond', 'clarify.respond'].includes(frame.method)) completeReply(socket, frame.params, '已答复')
  1460. }))
  1461. reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: `${kind}.request`, session_id: params.session_id, payload: { request_id: 'pending', message: '请确认' } } }))
  1462. for (kind of ['approval', 'clarify']) {
  1463. const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续任务' })
  1464. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting'))
  1465. saveHostTools(home, { approvalPolicy: 'allow' })
  1466. const interaction = store.list<WorkspaceInteraction>(owner, 'interaction').find(item => !item.resolved)!
  1467. expect(requests.filter(frame => ['approval.respond', 'clarify.respond'].includes(frame.method))).toHaveLength(kind === 'approval' ? 0 : 1)
  1468. await runtime.respond(owner, interaction.id, kind === 'approval' ? 'once' : '下一步')
  1469. await finished(run.id)
  1470. }
  1471. })
  1472. it('defaults legacy and newly created Bots to manual approval and prevents autonomous grants', () => {
  1473. const parent = agent('创建者')
  1474. expect(parent.approvalPolicy).toBe('ask')
  1475. const child = store.createAgent(owner, { name: '子成员', profile: parent.profile, approvalPolicy: 'allow' }, { createdByAgentId: parent.id, createdFromRunId: randomUUID() })
  1476. expect(child.approvalPolicy).toBe('ask')
  1477. expect(() => store.updateAgent(owner, child.id, { approvalPolicy: 'always' })).toThrow('approvalPolicy')
  1478. store.put(owner, 'agent', child.id, { ...child, remoteAgentId: randomUUID() })
  1479. expect(store.agentSummary(store.updateAgent(owner, child.id, { approvalPolicy: 'deny' })).approvalPolicy).toBe('ask')
  1480. expect(() => store.updateAgent(owner, child.id, { name: '不能改远端资料' })).toThrow('远端管理')
  1481. })
  1482. it('keeps a failed automatic approval pending and does not retry a replayed request', async () => {
  1483. const bot = agent('审批失败')
  1484. saveHostTools(home,{approvalPolicy:'allow'})
  1485. const rpc = WorkspaceGateway.prototype.rpc
  1486. let attempts = 0
  1487. const spy = vi.spyOn(WorkspaceGateway.prototype, 'rpc').mockImplementation(function (this: WorkspaceGateway, method, params) {
  1488. if (method === 'approval.respond' && attempts++ === 0) return Promise.reject(new HttpError(409, '暂时拒绝答复', 'gateway_rejected'))
  1489. return rpc.call(this, method, params)
  1490. })
  1491. try {
  1492. const notify = vi.fn()
  1493. runtime.onNotify = notify
  1494. reply = (socket, params) => {
  1495. const frame = JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'failure', message: '确认操作' } } })
  1496. socket.send(frame); socket.send(frame)
  1497. }
  1498. server.on('connection', socket => socket.on('message', raw => {
  1499. const frame = JSON.parse(String(raw))
  1500. if (frame.method === 'approval.respond') completeReply(socket, frame.params, '手动处理完成')
  1501. }))
  1502. const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续' })
  1503. await vi.waitFor(() => expect(notify.mock.calls.some(call => call[4]?.kind === 'approval')).toBe(true))
  1504. expect(attempts).toBe(1)
  1505. const interaction = store.list<WorkspaceInteraction>(owner, 'interaction').find(item => !item.resolved)!
  1506. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting')
  1507. await runtime.respond(owner, interaction.id, 'deny')
  1508. await finished(run.id)
  1509. } finally { spy.mockRestore() }
  1510. })
  1511. it('keeps parallel approval cards scoped to their member and deduplicates replayed requests and answers', async () => {
  1512. const a=agent('审批管理员'),b=agent('审批乙'),c=agent('审批丙'),g=store.createGroup(owner,{name:'并发审批',memberIds:[a.id,b.id,c.id],administratorId:a.id})
  1513. let admin=0
  1514. server.on('connection',socket=>socket.on('message',raw=>{const f=JSON.parse(String(raw));if(f.method==='approval.respond')completeReply(socket,f.params,'已确认')}))
  1515. reply=(socket,p)=>{
  1516. if(speaker(p)===a.name){completeReply(socket,p,admin++===0?'@审批乙 @审批丙':'汇总');return}
  1517. const frame=JSON.stringify({method:'event',params:{type:'approval.request',session_id:p.session_id,payload:{request_id:'same-request',message:'是否继续'}}})
  1518. socket.send(frame);socket.send(frame)
  1519. }
  1520. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  1521. await vi.waitFor(()=>expect(store.list<WorkspaceInteraction>(owner,'interaction').filter(i=>!i.resolved)).toHaveLength(2))
  1522. const interactions=store.list<WorkspaceInteraction>(owner,'interaction')
  1523. expect(store.require<WorkspaceConversation>(owner,'conversation',g.id).activeAgentStates).toEqual({[b.id]:'waiting',[c.id]:'waiting'})
  1524. await runtime.respond(owner,interactions[0]!.id,'once')
  1525. await runtime.respond(owner,interactions[0]!.id,'once')
  1526. await expect(runtime.respond(owner,interactions[0]!.id,'deny')).rejects.toThrow('其他答复')
  1527. expect(admin).toBe(1)
  1528. await runtime.respond(owner,interactions[1]!.id,'once')
  1529. await finished(root.id)
  1530. expect(requests.filter(r=>r.method==='approval.respond')).toHaveLength(2)
  1531. expect(admin).toBe(2)
  1532. })
  1533. it('retries a rolled-back cascade plan without losing or duplicating delegation', async () => {
  1534. const a=agent('事务管理员'),b=agent('事务成员'),g=store.createGroup(owner,{name:'调度事务',memberIds:[a.id,b.id],administratorId:a.id})
  1535. const original=store.put.bind(store);let failed=false,admin=0
  1536. 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)}
  1537. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name&&admin++===0?'@事务成员 继续':'完成')
  1538. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  1539. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('complete'),{timeout:4000})
  1540. expect(failed).toBe(true)
  1541. expect(store.list<any>(owner,'turn').filter(t=>t.agentId===b.id)).toHaveLength(1)
  1542. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  1543. })
  1544. it('upgrades an admitted v0.3.7 execution without submitting its prompt again', async () => {
  1545. const a=agent('升级执行'),c=direct(a.id)
  1546. reply=(socket,p)=>{recoveryHistory=[{role:'user',content:p.text},{role:'assistant',content:'升级前已完成'}];socket.terminate()}
  1547. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'只执行一次'})
  1548. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).error).toContain('正在恢复原执行'))
  1549. runtime.close()
  1550. const old=store.list<any>(owner,'turn')[0]!
  1551. for(const task of store.list<any>(owner,'turn'))store.remove(owner,'turn',task.id)
  1552. const legacy=store.require<any>(owner,'run',root.id)
  1553. Object.assign(legacy,{queue:[a.id],next:[],hostReturn:false,currentMessageId:old.currentMessageId,turnConfiguration:old.turnConfiguration})
  1554. store.put(owner,'run',root.id,legacy)
  1555. const binding=store.get<any>(owner,'binding',`${c.id}:${a.id}`)!
  1556. delete binding.taskId;store.put(owner,'binding',binding.id,binding)
  1557. runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
  1558. await finished(root.id)
  1559. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1560. expect(store.messages(owner,c.id).at(-1)?.content).toBe('升级前已完成')
  1561. })
  1562. it('does not read unrelated or orphaned historical runs when projecting a group prompt', async () => {
  1563. const a=agent('上下文隔离'),b=agent('组内成员'),g=store.createGroup(owner,{name:'干净群',memberIds:[a.id,b.id],administratorId:a.id})
  1564. store.put(owner,'run','historical-orphan',{id:'historical-orphan',conversationId:'unrelated-conversation',messageId:'missing-history',mentionIds:[],status:'complete',round:0,createdAt:1,updatedAt:1})
  1565. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'当前群任务'}).id)
  1566. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1567. })
  1568. it('pages backwards to find a long-running turn marker and never mistakes a tool call for a final answer', async () => {
  1569. const a=agent('分页恢复'),c=direct(a.id)
  1570. reply=(socket,p)=>{
  1571. recoveryHistory=[{role:'user',content:p.text},...Array.from({length:600},()=>({role:'tool',content:'工具结果'})),{role:'assistant',content:'最终结果'}]
  1572. socket.terminate()
  1573. }
  1574. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'长时间执行'}).id)
  1575. expect(store.messages(owner,c.id).at(-1)?.content).toBe('最终结果')
  1576. let submitted=''
  1577. reply=(socket,p)=>{submitted=p.text;recoveryHistory=[{role:'user',content:submitted},{role:'assistant',content:'准备调用工具',tool_calls:[{id:'tool'}]},{role:'tool',content:'工具输出'}];socket.terminate()}
  1578. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不要把过程当结论'})
  1579. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain'))
  1580. await runtime.reconcile(owner,root.id)
  1581. await new Promise(resolve=>setTimeout(resolve,100))
  1582. expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('uncertain')
  1583. recoveryHistory.push({role:'assistant',content:'真正完成'})
  1584. await runtime.reconcile(owner,root.id)
  1585. await finished(root.id)
  1586. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2)
  1587. })
  1588. it('keeps explicit replies visible and emits one notice when the configured cascade limit is reached', async () => {
  1589. const a=agent('轮数管理员'),b=agent('轮数成员'),g=store.createGroup(owner,{name:'有限轮次',memberIds:[a.id,b.id],administratorId:a.id,maxReplyRounds:2})
  1590. reply=(socket,p)=>completeReply(socket,p,speaker(p)===a.name?'@轮数成员 继续':'已完成本步')
  1591. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'开始'})
  1592. await finished(root.id)
  1593. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
  1594. expect(store.messages(owner,g.id).filter(m=>m.content.includes('轮数上限'))).toHaveLength(1)
  1595. store.updateConversation(owner,g.id,{mode:'free',autoReplyIds:[]})
  1596. reply=(socket,p)=>completeReply(socket,p,'[[YAOYAO_NO_REPLY_V1]]')
  1597. await finished(runtime.send(owner,g.id,{requestId:randomUUID(),content:'@轮数成员 显示这个字符串'}).id)
  1598. expect(store.messages(owner,g.id).at(-1)?.content).toBe('[[YAOYAO_NO_REPLY_V1]]')
  1599. })
  1600. it('retries a failed stop of a live task without falsely completing it or resubmitting work', async () => {
  1601. const a=agent('停止恢复'),c=direct(a.id)
  1602. reply=()=>{}
  1603. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待停止'})
  1604. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  1605. rejectedInterrupts=1
  1606. await runtime.stop(owner,root.id)
  1607. expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('interrupted')
  1608. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(2),{timeout:3000})
  1609. expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(2)
  1610. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1611. })
  1612. it('retains the stop barrier and original binding after ACK, including a restart, until native work is idle',async()=>{
  1613. const a=agent('真正停止'),c=direct(a.id)
  1614. reply=()=>{};nativeStillWorking=true
  1615. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'长任务'})
  1616. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  1617. await runtime.stop(owner,root.id)
  1618. await vi.waitFor(()=>expect(requests.some(r=>r.method==='session.active_list')).toBe(true))
  1619. const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
  1620. expect(store.require<Work>(owner,'turn',work.id)).toMatchObject({status:'interrupted',cleanupPending:true})
  1621. const original=store.require<any>(owner,'binding',`${c.id}:${a.id}`)
  1622. expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'下一轮'})).toThrow('尚未确认停止')
  1623. expect(()=>store.updateAgent(owner,a.id,{archived:true})).toThrow('尚未确认停止')
  1624. expect(runtime.idleForUpdate).toBe(false)
  1625. runtime.close()
  1626. runtime=new WorkspaceRuntime(store,nodes,uploads)
  1627. nativeStillWorking=false;runtime.start()
  1628. await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(false))
  1629. expect(store.require<any>(owner,'binding',`${c.id}:${a.id}`).storedId).toBe(original.storedId)
  1630. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1631. })
  1632. it('retains cleanup and refuses reuse when native stop confirmation times out',async()=>{
  1633. const a=agent('停止超时'),c=direct(a.id)
  1634. reply=()=>{}
  1635. const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'超时长任务'})
  1636. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  1637. const confirmation=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValueOnce(new HttpError(409,'Hermes 原任务尚未停止','session_still_stopping'))
  1638. try{
  1639. await runtime.stop(owner,root.id)
  1640. const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
  1641. await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupError).toContain('原任务尚未停止'))
  1642. expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(true)
  1643. expect(store.get(owner,'binding',`${c.id}:${a.id}`)).toBeTruthy()
  1644. expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能复用'})).toThrow('尚未确认停止')
  1645. expect(()=>store.changeConversationLifecycle(owner,c.id,'delete',store.conversationLifecycle(owner,c.id).confirmationToken)).toThrow('尚未确认停止')
  1646. expect(runtime.idleForUpdate).toBe(false)
  1647. }finally{confirmation.mockRestore()}
  1648. await vi.waitFor(()=>expect(store.list<Work>(owner,'turn').find(item=>item.runId===root.id)?.cleanupPending).toBe(false),{timeout:3000})
  1649. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
  1650. })
  1651. it('marks a stopped administrator as interrupted without sending a false completion notification', async () => {
  1652. const a=agent('管理员停止'),b=agent('普通成员'),g=store.createGroup(owner,{name:'停止管理员',memberIds:[a.id,b.id],administratorId:a.id})
  1653. reply=()=>{}
  1654. const notifications: string[]=[]
  1655. runtime.onNotify=(_owner,_c,run)=>notifications.push(run.status)
  1656. const root=runtime.send(owner,g.id,{requestId:randomUUID(),content:'等待'})
  1657. await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
  1658. await runtime.stopAgent(owner,g.id,a.id)
  1659. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',root.id).status).toBe('interrupted'))
  1660. expect(notifications).toEqual([])
  1661. })
  1662. it('randomizes old default avatars once while preserving photos, custom v2 settings and direct snapshots',()=>{
  1663. const a=store.createAgent(owner,{name:'旧头像',profile:'default',instructions:'保留规则'})
  1664. const photo='data:image/png;base64,aGVsbG8='
  1665. const b=store.createAgent(owner,{name:'照片',profile:'default',avatar:photo})
  1666. store.put(owner,'agent',a.id,{...a,avatar:'yaoyao-mascot:v1:triangle:ff0000:friendly'})
  1667. store.put(owner,'agent',b.id,{...b,avatar:photo})
  1668. store.db.prepare("DELETE FROM workspace_migrations WHERE id IN ('avatar-v2','avatar-random-default-v1')").run()
  1669. const random=vi.spyOn(Math,'random').mockReturnValue(0)
  1670. const migrated=new WorkspaceStore(home)
  1671. random.mockRestore()
  1672. const next=migrated.require<any>(owner,'agent',a.id)
  1673. expect(next.instructions).toBe('保留规则')
  1674. expect(decodeAgentAvatar(next.avatar)).toMatchObject({avatarMode:'mascot'})
  1675. expect(next.avatar).not.toBe(encodeAgentAvatar(defaultAgentIdentity(a.id,a.name)))
  1676. const image=migrated.require<any>(owner,'agent',b.id)
  1677. expect(decodeAgentAvatar(image.avatar)).toMatchObject({avatarMode:'image',imageDataURL:photo,imageCrop:'rounded'})
  1678. for(const c of migrated.list<WorkspaceConversation>(owner,'conversation')) expect(c.avatar).toBe(migrated.require<any>(owner,'agent',c.memberIds[0]!).avatar)
  1679. const customized=encodeAgentAvatar({...defaultAgentIdentity('bot'),bodyId:'star',expression:'proud',color:'#1488ff'})
  1680. migrated.updateAgent(owner,a.id,{avatar:customized})
  1681. migrated.close()
  1682. const reopened=new WorkspaceStore(home)
  1683. expect(reopened.require<any>(owner,'agent',a.id).avatar).toBe(customized)
  1684. expect(reopened.db.prepare("SELECT count(*) AS count FROM workspace_migrations WHERE id='avatar-random-default-v1'").get()!.count).toBe(1)
  1685. reopened.close()
  1686. })
  1687. // A member can have a queued follow-up while their current task needs approval.
  1688. it('keeps active avatar state ahead of queued work and publishes only recent outcomes', () => {
  1689. const a=agent('状态优先级'), c=direct(a.id), now=Date.now()
  1690. const run: WorkspaceRun={id:'avatar-run',conversationId:c.id,messageId:'unused',mentionIds:[],status:'complete',round:1,createdAt:now,updatedAt:now}
  1691. for (const [id,status] of [['current','waiting'],['next','queued']] as const)
  1692. store.put(owner,'turn',id,{id,conversationId:c.id,agentId:a.id,status,planned:true,updatedAt:now})
  1693. store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'failed',updatedAt:now})
  1694. store.saveRun(owner,run)
  1695. let summary=store.require<WorkspaceConversation>(owner,'conversation',c.id)
  1696. expect(summary.activeAgentStates).toEqual({[a.id]:'waiting'})
  1697. expect(summary.avatarSignals?.[a.id]).toMatchObject({id:'result',state:'failure'})
  1698. store.put(owner,'turn','result',{id:'result',conversationId:c.id,agentId:a.id,status:'complete',updatedAt:now-3000})
  1699. store.saveRun(owner,run)
  1700. expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).avatarSignals).toEqual({})
  1701. })
  1702. it('queues an unsubmitted turn while computer capacity is unavailable and submits it once after recovery',async()=>{
  1703. const a=agent('等候电脑'),c=direct(a.id),target=nodes.target(owner,'local')
  1704. vi.spyOn(target.session,'request').mockRejectedValueOnce(new HttpError(429,'资源不足','computer_quota'))
  1705. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待资源后执行'})
  1706. await vi.waitFor(()=>expect(store.list<any>(owner,'turn').find(work=>work.runId===run.id)?.resourceWait).toBe(true))
  1707. expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('queued')
  1708. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete'),{timeout:5000})
  1709. expect(requests.filter(request=>request.method==='prompt.submit')).toHaveLength(1)
  1710. })
  1711. it('keeps legacy VM Bots chatting on the server while their Runner is offline',async()=>{
  1712. const a=store.updateAgent(owner,agent('离线电脑').id,{execution:'computer',computer:'vm'}),c=direct(a.id)
  1713. nodes.targetForAgent=vi.fn(()=>{throw new HttpError(503,'节点未连接','runner_offline')})
  1714. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'普通聊天不需要虚拟机'})
  1715. await finished(run.id)
  1716. expect(nodes.targetForAgent).not.toHaveBeenCalled()
  1717. expect(store.get<any>(owner,'binding',`${c.id}:${a.id}`)).toMatchObject({execution:'profile'})
  1718. })
  1719. it('can stop an unsubmitted turn that is waiting for computer capacity',async()=>{
  1720. const a=store.updateAgent(owner,agent('可停止等候').id,{execution:'computer',computer:'vm'}),c=direct(a.id)
  1721. vi.spyOn(nodes.target(owner,'local').session,'request').mockRejectedValue(new HttpError(429,'资源不足','computer_quota'))
  1722. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待中停止'})
  1723. const work=await vi.waitFor(()=>{
  1724. const current=store.list<Work>(owner,'turn').find(work=>work.runId===run.id)
  1725. expect(current?.resourceWait).toBe(true)
  1726. return current!
  1727. })
  1728. await runtime.stop(owner,run.id)
  1729. await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).status).toBe('interrupted'))
  1730. })
  1731. it('forwards a generated artifact to a new task without changing its original provenance',async()=>{
  1732. const a=agent('产物接收者'),c=direct(a.id)
  1733. const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'first'})
  1734. await finished(first.id)
  1735. const message=store.messages(owner,c.id).find(message=>message.role==='assistant')!
  1736. const assets=new WorkspaceAssets(store,nodes,home)
  1737. const file=assets.publish(owner,message,a,'review.txt',Buffer.from('verified artifact'))
  1738. const sent=runtime.dispatch(owner,c.id,{requestId:randomUUID(),content:'复核附件',fileIds:[file.id]},{agentId:a.id,kind:'task_review'})
  1739. await finished(sent.id)
  1740. const attached=requests.find(request=>request.method==='file.attach')!
  1741. expect(Buffer.from(attached.params.data_url.split(',')[1],'base64').toString()).toBe('verified artifact')
  1742. expect(store.require<any>(owner,'file',file.id).messageId).toBe(message.id)
  1743. expect(store.require<WorkspaceMessage>(owner,'message',sent.messageId).attachments.map(file=>file.id)).toEqual([file.id])
  1744. assets.close()
  1745. })
  1746. it('migrates a stored Worker binding to server Hermes once and preserves prior conversation',async()=>{
  1747. const a=store.updateAgent(owner,agent('迁移测试').id,{execution:'computer',computer:'vm',vmExecution:'profile'}),c=direct(a.id)
  1748. const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'已确认的原会话事实'})
  1749. await finished(first.id)
  1750. const key=`${c.id}:${a.id}`,old=store.get<any>(owner,'binding',key)!
  1751. store.put(owner,'binding',key,{...old,execution:'computer',runnerId:'old-runner',hermesComputer:true})
  1752. nodes.targetForAgent=vi.fn(()=>{throw new Error('旧 Runner 不应打开')})
  1753. const second=runtime.send(owner,c.id,{requestId:randomUUID(),content:'继续这次任务'})
  1754. await finished(second.id)
  1755. const migrated=store.get<any>(owner,'binding',key)!
  1756. expect(migrated.execution).toBe('profile');expect(migrated.storedId).not.toBe(old.storedId)
  1757. expect(requests.filter(r=>r.method==='session.create')).toHaveLength(2)
  1758. expect(requests.filter(r=>r.method==='prompt.submit')[1].params.text).toContain('已确认的原会话事实')
  1759. const third=runtime.send(owner,c.id,{requestId:randomUUID(),content:'再次继续'})
  1760. await finished(third.id)
  1761. expect(requests.filter(r=>r.method==='session.resume')).toHaveLength(1)
  1762. expect(nodes.targetForAgent).not.toHaveBeenCalled()
  1763. })
  1764. it('keeps memory flags out of session.create and grants scoped memory through the bridge',async()=>{
  1765. const a=agent('独立记忆'),c=direct(a.id),target=nodes.target(owner,'local'),request=target.session.request.bind(target.session),bindings:any[]=[]
  1766. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
  1767. if(path.endsWith('/capabilities'))return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ready:true,native_tools:true,in_process:true,memory_isolation:true}))}
  1768. if(path.endsWith('/bind')){bindings.push(options?.body);return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,native_tools:true,workspace_memory:true}))}}
  1769. if(path.endsWith('/unbind'))return {status:200,headers:new Headers(),body:Buffer.from('{"ok":true}')}
  1770. return request(path,options)
  1771. })
  1772. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'验证完整创建链路'}).id)
  1773. const create=requests.find(r=>r.method==='session.create')!
  1774. expect(create.params).not.toHaveProperty('skip_memory')
  1775. expect(create.params).not.toHaveProperty('workspace_memory')
  1776. expect(bindings).toContainEqual(expect.objectContaining({workspace_memory:true}))
  1777. })
  1778. describe('Bot memory session reuse', () => {
  1779. beforeEach(() => {
  1780. const target = nodes.target(owner, 'local'), request = target.session.request.bind(target.session)
  1781. vi.spyOn(target.session, 'request').mockImplementation(async (path, options) => {
  1782. if (path.endsWith('/capabilities')) return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ready: true, native_tools: true, in_process: true, memory_isolation: true })) }
  1783. if (path.endsWith('/bind')) return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ok: true, native_tools: true, workspace_memory: true })) }
  1784. if (path.endsWith('/unbind')) return { status: 200, headers: new Headers(), body: Buffer.from('{"ok":true}') }
  1785. return request(path, options)
  1786. })
  1787. })
  1788. it('reuses persisted sessions for added facts and submits only new memory after restart', async () => {
  1789. const bot = agent('记忆复用'), c = direct(bot.id)
  1790. await runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: bot.id, tier: 'profile', content: '偏好内容甲' })
  1791. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1792. const original = store.list<any>(owner, 'binding')[0].storedId
  1793. await runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: bot.id, tier: 'profile', content: '新增内容乙' })
  1794. runtime.close(); runtime = new WorkspaceRuntime(store, nodes, uploads)
  1795. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第二轮' }).id)
  1796. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第三轮' }).id)
  1797. expect(store.list<any>(owner, 'binding')[0].storedId).toBe(original)
  1798. expect(requests.filter(r => r.method === 'session.create')).toHaveLength(1)
  1799. expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(2)
  1800. const prompts = requests.filter(r => r.method === 'prompt.submit').map(r => String(r.params.text))
  1801. expect(prompts[1]).toContain('新增内容乙'); expect(prompts[1]).not.toContain('偏好内容甲')
  1802. expect(prompts[2]).not.toContain('新增内容乙'); expect(prompts[2]).not.toContain('偏好内容甲')
  1803. })
  1804. it.each(['edit', 'forget'] as const)('rebuilds after %s and records the reason without restoring the old memory', async operation => {
  1805. const bot = agent('记忆失效'), c = direct(bot.id), record = vi.fn()
  1806. runtime.inspector = { record } as any
  1807. const input = { scope: 'agent' as const, agentId: bot.id, tier: 'profile' as const, content: '旧的长期事实' }
  1808. const fact = await runtime.knowledge.writeMemory(owner, { ...input, requestId: randomUUID() })
  1809. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1810. const revision = { ...input, id: fact.id, expectedRevision: fact.revision, requestId: randomUUID() }
  1811. if (operation === 'forget') await runtime.knowledge.forget(owner, revision)
  1812. else await runtime.knowledge.writeMemory(owner, { ...revision, content: '修正后的事实' })
  1813. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第二轮' }).id)
  1814. expect(requests.filter(r => r.method === 'session.create')).toHaveLength(2)
  1815. const prompt = requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text
  1816. expect(prompt).not.toContain('旧的长期事实')
  1817. if (operation === 'edit') expect(prompt).toContain('修正后的事实')
  1818. expect(record.mock.calls.some(([, , entry]) => entry.method === 'bot.session-reset' && entry.data.reason === 'memory_changed_or_removed')).toBe(true)
  1819. })
  1820. it('does not acknowledge an added fact when prompt admission is rejected', async () => {
  1821. const bot = agent('记忆提交'), c = direct(bot.id)
  1822. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1823. await runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: bot.id, tier: 'profile', content: '尚未确认的注入' })
  1824. rejectPrompt = '拒绝本次提交'
  1825. const rejected = runtime.send(owner, c.id, { requestId: randomUUID(), content: '第二轮' })
  1826. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', rejected.id).status).toBe('failed'))
  1827. rejectPrompt = undefined
  1828. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第三轮' }).id)
  1829. expect(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params.text).toContain('尚未确认的注入')
  1830. expect(requests.filter(r => r.method === 'session.create')).toHaveLength(1)
  1831. })
  1832. it('rebuilds once when an old or unconfirmed memory baseline cannot prove which facts were admitted', async () => {
  1833. const bot = agent('记忆基线'), c = direct(bot.id)
  1834. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1835. for (const state of ['legacy', 'pending']) {
  1836. const binding = store.list<any>(owner, 'binding')[0]
  1837. store.put(owner, 'binding', binding.id, state === 'legacy'
  1838. ? { ...binding, memoryState: undefined, memoryVersion: 'legacy' }
  1839. : { ...binding, memoryState: undefined, memoryVersion: undefined, memoryPending: true })
  1840. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: state }).id)
  1841. }
  1842. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '已恢复正常复用' }).id)
  1843. expect(requests.filter(r => r.method === 'session.create')).toHaveLength(3)
  1844. expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(1)
  1845. })
  1846. })
  1847. it('snapshots each message origin and uses inherited provenance for internal dispatch only',async()=>{
  1848. const bot=agent('来源上下文'),c=direct(bot.id),a=randomUUID(),b=randomUUID()
  1849. runtime.desktopEnvironments=new DesktopEnvironments(store,{} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
  1850. const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'在本机工作',deviceHost:a})
  1851. await finished(first.id)
  1852. const second=runtime.send(owner,c.id,{requestId:randomUUID(),content:'切换设备',deviceHost:b})
  1853. await finished(second.id)
  1854. const delegated=runtime.dispatch(owner,c.id,{requestId:randomUUID(),content:'继续子任务',deviceHost:b},{agentId:bot.id,kind:'assignment',deviceHost:a})
  1855. await finished(delegated.id)
  1856. const unknown=runtime.send(owner,c.id,{requestId:randomUUID(),content:'浏览器消息',deviceHost:null})
  1857. await finished(unknown.id)
  1858. expect([first.deviceHost,second.deviceHost,delegated.deviceHost,unknown.deviceHost]).toEqual([a,b,a,undefined])
  1859. const prompts=requests.filter(r=>r.method==='prompt.submit').map(r=>r.params.text)
  1860. expect(prompts[0]).toContain(a);expect(prompts[1]).toContain(b);expect(prompts[2]).toContain(a)
  1861. expect(prompts[3]).toContain('历史消息的设备来源不适用于本轮')
  1862. expect(store.require<any>(owner,'message',delegated.messageId).deviceHost).toBe(a)
  1863. })
  1864. it.each(['none','server','virtual'] as const)('copies between allowed hosts and preserves message-local aliases with default %s',async mode=>{
  1865. runtime.executionSettings=new ExecutionSettings(store,nodes,{isUserActive:()=>true} as any)
  1866. runtime.executionSettings.saveSelection({mode,revision:1})
  1867. const bot=agent('传文件'),c=direct(bot.id),target=nodes.target(owner,'local'),originalRequest=target.session.request.bind(target.session)
  1868. let binding:any,integrationError:unknown
  1869. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
  1870. if(path.startsWith('/api/plugins/yaoyao-bot-bridge/')){
  1871. if(path.endsWith('/bind'))binding=options?.body
  1872. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,native_tools:true,in_process:true,computer_runtime_version:2}))}
  1873. }
  1874. return originalRequest(path,options)
  1875. })
  1876. const desktop=new DesktopEnvironments(store,{pushAuthorizationVersion:()=>0} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
  1877. runtime.desktopEnvironments=desktop
  1878. const payload=Buffer.alloc(300_000,123),sha256=createHash('sha256').update(payload).digest('hex'),clientId=randomUUID(),commands:Array<{host:string;op:string}>=[]
  1879. const info={platform:'darwin',screen:false,accessibility:false,approved:[],full:[createHash('sha256').update('fixture:'+owner).digest('hex')]}
  1880. const machines=[{key:'server',host:{...info,id:randomUUID(),name:'server'},exchange:(body:unknown)=>desktop.exchange(body)},
  1881. {key:'mac1',host:{...info,id:randomUUID(),name:'mac1'},exchange:(body:unknown)=>desktop.remoteExchange(clientId,body)}]
  1882. const timers=machines.map(machine=>{let results:any[]=[];machine.exchange({host:machine.host,results});return setInterval(()=>{
  1883. const response=machine.exchange({host:machine.host,results})
  1884. results=response.commands.map((command:any)=>{commands.push({host:machine.key,op:command.operation==='shell'?'shell':command.action.op});return {id:command.id,value:command.operation==='shell'?{stdout:machine.key,stderr:'',exitCode:0}:command.action.op==='read'
  1885. ? {size:payload.length,data:payload.toString('base64')}
  1886. : {path:command.action.path,size:Buffer.from(command.action.data,'base64').length,sha256:createHash('sha256').update(Buffer.from(command.action.data,'base64')).digest('hex')}}})
  1887. },5)})
  1888. const publish=vi.spyOn(runtime,'publishArtifact')
  1889. const http=async(path:string,body:unknown)=>{const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)});expect(response.status).toBe(200);return response.json()}
  1890. reply=(socket,p)=>{void(async()=>{
  1891. try{
  1892. expect(p.text).toContain('电脑间传文件用 desktop_file_copy')
  1893. const catalog=await http('/tools/list',{}),tool=catalog.tools.find((tool:any)=>tool.name==='desktop_file_copy')
  1894. expect(tool.inputSchema.required).toEqual(['sourceHost','sourcePath','targetHost','targetPath'])
  1895. const call={toolId:tool.id,callId:randomUUID(),arguments:{sourceHost:'mac1',sourcePath:'Desktop/a.txt',targetHost:'server',targetPath:'Desktop/a.txt'}}
  1896. const result=await http('/tools/call',call)
  1897. expect(result.isError).not.toBe(true)
  1898. expect(result.structuredContent).toMatchObject({copied:true,source:{host:clientId},target:{host:'local'},size:payload.length,sha256})
  1899. expect(JSON.stringify(result).length).toBeLessThan(1500)
  1900. expect(await http('/tools/call',call)).toEqual(result)
  1901. const shell=catalog.tools.find((tool:any)=>tool.name==='desktop_shell')
  1902. for(const args of [{command:'pwd'},{host:'local',command:'pwd'},{host:clientId,command:'pwd'}]){
  1903. const result=await http('/tools/call',{toolId:shell.id,callId:randomUUID(),arguments:args})
  1904. expect(result.isError).not.toBe(true)
  1905. expect(result.structuredContent.stdout).toBe('mac1')
  1906. }
  1907. saveHostTools(home,{scriptMachine:false})
  1908. const denied=await http('/tools/call',{toolId:shell.id,callId:randomUUID(),arguments:{host:clientId,command:'pwd'}})
  1909. expect(denied.isError).toBe(true)
  1910. }catch(error){integrationError=error}
  1911. socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'文件复制完成',status:'complete'}}}))
  1912. })()}
  1913. try{
  1914. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'帮我把 mac1 桌面的 a.txt 放到服务器桌面',deviceHost:clientId}).id)
  1915. if(integrationError)throw integrationError
  1916. expect(commands).toEqual([{host:'mac1',op:'read'},{host:'server',op:'receive'},...Array.from({length:3},()=>({host:'mac1',op:'shell'}))])
  1917. expect(publish).not.toHaveBeenCalled();expect(store.list(owner,'file')).toEqual([])
  1918. }finally{timers.forEach(clearInterval);desktop.close()}
  1919. })
  1920. function modelBridge() {
  1921. const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
  1922. target.session.request = async (path, options) => {
  1923. if (path.endsWith('/model-settings/resolve')) {
  1924. const settings = (options?.body as any)?.settings ?? {}
  1925. return { status:200, headers:new Headers(), body:Buffer.from(JSON.stringify({version:1,confirmationMessage:null,effective:{provider:settings.provider??'base',model:settings.model??'base-model',reasoningEffort:settings.reasoningEffort??'medium',fastMode:settings.fastMode??'normal'}})) }
  1926. }
  1927. return original(path, options)
  1928. }
  1929. }
  1930. it('reuses only an acknowledged model on a warm unchanged session and still reapplies reasoning and speed', async () => {
  1931. modelBridge(); resumeExistingRuntime = true
  1932. const a = agent('连续追问'), c = direct(a.id)
  1933. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'auto' } })
  1934. const record = vi.fn(); runtime.inspector = { record } as any
  1935. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1936. expect(requests.filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
  1937. const before = requests.length
  1938. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '连续追问' }).id)
  1939. expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['reasoning', 'fast'])
  1940. expect(requests.slice(before).filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  1941. expect(record.mock.calls.some(([, , e]) => e.method === 'bot.model-settings' && e.data.reusedModel === true && e.data.resolveMs >= 0 && e.data.applyMs >= 0)).toBe(true)
  1942. })
  1943. it.each(['profile-change', 'cold-resume', 'external-switch', 'missing-info', 'unknown-revision'])(
  1944. 'reconfirms the model when reuse is uncertain: %s', async change => {
  1945. modelBridge(); resumeExistingRuntime = true
  1946. const a = agent('模型复用边界'), c = direct(a.id)
  1947. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'medium', fastMode: 'normal' } })
  1948. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1949. if (change === 'profile-change') profileMtime = 2
  1950. if (change === 'cold-resume') resumeExistingRuntime = false
  1951. if (change === 'external-switch') for (const info of modelInfo.values()) info.model = 'external-model'
  1952. if (change === 'missing-info') modelInfo.clear()
  1953. if (change === 'unknown-revision') profileMtime = undefined
  1954. const before = requests.length
  1955. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '核对后续轮次' }).id)
  1956. expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
  1957. },
  1958. )
  1959. it('discards the confirmed model after a failed setting acknowledgement', async () => {
  1960. modelBridge(); resumeExistingRuntime = true
  1961. const a = agent('配置失败后重试'), c = direct(a.id)
  1962. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
  1963. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1964. rejectedModelConfig = { value: 'wrong' }
  1965. const failed = runtime.send(owner, c.id, { requestId: randomUUID(), content: '失败轮次' })
  1966. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', failed.id).status).toBe('failed'))
  1967. expect(store.list<any>(owner, 'binding')[0].confirmedModel).toBeUndefined()
  1968. expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  1969. rejectedModelConfig = undefined
  1970. const before = requests.length
  1971. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '重试' }).id)
  1972. expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
  1973. })
  1974. it('uses live session info after tool binding rather than a stale resume snapshot', async () => {
  1975. modelBridge(); resumeExistingRuntime = true
  1976. const a = agent('绑定后状态'), c = direct(a.id)
  1977. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
  1978. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  1979. observeGatewayRequest = frame => {
  1980. if (frame.method !== 'config.get') return
  1981. for (const socket of server.clients) socket.send(JSON.stringify({ method: 'event', params: {
  1982. type: 'session.info', session_id: frame.params.session_id, payload: { model: 'rebuilt-model', provider: 'openai' },
  1983. } }))
  1984. }
  1985. const before = requests.length
  1986. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '重建后继续' }).id)
  1987. expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
  1988. })
  1989. it('prepares the session while read-only model resolution is pending and submits only after confirmation', async () => {
  1990. modelBridge()
  1991. const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
  1992. let release!: () => void
  1993. const pending = new Promise<void>(resolve => { release = resolve })
  1994. target.session.request = async (path, options) => {
  1995. if (path.endsWith('/model-settings/resolve')) await pending
  1996. return original(path, options)
  1997. }
  1998. const a = agent('并行准备'), c = direct(a.id)
  1999. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
  2000. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' })
  2001. await vi.waitFor(() => expect(requests.some(r => r.method === 'session.create')).toBe(true))
  2002. expect(requests.some(r => r.method === 'prompt.submit' || r.method === 'config.set')).toBe(false)
  2003. release(); await finished(run.id)
  2004. expect(requests.filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
  2005. })
  2006. it('cannot restore cached settings or submit after cancellation during model resolution', async () => {
  2007. modelBridge(); resumeExistingRuntime = true
  2008. const a = agent('准备阶段取消'), c = direct(a.id)
  2009. store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
  2010. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
  2011. const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
  2012. let release!: () => void
  2013. const pending = new Promise<void>(resolve => { release = resolve })
  2014. target.session.request = async (path, options) => {
  2015. if (path.endsWith('/model-settings/resolve')) await pending
  2016. return original(path, options)
  2017. }
  2018. const before = requests.length
  2019. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '取消这一轮' })
  2020. await vi.waitFor(() => expect(requests.slice(before).some(r => r.method === 'session.resume')).toBe(true))
  2021. await runtime.stop(owner, run.id)
  2022. release()
  2023. await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('interrupted'))
  2024. await new Promise(resolve => setTimeout(resolve, 20))
  2025. expect(requests.slice(before).some(r => r.method === 'config.set' || r.method === 'prompt.submit')).toBe(false)
  2026. expect(store.list<any>(owner, 'binding')[0]?.confirmedModel).toBeUndefined()
  2027. })
  2028. it.each([{value:'wrong'}, {confirm_required:true,confirm_message:'切换长上下文将增加费用'}])('stops before submission on a failed native model acknowledgement and keeps its confirmation in the profile: %j',async rejection=>{
  2029. modelBridge(); rejectedModelConfig=rejection
  2030. const a=agent('配置失败'),c=direct(a.id)
  2031. store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}})
  2032. const record=vi.fn(); runtime.inspector={record} as any
  2033. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能用旧模型执行'})
  2034. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  2035. expect(requests.some(r=>r.method==='prompt.submit')).toBe(false)
  2036. expect(record.mock.calls.some(([, , e])=>e.method==='bot.model-settings'&&e.data.applied===false&&e.data.revision===2)).toBe(true)
  2037. if('confirm_required' in rejection) expect(store.require<any>(owner,'agent',a.id).modelSettingsPendingConfirmation).toEqual({target:'["openai","model-a"]',message:rejection.confirm_message})
  2038. })
  2039. it('updates the same Bot across an existing direct chat and two groups without changing its Profile peers', async () => {
  2040. modelBridge()
  2041. const a=agent('模型甲'), b=agent('模型乙'), c=direct(a.id)
  2042. const groups=[1,2].map(n=>store.createGroup(owner,{name:`模型群${n}`,memberIds:[a.id,b.id],administratorId:a.id}))
  2043. const settings={provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}
  2044. store.updateAgent(owner,a.id,{modelSettings:settings})
  2045. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'建立已有会话'}).id)
  2046. const before=requests.length
  2047. store.updateAgent(owner,a.id,{modelSettings:{...settings,model:'model-b',reasoningEffort:'none',fastMode:'cold'}})
  2048. for(const chat of [c,...groups]) await finished(runtime.send(owner,chat.id,{requestId:randomUUID(),content:'@模型甲 继续'}).id)
  2049. const updates=requests.slice(before).filter(r=>r.method==='config.set')
  2050. expect(updates.map(r=>[r.params.key,r.params.value])).toEqual(Array.from({length:3},()=>[
  2051. ['model','model-b --provider openai --session'],['reasoning','none'],['fast','cold'],
  2052. ]).flat())
  2053. expect(requests.slice(before).some(r=>r.method==='session.resume')).toBe(true)
  2054. expect(store.require<any>(owner,'agent',b.id).modelSettings).toBeUndefined()
  2055. store.updateAgent(owner,a.id,{modelSettings:null})
  2056. const reset=requests.length
  2057. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'恢复继承'}).id)
  2058. expect(requests.slice(reset).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['base-model --provider base --session','medium','normal'])
  2059. })
  2060. it('keeps a running reply on its snapshot and applies new settings to the queued reply', async () => {
  2061. modelBridge()
  2062. const a=agent('切换成员'), c=direct(a.id)
  2063. store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'low',fastMode:'normal'}})
  2064. let release:()=>void=()=>{}
  2065. reply=(socket,p)=>{release=()=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))}
  2066. const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'第一轮'})
  2067. await vi.waitFor(()=>expect(requests.some(r=>r.method==='prompt.submit')).toBe(true))
  2068. const before=requests.length
  2069. store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-b',reasoningEffort:'ultra',fastMode:'auto'}})
  2070. const queued=runtime.send(owner,c.id,{requestId:randomUUID(),content:'排队的新轮'})
  2071. expect(requests.length).toBe(before)
  2072. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))
  2073. release(); await finished(first.id); await finished(queued.id)
  2074. expect(requests.slice(before).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['model-b --provider openai --session','ultra','auto'])
  2075. expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
  2076. })
  2077. it('does not send a prompt when the model bridge cannot resolve the saved setting', async () => {
  2078. const a=agent('失败成员'),c=direct(a.id)
  2079. store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'gone',reasoningEffort:'high',fastMode:'fast'}})
  2080. const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能回退'})
  2081. await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
  2082. expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
  2083. })
  2084. it('applies Bot settings to the live agent after a cold resume restores its persisted runtime during tool binding', async () => {
  2085. modelBridge()
  2086. const a = agent('冷启动模型切换'), c = direct(a.id)
  2087. const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
  2088. const models = new Map<string, Record<string, string>>()
  2089. const submitted: Array<Record<string, string>> = []
  2090. target.session.request = async (path, options) => {
  2091. if (path.endsWith('/capabilities')) return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ version: 1, ready: true, in_process: true, native_tools: true })) }
  2092. if (path.endsWith('/bind')) {
  2093. // Hermes acknowledges config.set before a deferred resume builds the agent.
  2094. // That build restores the stored runtime, replacing any premature settings.
  2095. const sid = (options?.body as any).session_id
  2096. models.set(sid, { model: 'persisted-model', reasoning: 'medium', fast: 'normal' })
  2097. return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ok: true, native_tools: true })) }
  2098. }
  2099. if (path.endsWith('/unbind')) return { status: 200, headers: new Headers(), body: Buffer.from('{"ok":true}') }
  2100. return original(path, options)
  2101. }
  2102. observeGatewayRequest = frame => {
  2103. if (frame.method !== 'config.set') return
  2104. const sid = frame.params.session_id, current = models.get(sid) ?? {}
  2105. current[frame.params.key] = frame.params.value
  2106. models.set(sid, current)
  2107. }
  2108. reply = (socket, params) => {
  2109. submitted.push({ ...models.get(params.session_id) })
  2110. socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: params.session_id, payload: { text: '完成', status: 'complete' } } }))
  2111. }
  2112. configuredCwds.set('default', '/profile/working-directory')
  2113. for (const settings of [
  2114. { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' },
  2115. { provider: 'custom:provider', model: 'model-b', reasoningEffort: 'none', fastMode: 'cold' },
  2116. null,
  2117. ]) {
  2118. store.updateAgent(owner, a.id, { modelSettings: settings })
  2119. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '切换模型并保留上下文' }).id)
  2120. }
  2121. expect(submitted).toEqual([
  2122. { model: 'model-a --provider openai --session', reasoning: 'high', fast: 'fast' },
  2123. { model: 'model-b --provider custom:provider --session', reasoning: 'none', fast: 'cold' },
  2124. { model: 'base-model --provider base --session', reasoning: 'medium', fast: 'normal' },
  2125. ])
  2126. expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(2)
  2127. })
  2128. it.each([
  2129. { hostname:'127.0.0.1', reported:true },
  2130. { hostname:'localhost', reported:true },
  2131. { hostname:'127.0.0.1', reported:false },
  2132. { hostname:'localhost', reported:false },
  2133. ])('keeps Hermes connected across client startup and shutdown at $hostname (metadata=$reported)', async ({hostname,reported}) => {
  2134. const bot=agent('独立服务器'),c=direct(bot.id),target=nodes.target(owner,'local')
  2135. target.url=new URL(`http://${hostname}:${target.url.port}`)
  2136. const original=target.session.request.bind(target.session)
  2137. const metadata={version:1,profile:'default',platform:'linux',osRelease:'6.8',arch:'x86_64',nativeTools:{terminal:['terminal'],files:['read_file','write_file'],desktop:['computer_use'],browser:['browser_navigate']}}
  2138. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>path.startsWith('/api/plugins/yaoyao-bot-bridge/')
  2139. ? {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,native_tools:true,in_process:true,...(reported?{runtime_environment:metadata}:{})}))}
  2140. : original(path,options))
  2141. configuredCwds.set('default','/hermes/project')
  2142. saveHostTools(home,{vm:false,cloud:false})
  2143. const desktop=new DesktopEnvironments(store,{pushAuthorizationVersion:()=>0} as any,{localNodeID:'fixture',requireSource:()=>{}} as any)
  2144. runtime.desktopEnvironments=desktop
  2145. const record=vi.fn();runtime.inspector={record} as any
  2146. const clientId=randomUUID()
  2147. try {
  2148. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:null}).id)
  2149. desktop.remoteExchange(clientId,{host:{id:randomUUID(),name:'服务器',platform:'darwin',screen:false,accessibility:false,approved:[]},results:[]})
  2150. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
  2151. desktop.dropHost(clientId)
  2152. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'检查服务端目录',deviceHost:clientId}).id)
  2153. const snapshots=record.mock.calls.map(([, ,entry])=>entry).filter(entry=>entry.method==='bot.environment').map(entry=>entry.data.snapshot)
  2154. expect(snapshots).toHaveLength(3)
  2155. expect(snapshots.every(snapshot=>snapshot.runtime.connected&&snapshot.cwd==='/hermes/project')).toBe(true)
  2156. expect(snapshots.map(snapshot=>snapshot.runtime.metadata)).toEqual(reported?[metadata,metadata,metadata]:[undefined,undefined,undefined])
  2157. expect(snapshots[0].desktop.hosts).toEqual([])
  2158. expect(snapshots[1].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',platform:'darwin',online:true})
  2159. expect(snapshots[2].desktop.hosts[0]).toMatchObject({id:clientId,kind:'computer',online:false})
  2160. const prompts=requests.filter(request=>request.method==='prompt.submit').map(request=>String(request.params.text))
  2161. expect(prompts).toHaveLength(3)
  2162. for(const prompt of prompts){
  2163. expect(prompt).toContain('服务端 Hermes 已连接')
  2164. expect(prompt).toContain('操作服务端运行环境时优先使用')
  2165. expect(prompt).toContain('Hermes 本轮工作目录(已确认):"/hermes/project"')
  2166. expect(prompt).toContain(reported?'"desktop":["computer_use"]':'原生能力与系统信息尚未由工具桥上报')
  2167. }
  2168. expect(prompts[1]).toContain(`host="${clientId}"`)
  2169. expect(prompts[2]).toContain('当前离线或未连接')
  2170. } finally {desktop.close()}
  2171. })
  2172. it('submits capability-based Bot context with current origins, confirmed cwd and no keyword-selected environment', async () => {
  2173. const bot = agent('环境助手'), c = direct(bot.id), target = nodes.target(owner, 'local')
  2174. const originalRequest = target.session.request.bind(target.session)
  2175. vi.spyOn(target.session, 'request').mockImplementation(async (path, options) => path.startsWith('/api/plugins/yaoyao-bot-bridge/')
  2176. ? { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ok: true, version: 1, ready: true, native_tools: true, in_process: true })) }
  2177. : originalRequest(path, options))
  2178. const record=vi.fn();runtime.inspector={record} as any
  2179. let binding:any,integrationError:unknown
  2180. const catalogs:string[][]=[]
  2181. const bridgeRequest=target.session.request.bind(target.session)
  2182. target.session.request=async(path,options)=>{if(path.endsWith('/bind'))binding=options?.body;return bridgeRequest(path,options)}
  2183. const originalReply=reply
  2184. reply=(socket,params)=>{void(async()=>{try{const response=await fetch(binding.bridge_url+'/tools/list',{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:'{}'});const result=await response.json() as any;catalogs.push(result.tools.filter((tool:any)=>/^(desktop_|computer_|cloud_computer_)/.test(tool.name)).map((tool:any)=>tool.name).sort())}catch(error){integrationError=error}originalReply(socket,params)})()}
  2185. configuredCwds.set('default', '/fixture/server-workspace')
  2186. saveHostTools(home, { vm: false, cloud: false })
  2187. const desktop = new DesktopEnvironments(store, { pushAuthorizationVersion: () => 0 } as any, { localNodeID: 'fixture', requireSource: () => {} } as any)
  2188. runtime.desktopEnvironments = desktop
  2189. const clientId = randomUUID(), grant = createHash('sha256').update('fixture:' + owner).digest('hex')
  2190. desktop.exchange({ host: { id: randomUUID(), name: '服务器', platform: 'darwin', screen: true, accessibility: true, approved: [grant] }, results: [] })
  2191. desktop.remoteExchange(clientId, { host: { id: randomUUID(), name: '工作 Mac', platform: 'darwin', screen: false, accessibility: false, approved: [], full: [grant], environment: {version:1,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/work',defaultCwd:'/Users/work',fileRoots:['/Users/work'],shellScope:'user',timezone:'Asia/Shanghai'} }, results: [] })
  2192. try {
  2193. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录,再解释 Grok 模型与虚拟机的区别', deviceHost: clientId }).id)
  2194. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续', deviceHost: clientId }).id)
  2195. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录', deviceHost: 'local' }).id)
  2196. await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '读取本机目录', deviceHost: null }).id)
  2197. const prompts = requests.filter(r => r.method === 'prompt.submit').map(r => String(r.params.text))
  2198. if(integrationError)throw integrationError
  2199. expect(prompts).toHaveLength(4)
  2200. const inspections=record.mock.calls.map(([, , entry])=>entry).filter(entry=>entry.method==='bot.environment')
  2201. expect(inspections).toHaveLength(4)
  2202. expect(inspections.map(entry=>entry.data.computerToolIds.slice().sort())).toEqual(catalogs)
  2203. expect(inspections.map(entry=>entry.data.snapshot.desktop.sourceHost)).toEqual([clientId,clientId,'local',undefined])
  2204. expect(inspections.every(entry=>entry.data.snapshot.cwd==='/fixture/server-workspace')).toBe(true)
  2205. expect(inspections[0].data.snapshot.desktop.hosts.find((host:any)=>host.id===clientId).metadata.fileRoots).toEqual(['/Users/work'])
  2206. expect(JSON.stringify(inspections)).not.toContain(grant)
  2207. const environments = prompts.map(prompt => prompt.split('【本轮环境与设备】')[1]!.split('【Bot 记忆与相关事实】')[0])
  2208. expect(environments[0]).toBe(environments[1])
  2209. expect(environments[0]).toContain('本轮用户消息来自电脑「工作 Mac」')
  2210. expect(environments[2]).toContain('本轮用户消息来自服务器「服务器」')
  2211. expect(environments[3]).toContain('「本机」无法确定,必须指定目标电脑')
  2212. expect(environments[0]).toContain('Hermes 本轮工作目录(已确认):"/fixture/server-workspace"')
  2213. for (const prompt of prompts) {
  2214. expect(prompt).not.toContain('当前环境是')
  2215. expect(prompt).not.toContain('cloud_computer_*')
  2216. expect(prompt).not.toContain('computer_shell')
  2217. expect(prompt.match(/本轮电脑清单/g)).toHaveLength(1)
  2218. expect(prompt).toContain('Shell="/bin/zsh"')
  2219. expect(prompt).toContain('文件工具允许根目录=["/Users/work"]')
  2220. expect(prompt).toContain(`host="${clientId}"`)
  2221. expect(prompt).toContain('屏幕控制=未授权;文件与命令=就绪')
  2222. expect(prompt).toContain('屏幕控制=就绪;文件与命令=未授权')
  2223. }
  2224. } finally { desktop.close() }
  2225. })
  2226. it.each(['none','server','virtual'] as const)('dispatches browser and VM tools with default %s and migrates exclusive sessions once',async mode=>{
  2227. const bot=agent('多环境回归'),c=direct(bot.id),target=nodes.target(owner,'local'),original=target.session.request.bind(target.session)
  2228. runtime.executionSettings=new ExecutionSettings(store,nodes,{isUserActive:()=>true} as any)
  2229. runtime.executionSettings.saveSelection({mode,revision:1})
  2230. saveHostTools(home,{managedBrowser:true,vm:true,cloud:false,scriptMachine:false,serverComputer:false})
  2231. let binding:any,integrationError:unknown
  2232. vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
  2233. if(path.startsWith('/api/plugins/yaoyao-bot-bridge/')){
  2234. if(path.endsWith('/bind'))binding=options?.body
  2235. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,native_tools:true,in_process:true,computer_runtime_version:2}))}
  2236. }
  2237. return original(path,options)
  2238. })
  2239. const vmTarget=vi.fn(()=>target);nodes.targetForAgent=vmTarget
  2240. const browserCall=vi.fn(async()=>({open:true,tabs:[{url:'https://example.com/'}]})),browserOpen=vi.fn(()=>({call:browserCall,close:async()=>{}}))
  2241. runtime.managedBrowsers={available:()=>true,openTurn:browserOpen} as any
  2242. const pluginOpen=vi.fn(async()=>({services:()=>[{name:'测试 stdio',transport:'stdio',toolCount:1}],warnings:()=>[],catalog:()=>[],dispose:async()=>{}}))
  2243. runtime.plugins={selected:()=>true,open:pluginOpen} as any
  2244. const http=async(path:string,body:unknown)=>{
  2245. const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)})
  2246. expect(response.status).toBe(200);return response.json()
  2247. }
  2248. reply=(socket,p)=>{void(async()=>{
  2249. try{
  2250. expect(binding.computer_policy).toEqual({mode:'profile',hostAccess:true})
  2251. expect(p.text).toContain('默认使用 managed_browser_open')
  2252. expect(p.text).toContain('不限制其他已开放且获授权的环境')
  2253. expect(p.text).toContain('测试 stdio')
  2254. const catalog=await http('/tools/list',{})
  2255. const call=async(name:string,args:unknown)=>{
  2256. const tool=catalog.tools.find((tool:any)=>tool.name===name);expect(tool).toBeDefined()
  2257. return http('/tools/call',{toolId:tool.id,callId:randomUUID(),arguments:args})
  2258. }
  2259. expect((await call('managed_browser_open',{url:'https://example.com/'})).isError).not.toBe(true)
  2260. expect((await call('computer_shell',{command:'pwd'})).isError).not.toBe(true)
  2261. saveHostTools(home,{vm:false})
  2262. expect((await call('computer_shell',{command:'pwd'})).isError).toBe(true)
  2263. saveHostTools(home,{vm:true})
  2264. }catch(error){integrationError=error}
  2265. socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'回归完成',status:'complete'}}}))
  2266. })()}
  2267. let statusAtVmCompletion:string|undefined
  2268. observeGatewayRequest=frame=>{if(frame.method==='computer.complete')statusAtVmCompletion=store.list<any>(owner,'turn').find(work=>work.agentId===bot.id)?.status}
  2269. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'打开网页,然后在虚拟机执行 pwd'}).id)
  2270. if(integrationError)throw integrationError
  2271. expect(browserOpen).toHaveBeenCalledOnce();expect(browserCall).toHaveBeenCalledWith('managed_browser_open',{url:'https://example.com/'},expect.any(String))
  2272. expect(vmTarget).toHaveBeenCalledWith(owner,expect.objectContaining({id:bot.id,execution:'computer'}))
  2273. expect(requests.filter(call=>call.method==='computer.invoke')).toHaveLength(1)
  2274. expect(requests.find(call=>call.method==='computer.invoke')?.params).toMatchObject({name:'computer_shell',arguments:{command:'pwd'}})
  2275. expect(requests.filter(call=>call.method==='computer.complete')).toHaveLength(1)
  2276. expect(statusAtVmCompletion).toBe('running')
  2277. const saved=store.list<any>(owner,'binding')[0]
  2278. store.put(owner,'binding',saved.id,{...saved,executionPolicyVersion:undefined})
  2279. reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))
  2280. const before=requests.length
  2281. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'继续'}).id)
  2282. expect(requests.slice(before).filter(call=>call.method==='session.create')).toHaveLength(1)
  2283. expect(store.list<any>(owner,'binding')[0].executionPolicyVersion).toBe(2)
  2284. const afterReset=requests.length
  2285. await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'再次继续'}).id)
  2286. expect(requests.slice(afterReset).some(call=>call.method==='session.create')).toBe(false)
  2287. expect(requests.slice(afterReset).some(call=>call.method==='session.resume')).toBe(true)
  2288. })