|
|
@@ -11,6 +11,8 @@ import { loadServerConfig } from '../../src/server/config'
|
|
|
import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
|
|
|
import { WorkspaceAssets } from '../../src/server/workspaceAssets'
|
|
|
import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
|
|
|
+import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
|
|
|
+import type { WorkspaceAgent } from '../../src/shared/workspace'
|
|
|
|
|
|
let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
|
|
|
const first: LocalUser = {
|
|
|
@@ -49,6 +51,15 @@ function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string,
|
|
|
.set('X-CSRF-Token', csrf)
|
|
|
.set('x-test-user', user)
|
|
|
}
|
|
|
+function shareComputer(...agents: WorkspaceAgent[]): SharedComputer {
|
|
|
+ const store = runtime.workspace
|
|
|
+ const shared = new SharedComputers(store, runtime.auth, runtime.workspaceRuntime.nodes, runtime.runners)
|
|
|
+ for (const agent of agents) {
|
|
|
+ shared.attachLocalVm('first', agent, 'fixture-runner')
|
|
|
+ store.put('first', 'agent', agent.id, agent)
|
|
|
+ }
|
|
|
+ return store.require('first', 'shared-computer', agents[0]!.computerEnvironmentId!)
|
|
|
+}
|
|
|
it('serves file-backed project and memory CRUD with revisions and account isolation', async () => {
|
|
|
const a = runtime.workspace.createAgent('first', { name: '记忆甲', profile: 'default' })
|
|
|
const b = runtime.workspace.createAgent('first', { name: '记忆乙', profile: 'default' })
|
|
|
@@ -229,10 +240,69 @@ describe('application workspace HTTP contract', () => {
|
|
|
expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
|
|
|
expect(store.cursor('first')).toBe(cursor)
|
|
|
})
|
|
|
+ it.each(['archive', 'delete'] as const)('%s detaches only the selected Bot from its automatic shared computer', async action => {
|
|
|
+ const store = runtime.workspace
|
|
|
+ const bot = store.createAgent('first', { name: '待移除共享成员', profile: 'default' })
|
|
|
+ const other = store.createAgent('first', { name: '继续使用电脑', profile: 'default' })
|
|
|
+ const shared = shareComputer(bot, other)
|
|
|
+ const otherBefore = store.require('first', 'agent', other.id)
|
|
|
+ const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
|
|
|
+ const path = `/api/app/conversations/${direct.id}/lifecycle`
|
|
|
+ const preview = (await req('get', path).expect(200)).body
|
|
|
+ // Another member's running task must not prevent removing this idle Bot.
|
|
|
+ store.put('first', 'turn', 'other-running', { id: 'other-running', agentId: other.id, status: 'running' })
|
|
|
+ await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
|
|
|
+ expect(store.require('first', 'shared-computer', shared.id)).toEqual({ ...shared, memberIds: [other.id] })
|
|
|
+ expect(store.require('first', 'agent', other.id)).toEqual(otherBefore)
|
|
|
+ if (action === 'delete') expect(store.get('first', 'agent', bot.id)).toBeUndefined()
|
|
|
+ else {
|
|
|
+ const archived = store.require<WorkspaceAgent>('first', 'agent', bot.id)
|
|
|
+ expect(archived.archived).toBe(true)
|
|
|
+ expect(archived.computerEnvironmentId).toBeUndefined()
|
|
|
+ expect(archived.computerEnvironmentName).toBeUndefined()
|
|
|
+ }
|
|
|
+ store.remove('first', 'turn', 'other-running')
|
|
|
+ })
|
|
|
+ it('keeps shared membership intact while the Bot is busy or its computer is under human control', async () => {
|
|
|
+ const store = runtime.workspace
|
|
|
+ const bot = store.createAgent('first', { name: '电脑成员', profile: 'default' })
|
|
|
+ const shared = shareComputer(bot)
|
|
|
+ const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
|
|
|
+ const path = `/api/app/conversations/${direct.id}/lifecycle`
|
|
|
+ const preview = (await req('get', path).expect(200)).body
|
|
|
+ for (const status of ['queued', 'running', 'waiting', 'uncertain', 'cancelling']) {
|
|
|
+ store.put('first', 'turn', 'busy', { id: 'busy', agentId: bot.id, status })
|
|
|
+ expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_running')
|
|
|
+ }
|
|
|
+ store.remove('first', 'turn', 'busy')
|
|
|
+ store.put('_system', 'computer-control', 'human', { owner: 'first', agentId: randomUUID(), environmentId: shared.id, expiresAt: Date.now() + 30_000 })
|
|
|
+ expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_execution_busy')
|
|
|
+ expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
|
|
|
+ expect(store.require('first', 'agent', bot.id)).toEqual(bot)
|
|
|
+ store.remove('_system', 'computer-control', 'human')
|
|
|
+ await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(200)
|
|
|
+ expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
|
|
|
+ expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBeUndefined()
|
|
|
+ })
|
|
|
+ it.each(['manual', 'compose', 'missing'] as const)('deletes a legacy archived Bot with a %s computer binding', async mode => {
|
|
|
+ const store = runtime.workspace
|
|
|
+ const bot = store.createAgent('first', { name: '旧版归档成员', profile: 'default' })
|
|
|
+ const shared = shareComputer(bot)
|
|
|
+ const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
|
|
|
+ store.put('first', 'agent', bot.id, { ...bot, archived: true })
|
|
|
+ store.put('first', 'conversation', direct.id, { ...direct, archived: true })
|
|
|
+ if (mode === 'missing') store.remove('first', 'shared-computer', shared.id)
|
|
|
+ else store.put('first', 'shared-computer', shared.id, { ...shared, managedLocalVm: undefined, managedCompose: mode === 'compose' })
|
|
|
+ await req('delete', `/api/app/agents/${bot.id}`).expect(200)
|
|
|
+ expect(store.get('first', 'agent', bot.id)).toBeUndefined()
|
|
|
+ expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
|
|
|
+ if (mode !== 'missing') expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
|
|
|
+ })
|
|
|
it('rejects a stale group confirmation and rolls back membership on a later deletion failure', async () => {
|
|
|
const store = runtime.workspace
|
|
|
const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
|
|
|
const bot = store.createAgent('first', { name: '成员', profile: 'default' })
|
|
|
+ const shared = shareComputer(bot, lead)
|
|
|
const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
|
|
|
const path = `/api/app/conversations/${direct.id}/lifecycle`
|
|
|
const old = (await req('get', path).expect(200)).body
|
|
|
@@ -249,6 +319,8 @@ describe('application workspace HTTP contract', () => {
|
|
|
expect(store.require<any>('first', 'conversation', group.id).memberIds).toContain(bot.id)
|
|
|
expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
|
|
|
expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
|
|
|
+ expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
|
|
|
+ expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBe(shared.id)
|
|
|
expect(store.cursor('first')).toBe(cursor)
|
|
|
})
|
|
|
it('deletes an unarchived group while preserving its member Bots', async () => {
|