Skip to content

[自动测试] 同时建两个同名群都会成功:建群查重在 await 建 Session 之前,团队里出现两个同名群,之后 Main Bot 按群名哪个都操作不了 #199

Closed

Description

@Fei2411

环境

  • DS Bot:公开仓库 main 9411ac3(v0.1.6)
  • DSH:npm @deepseek-ai/dsh@0.2.1-alpha.3(DSH Web,独立 DSH_HOME);单测用仓库 harness(@deepseek-ai/dsh@0.2.0-rc.2)
  • 模型:仓库自带 mock(dev/mock-llm.mjs),没有真实 key
  • 机器:Linux x64,Node 22.23.3
  • 发现:Helm「DS Bot 暴力测试」第 10 轮,2026-10-14(seed 1014170710)

复现步骤

  1. 团队里有 Chief(Main Bot)、Writer、Coder。
  2. 同时发两个 create-room,用同一个自定义群名:{name:"Ops", members:[Chief, Writer]} 和 {name:"Ops", members:[Chief, Coder]}(界面上连点两次「建群」、两个 Main Bot 同时 create_group 同名群,走的都是 createRoom)。
  3. 另一种触发方式:update-room {id:r1, name:"Ops2", workDir:<某文件夹>} 的同时 create-room {name:"Ops2"}。
  4. 然后让 Chief 调 update_group {group:"Ops", notice:"明天开会"}(post_to_group / read_group_chat / delete_group 也一样)。

单测(不需要 bench):zz-r10-races.test.js 的 r10-A / r10-A2 / r10-F,在 main 9411ac3 上 5/5 次都失败。
端到端(mock bench,DSH 0.2.1-alpha.3):r10-races-e2e.mjs,5 次并发同名建群 5/5 都建出两个群。

实际

  • 两个 create-room 都返回 ok,团队里有 两个叫 "Ops" 的群(端到端 5/5,单测 5/5)
  • 带 workDir 的改名和同名建群同时发生,也会出现两个 "Ops2"(单测 5/5)
  • 之后 Main Bot 按群名调用 update_group / post_to_group 等,都返回 Several group chats are called "Ops". Rename one first.——两个群都不能按名字操作,只能先由用户手动改名
  • 顺序执行时查重正常:第二次会报 Another group chat is already called "Ops"

期望

同名建群只有一个成功,另一个返回 Another group chat is already called "Ops";改名同理。

关键日志(已去敏)

A  {"ok":[true,true],"opsCount":2}
A2 {"reply":"Several group chats are called \"Ops\". Rename one first."}
F  {"ok":[true,true],"count":2}
e2e A: R10同名群0..4 → count 2 ×5

可能的问题位置(main 9411ac3)

Activity

  1. EventHorizonXT commented on Oct 15, 2026

    @EventHorizonXT

    我来跟进 #199,先在这里说明认领意向,避免重复工作。目前账号只有读取权限,无法自行分配 assignee;这条评论不代表已经取得独占分配。如果已有其他人在做,请告知。

    已阅读当前 dev 的 CONTRIBUTING,并核对 issue 全文、回复和公开 PR。在 dev 3adb2a7 的 roster.js 上,用临时模拟测试复现了并发创建两个同名群都成功;顺序执行时第二次会正常被拒绝。问题在于群名校验与写入之间存在异步窗口,属于插件内部逻辑,不需要修改 Harness。

    计划范围:

    后续从最新 dev 建立独立分支 fix/199-unique-group-names。先补回归测试,覆盖同名并发、不同名称并行、改名竞争、错误清理和重试,再运行全量单测、client.js 一致性检查、lint、扫描及官方 smoke。

    测试仅使用独立 DSH Web、仓库 mock、独立依赖和测试数据,不使用真实密钥,不操作已有 Desktop 或用户 DSH 数据。临时复现目前只验证了模拟服务下的插件逻辑,尚未做本修复的 Web 端到端验证;原生 Desktop/Windows 路径未实测,会在交付时明确说明。

    我目前不是仓库协作者,会遵守当前 PR 限制,完成后在此提供中文说明、测试证据和独立补丁供维护者评估。

  2. EventHorizonXT commented on Oct 15, 2026

    @EventHorizonXT

    #199 修复交付说明

    已按认领计划完成插件侧修复:异步创建或改名期间暂时占用归一化后的自定义群名,在成功或失败后释放。同名并发请求只允许一个成功,其他请求明确返回现有名称冲突错误。不同群名仍能并行处理,自动生成的默认群名沿用原有规则。

    只修改 ds-bot/src/host/roster.js,补充 test/room-names.test.js 和 test/roster.test.js;没有改动 Harness、client.js 或 CHANGELOG.md。

    验证基线:dev 3adb2a7240c9760b72ff584233e77cbb9aea195f。本地独立分支:fix/199-unique-group-names。

    验证 结果
    上游干净基线单测 547/547
    新增 17 项回归/对照 修复前 10 项按预期失败;修复后全部通过
    完整单测(DSH 0.2.0-rc.2 / 0.2.1-alpha.2) 分别 564/564
    client.js 一致性检查 通过
    lint 0 错误;14 条上游既有 warning,无新增
    仓库内容扫描 must fix 0
    独立 Web/mock 并发建群 修复前每批 6 个请求均成功并留下 6 个同名群;修复后 5 批均只有 1 个成功
    独立 Web/mock 改名与建群竞争 修复前每批留下 5 个同名群;修复后 5 批均只有 1 个成功
    Web 失败重试 工作目录校验失败后,同一名称可重新创建
    官方 smoke 40/40
    独立补丁 干净基线 apply --check、实际 apply 通过,三个文件与测试源码哈希一致

    新增测试覆盖大小写、引号、连续空白、长度截断后的同名请求;目录和权限异步窗口;两个 Main Bot 并发工具调用;用户 API 与 Main Bot 工具竞争;Session 创建失败、存盘失败归档与重试;不同名称并行及原有默认群名行为。

    测试只运行独立 DSH Web、仓库 mock 与任务专用依赖和数据。原生 Desktop、Windows 和 DSH alpha.3 未实测。本轮 Web/mock/MCP 进程均已停止,不操作用户已有 Desktop 或用户 DSH 数据。

    本补丁仅处理 #199 的自定义群名并发唯一性,不宣称修复 #200 的删除成员竞态或 #205 的成员更新丢失。

    补充本次提交复验记录:末轮完整单测曾在未改动的 test/connectors.test.js 的 after hook 清理临时目录时出现一次 ENOTEMPTY(563/564,失败日志已保留);未修改代码,随后重新完整运行得到 564/564。本补丁的 17 项新增测试均通过。

    独立完整补丁如下,基于上述 dev,可使用 git apply --check issue-199.patch 后再应用。

    完整补丁
    diff --git a/ds-bot/src/host/roster.js b/ds-bot/src/host/roster.js
    index 981bc34..5e0b917 100644
    --- a/ds-bot/src/host/roster.js
    +++ b/ds-bot/src/host/roster.js
    @@ -3,7 +3,7 @@
     import { mkdir } from 'node:fs/promises'
     import { join } from 'node:path'
     import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
    -import { currentColor, isColor, unquote } from './text.js'
    +import { currentColor, isColor, normalize, unquote } from './text.js'
     import { nameProblem } from '../shared/mentions.js'
     
     export function install(rt) {
    @@ -255,6 +255,20 @@ export function install(rt) {
         return next
       }
     
    +  // A name must stay occupied while Session creation or folder/permission changes await.
    +  const pendingRoomNames = new Set()
    +
    +  async function withRoomName(name, change) {
    +    const key = normalize(name ?? '')
    +    if (key !== '' && pendingRoomNames.has(key)) throw new Error(`Another group chat is already called "${name}"`)
    +    if (key !== '') pendingRoomNames.add(key)
    +    try {
    +      return await change()
    +    } finally {
    +      if (key !== '') pendingRoomNames.delete(key)
    +    }
    +  }
    +
       async function retitleRoom(room) {
         try {
           const agent = await rt.resolveAgent(room.id)
    @@ -290,31 +304,33 @@ export function install(rt) {
         if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
         if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
         const title = custom || autoRoomName(members)
    -    // The group's own Session only relays, so it stays in the team workspace; the
    -    // group's folder is for its members' Sessions.
    -    const id = await createSession(title)
    -    try {
    -      state.rooms[id] = {
    -        id,
    -        name: title,
    -        ...(custom ? { named: true } : {}),
    -        members,
    -        admin: adminId,
    -        mode: mode ?? 'admin',
    -        notice: cleanNotice(notice),
    -        createdAt: Date.now(),
    -        createdBy: createdBy ?? null,
    +    return withRoomName(custom, async () => {
    +      // The group's own Session only relays, so it stays in the team workspace; the
    +      // group's folder is for its members' Sessions.
    +      const id = await createSession(title)
    +      try {
    +        state.rooms[id] = {
    +          id,
    +          name: title,
    +          ...(custom ? { named: true } : {}),
    +          members,
    +          admin: adminId,
    +          mode: mode ?? 'admin',
    +          notice: cleanNotice(notice),
    +          createdAt: Date.now(),
    +          createdBy: createdBy ?? null,
    +        }
    +        await rt.save()
    +        return state.rooms[id]
    +      } catch (error) {
    +        // The group's Session was already made: archive it instead of leaving an orphan.
    +        delete state.rooms[id]
    +        void rt.save().catch(() => {})
    +        await ctx.workspaceRegistry.archiveSession(id)
    +          .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
    +        throw error
           }
    -      await rt.save()
    -      return state.rooms[id]
    -    } catch (error) {
    -      // The group's Session was already made: archive it instead of leaving an orphan.
    -      delete state.rooms[id]
    -      void rt.save().catch(() => {})
    -      await ctx.workspaceRegistry.archiveSession(id)
    -        .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
    -      throw error
    -    }
    +    })
       }
     
       // `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
    @@ -343,58 +359,60 @@ export function install(rt) {
         const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
         const moving = patch.workDir !== undefined
         if (moving && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
    -    const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
    -    // A Bot's own permissions are the user's to set, so a group's are too.
    -    if (patch.permission !== undefined && actorId !== undefined) throw new Error(`Only the user can set the permissions of "${room.name}"`)
    -    const permission = patch.permission === undefined ? undefined : rt.cleanRoomPermission(patch.permission)
    +    return withRoomName(name, async () => {
    +      const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
    +      // A Bot's own permissions are the user's to set, so a group's are too.
    +      if (patch.permission !== undefined && actorId !== undefined) throw new Error(`Only the user can set the permissions of "${room.name}"`)
    +      const permission = patch.permission === undefined ? undefined : rt.cleanRoomPermission(patch.permission)
     
    -    const changes = []
    -    const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
    -    const added = members.filter(member => !room.members.includes(member))
    -    const removed = room.members.filter(member => !members.includes(member))
    -    if (added.length > 0) changes.push(`added ${names(added)}`)
    -    if (removed.length > 0) changes.push(`removed ${names(removed)}`)
    -    // A member who leaves and comes back starts from a fresh look at the log.
    -    for (const member of removed) {
    -      if (room.seen) delete room.seen[member]
    -      rt.dropGroupSession(room, member)
    -    }
    -    room.members = members
    -    if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin')
    -    room.admin = admin
    -    if (patch.mode !== undefined && patch.mode !== room.mode) {
    -      room.mode = patch.mode
    -      changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
    -    }
    -    if (notice !== undefined && notice !== (room.notice ?? '')) {
    -      room.notice = notice
    -      changes.push(notice ? 'updated the notice' : 'cleared the notice')
    -    }
    -    // Members' group conversations move on to the folder at their next turn there.
    -    if (moving && workDir !== room.workDir) {
    -      if (workDir === undefined) delete room.workDir
    -      else room.workDir = workDir
    -      changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
    -    }
    -    if (permission !== undefined && await rt.setRoomPermission(room, permission)) {
    -      changes.push(permission ? `every member now works with the ${permission} permissions here` : 'every member uses its own permissions again')
    -    }
    -    const before = room.name
    -    if (name !== undefined) {
    -      if (name !== '') {
    -        room.named = true
    -        room.name = name
    -      } else {
    -        delete room.named
    +      const changes = []
    +      const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
    +      const added = members.filter(member => !room.members.includes(member))
    +      const removed = room.members.filter(member => !members.includes(member))
    +      if (added.length > 0) changes.push(`added ${names(added)}`)
    +      if (removed.length > 0) changes.push(`removed ${names(removed)}`)
    +      // A member who leaves and comes back starts from a fresh look at the log.
    +      for (const member of removed) {
    +        if (room.seen) delete room.seen[member]
    +        rt.dropGroupSession(room, member)
           }
    -    }
    -    if (!room.named) room.name = autoRoomName(room.members)
    -    if (room.name !== before) {
    -      changes.push(`renamed it "${room.name}"`)
    -      await retitleRoom(room)
    -    }
    -    await rt.save()
    -    return { room, changes }
    +      room.members = members
    +      if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin')
    +      room.admin = admin
    +      if (patch.mode !== undefined && patch.mode !== room.mode) {
    +        room.mode = patch.mode
    +        changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
    +      }
    +      if (notice !== undefined && notice !== (room.notice ?? '')) {
    +        room.notice = notice
    +        changes.push(notice ? 'updated the notice' : 'cleared the notice')
    +      }
    +      // Members' group conversations move on to the folder at their next turn there.
    +      if (moving && workDir !== room.workDir) {
    +        if (workDir === undefined) delete room.workDir
    +        else room.workDir = workDir
    +        changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
    +      }
    +      if (permission !== undefined && await rt.setRoomPermission(room, permission)) {
    +        changes.push(permission ? `every member now works with the ${permission} permissions here` : 'every member uses its own permissions again')
    +      }
    +      const before = room.name
    +      if (name !== undefined) {
    +        if (name !== '') {
    +          room.named = true
    +          room.name = name
    +        } else {
    +          delete room.named
    +        }
    +      }
    +      if (!room.named) room.name = autoRoomName(room.members)
    +      if (room.name !== before) {
    +        changes.push(`renamed it "${room.name}"`)
    +        await retitleRoom(room)
    +      }
    +      await rt.save()
    +      return { room, changes }
    +    })
       }
     
       async function deleteRoom(id) {
    diff --git a/ds-bot/test/room-names.test.js b/ds-bot/test/room-names.test.js
    new file mode 100644
    index 0000000..a97b787
    --- /dev/null
    +++ b/ds-bot/test/room-names.test.js
    @@ -0,0 +1,171 @@
    +import assert from 'node:assert/strict'
    +import { test } from 'node:test'
    +import { install } from '../src/host/roster.js'
    +import { ROOM_NAME_MAX } from '../src/host/constants.js'
    +import { normalize, unquote } from '../src/host/text.js'
    +
    +const deferred = () => {
    +  let resolve
    +  const promise = new Promise(done => { resolve = done })
    +  return { promise, resolve }
    +}
    +
    +function start() {
    +  const state = {
    +    workspaceId: 'w', rooms: {},
    +    bots: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Writer' } },
    +  }
    +  const made = [], archived = []
    +  const ctx = {
    +    workspaceRegistry: { get: () => ({}), archiveSession: async id => { archived.push(id) } },
    +    sessionController: { create: async () => {
    +      const sessionId = `s${made.length + 1}`
    +      made.push(sessionId)
    +      return { sessionId }
    +    } },
    +    sessionTitle: { rename: async () => {} },
    +  }
    +  const rt = {
    +    ctx, config: {}, defaults: {}, state,
    +    readOnly: () => undefined,
    +    resolveAgent: async id => ({ session: { id } }),
    +    botOf: id => state.bots[id], roomOf: id => state.rooms[id],
    +    findRooms: ref => state.rooms[ref] ? [state.rooms[ref]] : Object.values(state.rooms)
    +      .filter(room => normalize(room.name) === normalize(unquote(ref))),
    +    isMain: id => id === 'a', isAdminOf: (room, id) => room.admin === id,
    +    save: async () => {}, warn: () => {}, dropGroupSession: () => {},
    +    cleanWorkDir: async dir => dir, cleanRoomPermission: value => value,
    +    setRoomPermission: async () => true,
    +  }
    +  install(rt)
    +  const create = name => rt.createRoom({ name, members: ['a', 'b'] })
    +  return { rt, ctx, state, made, archived, create }
    +}
    +
    +function pause(object, key) {
    +  const entered = deferred(), resume = deferred()
    +  const original = object[key]
    +  object[key] = async (...args) => {
    +    entered.resolve()
    +    await resume.promise
    +    return original(...args)
    +  }
    +  return { entered: entered.promise, resume: resume.resolve }
    +}
    +
    +const oneWinner = results => {
    +  assert.equal(results.filter(result => result.status === 'fulfilled').length, 1)
    +  const error = results.find(result => result.status === 'rejected')?.reason
    +  assert.match(error?.message ?? '', /Another group chat is already called/)
    +}
    +
    +for (const names of [['Ops', 'Ops'], ['"Ops Team"', '  oPs   Team  '], ['x'.repeat(ROOM_NAME_MAX), 'x'.repeat(ROOM_NAME_MAX + 1)]]) {
    +  test(`concurrent group creation keeps one name: ${names[0].slice(0, 12)}`, async () => {
    +    const { ctx, state, made, create } = start()
    +    const blocked = pause(ctx.sessionController, 'create')
    +    const first = create(names[0])
    +    await blocked.entered
    +    const results = Promise.allSettled([first, create(names[1])])
    +    blocked.resume()
    +    oneWinner(await results)
    +    assert.equal(Object.keys(state.rooms).length, 1)
    +    assert.equal(made.length, 1)
    +  })
    +}
    +
    +test('different group names can create Sessions at the same time', async () => {
    +  const { ctx, create, state } = start()
    +  const both = deferred(), resume = deferred()
    +  const original = ctx.sessionController.create
    +  let entered = 0
    +  ctx.sessionController.create = async () => {
    +    if (++entered === 2) both.resolve()
    +    await resume.promise
    +    return original()
    +  }
    +  const pending = Promise.all([create('Ops'), create('Docs')])
    +  await both.promise
    +  resume.resolve()
    +  await pending
    +  assert.equal(Object.keys(state.rooms).length, 2)
    +})
    +
    +for (const field of ['workDir', 'permission']) {
    +  for (const other of ['create', 'rename']) {
    +    test(`a rename waiting for ${field} cannot race another ${other} to the same name`, async () => {
    +      const { rt, state, create } = start()
    +      const firstRoom = await create('First')
    +      const secondRoom = await create('Second')
    +      const blocked = pause(rt, field === 'workDir' ? 'cleanWorkDir' : 'setRoomPermission')
    +      const first = rt.updateRoom(firstRoom.id, { name: 'Ops', [field]: 'test-value' })
    +      await blocked.entered
    +      const next = other === 'create' ? create('ops') : rt.updateRoom(secondRoom.id, { name: 'ops' })
    +      const results = Promise.allSettled([first, next])
    +      blocked.resume()
    +      oneWinner(await results)
    +      assert.equal(Object.values(state.rooms).filter(room => normalize(room.name) === 'ops').length, 1)
    +    })
    +  }
    +}
    +
    +test('a pending create also excludes a rename, and a rejected contender does not free its name', async () => {
    +  const { rt, ctx, state, create } = start()
    +  const room = await create('First')
    +  const blocked = pause(ctx.sessionController, 'create')
    +  const first = create('Ops')
    +  await blocked.entered
    +  const second = await Promise.allSettled([rt.updateRoom(room.id, { name: 'ops' })])
    +  const third = await Promise.allSettled([create('OPS')])
    +  blocked.resume()
    +  await first
    +  assert.equal(second[0].status, 'rejected')
    +  assert.equal(third[0].status, 'rejected')
    +  assert.equal(Object.values(state.rooms).filter(room => normalize(room.name) === 'ops').length, 1)
    +})
    +
    +for (const field of ['workDir', 'permission']) {
    +  test(`a failed ${field} rename releases its target name`, async () => {
    +    const { rt, create } = start()
    +    const room = await create('First')
    +    rt[field === 'workDir' ? 'cleanWorkDir' : 'setRoomPermission'] = async () => { throw new Error('failed change') }
    +    await assert.rejects(rt.updateRoom(room.id, { name: 'Ops', [field]: 'test-value' }), /failed change/)
    +    assert.equal(room.name, 'First')
    +    assert.equal((await create('Ops')).name, 'Ops')
    +  })
    +}
    +
    +test('a failed Session creation releases its group name for retry', async () => {
    +  const { ctx, create } = start()
    +  const original = ctx.sessionController.create
    +  ctx.sessionController.create = async () => { throw new Error('failed Session') }
    +  await assert.rejects(create('Ops'), /failed Session/)
    +  ctx.sessionController.create = original
    +  assert.equal((await create('Ops')).name, 'Ops')
    +})
    +
    +test('a failed save archives the new Session and releases the group name', async () => {
    +  const { rt, state, archived, create } = start()
    +  rt.save = async () => { throw new Error('failed save') }
    +  await assert.rejects(create('Ops'), /failed save/)
    +  assert.deepEqual(archived, ['s1'])
    +  assert.deepEqual(state.rooms, {})
    +  rt.save = async () => {}
    +  assert.equal((await create('Ops')).name, 'Ops')
    +})
    +
    +test('a group can keep its own name and release its old name after renaming', async () => {
    +  const { rt, create } = start()
    +  const room = await create('First')
    +  await rt.updateRoom(room.id, { name: 'First' })
    +  await rt.updateRoom(room.id, { name: 'Ops' })
    +  assert.equal((await create('First')).name, 'First')
    +  await rt.updateRoom(room.id, { notice: 'ready' })
    +  assert.equal(room.notice, 'ready')
    +})
    +
    +test('automatic group names still allow the same members in separate groups', async () => {
    +  const { create } = start()
    +  const rooms = await Promise.all([create(''), create('')])
    +  assert.equal(rooms[0].name, rooms[1].name)
    +  assert.notEqual(rooms[0].id, rooms[1].id)
    +})
    diff --git a/ds-bot/test/roster.test.js b/ds-bot/test/roster.test.js
    index 180b1c6..b50539c 100644
    --- a/ds-bot/test/roster.test.js
    +++ b/ds-bot/test/roster.test.js
    @@ -526,3 +526,36 @@ test('a write that fails at save time archives the Session it made', async (t) =
       const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
       assert.equal(Object.values(saved.bots).some(bot => bot.name === 'Phantom'), false)
     })
    +
    +for (const origin of ['another Main Bot', 'the user']) {
    +  test(`a Main Bot and ${origin} cannot create the same group concurrently`, async (t) => {
    +    const { call, stop, ctx } = start(OLD_TEAM)
    +    t.after(stop)
    +    await call('state', {})
    +    let entered, resume
    +    const held = new Promise(resolve => { resume = resolve })
    +    const began = new Promise(resolve => { entered = resolve })
    +    const create = ctx.sessionController.create
    +    ctx.sessionController.create = async (...args) => {
    +      entered()
    +      await held
    +      return create(...args)
    +    }
    +    const tool = ctx.registeredTools.get('create_group')
    +    const first = tool.execute({ name: 'Ops', members: ['Writer'] }, { agent: { id: 'm1' } })
    +    await began
    +    const second = origin === 'the user'
    +      ? call('create-room', { name: 'ops', members: ['m1', 'w2'] }).then(result => result.ok ? 'Created group' : result.error.message)
    +      : tool.execute({ name: 'ops', members: ['Chief'] }, { agent: { id: 'w2' } })
    +    await new Promise(resolve => setImmediate(resolve))
    +    resume()
    +    const results = await Promise.all([first, second])
    +    assert.equal(results.filter(result => result.startsWith('Created group')).length, 1)
    +    assert.equal(results.filter(result => result.includes('Another group chat is already called')).length, 1)
    +    const rooms = (await call('state', {})).value.rooms
    +    assert.equal(rooms.length, 1)
    +    assert.equal(rooms[0].name, 'Ops')
    +    const updated = await ctx.registeredTools.get('update_group').execute({ group: 'Ops', notice: 'Ready' }, { agent: { id: 'm1' } })
    +    assert.match(updated, /^Updated "Ops"/)
    +  })
    +}
  3. added
    bugSomething isn't working
    P3Suggestion or narrow case
    area/groupGroup chats, mentions, Bot-to-Bot messages
    on Oct 15, 2026
  4. added a commit that references this issue on Oct 17, 2026
    0a28b8c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Suggestion or narrow casearea/groupGroup chats, mentions, Bot-to-Bot messagesauto-foundFound by automated testingbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions