workspaceRoutes.test.ts 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { randomUUID } from 'node:crypto'
  7. import { DatabaseSync } from 'node:sqlite'
  8. import request from 'supertest'
  9. import type Koa from 'koa'
  10. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  11. import { loadServerConfig } from '../../src/server/config'
  12. import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
  13. import { WorkspaceAssets } from '../../src/server/workspaceAssets'
  14. import type { StoredWorkspaceFile } from '../../src/server/workspaceAssets'
  15. import { saveFileAccess } from '../../src/server/fileAccess'
  16. import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
  17. import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
  18. import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
  19. let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
  20. const first: LocalUser = {
  21. id: 'first',
  22. username: 'first',
  23. role: 'admin',
  24. enabled: true,
  25. mustChangePassword: false,
  26. createdAt: 1,
  27. updatedAt: 1,
  28. }
  29. class TestAuth extends LocalAuthStore {
  30. override current(ctx: Koa.Context) {
  31. return this.require(ctx)
  32. }
  33. override require(ctx: Koa.Context) {
  34. return { ...first, id: ctx.get('x-test-user') || 'first' }
  35. }
  36. override requireAdmin(ctx: Koa.Context) {
  37. return this.require(ctx)
  38. }
  39. override canUseSource() { return true }
  40. override currentFromCookieHeader() {
  41. return first
  42. }
  43. override isUserActive() {
  44. return true
  45. }
  46. }
  47. function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, user = 'first') {
  48. return request(runtime.app.callback())
  49. [method](path)
  50. .set('Host', '127.0.0.1:15300')
  51. .set('Origin', 'http://127.0.0.1:15300')
  52. .set('Cookie', cookie)
  53. .set('X-CSRF-Token', csrf)
  54. .set('x-test-user', user)
  55. }
  56. function shareComputer(...agents: WorkspaceAgent[]): SharedComputer {
  57. const store = runtime.workspace
  58. const shared = new SharedComputers(store, runtime.auth, runtime.workspaceRuntime.nodes, runtime.runners)
  59. for (const agent of agents) {
  60. shared.attachLocalVm('first', agent, 'fixture-runner')
  61. store.put('first', 'agent', agent.id, agent)
  62. }
  63. return store.require('first', 'shared-computer', agents[0]!.computerEnvironmentId!)
  64. }
  65. it('serves file-backed project and memory CRUD with revisions and account isolation', async () => {
  66. const a = runtime.workspace.createAgent('first', { name: '记忆甲', profile: 'default' })
  67. const b = runtime.workspace.createAgent('first', { name: '记忆乙', profile: 'default' })
  68. const group = runtime.workspace.createGroup('first', { name: '项目群', memberIds: [a.id, b.id], collaborationMode: 'discussion' })
  69. const project = await req('post', '/api/app/workspace/projects').send({ requestId: randomUUID(), name: '项目', description: '多群共享', memberIds: [a.id, b.id], groupIds: [group.id] }).expect(200)
  70. const id = project.body.project.id
  71. const created = await req('post', '/api/app/workspace/memories').send({ requestId: randomUUID(), scope: 'project', projectId: id, agentId: a.id, content: '接口兼容旧版本', tier: 'profile' }).expect(200)
  72. const memory = created.body.memory
  73. expect((await req('get', `/api/app/workspace/memories?scope=project&projectId=${id}`).expect(200)).body.memories).toHaveLength(1)
  74. await req('post', '/api/app/workspace/memories').send({ requestId: randomUUID(), scope: 'project', projectId: id, agentId: a.id, id: memory.id, expectedRevision: 0, content: '旧版本修改', tier: 'profile' }).expect(400)
  75. expect((await req('get', `/api/app/workspace/memories/${memory.id}/revisions?scope=project&agentId=${a.id}&projectId=${id}`).expect(200)).body.revisions).toHaveLength(1)
  76. expect((await req('get', '/api/app/workspace/projects', 'second').expect(200)).body.projects).toEqual([])
  77. expect((await req('get', `/api/app/workspace/memory-export?scope=project&projectId=${id}`).expect(200)).text).toContain('接口兼容旧版本')
  78. await req('post', '/api/app/workspace/memories/forget').send({ requestId: randomUUID(), scope: 'project', projectId: id, agentId: a.id, id: memory.id, expectedRevision: 1 }).expect(200)
  79. expect((await req('get', `/api/app/workspace/memories?scope=project&projectId=${id}`).expect(200)).body.memories).toEqual([])
  80. })
  81. beforeEach(async () => {
  82. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-routes-'))
  83. upstream = []
  84. bridgeReady = false
  85. const config = loadServerConfig({
  86. HERMES_YAOYAO_HOME: home,
  87. HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
  88. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  89. })
  90. runtime = createApplication({
  91. config,
  92. auth: new TestAuth(home, false),
  93. fetchImpl: (async (input) => {
  94. const path = new URL(String(input)).pathname
  95. upstream.push(path)
  96. const body =
  97. path === '/api/plugins/yaoyao-bot-bridge/capabilities' && bridgeReady
  98. ? {version:1,ready:true,in_process:true,native_tools:true}
  99. : path === '/api/pair/v1/capabilities'
  100. ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64)}
  101. : path === '/api/pair/v1/claim'
  102. ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64),deviceId:'22222222-2222-4222-8222-222222222222',token:'delegated-token',scopes:['agents.read','sessions.execute','history.read'],serverUrl:'http://child.test:15300/node/22222222-2222-4222-8222-222222222222'}
  103. : path === '/api/status'
  104. ? { auth_required: true }
  105. : path === '/api/auth/me'
  106. ? { user_id: 'upstream' }
  107. : path.endsWith('/api/profiles')
  108. ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
  109. : { ok: true }
  110. return new Response(JSON.stringify(body), {
  111. status: path.includes('/plugins/') && !bridgeReady ? 404 : 200,
  112. headers: { 'Content-Type': 'application/json' },
  113. })
  114. }) as typeof fetch,
  115. })
  116. const b = await request(runtime.app.callback())
  117. .get('/api/app/bootstrap')
  118. .set('Host', '127.0.0.1:15300')
  119. .expect(200)
  120. cookie = (b.headers['set-cookie'] as unknown as string[]).map((v) => v.split(';')[0]).join('; ')
  121. csrf = b.body.csrfToken
  122. })
  123. afterEach(() => {
  124. runtime.close()
  125. rmSync(home, { recursive: true, force: true })
  126. })
  127. describe('application workspace HTTP contract', () => {
  128. it.each(['empty', 'existing'] as const)('serves a complete %s Bot snapshot before any Runner or virtual machine is created', async state => {
  129. const store = runtime.workspace
  130. expect(runtime.runners.records()).toEqual([])
  131. expect(store.list('first', 'shared-computer')).toEqual([])
  132. let conversation: WorkspaceConversation | undefined
  133. if (state === 'existing') {
  134. store.createAgent('first', { name: '尚未配置电脑的 Bot', profile: 'default' })
  135. conversation = store.list<WorkspaceConversation>('first', 'conversation')[0]!
  136. store.saveMessage('first', { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'assistant',
  137. content: '没有虚拟机也能读取聊天记录', reasoning: '', status: 'complete', tools: [], attachments: [], createdAt: Date.now() })
  138. }
  139. const requestsBeforeSnapshot = [...upstream]
  140. const response = await req('get', '/api/app/workspace/snapshot').expect(200).expect('Content-Type', /json/)
  141. const snapshot = response.body
  142. expect(Number.isSafeInteger(snapshot.cursor)).toBe(true)
  143. expect(snapshot.cursor).toBeGreaterThanOrEqual(0)
  144. for (const key of ['agents', 'conversations', 'details', 'projects']) expect(Array.isArray(snapshot[key])).toBe(true)
  145. const client = new WorkspaceTranscriptStore()
  146. expect(() => client.hydrate(snapshot)).not.toThrow()
  147. expect(client.cursor).toBe(snapshot.cursor)
  148. if (conversation) {
  149. expect(client.conversations.map(row => row.id)).toEqual([conversation.id])
  150. expect(client.get(conversation.id)?.messages.map(message => message.content)).toEqual(['没有虚拟机也能读取聊天记录'])
  151. } else {
  152. expect(snapshot).toMatchObject({ cursor: 0, agents: [], conversations: [], details: [] })
  153. }
  154. expect(runtime.runners.records()).toEqual([])
  155. expect(store.list('first', 'shared-computer')).toEqual([])
  156. expect(upstream).toEqual(requestsBeforeSnapshot)
  157. })
  158. it('does not advertise retired remote Bot references and preserves their existing history', async () => {
  159. const store = runtime.workspace
  160. const agent = store.createAgent('first', { name: '历史远程 Bot', profile: 'default' })
  161. store.put('first', 'agent', agent.id, { ...agent, remoteAgentId: randomUUID() })
  162. const conversation = store.list<import('../../src/shared/workspace').WorkspaceConversation>('first', 'conversation')[0]!
  163. store.saveMessage('first', { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'assistant', content: '保留历史回复', reasoning: '', status: 'complete', tools: [], attachments: [], createdAt: Date.now() })
  164. const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
  165. expect(capabilities).not.toContain('remoteAgentReferences')
  166. expect((await req('get', `/api/app/nodes/${randomUUID()}/agents`).expect(410)).body.code).toBe('remote_agent_removed')
  167. expect((await req('post', '/api/app/agents/remote').send({ nodeId: randomUUID(), agentId: randomUUID() }).expect(410)).body.code).toBe('remote_agent_removed')
  168. expect((await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body.messages.some((message: { content: string }) => message.content === '保留历史回复')).toBe(true)
  169. expect(store.get('first', 'agent', agent.id)).toBeDefined()
  170. })
  171. it('uses the same ordering for snapshots, list refreshes and pin/message events', async () => {
  172. const store = runtime.workspace
  173. store.createAgent('first', { name: '排序验证', profile: 'default' })
  174. const base = store.list<import('../../src/shared/workspace').WorkspaceConversation>('first', 'conversation')[0]!
  175. store.remove('first', 'conversation', base.id)
  176. for (const item of [
  177. { id: 'a-old', lastMessageAt: 100, pinned: false },
  178. { id: 'b-new', lastMessageAt: 200, pinned: false },
  179. { id: 'z-pin', lastMessageAt: 50, pinned: true },
  180. ]) store.put('first', 'conversation', item.id, { ...base, ...item })
  181. const snapshot = (await req('get', '/api/app/workspace/snapshot').expect(200)).body
  182. const list = (await req('get', '/api/app/conversations').expect(200)).body.conversations
  183. expect(snapshot.conversations.map((c: { id: string }) => c.id)).toEqual(['z-pin', 'b-new', 'a-old'])
  184. expect(list.map((c: { id: string }) => c.id)).toEqual(snapshot.conversations.map((c: { id: string }) => c.id))
  185. const clients = [new WorkspaceTranscriptStore(), new WorkspaceTranscriptStore()]
  186. clients.forEach(client => client.hydrate(snapshot))
  187. const stop = store.observe((owner, event) => { if (owner === 'first') clients.forEach(client => client.apply(event)) })
  188. try {
  189. store.saveMessage('first', { id: randomUUID(), conversationId: 'a-old', seq: 0, role: 'assistant',
  190. content: '新的回复', reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 300 })
  191. clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(['z-pin', 'a-old', 'b-new']))
  192. await req('patch', '/api/app/conversations/a-old').send({ pinned: true }).expect(200)
  193. const expected = ['a-old', 'z-pin', 'b-new']
  194. clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(expected))
  195. expect((await req('get', '/api/app/workspace/snapshot').expect(200)).body.conversations.map((c: { id: string }) => c.id)).toEqual(expected)
  196. expect((await req('get', '/api/app/conversations').expect(200)).body.conversations.map((c: { id: string }) => c.id)).toEqual(expected)
  197. } finally { stop() }
  198. })
  199. it.each(['direct', 'group'] as const)('uses versioned unread acknowledgments for %s without recounting history', async kind => {
  200. const store = runtime.workspace
  201. const first = store.createAgent('first', { name: '未读甲', profile: 'default' })
  202. const second = store.createAgent('first', { name: '未读乙', profile: 'default' })
  203. const conversation = kind === 'group'
  204. ? store.createGroup('first', { name: '未读群', memberIds: [first.id, second.id], administratorId: first.id })
  205. : store.list<any>('first', 'conversation').find(c => c.memberIds[0] === first.id)
  206. const task = kind === 'group' ? store.tasks('first', conversation.id)[0] : undefined
  207. const message: import('../../src/shared/workspace').WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, conversationTaskId: task?.id,
  208. seq: 0, role: 'assistant', content: '进行中', reasoning: '', status: 'streaming', attachments: [], tools: [], createdAt: Date.now() }
  209. store.saveMessage('first', message)
  210. const snapshot = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
  211. const read = snapshot.task ?? snapshot.conversation
  212. expect(read.unread).toBe(false)
  213. message.status = 'complete'; message.content = '已完成'; store.saveMessage('first', message)
  214. const path = `/api/app/conversations/${conversation.id}${task ? `/tasks/${task.id}` : ''}/read`
  215. const display = vi.spyOn(store, 'messageForDisplay')
  216. const stale = (await req('put', path).send({ seq: read.lastSeq, unreadVersion: read.unreadVersion }).expect(200)).body
  217. expect(stale.conversation.unread).toBe(true)
  218. const current = stale.task ?? stale.conversation
  219. const acknowledged = (await req('put', path).send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(200)).body
  220. expect(acknowledged.conversation).toMatchObject({ unread: false, unreadCount: 0 })
  221. expect(display).not.toHaveBeenCalled()
  222. await req('put', path, 'foreign').send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(404)
  223. await req('put', path).send({ seq: current.lastSeq, unreadVersion: -1 }).expect(400)
  224. display.mockRestore()
  225. })
  226. it.each(['archive', 'delete'] as const)('%s removes a confirmed ordinary member from all groups atomically', async action => {
  227. const store = runtime.workspace
  228. const lead = store.createAgent('first', { name: '管理者', profile: 'default' })
  229. const bot = store.createAgent('first', { name: '待处理成员', profile: 'default' })
  230. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  231. const group = store.createGroup('first', { name: '设计群', memberIds: [lead.id, bot.id], administratorId: lead.id, autoReplyIds: [bot.id], memberRoles: { [bot.id]: { name: '设计', description: '' } } })
  232. const oldGroup = store.createGroup('first', { name: '旧群', memberIds: [lead.id, bot.id], administratorId: lead.id })
  233. store.updateConversation('first', oldGroup.id, { archived: true })
  234. const path = `/api/app/conversations/${direct.id}/lifecycle`
  235. const preview = (await req('get', path).expect(200)).body
  236. expect(preview.groups).toEqual(expect.arrayContaining([{ id: group.id, name: '设计群', administrator: false }, { id: oldGroup.id, name: '旧群', administrator: false }]))
  237. await req('post', path).send({ action }).expect(409)
  238. expect((await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(409)).body.code).toBe('agent_in_group')
  239. await req('get', path, 'foreign').expect(404)
  240. await req('post', path, 'foreign').send({ action, confirmationToken: preview.confirmationToken }).expect(404)
  241. await req('post', path).set('X-CSRF-Token', 'invalid').send({ action, confirmationToken: preview.confirmationToken }).expect(403)
  242. await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
  243. expect(store.require<any>('first', 'conversation', group.id)).toMatchObject({ memberIds: [lead.id], autoReplyIds: [], memberRoles: {}, administratorId: lead.id })
  244. expect(store.require<any>('first', 'conversation', oldGroup.id).memberIds).toEqual([lead.id])
  245. if (action === 'delete') {
  246. expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  247. expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
  248. } else {
  249. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(true)
  250. await req('post', path).send({ action: 'restore' }).expect(200)
  251. expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
  252. expect(store.require<any>('first', 'conversation', group.id).memberIds).toEqual([lead.id])
  253. }
  254. expect(store.get('first', 'agent', lead.id)).toBeDefined()
  255. })
  256. it.each(['archive', 'delete'] as const)('blocks %s for a group administrator without removing other memberships', async action => {
  257. const store = runtime.workspace
  258. const bot = store.createAgent('first', { name: '群管理者', profile: 'default' })
  259. const other = store.createAgent('first', { name: '其他成员', profile: 'default' })
  260. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  261. const regular = store.createGroup('first', { name: '普通群', memberIds: [bot.id, other.id], administratorId: other.id })
  262. const managed = store.createGroup('first', { name: '管理群', memberIds: [bot.id, other.id], administratorId: bot.id })
  263. const path = `/api/app/conversations/${direct.id}/lifecycle`
  264. const preview = (await req('get', path).expect(200)).body
  265. const cursor = store.cursor('first')
  266. const result = await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(409)
  267. expect(result.body.code).toBe('agent_group_administrator')
  268. expect(result.body.error).toContain('管理群')
  269. expect(store.require<any>('first', 'conversation', regular.id).memberIds).toContain(bot.id)
  270. expect(store.require<any>('first', 'conversation', managed.id).administratorId).toBe(bot.id)
  271. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
  272. expect(store.cursor('first')).toBe(cursor)
  273. })
  274. it.each(['archive', 'delete'] as const)('%s detaches only the selected Bot from its automatic shared computer', async action => {
  275. const store = runtime.workspace
  276. const bot = store.createAgent('first', { name: '待移除共享成员', profile: 'default' })
  277. const other = store.createAgent('first', { name: '继续使用电脑', profile: 'default' })
  278. const shared = shareComputer(bot, other)
  279. const otherBefore = store.require('first', 'agent', other.id)
  280. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  281. const path = `/api/app/conversations/${direct.id}/lifecycle`
  282. const preview = (await req('get', path).expect(200)).body
  283. // Another member's running task must not prevent removing this idle Bot.
  284. store.put('first', 'turn', 'other-running', { id: 'other-running', agentId: other.id, status: 'running' })
  285. await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
  286. expect(store.require('first', 'shared-computer', shared.id)).toEqual({ ...shared, memberIds: [other.id] })
  287. expect(store.require('first', 'agent', other.id)).toEqual(otherBefore)
  288. if (action === 'delete') expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  289. else {
  290. const archived = store.require<WorkspaceAgent>('first', 'agent', bot.id)
  291. expect(archived.archived).toBe(true)
  292. expect(archived.computerEnvironmentId).toBeUndefined()
  293. expect(archived.computerEnvironmentName).toBeUndefined()
  294. }
  295. store.remove('first', 'turn', 'other-running')
  296. })
  297. it('keeps shared membership intact while the Bot is busy or its computer is under human control', async () => {
  298. const store = runtime.workspace
  299. const bot = store.createAgent('first', { name: '电脑成员', profile: 'default' })
  300. const shared = shareComputer(bot)
  301. const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
  302. const path = `/api/app/conversations/${direct.id}/lifecycle`
  303. const preview = (await req('get', path).expect(200)).body
  304. for (const status of ['queued', 'running', 'waiting', 'uncertain', 'cancelling']) {
  305. store.put('first', 'turn', 'busy', { id: 'busy', agentId: bot.id, status })
  306. expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_running')
  307. }
  308. store.remove('first', 'turn', 'busy')
  309. store.put('_system', 'computer-control', 'human', { owner: 'first', agentId: randomUUID(), environmentId: shared.id, expiresAt: Date.now() + 30_000 })
  310. expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_execution_busy')
  311. expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
  312. expect(store.require('first', 'agent', bot.id)).toEqual(bot)
  313. store.remove('_system', 'computer-control', 'human')
  314. await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(200)
  315. expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
  316. expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBeUndefined()
  317. })
  318. it.each(['manual', 'compose', 'missing'] as const)('deletes a legacy archived Bot with a %s computer binding', async mode => {
  319. const store = runtime.workspace
  320. const bot = store.createAgent('first', { name: '旧版归档成员', profile: 'default' })
  321. const shared = shareComputer(bot)
  322. const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
  323. store.put('first', 'agent', bot.id, { ...bot, archived: true })
  324. store.put('first', 'conversation', direct.id, { ...direct, archived: true })
  325. if (mode === 'missing') store.remove('first', 'shared-computer', shared.id)
  326. else store.put('first', 'shared-computer', shared.id, { ...shared, managedLocalVm: undefined, managedCompose: mode === 'compose' })
  327. await req('delete', `/api/app/agents/${bot.id}`).expect(200)
  328. expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  329. expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
  330. if (mode !== 'missing') expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
  331. })
  332. it('rejects a stale group confirmation and rolls back membership on a later deletion failure', async () => {
  333. const store = runtime.workspace
  334. const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
  335. const bot = store.createAgent('first', { name: '成员', profile: 'default' })
  336. const shared = shareComputer(bot, lead)
  337. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  338. const path = `/api/app/conversations/${direct.id}/lifecycle`
  339. const old = (await req('get', path).expect(200)).body
  340. const group = store.createGroup('first', { name: '后来加入的群', memberIds: [lead.id, bot.id], administratorId: lead.id })
  341. expect((await req('post', path).send({ action: 'delete', confirmationToken: old.confirmationToken }).expect(409)).body.code).toBe('lifecycle_confirmation_stale')
  342. const current = (await req('get', path).expect(200)).body
  343. store.put('first', 'turn', 'pending', { id: 'pending', agentId: bot.id, conversationId: group.id, status: 'uncertain' })
  344. await req('post', path).send({ action: 'archive', confirmationToken: current.confirmationToken }).expect(409)
  345. store.remove('first', 'turn', 'pending')
  346. const cursor = store.cursor('first')
  347. const failure = vi.spyOn(store, 'deleteAgent').mockImplementation(() => { throw new Error('模拟写入失败') })
  348. await req('post', path).send({ action: 'delete', confirmationToken: current.confirmationToken }).expect(500)
  349. failure.mockRestore()
  350. expect(store.require<any>('first', 'conversation', group.id).memberIds).toContain(bot.id)
  351. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
  352. expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
  353. expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
  354. expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBe(shared.id)
  355. expect(store.cursor('first')).toBe(cursor)
  356. })
  357. it('retries remote cleanup through either delete endpoint after the local Bot and chat are gone',async()=>{
  358. const store=runtime.workspace,bot=store.createAgent('first',{name:'远端清理重试',profile:'default'})
  359. const direct=store.list<WorkspaceConversation>('first','conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
  360. const path=`/api/app/conversations/${direct.id}/lifecycle`,preview=(await req('get',path).expect(200)).body
  361. const cleanup=vi.spyOn(runtime.workspaceRuntime,'cleanupAgent').mockRejectedValueOnce(new Error('模拟远端清理失败')).mockResolvedValue(undefined)
  362. await req('delete',`/api/app/agents/${bot.id}`).expect(409)
  363. expect(cleanup).not.toHaveBeenCalled()
  364. await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(500)
  365. expect(store.get('first','agent',bot.id)).toBeUndefined()
  366. expect(store.get('first','conversation',direct.id)).toBeUndefined()
  367. expect(store.get('first','agent-deletion',bot.id)).toBeTruthy()
  368. await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(200)
  369. await req('delete',`/api/app/agents/${bot.id}`).expect(200)
  370. expect(cleanup).toHaveBeenCalledTimes(3)
  371. await req('delete',`/api/app/agents/${bot.id}`,'another').expect(404)
  372. cleanup.mockRestore()
  373. })
  374. it('deletes an unarchived group while preserving its member Bots', async () => {
  375. const store = runtime.workspace
  376. const a = store.createAgent('first', { name: '甲', profile: 'default' }), b = store.createAgent('first', { name: '乙', profile: 'default' })
  377. const group = store.createGroup('first', { name: '待删除群', memberIds: [a.id, b.id], administratorId: a.id })
  378. const path = `/api/app/conversations/${group.id}/lifecycle`
  379. const preview = (await req('get', path).expect(200)).body
  380. await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(200)
  381. expect(store.get('first', 'conversation', group.id)).toBeUndefined()
  382. expect(store.get('first', 'agent', a.id)).toBeDefined()
  383. expect(store.get('first', 'agent', b.id)).toBeDefined()
  384. })
  385. it('deletes only archived owned chats, clears their records, and preserves library files and members', async () => {
  386. const store = runtime.workspace
  387. const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
  388. const member = store.createAgent('first', { name: '成员', profile: 'default' })
  389. const group = store.createGroup('first', { name: '待删除群聊', memberIds: [lead.id, member.id], administratorId: lead.id })
  390. const task = store.tasks('first', group.id)[0]!
  391. const path = `/api/app/conversations/${group.id}`
  392. await req('delete', path).expect(409)
  393. store.updateConversation('first', group.id, { archived: true })
  394. store.put('second', 'conversation', group.id, group)
  395. for (const kind of ['message', 'run', 'turn', 'interaction', 'interaction-binding', 'binding', 'assignment']) {
  396. store.put('first', kind, kind, { id: kind, conversationId: group.id, conversationTaskId: task.id, status: 'complete' })
  397. store.put('second', kind, kind, { id: kind, conversationId: group.id })
  398. }
  399. store.put('first', 'context', task.id, { used: 5 })
  400. const bindingKey = `${group.id}:${task.id}:${lead.id}`
  401. store.put('first', 'binding', bindingKey, { conversationTaskId: task.id, sessionId: 'session' })
  402. store.put('first', 'interaction-binding', 'legacy-interaction', { key: bindingKey, taskId: 'turn' })
  403. store.put('second', 'binding', bindingKey, { sessionId: 'other-session' })
  404. store.put('first', 'file', 'file', { id: 'file', conversationId: group.id, messageId: 'message', name: '保留.txt' })
  405. await req('delete', path, 'third').expect(404)
  406. await req('delete', path).set('X-CSRF-Token', 'invalid').expect(403)
  407. await req('delete', path).expect(200)
  408. await req('get', path).expect(404)
  409. expect(store.get('first', 'context', task.id)).toBeUndefined()
  410. expect(store.get('first', 'conversation-task', task.id)).toBeUndefined()
  411. expect(store.list('first', 'message')).toEqual([])
  412. expect(store.list('first', 'run')).toEqual([])
  413. expect(store.list('first', 'binding')).toEqual([])
  414. expect(store.list('first', 'interaction-binding')).toEqual([])
  415. expect(store.get('second', 'binding', bindingKey)).toEqual({ sessionId: 'other-session' })
  416. expect(store.get('first', 'file', 'file')).toEqual({ id: 'file', name: '保留.txt' })
  417. expect(store.list('first', 'agent')).toHaveLength(2)
  418. expect(store.get('second', 'conversation', group.id)).toEqual(group)
  419. expect(store.get('second', 'message', 'message')).toBeDefined()
  420. expect((await req('get', '/api/app/events')).body.events.some((e: any) => e.type === 'conversation.deleted' && e.conversationId === group.id)).toBe(true)
  421. })
  422. it('deletes an archived Bot and its direct chat only after group references are removed', async () => {
  423. const store = runtime.workspace
  424. const bot = store.createAgent('first', { name: '归档 Bot', profile: 'default' })
  425. const member = store.createAgent('first', { name: '成员', profile: 'default' })
  426. const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)!
  427. const group = store.createGroup('first', { name: '关联群', memberIds: [bot.id, member.id], administratorId: member.id })
  428. const path = `/api/app/agents/${bot.id}`
  429. await req('delete', path).expect(409)
  430. store.updateAgent('first', bot.id, { archived: true })
  431. await req('delete', `/api/app/conversations/${direct.id}`).expect(400)
  432. expect((await req('delete', path).expect(409)).body.code).toBe('agent_in_group')
  433. expect(store.get('first', 'agent', bot.id)).toBeDefined()
  434. store.updateConversation('first', group.id, { memberIds: [member.id] })
  435. await req('delete', path, 'second').expect(404)
  436. await req('delete', path).expect(200)
  437. expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  438. expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
  439. expect(store.get('first', 'conversation', group.id)).toBeDefined()
  440. expect(store.get('first', 'agent', member.id)).toBeDefined()
  441. })
  442. it('keeps archived chats intact while runs or delegated goals are unresolved', async () => {
  443. const store = runtime.workspace
  444. const bot = store.createAgent('first', { name: '运行中', profile: 'default' })
  445. const direct = store.list<any>('first', 'conversation')[0]!
  446. store.updateAgent('first', bot.id, { archived: true })
  447. store.put('first', 'run', 'pending', { id: 'pending', conversationId: direct.id, status: 'uncertain' })
  448. const path = `/api/app/agents/${bot.id}`
  449. await req('delete', path).expect(409)
  450. expect(store.get('first', 'conversation', direct.id)).toBeDefined()
  451. store.remove('first', 'run', 'pending')
  452. store.put('first', 'goal', 'delegated', { id: 'delegated', conversationId: 'another-chat', origin: { conversationId: direct.id }, status: 'waiting' })
  453. await req('delete', path).expect(409)
  454. expect(store.get('first', 'agent', bot.id)).toBeDefined()
  455. })
  456. it('resumes a stopped goal from a real user request without duplicating its run',async()=>{
  457. vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
  458. vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()
  459. const lead=runtime.workspace.createAgent('first',{name:'任务负责人',profile:'default',canManageTeam:true})
  460. const member=runtime.workspace.createAgent('first',{name:'成员',profile:'default'})
  461. const source=runtime.workspace.list<any>('first','conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
  462. const original=runtime.workspaceRuntime.send('first',source.id,{requestId:randomUUID(),content:'完成任务'})
  463. const team=runtime.workspace.createGroup('first',{name:'任务组',memberIds:[lead.id,member.id],administratorId:lead.id})
  464. const task=runtime.workspace.tasks('first',team.id)[0]!
  465. runtime.workspaceRuntime.tasks.begin('first',task,lead,'完成任务',{conversationId:source.id,runId:original.id,agentId:lead.id})
  466. await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/stop`).send({}).expect(200)
  467. const input={requestId:randomUUID()}
  468. const resumed=await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`).send(input).expect(200)
  469. expect(resumed.body.goal.status).toBe('running')
  470. const repeated=await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`).send(input).expect(200)
  471. expect(repeated.body.run.id).toBe(resumed.body.run.id)
  472. await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`,'second').send({requestId:randomUUID()}).expect(404)
  473. })
  474. it('starts an explicit delivery and lets only its owner edit versioned acceptance criteria',async()=>{
  475. vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
  476. vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()
  477. const lead=runtime.workspace.createAgent('first',{name:'负责人',profile:'default',canManageTeam:true})
  478. const member=runtime.workspace.createAgent('first',{name:'成员',profile:'default'})
  479. const team=runtime.workspace.createGroup('first',{name:'交付群',memberIds:[lead.id,member.id],administratorId:lead.id})
  480. const task=runtime.workspace.tasks('first',team.id)[0]!
  481. const path=`/api/app/conversations/${team.id}`
  482. const input={requestId:randomUUID(),taskId:task.id,content:'完成并交付报告',mode:'goal'}
  483. const sent=await req('post',`${path}/messages`).send(input).expect(202)
  484. expect(sent.body.run.goalId).toBe(task.id)
  485. expect((await req('post',`${path}/messages`).send(input).expect(202)).body.run.id).toBe(sent.body.run.id)
  486. expect((await req('get',`${path}/tasks/${task.id}/plan`).expect(200)).body.assignments).toEqual([])
  487. const update={requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['包含三种方案的比较']}
  488. const edited=await req('patch',`${path}/tasks/${task.id}/plan`).send(update).expect(200)
  489. expect(edited.body.goal).toMatchObject({acceptanceRevision:2,acceptanceCriteria:update.acceptanceCriteria})
  490. await req('patch',`${path}/tasks/${task.id}/plan`,'second').send({...update,requestId:randomUUID()}).expect(404)
  491. expect((await req('patch',`${path}/tasks/${task.id}/plan`).send({...update,requestId:randomUUID()}).expect(409)).body.code).toBe('goal_criteria_changed')
  492. })
  493. it('creates a bot with team tools on by default and does not require the bridge', async () => {
  494. bridgeReady = false
  495. const created = await req('post','/api/app/agents').send({name:'老板',profile:'default'})
  496. expect(created.status, JSON.stringify(created.body)).toBe(201)
  497. const agent = created.body.agent
  498. expect(agent.canManageTeam).toBe(true)
  499. await req('patch',`/api/app/agents/${agent.id}`,'second').send({name:'别人改的'}).expect(404)
  500. expect((await req('get','/api/app/agents')).body.agents[0].canManageTeam).toBe(true)
  501. })
  502. it('accepts only child pairing, hides credentials, and scopes address edits to the owner', async () => {
  503. const code = new URL('yaoyao://pair')
  504. for (const [key,value] of Object.entries({v:'1',url:'http://child.test:15300',node:'11111111-1111-4111-8111-111111111111',id:'33333333-3333-4333-8333-333333333333',fingerprint:'f'.repeat(64),secret:'s'.repeat(64)})) code.searchParams.set(key,value)
  505. await req('post','/api/app/nodes').send({name:'legacy',url:'http://child.test:9119',username:'user',password:'password'}).expect(400)
  506. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href.replace('://pair','://login')}).expect(400)
  507. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href}).expect(201)
  508. const listing=await req('get','/api/app/nodes').expect(200), node=listing.body.nodes[0]
  509. expect(node.transport).toBe('paired-web'); expect(JSON.stringify(listing.body)).not.toContain('delegated-token'); expect(node.secret).toBeUndefined()
  510. expect((await req('get','/api/app/nodes','second').expect(200)).body.nodes).toEqual([])
  511. await req('patch',`/api/app/nodes/${node.id}`,'second').send({name:'other',url:'http://new-ip.test:15300'}).expect(404)
  512. await req('patch',`/api/app/nodes/${node.id}`).send({name:'new name',url:'http://new-ip.test:15300'}).expect(200)
  513. const updated=(await req('get','/api/app/nodes').expect(200)).body.nodes[0]
  514. expect(updated.id).toBe(node.id); expect(updated.url).toBe('http://new-ip.test:15300/')
  515. const sources=(await req('get','/api/app/agents/sources').expect(200)).body.sources
  516. // Pairing still manages the node; retired remote Profiles are not Bot sources.
  517. expect(sources.some((s:any)=>s.nodeId===node.id)).toBe(false)
  518. })
  519. it('archives native-chat uploads through the standard file API with user ownership', async () => {
  520. saveFileAccess(home, { mode: 'all', folders: [] })
  521. const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
  522. await assets.archiveText(
  523. 'first',
  524. '{"path":"/tmp/native-upload.txt"}',
  525. 'local',
  526. 'default',
  527. 'native-session',
  528. 'native-message',
  529. 'user',
  530. )
  531. const page = await req('get', '/api/app/files').expect(200)
  532. expect(page.body.items[0]).toMatchObject({
  533. name: 'native-upload.txt',
  534. sender: 'user',
  535. profile: 'default',
  536. })
  537. expect(upstream).toContain('/api/files/download')
  538. expect((await req('get', '/api/app/files', 'second')).body.items).toEqual([])
  539. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  540. })
  541. it('works without plugin discovery, installation, or any plugin HTTP request', async () => {
  542. const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
  543. expect(capabilities).toContain('editableGroups')
  544. expect(capabilities).not.toContain('immutableGroups')
  545. await req('get', '/api/app/files').expect(200)
  546. await req('get', '/api/app/voice/runtime').expect(200)
  547. await req('get', '/api/app/system/update/status').expect(200)
  548. await req('post', '/api/app/plugins/yaoyao/reconcile').send({}).expect(410)
  549. await req('get', '/api/plugins/yaoyao/v1/capabilities').expect(410)
  550. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  551. })
  552. it('changes members over HTTP while protecting the administrator and ownership', async () => {
  553. const a = (
  554. await req('post', '/api/app/agents').send({ name: '编辑', profile: 'default' }).expect(201)
  555. ).body.agent
  556. const b = (
  557. await req('post', '/api/app/agents').send({ name: '审查', profile: 'default' }).expect(201)
  558. ).body.agent
  559. const c = (
  560. await req('post', '/api/app/conversations')
  561. .send({ name: '团队', memberIds: [a.id, b.id], administratorId: a.id })
  562. .expect(201)
  563. ).body.conversation
  564. const rows = await req('get', '/api/app/conversations').expect(200)
  565. expect(rows.body.conversations).toHaveLength(3)
  566. await req('patch', `/api/app/conversations/${c.id}`)
  567. .send({ memberIds: [b.id], administratorId: b.id })
  568. .expect(400)
  569. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id] }).expect(200)
  570. expect((await req('get', `/api/app/conversations/${c.id}`)).body.conversation.memberIds).toEqual([a.id])
  571. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id, b.id] }).expect(200)
  572. await req('get', `/api/app/conversations/${c.id}`, 'second').expect(404)
  573. await req('patch', `/api/app/agents/${a.id}`, 'second').send({ name: '入侵' }).expect(404)
  574. expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])
  575. expect(upstream.some((p) => p.includes('sessions'))).toBe(false)
  576. })
  577. it('creates, lists, renames, selects, and deletes group tasks without changing remote members', async () => {
  578. const a = (
  579. await req('post', '/api/app/agents').send({ name: '本机管理员', profile: 'default' }).expect(201)
  580. ).body.agent
  581. const b = (
  582. await req('post', '/api/app/agents').send({ name: '远程成员', nodeId: 'local', profile: 'default' }).expect(201)
  583. ).body.agent
  584. const conversation = (
  585. await req('post', '/api/app/conversations')
  586. .send({ name: '多任务团队', memberIds: [a.id, b.id], administratorId: a.id })
  587. .expect(201)
  588. ).body.conversation
  589. const initial = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  590. expect(initial).toHaveLength(1)
  591. expect(initial[0]).toMatchObject({
  592. conversationId: conversation.id,
  593. title: '新任务',
  594. titleSource: 'automatic',
  595. messageCount: 0,
  596. })
  597. const task = (
  598. await req('post', `/api/app/conversations/${conversation.id}/tasks`)
  599. .send({ title: '远程 Agent 调研' })
  600. .expect(201)
  601. ).body.task
  602. expect(task).toMatchObject({ title: '远程 Agent 调研', titleSource: 'user' })
  603. const renamed = (
  604. await req('patch', `/api/app/conversations/${conversation.id}/tasks/${task.id}`)
  605. .send({ title: '远程 Agent 复核' })
  606. .expect(200)
  607. ).body.task
  608. expect(renamed).toMatchObject({ title: '远程 Agent 复核', titleSource: 'user' })
  609. const detail = (await req('get', `/api/app/conversations/${conversation.id}?taskId=${task.id}`).expect(200)).body
  610. expect(detail.task.id).toBe(task.id)
  611. expect(detail.tasks.map((row: { id: string }) => row.id)).toContain(task.id)
  612. expect(detail.messages).toEqual([])
  613. expect(detail.conversation).toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  614. expect((await req('put', `/api/app/conversations/${conversation.id}/tasks/${task.id}/read`).send({ seq: 100 }).expect(200)).body.task)
  615. .toMatchObject({ readSeq: 0, lastSeq: 0, unreadCount: 0 })
  616. await req('get', `/api/app/conversations/${conversation.id}/tasks`, 'second').expect(404)
  617. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${task.id}`).expect(200)
  618. const remaining = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  619. expect(remaining.map((row: { id: string }) => row.id)).not.toContain(task.id)
  620. expect((await req('get', `/api/app/conversations/${conversation.id}`)).body.conversation)
  621. .toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  622. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${initial[0].id}`).expect(200)
  623. const replacement = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  624. expect(replacement).toHaveLength(1)
  625. expect(replacement[0].id).not.toBe(initial[0].id)
  626. })
  627. it('archives files on the Web server, preserves native library fields, and blocks cross-user downloads', async () => {
  628. const f = (
  629. await req('post', '/api/app/uploads')
  630. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  631. .expect(201)
  632. ).body.files[0]
  633. expect(f.path).toBeUndefined()
  634. expect(runtime.uploads.cleanupUncommitted(0)).toBe(0)
  635. const list = await req('get', '/api/app/files').expect(200),
  636. item = list.body.items[0]
  637. expect(typeof item.id).toBe('number')
  638. expect(item.path).toBe(`/api/app/files/${f.id}/download`)
  639. expect(item).toMatchObject({ name: 'hello.txt', size: 5, exists: true, archiveStatus: 'ready' })
  640. expect((await req('get', item.path).expect(200)).text).toBe('hello')
  641. await req('get', item.path, 'second').expect(404)
  642. await req('get', `/api/app/files/${item.id}/download`, 'second').expect(404)
  643. expect((await req('get', '/api/app/files', 'second')).body.total).toBe(0)
  644. expect(upstream.some((p) => p.includes('/files'))).toBe(false)
  645. })
  646. it('does not serve uploaded HTML or SVG as executable same-origin previews', async () => {
  647. const f = (
  648. await req('post', '/api/app/uploads')
  649. .attach('files', Buffer.from('<svg onload="alert(1)"/>'), {
  650. filename: 'unsafe.svg',
  651. contentType: 'image/svg+xml',
  652. })
  653. .expect(201)
  654. ).body.files[0]
  655. const response = await req('get', `/api/app/files/${f.id}/preview`).expect(200)
  656. expect(response.headers['content-type']).toContain('application/octet-stream')
  657. expect(response.headers['content-disposition']).toContain('attachment')
  658. })
  659. it.each([
  660. '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx',
  661. 'café-附件-📄.txt',
  662. ])('preserves the UTF-8 upload name %s in metadata, library search and downloads', async name => {
  663. const file = (await req('post', '/api/app/uploads')
  664. .attach('files', Buffer.from('file contents'), { filename: name, contentType: 'application/octet-stream' })
  665. .expect(201)).body.files[0]
  666. expect(file.name).toBe(name)
  667. expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
  668. const library = await req('get', `/api/app/files?search=${encodeURIComponent(name)}`).expect(200)
  669. expect(library.body.items[0]?.name).toBe(name)
  670. const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
  671. expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
  672. })
  673. it('recovers legacy upload names in history, events, search and downloads without changing stored files', async () => {
  674. const name = '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx'
  675. const corrupted = Buffer.from(name, 'utf8').toString('latin1')
  676. const uploaded = (await req('post', '/api/app/uploads')
  677. .attach('files', Buffer.from('preserved file contents'), { filename: 'legacy.docx', contentType: 'application/octet-stream' })
  678. .expect(201)).body.files[0]
  679. const database = new DatabaseSync(join(home, 'uploads.sqlite3'))
  680. try { database.prepare('UPDATE uploads SET name=? WHERE id=?').run(corrupted, uploaded.id) }
  681. finally { database.close() }
  682. const file = { ...runtime.workspace.require<StoredWorkspaceFile>('first', 'file', uploaded.id), name: corrupted }
  683. runtime.workspace.put('first', 'file', file.id, file)
  684. const agent = runtime.workspace.createAgent('first', { name: '附件测试', profile: 'default' })
  685. const conversation = runtime.workspace.list<WorkspaceConversation>('first', 'conversation').find(c => c.memberIds[0] === agent.id)!
  686. runtime.workspace.saveMessage('first', {
  687. id: 'legacy-upload', conversationId: conversation.id, seq: 0, role: 'user', content: '请检查附件',
  688. reasoning: '', tools: [], attachments: [file], status: 'complete', createdAt: 1,
  689. })
  690. expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
  691. const history = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
  692. expect(history.messages[0]?.attachments[0]?.name).toBe(name)
  693. const event = runtime.workspace.events('first', 0).find(event => event.type === 'message.changed' && (event.data as { id: string }).id === 'legacy-upload')!
  694. expect((event.data as { attachments: { name: string }[] }).attachments[0]?.name).toBe(name)
  695. const library = await req('get', `/api/app/files?search=${encodeURIComponent('信息采集表')}`).expect(200)
  696. expect(library.body.items[0]?.name).toBe(name)
  697. const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
  698. expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
  699. expect(readFileSync(file.path, 'utf8')).toBe('preserved file contents')
  700. expect(runtime.workspace.require<StoredWorkspaceFile>('first', 'file', file.id).name).toBe(corrupted)
  701. })
  702. it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
  703. await req('put', '/api/app/admin/duplex-voice')
  704. .send({
  705. apiKey: 'private-test-key',
  706. voices: [
  707. { id: 'a', name: '甲' },
  708. { id: 'b', name: '乙' },
  709. ],
  710. currentVoiceId: 'a',
  711. })
  712. .expect(200)
  713. const publicResponse = await req('get', '/api/app/admin/duplex-voice').expect(200)
  714. expect(publicResponse.body.hasApiKey).toBe(true)
  715. expect(publicResponse.body.apiKey).toBeUndefined()
  716. await req('put', '/api/app/voice/current-voice', 'second')
  717. .send({ currentVoiceId: 'b' })
  718. .expect(200)
  719. expect((await req('get', '/api/app/voice/runtime')).body.currentVoiceId).toBe('a')
  720. expect((await req('get', '/api/app/voice/runtime', 'second')).body.currentVoiceId).toBe('b')
  721. expect(
  722. readFileSync(join(home, 'workspace.sqlite3')).includes(Buffer.from('private-test-key')),
  723. ).toBe(false)
  724. })
  725. it('stores monotonic profile-scoped context snapshots without a plugin', async () => {
  726. const path = '/api/app/session-context/session-one?profile=default'
  727. const b = { usedTokens: 200, limitTokens: 1000, percent: 20, observedAt: 20 }
  728. await req('put', path).send(b).expect(200)
  729. await req('put', path)
  730. .send({ ...b, usedTokens: 10, observedAt: 10 })
  731. .expect(200)
  732. expect((await req('get', path)).body.snapshot).toMatchObject({ ...b, sessionId: 'session-one' })
  733. expect((await req('get', path, 'second')).body.snapshot).toBeNull()
  734. expect(
  735. (await req('get', '/api/app/session-context/session-one?profile=other')).body.snapshot,
  736. ).toBeNull()
  737. })
  738. it('protects internal Hermes session IDs on native HTTP endpoints', async () => {
  739. runtime.workspace.put('first', 'binding', 'binding', {
  740. storedId: 'internal-session',
  741. runtimeId: 'internal-runtime',
  742. aliases: ['old-id'],
  743. })
  744. await req('get', '/api/app/sessions/internal-session/messages').expect(404)
  745. await req('get', '/api/sessions/old-id/messages', 'second').expect(404)
  746. expect(upstream.some((p) => p.includes('internal-session') || p.includes('old-id'))).toBe(false)
  747. })
  748. })
  749. it('prepares CSRF with capabilities and returns an idempotent committed send receipt', async () => {
  750. const caps = await req('get', '/api/app/capabilities').expect(200)
  751. expect(caps.body.csrfToken).toBe(csrf)
  752. expect(caps.headers['cache-control']).toBe('no-store')
  753. const store = runtime.workspace
  754. store.createAgent('first', { name: '回执测试', profile: 'default' })
  755. const conversation = store.list<any>('first', 'conversation')[0]
  756. const input = { requestId: randomUUID(), content: '确认后立即恢复' }
  757. const send = () => req('post', `/api/app/conversations/${conversation.id}/messages`).send(input).expect(202)
  758. const a = (await send()).body, b = (await send()).body
  759. expect(a.requestId).toBe(input.requestId)
  760. expect(a.message.id).toBe(a.run.messageId)
  761. expect(a.conversation.id).toBe(conversation.id)
  762. expect(a.cursor).toBeGreaterThan(0)
  763. expect(b.message.id).toBe(a.message.id)
  764. expect(b.run.id).toBe(a.run.id)
  765. expect(store.messages('first', conversation.id).filter(m => m.role === 'user')).toHaveLength(1)
  766. })
  767. it('aggregates per-agent daily token usage for today, month and lifetime', async () => {
  768. const store = runtime.workspace
  769. const agent = store.createAgent('first', { name: '用量统计', profile: 'default' })
  770. const other = store.createAgent('first', { name: '用量其他', profile: 'default' })
  771. const now = new Date(),
  772. pad = (n: number) => String(n).padStart(2, '0'),
  773. today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
  774. month = today.slice(0, 7),
  775. earlier = new Date(now.getFullYear(), now.getMonth(), now.getDate() === 1 ? 0 : 1),
  776. older = `${earlier.getFullYear()}-${pad(earlier.getMonth() + 1)}-${pad(earlier.getDate())}`
  777. const seed = (agentId: string, date: string, input: number, output: number) =>
  778. store.put('first', 'agent-token-day', `${agentId}:${date}`, { agentId, date, input, output, total: input + output, updatedAt: 1 })
  779. seed(agent.id, today, 300, 100)
  780. seed(agent.id, older, 2000, 1000)
  781. seed(other.id, today, 99999, 99999)
  782. const usage = (await req('get', `/api/app/agents/${agent.id}/usage`).expect(200)).body
  783. expect(usage.today).toBe(today)
  784. expect(usage.todayUsage).toEqual({ input: 300, output: 100, total: 400 })
  785. expect(usage.monthUsage).toEqual(older.startsWith(month)
  786. ? { input: 2300, output: 1100, total: 3400 }
  787. : { input: 300, output: 100, total: 400 })
  788. expect(usage.totalUsage).toEqual({ input: 2300, output: 1100, total: 3400 })
  789. expect(usage.daily.map((d: any) => d.date)).toEqual([today, older])
  790. await req('get', `/api/app/agents/${randomUUID()}/usage`).expect(404)
  791. })
  792. function stubBotModels(warning: string | null = null, duringResolve?: () => void) {
  793. const original=runtime.upstreamSession.request.bind(runtime.upstreamSession)
  794. return vi.spyOn(runtime.upstreamSession,'request').mockImplementation(async (path, options) => {
  795. const defaults={provider:'openai',model:'model-a',reasoningEffort:'medium',fastMode:'normal'}
  796. if(path.endsWith('/model-options'))return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({version:1,defaults,models:[{provider:'openai',model:'model-a',name:'A',reasoningEfforts:['none','medium','high'],reasoningKnown:true,fastModes:['normal','fast','auto','cold'],api_key:'must-not-leak'}]}))}
  797. if(path.endsWith('/model-settings/resolve')){
  798. duringResolve?.()
  799. const settings=(options?.body as any)?.settings??{}
  800. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({version:1,effective:{...defaults,...Object.fromEntries(Object.entries(settings).filter(([,v])=>v!==null))},confirmationMessage:warning}))}
  801. }
  802. return original(path,options)
  803. })
  804. }
  805. it('exposes owned Bot model capabilities and saves settings without altering its peers or Profile', async () => {
  806. stubBotModels()
  807. const a=runtime.workspace.createAgent('first',{name:'模型甲',profile:'default'}),b=runtime.workspace.createAgent('first',{name:'模型乙',profile:'default'})
  808. const path=`/api/app/agents/${a.id}`
  809. const options=(await req('get',`${path}/model-options`).expect(200)).body
  810. expect(options).toMatchObject({revision:1,settings:null,defaults:{model:'model-a'}})
  811. expect(JSON.stringify(options)).not.toContain('must-not-leak')
  812. await req('get',`${path}/model-options`,'second').expect(404)
  813. const modelSettings={provider:'openai',model:'model-a',reasoningEffort:'none',fastMode:'auto'}
  814. const saved=(await req('patch',path).send({modelSettings,expectedRevision:1}).expect(200)).body.agent
  815. expect(saved.modelSettings).toEqual(modelSettings)
  816. expect(runtime.workspace.require<any>('first','agent',b.id).modelSettings).toBeUndefined()
  817. await req('patch',path).send({name:'改名'}).expect(200)
  818. expect(runtime.workspace.require<any>('first','agent',a.id).modelSettings).toEqual(modelSettings)
  819. await req('patch',path).send({modelSettings:null,expectedRevision:1}).expect(409)
  820. expect(upstream.some(p=>p.includes('/model/set'))).toBe(false)
  821. })
  822. it('requires model confirmation before saving and binds it to the resolved selection', async () => {
  823. stubBotModels('费用较高,请确认')
  824. const a=runtime.workspace.createAgent('first',{name:'确认模型',profile:'default'}),path=`/api/app/agents/${a.id}`
  825. const body={expectedRevision:1,modelSettings:{provider:'openai',model:'model-a',reasoningEffort:null,fastMode:null}}
  826. const confirmation=(await req('patch',path).send(body).expect(200)).body
  827. expect(confirmation.confirmationRequired).toBe(true)
  828. expect(runtime.workspace.require<any>('first','agent',a.id).revision).toBe(1)
  829. const result=(await req('patch',path).send({...body,confirmedModel:confirmation.confirmationTarget}).expect(200)).body.agent
  830. expect(result.modelSettingsConfirmation).toBe('["openai","model-a"]')
  831. const changed=(await req('patch',path).send({...body,expectedRevision:2,modelSettings:{...body.modelSettings,model:'model-b'},confirmedModel:confirmation.confirmationTarget}).expect(200)).body
  832. expect(changed.confirmationTarget).toBe('["openai","model-b"]')
  833. expect(runtime.workspace.require<any>('first','agent',a.id).revision).toBe(2)
  834. })
  835. it('detects a concurrent edit after asynchronous model validation without overwriting the new record', async () => {
  836. const a=runtime.workspace.createAgent('first',{name:'并发编辑',profile:'default'})
  837. stubBotModels(null,()=>runtime.workspace.updateAgent('first',a.id,{name:'另一端的新名称'}))
  838. await req('patch',`/api/app/agents/${a.id}`).send({expectedRevision:1,modelSettings:null}).expect(409)
  839. expect(runtime.workspace.require<any>('first','agent',a.id)).toMatchObject({name:'另一端的新名称',revision:2})
  840. expect(runtime.workspace.require<any>('first','agent',a.id).modelSettings).toBeUndefined()
  841. })
  842. it('preserves ordinary Bot editing when the installed bridge predates model settings', async () => {
  843. const a=runtime.workspace.createAgent('first',{name:'旧工具桥',profile:'default'}),path=`/api/app/agents/${a.id}`
  844. expect((await req('get',`${path}/model-options`).expect(409)).body.code).toBe('model_settings_upgrade_required')
  845. await req('patch',path).send({instructions:'新规则'}).expect(200)
  846. await req('patch',path).send({modelSettings:{provider:'openai',model:null,reasoningEffort:null,fastMode:null},expectedRevision:2}).expect(400)
  847. })
  848. it('confirms a session-dependent native warning in the profile save flow',async()=>{
  849. stubBotModels()
  850. const a=runtime.workspace.createAgent('first',{name:'长上下文切换',profile:'default'}),path=`/api/app/agents/${a.id}`
  851. runtime.workspace.put('first','agent',a.id,{...a,modelSettingsPendingConfirmation:{target:'["openai","model-a"]',message:'切换长上下文将增加费用'}})
  852. const body={expectedRevision:1,modelSettings:null}
  853. const warning=(await req('patch',path).send(body).expect(200)).body
  854. expect(warning.confirmationMessage).toContain('长上下文')
  855. const saved=(await req('patch',path).send({...body,confirmedModel:warning.confirmationTarget}).expect(200)).body.agent
  856. expect(saved.modelSettingsConfirmation).toBe('["openai","model-a"]')
  857. expect(saved.modelSettingsPendingConfirmation).toBeUndefined()
  858. })
  859. it('saves a new base Profile before reloading and validating its model catalogue',async()=>{
  860. const a=runtime.workspace.createAgent('first',{name:'换来源',profile:'other'}),path=`/api/app/agents/${a.id}`
  861. runtime.workspace.updateAgent('first',a.id,{modelSettings:{provider:'old-provider',model:'old-model',reasoningEffort:'high',fastMode:'cold'}})
  862. // Even an old bridge must allow source-only edits, preserving the draft values.
  863. const saved=(await req('patch',path).send({profile:'default'}).expect(200)).body.agent
  864. expect(saved.profile).toBe('default');expect(saved.modelSettings.model).toBe('old-model')
  865. stubBotModels()
  866. expect((await req('get',`${path}/model-options`).expect(200)).body).toMatchObject({revision:saved.revision,settings:saved.modelSettings})
  867. })