瀏覽代碼

发布 v0.3.6:允许群聊增减成员并保护管理员

LSamien 1 月之前
父節點
當前提交
9de27b0fc3

+ 3 - 3
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.5**,配套 iOS **1.3(170)**。详见 [发布说明](docs/releases/v0.3.5.md)。
+当前发布版本:**v0.3.6**,配套 iOS **1.3(171)**。详见 [发布说明](docs/releases/v0.3.6.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 
@@ -9,8 +9,8 @@
 - 在已有 Hermes Profile 基础上创建角色,填写名称、头像、提示词和规则。创建不会复制或修改基础 Profile。
 - 每个角色只有一条连续单聊;角色可加入多个群聊。单聊与各群聊分别使用独立会话。
 - 聊天列表混排角色单聊与群聊,不含默认 Profile 对话,没有独立话题列表。
-- 群成员创建后固定。可修改群规则、管理员、协作方式和自动回复轮数;默认管理员协调、3 轮。
-- 角色规则在下一次执行生效。归档角色隐藏单聊并禁止加入新群,但不改变已有群成员。
+- 群聊可增减成员,当前管理员不能移除。可修改群规则、管理员、协作方式和自动回复轮数;默认管理员协调、3 轮。
+- 角色规则在下一次执行生效。归档角色隐藏单聊并禁止再加入群聊,但不自动移出现有群聊。
 - 原生对话保留独立入口,由 Web 转发给 Hermes。
 - 新聊天、事件及文件按登录用户隔离;基础 Profile 的工具、技能和既有记忆能力继续由 Hermes 管理。
 

+ 16 - 0
docs/releases/v0.3.6.md

@@ -0,0 +1,16 @@
+# v0.3.6
+
+群聊设置支持添加和移除成员,当前管理员不能移除。若需移除原管理员,先更换管理员并保存,再调整成员。
+
+- Web 与 iOS 保留原有设置页面,放开成员选择;已建群可以只保留管理员,最多 8 位成员。
+- 成员变化后同步更新群头像、人数和 @ 列表,保留聊天历史及原有会话。
+- 移除成员时自动清理自动回复名单与尚未执行的回复队列;已经开始的回复可以完成,后续不再调度该成员。
+- 服务端校验管理员保护、成员归属、重复成员及已归档角色,避免绕过界面限制。
+
+配套 iOS 1.3(171)。
+
+## 验证
+
+- 85 个文件、468 项 Web 测试通过,涵盖管理员保护、成员增减、历史保留、自动回复清理、执行中移除及新成员回复。
+- 浏览器完成成员移除与新增、管理员选项禁用、重新打开设置、跨页面同步、头像组合和新成员聊天验收。
+- iOS 的 9 项协议测试及完整导航、群设置界面验收通过;成员变化保存后重新打开仍然有效。

+ 11 - 0
docs/workspace-acceptance.md

@@ -1,5 +1,16 @@
 # Web 聊天迁移验收
 
+## 当前成员编辑验收
+
+群聊已支持增减成员。当前管理员不能移除;更换管理员并保存后,原管理员可以作为普通成员移除。下文的固定成员说明为首次迁移的历史验收记录。
+
+- 服务端校验用户归属、管理员保护、已归档角色和人数,保留历史并同步清理自动回复名单。
+- 执行中的回复允许完成,移除成员后不再执行其排队回复或接力。
+- 浏览器验证只保留管理员、重新打开设置后变更仍在、新增成员正常回复,以及另一页面的群头像同步。
+- iOS 模拟器验证管理员开关禁用、普通成员移除后保存并重新打开、重新加入,以及原有导航与聊天历史保持正常。
+
+## 首次迁移记录
+
 验收日期:2026-09-03。涉及 `hermes-yaoyao` 与 `hermes-mobile`,旧插件数据未导入或删除。
 
 ## 自动验证

+ 3 - 1
docs/workspace-chat.md

@@ -2,7 +2,9 @@
 
 ## 身份和数据
 
-`WorkspaceAgent` 引用不可变的 `(nodeId, profile)`,保存名称、头像及角色规则。`WorkspaceConversation` 为 `direct` 或 `group`;每个角色只有一个 direct,group 的成员集合不可变,协议不包含 topicId。角色名在用户内唯一,以便精确 @。
+`WorkspaceAgent` 引用不可变的 `(nodeId, profile)`,保存名称、头像及角色规则。`WorkspaceConversation` 为 `direct` 或 `group`;每个角色只有一个 direct,group 支持增减成员,协议不包含 topicId。角色名在用户内唯一,以便精确 @。
+
+创建群聊选择 2~8 位成员;编辑时可以只保留管理员,最多 8 位。当前管理员不能移除,须先更换管理员并保存后再移除原管理员。新增成员必须属于当前用户且未归档。移除成员保留聊天记录及原会话,更新头像组合和提及名单,同时清理自动回复名单;进行中的回复可完成,后续接力不再调度已移除成员。
 
 应用数据保存在 Web 数据目录的 `workspace.sqlite3`。每一条实体、命令和事件包含服务端用户归属。隐藏上游会话只由 WorkspaceRuntime 驱动,原生历史及 RPC 不允许直接访问。基础 Hermes Profile 的记忆和工具权限没有被复制为新的隔离运行环境。
 

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.5",
+  "version": "0.3.6",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.5",
+      "version": "0.3.6",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.5",
+  "version": "0.3.6",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.5",
-  "webVersion": "0.3.5",
-  "gitTag": "v0.3.5"
+  "releaseVersion": "0.3.6",
+  "webVersion": "0.3.6",
+  "gitTag": "v0.3.6"
 }

+ 8 - 7
src/client/views/ConversationsView.vue

@@ -285,7 +285,7 @@ async function save() {
         mode: form.mode,
         autoReplyIds: form.autoReplyIds,
         maxReplyRounds: form.maxReplyRounds,
-        ...(dialog.value === 'group' ? { memberIds: form.memberIds } : {}),
+        memberIds: form.memberIds,
       }
       const result = await apiRequest<{ conversation: Conversation }>(
         dialog.value === 'group'
@@ -474,7 +474,7 @@ onBeforeUnmount(() => {
     /></template>
     <section class="workspace-chat" aria-label="聊天">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || '团队'"
-        :subtitle="active?.kind === 'group' ? `${members.length} 位成员 · 成员已固定` : ''"
+        :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
         :show-tools="showThinking" :allow-branch="false" :thinking="!!active?.activeRunId"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
@@ -554,21 +554,22 @@ onBeforeUnmount(() => {
           />
         </label>
         <fieldset v-if="!isAgentDialog">
-          <legend>{{ dialog === 'group' ? '选择成员(创建后不可变更)' : '固定成员' }}</legend>
+          <legend>选择成员</legend>
           <label
             v-for="a in agents.filter((a) =>
-              dialog === 'group' ? !a.archived : form.memberIds.includes(a.id),
+              !a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id)),
             )"
             :key="a.id"
             ><input
               v-model="form.memberIds"
               type="checkbox"
               :value="a.id"
-              :disabled="dialog === 'editGroup'"
+              :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
             />{{ a.name }}</label
-          ><small v-if="agents.filter((a) => !a.archived).length < 2"
+          ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived).length < 2"
             >至少需要两个 Agent 才能创建群聊。</small
           >
+          <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
         </fieldset>
         <template v-if="!isAgentDialog"
           ><label
@@ -605,7 +606,7 @@ onBeforeUnmount(() => {
         ></template>
         <footer>
           <button class="quiet-button" type="button" @click="closeDialog">取消</button
-          ><button class="solid-button" :disabled="busy || (!isAgentDialog && form.memberIds.length < 2)">
+          ><button class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
             {{ busy ? '保存中…' : '保存' }}
           </button>
         </footer>

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -59,7 +59,7 @@ export function workspaceRouter(
       features: [
         'agents',
         'conversations',
-        'immutableGroups',
+        'editableGroups',
         'files',
         'voice',
         'context',

+ 8 - 1
src/server/workspaceRuntime.ts

@@ -205,6 +205,9 @@ export class WorkspaceRuntime {
         if (!['queued', 'running'].includes(run.status)) break
         if (!this.userActive(owner)) throw new HttpError(403, '账号已停用', 'account_disabled')
         const c = this.store.require<Conversation>(owner, 'conversation', run.conversationId)
+        // A current reply may finish, but removed members must not start another turn.
+        run.queue = run.queue.filter(agentId => c.memberIds.includes(agentId))
+        run.next = run.next.filter(agentId => c.memberIds.includes(agentId))
         if (!run.queue.length) {
           if (
             c.kind === 'direct' ||
@@ -276,7 +279,9 @@ export class WorkspaceRuntime {
       if (!configuration) throw new Error('运行配置缺失,已停止自动协作')
       if (configuration.mode === 'free' || agentId === configuration.administratorId)
         run.next.push(
-          ...mentionedAgents(message.content, configuration.members).filter((id) => id !== agentId),
+          ...mentionedAgents(message.content, configuration.members).filter(
+            id => id !== agentId && c.memberIds.includes(id),
+          ),
         )
       if (
         c.kind === 'group' &&
@@ -285,6 +290,8 @@ export class WorkspaceRuntime {
       )
         run.hostReturn = true
       if (run.queue[0] === agentId) run.queue.shift()
+      run.queue = run.queue.filter(id => c.memberIds.includes(id))
+      run.next = run.next.filter(id => c.memberIds.includes(id))
       run.activeAgentId = run.queue[0]
       run.currentMessageId = undefined
       run.error = undefined

+ 19 - 4
src/server/workspaceStore.ts

@@ -62,6 +62,7 @@ export const conversationPatch = z
     name: name.optional(),
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
+    memberIds: z.array(z.string().uuid()).min(1).max(8).optional(),
     administratorId: z.string().uuid().optional(),
     mode: z.enum(['host', 'free']).optional(),
     autoReplyIds: z.array(z.string().uuid()).max(8).optional(),
@@ -301,12 +302,26 @@ export class WorkspaceStore {
       c = this.require<Conversation>(owner, 'conversation', id)
     if (c.kind === 'direct' && Object.keys(patch).some((k) => k !== 'pinned'))
       throw new HttpError(400, '请编辑 Agent 资料', 'edit_agent_instead')
+    const memberIds = patch.memberIds ?? c.memberIds
+    const members = new Set(memberIds)
+    if (!members.has(c.administratorId))
+      throw new HttpError(400, '当前管理员不能移除,请先更换管理员并保存', 'administrator_required')
     if (
-      (patch.administratorId && !c.memberIds.includes(patch.administratorId)) ||
-      patch.autoReplyIds?.some((a) => !c.memberIds.includes(a))
+      members.size !== memberIds.length ||
+      !members.has(patch.administratorId ?? c.administratorId) ||
+      patch.autoReplyIds?.some((a) => !members.has(a) && !c.memberIds.includes(a))
     )
-      throw new HttpError(400, '只能选择现有成员', 'invalid_members')
-    const next = { ...c, ...patch, updatedAt: Date.now() }
+      throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
+    for (const memberId of memberIds) {
+      const agent = this.require<Agent>(owner, 'agent', memberId)
+      if (!c.memberIds.includes(memberId) && agent.archived)
+        throw new HttpError(409, '已归档 Agent 不能加入群聊', 'agent_archived')
+    }
+    const next = {
+      ...c, ...patch, memberIds,
+      autoReplyIds: (patch.autoReplyIds ?? c.autoReplyIds).filter(a => members.has(a)),
+      updatedAt: Date.now(),
+    }
     this.atomic(() => {
       this.put(owner, 'conversation', id, next)
       this.event(owner, 'conversation.changed', next, id)

+ 28 - 3
tests/e2e/workspace-chat.spec.ts

@@ -18,7 +18,7 @@ async function createAgent(page: Page, name: string) {
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
-test('created roles and immutable teams share a durable chat list without plugin calls', async ({
+test('created roles and editable teams share a durable chat list without plugin calls', async ({
   page,
   context,
 }, testInfo) => {
@@ -70,7 +70,7 @@ test('created roles and immutable teams share a durable chat list without plugin
   )
   await page.getByRole('button', { name: '聊天设置', exact: true }).click()
   await expect(dialog.getByRole('checkbox', { name: '产品经理', exact: true })).toBeDisabled()
-  await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).toBeDisabled()
+  await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).toBeEnabled()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
   await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请@开发工程师给出实现结果')
   await page.getByRole('button', { name: '发送消息', exact: true }).click()
@@ -82,11 +82,36 @@ test('created roles and immutable teams share a durable chat list without plugin
   await expect(second.locator('.message--assistant')).toHaveCount(2)
   await page.screenshot({ path: testInfo.outputPath('mixed-chats.png'), fullPage: true })
   const groupURL=page.url()
+  await page.getByRole('button', { name: '聊天设置', exact: true }).click()
+  await dialog.getByRole('checkbox', { name: '开发工程师', exact: true }).uncheck()
+  await expect(dialog.getByRole('checkbox', { name: '产品经理', exact: true })).toBeDisabled()
+  await dialog.getByRole('button', { name: '保存', exact: true }).click()
+  await expect(groupRow.locator('.team-avatar__member')).toHaveCount(1)
+  await expect(page.locator('.message--assistant')).toHaveCount(2)
+  await expect(second.locator('.desktop-sidebar .sidebar-item').filter({ hasText: '产品开发团队' }).locator('.team-avatar__member')).toHaveCount(1)
+  await page.reload()
+  await page.getByRole('button', { name: '聊天设置', exact: true }).click()
+  await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).not.toBeChecked()
+  await dialog.getByRole('button', { name: '取消', exact: true }).click()
+  await createAgent(page, '新成员')
+  await page.goto(groupURL)
+  await page.getByRole('button', { name: '聊天设置', exact: true }).click()
+  await dialog.getByRole('checkbox', { name: '新成员', exact: true }).check()
+  await dialog.getByRole('checkbox', { name: '产品经理', exact: true }).scrollIntoViewIfNeeded()
+  await page.screenshot({ path: testInfo.outputPath('member-settings.png'), fullPage: true })
+  await dialog.getByRole('button', { name: '保存', exact: true }).click()
+  await expect(groupRow.locator('.team-avatar__member')).toHaveCount(2)
+  await expect(groupRow.locator('[aria-label="开发工程师 的头像"]')).toHaveCount(0)
+  await expect(groupRow.locator('[aria-label="新成员 的头像"]')).toHaveCount(1)
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请@新成员继续')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.locator('.message--assistant')).toHaveCount(4)
+  await expect(page.locator('.message--assistant').nth(2)).toContainText('我是新成员')
   await page.getByRole('button',{name:'文件库',exact:true}).click()
   await page.getByRole('button',{name:'查看来源',exact:true}).first().click()
   await expect(page).toHaveURL(groupURL)
   await page.getByRole('button', { name: '归档聊天', exact: true }).click()
-  await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(2)
+  await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(3)
   await page.getByRole('checkbox', { name: '显示已归档', exact: true }).first().check()
   await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(1)
   await page.getByRole('button', { name: '恢复聊天', exact: true }).click()

+ 48 - 2
tests/server/workspace.test.ts

@@ -281,7 +281,7 @@ describe('Web-owned workspace', () => {
     expect(() => store.require(other, 'conversation', direct(a.id).id)).toThrow('记录不存在')
     expect(requests).toEqual([])
   })
-  it('keeps membership immutable and validates member ownership', () => {
+  it('protects the current administrator and validates member ownership', () => {
     const a = agent('甲'),
       b = agent('乙'),
       foreign = agent('外部', other)
@@ -290,7 +290,10 @@ describe('Web-owned workspace', () => {
       memberIds: [a.id, b.id],
       administratorId: a.id,
     })
-    expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id] })).toThrow()
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id] })).toThrow('当前管理员不能移除')
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [b.id], administratorId: b.id })).toThrow('当前管理员不能移除')
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, foreign.id] })).toThrow('记录不存在')
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, a.id] })).toThrow('群成员或管理员无效')
     expect(() =>
       store.createGroup(owner, {
         name: '越权',
@@ -306,6 +309,49 @@ describe('Web-owned workspace', () => {
     expect(() =>
       store.createGroup(owner, { name: '新群', memberIds: [a.id, b.id], administratorId: a.id }),
     ).toThrow('已归档')
+    expect(store.updateConversation(owner, c.id, { memberIds: [a.id] }).memberIds).toEqual([a.id])
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id, b.id] })).toThrow('已归档')
+  })
+  it('adds and removes members, cleans automatic replies, and permits removal after administrator transfer', async () => {
+    const a = agent('管理员'), b = agent('旧成员'), added = agent('新成员')
+    const c = store.createGroup(owner, { name: '成员维护', memberIds: [a.id, b.id], administratorId: a.id, autoReplyIds: [b.id] })
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '保留这条历史' }).id)
+    const before = store.require<WorkspaceConversation>(owner, 'conversation', c.id)
+    const history = store.messages(owner, c.id)
+    const changed = store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
+    expect(changed.memberIds).toEqual([a.id, added.id])
+    expect(changed.autoReplyIds).toEqual([])
+    expect(changed.lastMessageAt).toBe(before.lastMessageAt)
+    expect(store.messages(owner, c.id)).toEqual(history)
+    expect(store.events(owner, 0).at(-1)).toMatchObject({ type: 'conversation.changed', data: { memberIds: [a.id, added.id] } })
+    store.updateConversation(owner, c.id, { administratorId: added.id })
+    expect(store.updateConversation(owner, c.id, { memberIds: [added.id] }).memberIds).toEqual([added.id])
+    expect(() => store.updateConversation(owner, c.id, { memberIds: [a.id] })).toThrow('当前管理员不能移除')
+  })
+  it('skips removed queued members and accepts a newly added member on the next request', async () => {
+    const a = agent('管理员'), b = agent('待回复'), added = agent('新成员')
+    const c = store.createGroup(owner, { name: '队列维护', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', autoReplyIds: [a.id, b.id] })
+    let turns = 0
+    reply = (socket, p) => {
+      if (turns++ === 0) store.updateConversation(owner, c.id, { memberIds: [a.id, added.id] })
+      socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: '请@待回复继续' } } }))
+    }
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' }).id)
+    expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id])
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '请@新成员继续' }).id)
+    expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([a.id, added.id])
+  })
+  it('finishes an in-flight removed member reply without scheduling it again', async () => {
+    const a = agent('管理员'), b = agent('正在回复')
+    const c = store.createGroup(owner, { name: '回复中移除', memberIds: [a.id, b.id], administratorId: a.id })
+    let turns = 0
+    reply = (socket, p) => {
+      if (turns++ === 0) store.updateConversation(owner, c.id, { memberIds: [a.id] })
+      socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: p.session_id, payload: { text: '请@正在回复继续' } } }))
+    }
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '@正在回复 开始' }).id)
+    expect(store.messages(owner, c.id).filter(m => m.role === 'assistant').map(m => m.agentId)).toEqual([b.id, a.id])
+    expect(() => runtime.send(owner, c.id, { requestId: randomUUID(), content: '继续', mentionIds: [b.id] })).toThrow('只能 @ 群内成员')
   })
   it('dispatches a request exactly once across retries and rejects payload reuse', async () => {
     const a = agent('编辑'),

+ 8 - 3
tests/server/workspaceRoutes.test.ts

@@ -108,7 +108,9 @@ describe('application workspace HTTP contract', () => {
     expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
   })
   it('works without plugin discovery, installation, or any plugin HTTP request', async () => {
-    await req('get', '/api/app/capabilities').expect(200)
+    const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
+    expect(capabilities).toContain('editableGroups')
+    expect(capabilities).not.toContain('immutableGroups')
     await req('get', '/api/app/files').expect(200)
     await req('get', '/api/app/voice/runtime').expect(200)
     await req('get', '/api/app/system/update/status').expect(200)
@@ -116,7 +118,7 @@ describe('application workspace HTTP contract', () => {
     await req('get', '/api/plugins/yaoyao/v1/capabilities').expect(410)
     expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
   })
-  it('creates application identities and enforces ownership and immutable members over HTTP', async () => {
+  it('changes members over HTTP while protecting the administrator and ownership', async () => {
     const a = (
       await req('post', '/api/app/agents').send({ name: '编辑', profile: 'default' }).expect(201)
     ).body.agent
@@ -131,8 +133,11 @@ describe('application workspace HTTP contract', () => {
     const rows = await req('get', '/api/app/conversations').expect(200)
     expect(rows.body.conversations).toHaveLength(3)
     await req('patch', `/api/app/conversations/${c.id}`)
-      .send({ memberIds: [a.id] })
+      .send({ memberIds: [b.id], administratorId: b.id })
       .expect(400)
+    await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id] }).expect(200)
+    expect((await req('get', `/api/app/conversations/${c.id}`)).body.conversation.memberIds).toEqual([a.id])
+    await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id, b.id] }).expect(200)
     await req('get', `/api/app/conversations/${c.id}`, 'second').expect(404)
     await req('patch', `/api/app/agents/${a.id}`, 'second').send({ name: '入侵' }).expect(404)
     expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])