Parcourir la source

简化群聊创建并按需启用交付目标

Samien il y a 1 mois
Parent
commit
f70ce33464

+ 14 - 3
docs/bot-agent-teams.md

@@ -4,11 +4,21 @@
 
 
 ## 使用
 ## 使用
 
 
-在 Bot 模式创建或编辑 Agent,打开「允许组建团队」并保存。例如给它发送:
+日常建群只需要名称、成员、负责人和可选群规则,创建后直接聊天。团队模板和高级协作设置默认收起。顶部的「话题」用于隔离不同聊天上下文,不代表已经启动交付目标。
+
+需要机器人自主组队时,在 Bot 模式创建或编辑 Agent,打开「允许组建团队」并保存。例如只准备团队:
+
+> 建一个调研群,优先复用已有成员,先不执行任务。
+
+这时只创建成员和群,不启动目标、不拆子任务、不生成验收清单。简单问答、方案讨论和一次性咨询同样直接回复。需要实际交付时可以发送:
 
 
 > 帮我完成项目调研,需要哪些成员就先查看已有 Agent,缺少时再创建。组建团队、启动任务,并告诉我到哪个团队查看结果。
 > 帮我完成项目调研,需要哪些成员就先查看已有 Agent,缺少时再创建。组建团队、启动任务,并告诉我到哪个团队查看结果。
 
 
-Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员和团队、启动独立团队任务、读取进展和结果。新团队自动加入发起者并将其设为管理员,随后使用已有的 @分派和管理员复核机制。成员、团队和任务写入当前账号的 `workspace.sqlite3`,发布既有变更事件供 Web/iOS 同步。
+Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员和团队、启动交付目标、读取进展和结果。新团队自动加入发起者并将其设为管理员。普通聊天保留 @协作;目标执行使用结构化分派和复核,避免同一工作再通过 @重复派发。成员、团队和任务写入当前账号的 `workspace.sqlite3`,发布既有变更事件供客户端同步。
+
+Web/macOS 的群聊输入区可开启「交付目标」,发送后在当前话题启动目标,不另建一个会话任务。负责人需已获准组队,且当前回复已结束。在群内明确提出交付要求时,管理员也可通过工具将当前话题升级为目标。已有目标不重复启动;其他工作可新建话题。
+
+验收要求由管理员从用户原始请求中提炼。管理员能直接完成时无需子任务,确实需要分工时才创建子任务和依赖。「交付进展」默认显示状态、完成数量和结果,任务与验收详情默认折叠;展开后可调整进行中目标的验收要求。修改采用独立版本检查,旧版本不能覆盖新要求,完成也必须使用最新验收版本。
 
 
 启动任务返回「已启动,尚未完成」。工作在团队任务内继续。子任务执行结束后,结果自动回到原会话并唤醒管理员复核;可通过「打开任务」进入具体任务。执行结束与目标完成分开记录,完成必须满足子任务验收和目标检查。
 启动任务返回「已启动,尚未完成」。工作在团队任务内继续。子任务执行结束后,结果自动回到原会话并唤醒管理员复核;可通过「打开任务」进入具体任务。执行结束与目标完成分开记录,完成必须满足子任务验收和目标检查。
 
 
@@ -29,6 +39,7 @@ Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员
 | `workspace_create_agent` | 创建成员,禁止授予组队权限 |
 | `workspace_create_agent` | 创建成员,禁止授予组队权限 |
 | `workspace_create_team` | 创建管理员协作模式的团队 |
 | `workspace_create_team` | 创建管理员协作模式的团队 |
 | `workspace_start_team_task` | 在发起者管理的团队启动任务 |
 | `workspace_start_team_task` | 在发起者管理的团队启动任务 |
+| `workspace_update_team_goal` | 提炼或调整验收要求,校验 `expectedRevision` |
 | `workspace_get_team_task` | 读取状态、最近 20 条消息和待处理问题 |
 | `workspace_get_team_task` | 读取状态、最近 20 条消息和待处理问题 |
 | `workspace_assign_task` | 分派具有负责人、依赖和验收条件的子任务 |
 | `workspace_assign_task` | 分派具有负责人、依赖和验收条件的子任务 |
 | `workspace_update_assignment` / `workspace_cancel_assignment` | 调整或停止子任务,保留原因与历史 |
 | `workspace_update_assignment` / `workspace_cancel_assignment` | 调整或停止子任务,保留原因与历史 |
@@ -39,7 +50,7 @@ Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员
 
 
 所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
 所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
 
 
-账号和调用 Agent 从正在运行的服务端轮次取得,模型参数不能指定 owner、token 或调用身份。创建成员只能引用当前账号获准的基础 Profile;子账号的分配规则保持有效。只有团队管理员可以启动或读取该团队的任务,禁止从当前团队任务递归启动自身任务。
+账号和调用 Agent 从正在运行的服务端轮次取得,模型参数不能指定 owner、token 或调用身份。创建成员只能引用当前账号获准的基础 Profile;子账号的分配规则保持有效。只有团队管理员可以启动或读取该团队的任务。当前群内的用户指令可以将尚无目标的话题升级为目标;自动回传不能重复启动目标。
 
 
 每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。
 每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。
 
 

+ 4 - 0
docs/workspace-chat.md

@@ -8,6 +8,8 @@
 
 
 新建群聊保留原有 8 套团队预设。模板将角色分配给已创建的 Agent,`memberRoles` 以成员 ID 保存群内角色名称与职责。分工仅在对应群聊执行时加入提示词,不修改 Agent 的身份与独立规则;移除成员会同步清理分工。模板所需成员不足时,先创建足够的 Agent 再使用模板。
 新建群聊保留原有 8 套团队预设。模板将角色分配给已创建的 Agent,`memberRoles` 以成员 ID 保存群内角色名称与职责。分工仅在对应群聊执行时加入提示词,不修改 Agent 的身份与独立规则;移除成员会同步清理分工。模板所需成员不足时,先创建足够的 Agent 再使用模板。
 
 
+Web/macOS 建群默认仅展示名称、成员、负责人和可选群规则,模板和高级协作设置折叠。会话任务在界面称为「话题」。建群与普通发消息不会自动创建目标、子任务或验收要求;明确交付时可开启「交付目标」,或让已获准组队的管理员按用户指令启动目标。管理员可直接完成,分工按需使用。
+
 应用数据保存在 Web 数据目录的 `workspace.sqlite3`。每一条实体、命令和事件包含服务端用户归属。隐藏上游会话只由 WorkspaceRuntime 驱动,原生历史及 RPC 不允许直接访问。基础 Hermes Profile 的记忆和工具权限没有被复制为新的隔离运行环境。
 应用数据保存在 Web 数据目录的 `workspace.sqlite3`。每一条实体、命令和事件包含服务端用户归属。隐藏上游会话只由 WorkspaceRuntime 驱动,原生历史及 RPC 不允许直接访问。基础 Hermes Profile 的记忆和工具权限没有被复制为新的隔离运行环境。
 
 
 ## API
 ## API
@@ -16,6 +18,8 @@
 - `GET/POST /api/app/agents`、`PATCH /api/app/agents/:id`:角色;`GET /api/app/agents/sources`:基础来源。
 - `GET/POST /api/app/agents`、`PATCH /api/app/agents/:id`:角色;`GET /api/app/agents/sources`:基础来源。
 - `GET/POST /api/app/conversations`、`GET/PATCH /api/app/conversations/:id`:混合列表和聊天详情。创建接口仅创建群聊;创建角色自动创建单聊。
 - `GET/POST /api/app/conversations`、`GET/PATCH /api/app/conversations/:id`:混合列表和聊天详情。创建接口仅创建群聊;创建角色自动创建单聊。
 - `GET/POST /api/app/conversations/:id/messages`:历史和发送。发送需 UUID `requestId`、`content`、`mentionIds`、`fileIds`。重复编号与内容返回同一运行,内容冲突返回 409。
 - `GET/POST /api/app/conversations/:id/messages`:历史和发送。发送需 UUID `requestId`、`content`、`mentionIds`、`fileIds`。重复编号与内容返回同一运行,内容冲突返回 409。
+- 发送的可选 `mode` 为 `chat` 或 `goal`;缺省保持聊天。`goal` 仅用于尚无目标且没有运行中回复的群话题,需要有效管理员及团队工具能力,原子保存用户消息、目标和执行,不创建额外话题。
+- `GET/PATCH /api/app/conversations/:id/tasks/:taskId/plan`:查看目标与子任务;PATCH 用 `requestId`、`expectedRevision` 和 `acceptanceCriteria` 修改进行中目标的验收条件。`expectedRevision` 对应 `goal.acceptanceRevision`(旧记录默认为 1),冲突返回 409。完成工具在验收版本大于 1 时必须传入最新 `acceptanceRevision`,旧验收依据不能直接用于完成。
 - `PUT /api/app/conversations/:id/read`:单调递增的已读序号。
 - `PUT /api/app/conversations/:id/read`:单调递增的已读序号。
 - `POST /api/app/runs/:id/stop`、`/reconcile`:停止和核对不确定状态。
 - `POST /api/app/runs/:id/stop`、`/reconcile`:停止和核对不确定状态。
 - `POST /api/app/interactions/:id/respond`:审批或澄清。
 - `POST /api/app/interactions/:id/respond`:审批或澄清。

+ 87 - 24
src/client/components/workspace/TaskPlan.vue

@@ -1,33 +1,96 @@
 <script setup lang="ts">
 <script setup lang="ts">
+import { computed, nextTick, ref } from 'vue'
 import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 import type { WorkspaceAgent } from '@shared/workspace'
 import type { WorkspaceAgent } from '@shared/workspace'
-defineProps<{ goal?: AgentGoal; assignments: AgentAssignment[]; agents: WorkspaceAgent[] }>()
-defineEmits<{stop:[];resume:[]}>()
-const labels: Record<string,string> = {pending:'待执行',running:'进行中',review:'待复核',complete:'已完成',failed:'执行失败',blocked:'遇到阻碍',cancelling:'正在停止',cancelled:'已停止',waiting:'需要补充信息'}
+const props = defineProps<{
+  goal?: AgentGoal
+  assignments: AgentAssignment[]
+  agents: WorkspaceAgent[]
+  saveCriteria?: (goalId: string, expectedRevision: number, acceptanceCriteria: string[]) => Promise<void>
+}>()
+defineEmits<{ stop: []; resume: [] }>()
+const labels: Record<string, string> = { pending: '待执行', running: '进行中', review: '待复核', complete: '已完成', failed: '执行失败', blocked: '遇到阻碍', cancelling: '正在停止', cancelled: '已停止', waiting: '需要补充信息' }
+const completed = computed(() => props.assignments.filter(a => a.status === 'complete').length)
+const editable = computed(() => !!props.saveCriteria && !!props.goal && ['running', 'review', 'waiting'].includes(props.goal.status))
+const editing = ref(false), saving = ref(false), draft = ref(''), error = ref('')
+const editor = ref<HTMLTextAreaElement>(), editButton = ref<HTMLButtonElement>()
+let revision = 1
+async function edit() {
+  if (!props.goal) return
+  draft.value = props.goal.acceptanceCriteria.join('\n')
+  revision = props.goal.acceptanceRevision ?? 1
+  error.value = ''
+  editing.value = true
+  await nextTick()
+  editor.value?.focus()
+}
+async function closeEditor() {
+  editing.value = false
+  await nextTick()
+  editButton.value?.focus()
+}
+async function save() {
+  if (!props.goal || !props.saveCriteria || saving.value) return
+  const criteria = draft.value.split('\n').map(line => line.trim()).filter(Boolean)
+  if (!criteria.length || criteria.length > 12 || criteria.some(line => line.length > 1000)) {
+    error.value = '请填写 1 至 12 条验收要求,每条不超过 1000 字。'
+    return
+  }
+  saving.value = true
+  error.value = ''
+  try {
+    await props.saveCriteria(props.goal.id, revision, criteria)
+    await closeEditor()
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
+  finally { saving.value = false }
+}
 </script>
 </script>
 <template>
 <template>
-  <details v-if="goal || assignments.length" class="task-plan">
-    <summary>任务进度 <span v-if="goal">{{ labels[goal.status] }}</span></summary>
-    <ol v-if="assignments.length">
-      <li v-for="item in assignments" :key="item.id">
-        <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
-        <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}<template v-if="agents.find(a => a.id === item.agentId)?.temporaryGoalId"> · 临时助手{{ agents.find(a => a.id === item.agentId)?.archived ? (agents.find(a => a.id === item.agentId)?.cleanupState === 'pending' ? '(已退役,等待清理)' : '(已退役)') : '' }}</template></small>
-        <p v-if="item.review">{{ item.review }}</p>
-      </li>
-    </ol>
+  <section v-if="goal || assignments.length" class="task-plan" aria-label="目标进度">
+    <header>
+      <strong>交付进展</strong>
+      <span v-if="goal">{{ labels[goal.status] }}</span>
+      <small v-if="assignments.length">{{ completed }}/{{ assignments.length }} 项完成</small>
+      <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
+      <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
+    </header>
     <p v-if="goal?.result" class="result">{{ goal.result }}</p>
     <p v-if="goal?.result" class="result">{{ goal.result }}</p>
-    <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
-      <li v-for="(criterion,index) in goal.acceptanceCriteria" :key="index">
-        <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
-      </li>
-    </ul>
-    <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
-    <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
-  </details>
+    <details>
+      <summary>任务与验收详情</summary>
+      <div class="plan-details">
+        <p v-if="goal" class="objective">{{ goal.objective }}</p>
+        <ol v-if="assignments.length">
+          <li v-for="item in assignments" :key="item.id">
+            <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
+            <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}<template v-if="agents.find(a => a.id === item.agentId)?.temporaryGoalId"> · 临时助手{{ agents.find(a => a.id === item.agentId)?.archived ? (agents.find(a => a.id === item.agentId)?.cleanupState === 'pending' ? '(已退役,等待清理)' : '(已退役)') : '' }}</template></small>
+            <p v-if="item.review">{{ item.review }}</p>
+          </li>
+        </ol>
+        <form v-if="editing" @submit.prevent="save">
+          <label>验收要求<textarea ref="editor" v-model="draft" :disabled="saving" rows="4" aria-describedby="criteria-help" /></label>
+          <small id="criteria-help">每行一条,描述你需要的交付结果。</small>
+          <p v-if="error" class="criteria-error" role="alert">{{ error }}</p>
+          <div class="criteria-actions"><button type="submit" :disabled="saving || !editable">{{ saving ? '保存中…' : '保存验收要求' }}</button><button type="button" :disabled="saving" @click="closeEditor">取消</button></div>
+        </form>
+        <template v-else>
+          <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
+            <li v-for="(criterion, index) in goal.acceptanceCriteria" :key="index">
+              <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
+            </li>
+          </ul>
+          <button v-if="editable" ref="editButton" type="button" @click="edit">调整验收要求</button>
+        </template>
+      </div>
+    </details>
+  </section>
 </template>
 </template>
 <style scoped>
 <style scoped>
-.task-plan{width:100%;max-width:760px;margin:0 auto 8px;border:1px solid var(--line);border-radius:12px;padding:0 14px;font-size:13px;max-height:34vh;overflow:auto}
-summary{min-height:44px;display:flex;align-items:center;gap:12px;cursor:pointer;list-style:revert}summary span,small{color:var(--text-muted)}
-button{min-height:44px;padding:8px 12px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}
-ol{padding-left:18px;margin-top:0}li{padding:7px 0}li div{display:flex;gap:12px;justify-content:space-between}li div span{white-space:nowrap;color:var(--text-muted)}p{white-space:pre-wrap;line-height:1.6}.criteria{padding-left:18px}.criteria span{font-size:12px;color:var(--text-muted)}summary:focus-visible{outline:2px solid var(--text-primary);outline-offset:3px}
+.task-plan{width:100%;max-width:760px;margin:0 auto 8px;border:1px solid var(--line);border-radius:12px;padding:0 14px;font-size:13px;box-sizing:border-box}
+header{display:flex;align-items:center;gap:10px;min-height:44px;flex-wrap:wrap}header>span{color:var(--text-secondary)}header>small{margin-right:auto}header>button{margin-left:auto}
+summary{min-height:44px;align-content:center;cursor:pointer;color:var(--text-secondary)}small{font-size:12px;color:var(--text-secondary)}
+button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-hover)}button:disabled{opacity:.55;cursor:default}
+.plan-details{max-height:32vh;overflow:auto;padding-bottom:12px}.result{max-height:12vh;overflow:auto;margin:0;padding:8px 0}.objective{color:var(--text-secondary)}
+ol,.criteria{padding-left:18px;margin-top:0}li{padding:7px 0}li div{display:flex;gap:12px;justify-content:space-between}li div span{white-space:nowrap;color:var(--text-secondary)}p{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.criteria span{font-size:12px;color:var(--text-secondary)}
+label{display:grid;gap:8px}textarea{width:100%;box-sizing:border-box;resize:vertical;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;line-height:1.5}.criteria-actions{display:flex;gap:8px;margin-top:10px}.criteria-error{color:var(--danger)}
+summary:focus-visible,button:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 </style>
 </style>

+ 63 - 18
src/client/views/ConversationsView.vue

@@ -61,9 +61,13 @@ const active = ref<Conversation>(),
   interactions = ref<Interaction[]>([]),
   interactions = ref<Interaction[]>([]),
   context = ref<Record<string, unknown> | null>(null)
   context = ref<Record<string, unknown> | null>(null)
 const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
 const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
+const deliveryMode = ref(false)
+const canRequestGoal = computed(() => active.value?.kind === 'group' && !active.value.archived && !activeTask.value?.goal
+  && members.value.some(a => a.id === active.value?.administratorId && a.canManageTeam && !a.archived && !a.remoteAgentId))
 const taskFiles = new Map<string,File[]>()
 const taskFiles = new Map<string,File[]>()
 const taskQuotes = new Map<string,UiMessage | null>()
 const taskQuotes = new Map<string,UiMessage | null>()
 const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
 const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
+watch(composerKey, () => { deliveryMode.value = false })
 const text = ref(''),
 const text = ref(''),
   error = ref(''),
   error = ref(''),
   busy = ref(false),
   busy = ref(false),
@@ -338,7 +342,7 @@ async function selectTask(event:Event){
     if(active.value?.id!==conversationId)return
     if(active.value?.id!==conversationId)return
     tasks.value=[result.task,...tasks.value.filter(task=>task.id!==result.task.id)]
     tasks.value=[result.task,...tasks.value.filter(task=>task.id!==result.task.id)]
     await router.push({query:{...route.query,taskId:result.task.id}})
     await router.push({query:{...route.query,taskId:result.task.id}})
-  }catch(cause){error.value=cause instanceof Error?cause.message:'新建任务失败'}finally{creatingTask.value=false}
+  }catch(cause){error.value=cause instanceof Error?cause.message:'新建话题失败'}finally{creatingTask.value=false}
 }
 }
 async function stopTask() {
 async function stopTask() {
   if (!active.value || !activeTask.value) return
   if (!active.value || !activeTask.value) return
@@ -354,6 +358,17 @@ async function resumeTask() {
     await load(undefined,true)
     await load(undefined,true)
   } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
   } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
 }
 }
+async function saveGoalCriteria(goalId: string, expectedRevision: number, acceptanceCriteria: string[]) {
+  const conversationId = active.value?.id
+  if (!conversationId || activeTask.value?.id !== goalId) throw new Error('话题已切换,请重新打开目标')
+  const result = await apiRequest<{goal: NonNullable<WorkspaceTask['goal']>}>(`/api/app/conversations/${conversationId}/tasks/${goalId}/plan`, {
+    method: 'PATCH', body: { requestId: createUuid(), expectedRevision, acceptanceCriteria },
+  })
+  if (active.value?.id === conversationId && activeTask.value?.id === goalId) {
+    activeTask.value.goal = result.goal
+    await load(conversationId, true)
+  }
+}
 function openTaskLink(event: MouseEvent) {
 function openTaskLink(event: MouseEvent) {
   if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
   if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
   const anchor=(event.target as Element)?.closest?.('a[href]')
   const anchor=(event.target as Element)?.closest?.('a[href]')
@@ -534,7 +549,8 @@ async function send() {
   if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
   if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
   busy.value = true
   busy.value = true
   error.value = ''
   error.value = ''
-  const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id)])
+  const mode = deliveryMode.value ? 'goal' : 'chat'
+  const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id), mode])
   if (!pendingRequestId || pendingFingerprint !== fingerprint) {
   if (!pendingRequestId || pendingFingerprint !== fingerprint) {
     pendingRequestId = createUuid()
     pendingRequestId = createUuid()
     pendingFingerprint = fingerprint
     pendingFingerprint = fingerprint
@@ -544,6 +560,7 @@ async function send() {
       method: 'POST',
       method: 'POST',
       body: {
       body: {
         requestId: pendingRequestId,
         requestId: pendingRequestId,
+        mode,
         ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
         ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
         content: text.value,
         content: text.value,
         mentionIds: mentions.value,
         mentionIds: mentions.value,
@@ -555,6 +572,7 @@ async function send() {
     mentions.value = []
     mentions.value = []
     pendingRequestId = undefined
     pendingRequestId = undefined
     pendingFingerprint = ''
     pendingFingerprint = ''
+    deliveryMode.value = false
     await load(c.id, true)
     await load(c.id, true)
     await refresh()
     await refresh()
     return true
     return true
@@ -748,9 +766,9 @@ onBeforeUnmount(() => {
           <div v-if="active" class="header-actions">
           <div v-if="active" class="header-actions">
             <button v-if="active.kind==='direct'" type="button" class="icon-button" aria-label="电脑与定时任务" title="电脑与定时任务" :aria-pressed="dockView==='computer'" @click="toggleDock('computer')"><AppIcon name="monitor"/></button>
             <button v-if="active.kind==='direct'" type="button" class="icon-button" aria-label="电脑与定时任务" title="电脑与定时任务" :aria-pressed="dockView==='computer'" @click="toggleDock('computer')"><AppIcon name="monitor"/></button>
             <span v-if="active.kind === 'group' && tasks.length" class="task-picker-shell">
             <span v-if="active.kind === 'group' && tasks.length" class="task-picker-shell">
-              <select class="task-picker" aria-label="当前任务" :aria-busy="creatingTask" :disabled="creatingTask" :value="activeTask?.id" @change="selectTask">
-                <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
-                <option :value="CREATE_TASK_VALUE">新建任务</option>
+              <select class="task-picker" aria-label="当前话题" :aria-busy="creatingTask" :disabled="creatingTask" :value="activeTask?.id" @change="selectTask">
+                <option v-for="task in tasks" :key="task.id" :value="task.id">{{ !task.messageCount && task.titleSource === 'automatic' ? '新话题' : task.title }}</option>
+                <option :value="CREATE_TASK_VALUE">新建话题</option>
               </select>
               </select>
               <AppIcon name="chevron-down" :size="14"/>
               <AppIcon name="chevron-down" :size="14"/>
             </span>
             </span>
@@ -767,7 +785,11 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event">
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event">
         <template #before-input>
         <template #before-input>
-          <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
+          <TaskPlan :key="activeTask?.id" :goal="activeTask?.goal" :assignments="assignments" :agents="agents" :save-criteria="saveGoalCriteria" @stop="stopTask" @resume="resumeTask" />
+          <div v-if="active.kind === 'group' && !activeTask?.goal" class="delivery-mode">
+            <button type="button" :aria-pressed="deliveryMode" :disabled="!canRequestGoal || busy || loading || !!activeTask?.activeRunId" aria-describedby="delivery-mode-help" @click="deliveryMode = !deliveryMode">交付目标</button>
+            <small id="delivery-mode-help">{{ !canRequestGoal ? '负责人开启“允许组建团队”后可用' : deliveryMode ? '发送后由负责人推进交付;验收要求可随时查看和调整' : '默认直接聊天;需要持续完成具体交付时开启' }}</small>
+          </div>
         </template>
         </template>
       </ComposerShell>
       </ComposerShell>
     </section>
     </section>
@@ -796,14 +818,18 @@ onBeforeUnmount(() => {
           <h2 id="conversation-editor-title">
           <h2 id="conversation-editor-title">
             {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : dialog === 'editAgent' ? '机器人设置' : '群聊设置' }}
             {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : dialog === 'editAgent' ? '机器人设置' : '群聊设置' }}
           </h2>
           </h2>
-          <button type="button" aria-label="关闭" @click="closeDialog">×</button>
+          <button type="button" class="icon-button" aria-label="关闭" @click="closeDialog"><AppIcon name="close" :size="18" /></button>
         </header>
         </header>
+        <div class="editor-content">
         <p v-if="error" class="error" role="alert">{{ error }}</p>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
-        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
+        <details v-if="dialog === 'group'" class="group-options">
+          <summary>从团队模板选择(可选)</summary>
+          <TeamPresetPicker :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
+        </details>
         <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
-        <div v-else-if="!isAgentDialog" class="team-avatar-settings">
+        <div v-else-if="dialog === 'editGroup'" class="team-avatar-settings">
           <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? editingConversation : undefined)" :size="64" />
           <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? editingConversation : undefined)" :size="64" />
           <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
           <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
         </div>
         </div>
@@ -820,9 +846,8 @@ onBeforeUnmount(() => {
           ><small v-if="!sources.length"
           ><small v-if="!sources.length"
             >{{ auth.isBotOnly ? '暂无可用的已分配机器人,请联系管理员分配或检查连接。' : '没有可用的基础机器人,请检查 Web 的 Hermes 连接。' }}</small
             >{{ auth.isBotOnly ? '暂无可用的已分配机器人,请联系管理员分配或检查连接。' : '没有可用的基础机器人,请检查 Web 的 Hermes 连接。' }}</small
           ></label
           ></label
-        ><label
-          >{{ isAgentDialog ? '角色提示词与规则' : '群规则'
-          }}<textarea
+        ><label v-if="isAgentDialog"
+          >角色提示词与规则<textarea
             v-model="form.instructions"
             v-model="form.instructions"
             :readonly="isRemoteAgent"
             :readonly="isRemoteAgent"
             rows="6"
             rows="6"
@@ -838,7 +863,7 @@ onBeforeUnmount(() => {
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
           <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础机器人,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
           <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础机器人,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
         </label>
         </label>
-        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程机器人</button>
+        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" class="remote-picker" @click="remotePickerOpen = true">添加远程机器人</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
           <legend>角色分配</legend>
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
@@ -875,7 +900,7 @@ onBeforeUnmount(() => {
         </fieldset>
         </fieldset>
         <template v-if="!isAgentDialog"
         <template v-if="!isAgentDialog"
           ><label
           ><label
-            >管理员<select v-model="form.administratorId" required>
+            >负责人<select v-model="form.administratorId" required>
               <option
               <option
                 v-for="a in agents.filter((a) => form.memberIds.includes(a.id))"
                 v-for="a in agents.filter((a) => form.memberIds.includes(a.id))"
                 :key="a.id"
                 :key="a.id"
@@ -884,7 +909,12 @@ onBeforeUnmount(() => {
                 {{ a.name }}
                 {{ a.name }}
               </option>
               </option>
             </select></label
             </select></label
-          ><label
+          >
+          <label>群规则(可选)<textarea v-model="form.instructions" rows="3" maxlength="24000" placeholder="例如:回答简洁,重要结论注明来源" /></label>
+          <p v-if="dialog === 'group'" class="group-help">创建后即可聊天。需要交付具体结果时,再开启交付目标。</p>
+          <details class="group-options">
+            <summary>高级协作设置</summary>
+          <label
             >协作方式<select v-model="form.mode">
             >协作方式<select v-model="form.mode">
               <option value="host">管理员协调</option>
               <option value="host">管理员协调</option>
               <option value="free">自由协作</option>
               <option value="free">自由协作</option>
@@ -906,7 +936,10 @@ onBeforeUnmount(() => {
               min="1"
               min="1"
               max="100"
               max="100"
               required /></label
               required /></label
-        ></template>
+        >
+          </details>
+        </template>
+        </div>
         <footer>
         <footer>
           <button class="quiet-button" type="button" @click="closeDialog">取消</button
           <button class="quiet-button" type="button" @click="closeDialog">取消</button
           ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
           ><button v-if="!isRemoteAgent" class="solid-button" :disabled="busy || (!isAgentDialog && (form.memberIds.length < (dialog === 'group' ? 2 : 1) || form.memberIds.length > 8))">
@@ -925,7 +958,7 @@ onBeforeUnmount(() => {
   border-radius: 18px;
   border-radius: 18px;
   width: min(520px, calc(100vw - 48px));
   width: min(520px, calc(100vw - 48px));
   max-height: 85vh;
   max-height: 85vh;
-  overflow: auto;
+  overflow: hidden;
   background: var(--surface);
   background: var(--surface);
   color: var(--text-primary);
   color: var(--text-primary);
   padding: 25px;
   padding: 25px;
@@ -933,6 +966,15 @@ onBeforeUnmount(() => {
 .editor::backdrop {
 .editor::backdrop {
   background: #0006;
   background: #0006;
 }
 }
+.editor form{display:flex;flex-direction:column;max-height:calc(85dvh - 32px);min-height:0}
+.editor-content{min-height:0;overflow:auto;padding:0 4px;scrollbar-gutter:stable}
+.editor header,.editor footer{flex-shrink:0}
+.editor header{min-height:44px}
+.editor .remote-picker,.editor footer button{min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;font-size:13px;cursor:pointer}
+.editor .remote-picker:hover,.editor footer .quiet-button:hover{background:var(--surface-hover)}
+.editor footer .solid-button{background:var(--text-primary);color:var(--surface);border-color:var(--text-primary)}
+.editor footer button:disabled{opacity:.5;cursor:default}
+.editor .remote-picker:focus-visible,.editor footer button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .editor header,
 .editor header,
 .editor footer {
 .editor footer {
   display: flex;
   display: flex;
@@ -942,6 +984,7 @@ onBeforeUnmount(() => {
 }
 }
 .editor h2 {
 .editor h2 {
   font-size: 20px;
   font-size: 20px;
+  margin: 12px 0;
 }
 }
 .editor label {
 .editor label {
   display: flex;
   display: flex;
@@ -952,6 +995,8 @@ onBeforeUnmount(() => {
 }
 }
 .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
 .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
 .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
 .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
+.group-options{margin:12px 0;border-top:1px solid var(--line)}.group-options summary{min-height:44px;align-content:center;cursor:pointer;font-size:13px;color:var(--text-secondary)}.group-options summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.group-help{font-size:13px;line-height:1.6;color:var(--text-secondary)}
+.delivery-mode{display:flex;align-items:center;gap:10px;margin:0 auto 8px;max-width:760px;width:100%;font-size:13px}.delivery-mode button{flex-shrink:0;min-height:44px;padding:7px 12px;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-secondary);font:inherit;cursor:pointer}.delivery-mode button:hover:not(:disabled){background:var(--surface-hover)}.delivery-mode button[aria-pressed=true]{border-color:var(--accent);color:var(--text-primary);background:var(--surface-hover)}.delivery-mode button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.delivery-mode button:disabled{opacity:.55;cursor:default}.delivery-mode small{font-size:12px;color:var(--text-secondary);line-height:1.5}
 @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
 @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
 .team-management-permission > span {
 .team-management-permission > span {
   display: flex;
   display: flex;
@@ -984,7 +1029,7 @@ onBeforeUnmount(() => {
 }
 }
 .editor footer {
 .editor footer {
   justify-content: flex-end;
   justify-content: flex-end;
-  margin-top: 25px;
+  margin-top: 12px;
 }
 }
 .status {
 .status {
   margin-top: 8px;
   margin-top: 8px;

+ 27 - 2
src/server/taskCoordinator.ts

@@ -9,6 +9,11 @@ import type { AgentGoal, AgentAssignment, TaskDelivery, TaskOrigin } from '../sh
 const terminalRun = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 const terminalRun = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 const terminalGoal = (status: string) => ['complete', 'blocked', 'cancelled'].includes(status)
 const terminalGoal = (status: string) => ['complete', 'blocked', 'cancelled'].includes(status)
 const criteria = z.array(z.string().trim().min(1).max(1000)).max(12).default([])
 const criteria = z.array(z.string().trim().min(1).max(1000)).max(12).default([])
+export const goalCriteriaInput = z.object({
+  requestId: z.string().uuid(),
+  expectedRevision: z.number().int().positive(),
+  acceptanceCriteria: z.array(z.string().trim().min(1).max(1000)).min(1).max(12),
+}).strict()
 export const assignmentInput = z.object({
 export const assignmentInput = z.object({
   requestId: z.string().uuid(), goalId: z.string().uuid(), agentId: z.string().uuid(),
   requestId: z.string().uuid(), goalId: z.string().uuid(), agentId: z.string().uuid(),
   title: z.string().trim().min(1).max(100), brief: z.string().trim().min(1).max(16000),
   title: z.string().trim().min(1).max(100), brief: z.string().trim().min(1).max(16000),
@@ -28,6 +33,7 @@ export const assignmentCancel = z.object({requestId:z.string().uuid(),assignment
 export const finishGoalInput = z.object({
 export const finishGoalInput = z.object({
   requestId: z.string().uuid(), goalId: z.string().uuid(), status: z.enum(['complete', 'blocked', 'waiting']),
   requestId: z.string().uuid(), goalId: z.string().uuid(), status: z.enum(['complete', 'blocked', 'waiting']),
   result: z.string().trim().min(1).max(16000), artifactIds: z.array(z.string().uuid()).max(24).default([]),
   result: z.string().trim().min(1).max(16000), artifactIds: z.array(z.string().uuid()).max(24).default([]),
+  acceptanceRevision: z.number().int().positive().optional(),
   checks: z.array(z.object({criterion:z.number().int().min(0).max(11),passed:z.boolean(),evidence:z.string().trim().min(1).max(2000)}).strict()).max(12).default([]),
   checks: z.array(z.object({criterion:z.number().int().min(0).max(11),passed:z.boolean(),evidence:z.string().trim().min(1).max(2000)}).strict()).max(12).default([]),
 }).strict()
 }).strict()
 export const resumeGoalInput = z.object({requestId:z.string().uuid(),goalId:z.string().uuid()}).strict()
 export const resumeGoalInput = z.object({requestId:z.string().uuid(),goalId:z.string().uuid()}).strict()
@@ -56,12 +62,27 @@ export class WorkspaceTaskCoordinator {
     const old = this.store.get<AgentGoal>(owner, 'goal', task.id)
     const old = this.store.get<AgentGoal>(owner, 'goal', task.id)
     if (old) throw new HttpError(409, '这个任务已有目标,请继续原任务或新建任务', 'goal_exists')
     if (old) throw new HttpError(409, '这个任务已有目标,请继续原任务或新建任务', 'goal_exists')
     const goal: AgentGoal = { id: task.id, conversationId: task.conversationId, coordinatorId: coordinator.id,
     const goal: AgentGoal = { id: task.id, conversationId: task.conversationId, coordinatorId: coordinator.id,
-      authorityRevision: coordinator.teamAuthorizationVersion ?? 0, objective, acceptanceCriteria: acceptanceCriteria.length ? acceptanceCriteria : ['对照原始请求核验交付结果'],
+      authorityRevision: coordinator.teamAuthorizationVersion ?? 0, objective, acceptanceCriteria: acceptanceCriteria.length ? acceptanceCriteria : ['对照原始请求核验交付结果'], acceptanceRevision: 1,
       authorizationVersion: this.runtime.authorizationVersion(owner), activation: 1,
       authorizationVersion: this.runtime.authorizationVersion(owner), activation: 1,
       status: 'running', origin, artifactIds: [], revision: 1, automaticWakes: 0, createdAt: Date.now(), updatedAt: Date.now() }
       status: 'running', origin, artifactIds: [], revision: 1, automaticWakes: 0, createdAt: Date.now(), updatedAt: Date.now() }
     this.saveGoal(owner, goal)
     this.saveGoal(owner, goal)
     return goal
     return goal
   }
   }
+  updateCriteria(owner: string, goalId: string, input: unknown, actorId?: string): AgentGoal {
+    const body = parse(goalCriteriaInput, input)
+    return this.store.command(owner, body.requestId, { operation: 'goal.criteria', goalId, actorId: actorId ?? 'user', ...body }, () => {
+      const goal = actorId ? this.requireCoordinator(owner, goalId, actorId) : this.store.require<AgentGoal>(owner, 'goal', goalId)
+      if (!this.authorized(owner, goal)) throw new HttpError(403, '目标授权已失效', 'goal_forbidden')
+      if (!['running', 'review', 'waiting'].includes(goal.status)) throw new HttpError(409, '只能调整进行中的目标,请先继续任务', 'goal_not_editable')
+      if ((goal.acceptanceRevision ?? 1) !== body.expectedRevision) throw new HttpError(409, '验收要求已更新,请重新查看后修改', 'goal_criteria_changed')
+      goal.acceptanceCriteria = body.acceptanceCriteria
+      goal.acceptanceRevision = body.expectedRevision + 1
+      goal.checks = undefined
+      goal.revision++
+      this.saveGoal(owner, goal)
+      return goal
+    })
+  }
   private saveGoal(owner: string, goal: AgentGoal) {
   private saveGoal(owner: string, goal: AgentGoal) {
     goal.updatedAt = Date.now()
     goal.updatedAt = Date.now()
     this.store.put(owner, 'goal', goal.id, goal)
     this.store.put(owner, 'goal', goal.id, goal)
@@ -181,6 +202,8 @@ export class WorkspaceTaskCoordinator {
     const body = parse(finishGoalInput, input), goal = this.requireCoordinator(owner, body.goalId, actorId)
     const body = parse(finishGoalInput, input), goal = this.requireCoordinator(owner, body.goalId, actorId)
     return this.store.command(owner, body.requestId, { actorId, operation: 'goal.finish', ...body }, () => {
     return this.store.command(owner, body.requestId, { actorId, operation: 'goal.finish', ...body }, () => {
       if (terminalGoal(goal.status)) throw new HttpError(409, '目标已结束', 'goal_terminal')
       if (terminalGoal(goal.status)) throw new HttpError(409, '目标已结束', 'goal_terminal')
+      if (body.status === 'complete' && (body.acceptanceRevision ?? 1) !== (goal.acceptanceRevision ?? 1))
+        throw new HttpError(409, '验收要求已修改,请按最新要求重新核验', 'goal_criteria_changed')
       const assignments=this.assignments(owner,goal.id)
       const assignments=this.assignments(owner,goal.id)
       if (body.status === 'complete' && (assignments.some(a => !['complete','cancelled'].includes(a.status)) || (assignments.length>0&&!assignments.some(a=>a.status==='complete'))))
       if (body.status === 'complete' && (assignments.some(a => !['complete','cancelled'].includes(a.status)) || (assignments.length>0&&!assignments.some(a=>a.status==='complete'))))
         throw new HttpError(409, '还有未验收的子任务,不能宣布目标完成', 'goal_incomplete')
         throw new HttpError(409, '还有未验收的子任务,不能宣布目标完成', 'goal_incomplete')
@@ -358,7 +381,9 @@ export class WorkspaceTaskCoordinator {
     if (activeRuns) return
     if (activeRuns) return
     const reviewing = goal.reviewRunId && this.store.get<Run>(owner, 'run', goal.reviewRunId)
     const reviewing = goal.reviewRunId && this.store.get<Run>(owner, 'run', goal.reviewRunId)
     if (reviewing && !terminalRun(reviewing.status)) return
     if (reviewing && !terminalRun(reviewing.status)) return
-    const batch = assignments.map(a => `${a.id}:${a.status}:${a.attempt}`).join('|') || 'initial'
+    const assignmentBatch = assignments.map(a => `${a.id}:${a.status}:${a.attempt}`).join('|') || 'initial'
+    // A user's changed requirements need a fresh review even when assignments are unchanged.
+    const batch = assignmentBatch + ((goal.acceptanceRevision ?? 1) > 1 ? `:criteria:${goal.acceptanceRevision}` : '')
     if (goal.lastReviewedBatch === batch) {
     if (goal.lastReviewedBatch === batch) {
       if (goal.status === 'review') this.deliver(owner, goal, 'review', batch)
       if (goal.status === 'review') this.deliver(owner, goal, 'review', batch)
       return
       return

+ 15 - 3
src/server/workspaceRoutes.ts

@@ -5,7 +5,7 @@ import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
 import { createReadStream, statSync } from 'node:fs'
 import { z } from 'zod'
 import { z } from 'zod'
 import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
 import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
-import { WorkspaceRuntime } from './workspaceRuntime.js'
+import { WorkspaceRuntime, sendInput } from './workspaceRuntime.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import {
 import {
   WorkspaceAssets,
   WorkspaceAssets,
@@ -188,6 +188,11 @@ export function workspaceRouter(
     ctx.body = { goal: store.get(user, 'goal', ctx.params.taskId) ?? null,
     ctx.body = { goal: store.get(user, 'goal', ctx.params.taskId) ?? null,
       assignments: runtime.tasks.assignments(user, ctx.params.taskId) }
       assignments: runtime.tasks.assignments(user, ctx.params.taskId) }
   })
   })
+  router.patch('/api/app/conversations/:id/tasks/:taskId/plan', (ctx) => {
+    const user = owner(ctx)
+    store.requireTask(user, ctx.params.id, ctx.params.taskId)
+    ctx.body = { goal: runtime.tasks.updateCriteria(user, ctx.params.taskId, body(ctx)) }
+  })
   router.post('/api/app/conversations/:id/tasks/:taskId/stop', async (ctx) => {
   router.post('/api/app/conversations/:id/tasks/:taskId/stop', async (ctx) => {
     const user=owner(ctx)
     const user=owner(ctx)
     store.requireTask(user,ctx.params.id,ctx.params.taskId)
     store.requireTask(user,ctx.params.id,ctx.params.taskId)
@@ -281,9 +286,16 @@ export function workspaceRouter(
       ),
       ),
     }
     }
   })
   })
-  router.post('/api/app/conversations/:id/messages', (ctx) => {
+  router.post('/api/app/conversations/:id/messages', async (ctx) => {
     const user = owner(ctx)
     const user = owner(ctx)
-    ctx.body = { run: runtime.send(user, ctx.params.id, body(ctx)) }
+    const input = parse(sendInput, body(ctx))
+    if (input.mode === 'goal') {
+      const version = auth.pushAuthorizationVersion(user)
+      const conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
+      await runtime.teamTools.requireAvailable(user, store.require<WorkspaceAgent>(user, 'agent', conversation.administratorId))
+      if (auth.pushAuthorizationVersion(user) !== version) throw new HttpError(401, '账号授权已变化,请重新登录', 'session_revoked')
+    }
+    ctx.body = { run: runtime.send(user, ctx.params.id, input) }
     ctx.status = 202
     ctx.status = 202
   })
   })
   router.put('/api/app/conversations/:id/read', (ctx) => {
   router.put('/api/app/conversations/:id/read', (ctx) => {

+ 16 - 1
src/server/workspaceRuntime.ts

@@ -57,6 +57,7 @@ export const sendInput = z
     requestId: z.string().uuid(),
     requestId: z.string().uuid(),
     content: z.string().max(65_536).default(''),
     content: z.string().max(65_536).default(''),
     taskId: z.string().uuid().optional(),
     taskId: z.string().uuid().optional(),
+    mode: z.enum(['chat', 'goal']).optional(),
     mentionIds: z.array(z.string().uuid()).max(8).default([]),
     mentionIds: z.array(z.string().uuid()).max(8).default([]),
     fileIds: z.array(z.string().uuid()).max(8).default([]),
     fileIds: z.array(z.string().uuid()).max(8).default([]),
   })
   })
@@ -116,6 +117,19 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       const now = Date.now(),
       const now = Date.now(),
         runId = randomUUID(),
         runId = randomUUID(),
         agents = this.store.taskMemberIds(owner,c,conversationTaskId).map((id) => this.store.require<Agent>(owner, 'agent', id))
         agents = this.store.taskMemberIds(owner,c,conversationTaskId).map((id) => this.store.require<Agent>(owner, 'agent', id))
+      let goal = conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', conversationTaskId) : undefined
+      if (body.mode === 'goal') {
+        if (source || c.kind !== 'group' || !conversationTask || !body.content.trim())
+          throw new HttpError(400, '请在群聊中说明需要交付的结果', 'goal_request_invalid')
+        if (goal) throw new HttpError(409, '当前话题已有目标,请继续原目标或新建话题', 'goal_exists')
+        const coordinator = this.store.require<Agent>(owner, 'agent', c.administratorId)
+        if (!coordinator.canManageTeam || coordinator.archived || coordinator.remoteAgentId)
+          throw new HttpError(403, '请先为负责人开启允许组建团队', 'goal_forbidden')
+        if (this.store.list<Run>(owner, 'run').some(r => r.conversationTaskId === conversationTaskId && !['complete', 'failed', 'interrupted'].includes(r.status)))
+          throw new HttpError(409, '请等待当前回复结束后再启动目标', 'goal_busy')
+        goal = this.tasks.begin(owner, conversationTask, coordinator, body.content.trim(),
+          { conversationId, conversationTaskId, runId, agentId: coordinator.id })
+      }
       const mentions = [
       const mentions = [
         ...new Set(
         ...new Set(
           [...body.mentionIds, ...mentionedAgents(body.content, agents)],
           [...body.mentionIds, ...mentionedAgents(body.content, agents)],
@@ -167,6 +181,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         updatedAt: now,
         updatedAt: now,
         authorizationVersion: this.authorizationVersion(owner),
         authorizationVersion: this.authorizationVersion(owner),
         ...(source ? { assignmentId: source.assignmentId, targetAgentId: source.targetAgentId, triggerKind: source.kind, internalInstruction: source.instruction } : {}),
         ...(source ? { assignmentId: source.assignmentId, targetAgentId: source.targetAgentId, triggerKind: source.kind, internalInstruction: source.instruction } : {}),
+        ...(goal && ['running', 'review', 'waiting'].includes(goal.status) ? { goalId: goal.id, targetAgentId: source?.targetAgentId ?? goal.coordinatorId } : {}),
       }
       }
       this.admit(owner, run, c, message)
       this.admit(owner, run, c, message)
       this.uploads.markReferenced(body.fileIds, owner)
       this.uploads.markReferenced(body.fileIds, owner)
@@ -612,7 +627,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           `本轮用户指定成员:${this.store.require<Run>(owner, 'run', run.runId).mentionIds.map(id => members.find(a => a.id === id)).filter(Boolean).map(a => '@' + a!.name).join('、') || '未指定'}`,
           `本轮用户指定成员:${this.store.require<Run>(owner, 'run', run.runId).mentionIds.map(id => members.find(a => a.id === id)).filter(Boolean).map(a => '@' + a!.name).join('、') || '未指定'}`,
           '角色规则不赋予额外工具权限;仍遵守基础 Hermes 的工具和安全约束。',
           '角色规则不赋予额外工具权限;仍遵守基础 Hermes 的工具和安全约束。',
           this.store.require<Run>(owner, 'run', run.runId).internalInstruction || '',
           this.store.require<Run>(owner, 'run', run.runId).internalInstruction || '',
-          goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
+          goal && ['running', 'review', 'waiting'].includes(goal.status) ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求(版本 ${goal.acceptanceRevision ?? 1}):${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '先从用户要求提炼少量具体、可核对的交付条件;若仍是默认验收要求,使用 workspace_update_team_goal 保存。尊重用户调整后的要求。能直接完成就直接完成,不必创建子任务;仅确实需要分工时使用 workspace_assign_task,成员结果通过 workspace_review_assignment 复核,不要再用 @ 重复派发同一工作。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户,完成时提供实际依据。'}` : '',
           agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
           agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
           team ? TEAM_TOOL_RULES : '',
           team ? TEAM_TOOL_RULES : '',
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',

+ 4 - 3
src/server/workspaceScheduler.ts

@@ -120,10 +120,11 @@ export abstract class WorkspaceScheduler {
   }
   }
   protected admit(owner: string, root: Run, c: Conversation, trigger: Message): void {
   protected admit(owner: string, root: Run, c: Conversation, trigger: Message): void {
     this.store.put(owner, 'run', root.id, root)
     this.store.put(owner, 'run', root.id, root)
-    const ids = root.assignmentId && root.targetAgentId ? [root.targetAgentId] : c.kind === 'direct' || c.mode === 'host' ? [c.administratorId]
+    const targeted = (root.assignmentId || root.goalId) && root.targetAgentId
+    const ids = targeted ? [root.targetAgentId!] : c.kind === 'direct' || c.mode === 'host' ? [c.administratorId]
       : root.mentionIds.length ? root.mentionIds : [...new Set([c.administratorId, ...c.autoReplyIds])]
       : root.mentionIds.length ? root.mentionIds : [...new Set([c.administratorId, ...c.autoReplyIds])]
     for (const id of ids) this.enqueue(owner, root, id, 0, root.id, trigger.seq,
     for (const id of ids) this.enqueue(owner, root, id, 0, root.id, trigger.seq,
-      c.kind === 'direct' || root.assignmentId ? 'mentioned' : c.mode === 'host' || !root.mentionIds.length ? 'automatic' : 'mentioned', !!root.assignmentId || c.kind === 'group' && id === c.administratorId && (c.mode === 'host' || !root.mentionIds.length))
+      c.kind === 'direct' || targeted ? 'mentioned' : c.mode === 'host' || !root.mentionIds.length ? 'automatic' : 'mentioned', !!targeted || c.kind === 'group' && id === c.administratorId && (c.mode === 'host' || !root.mentionIds.length))
     this.updateRoot(owner, root.id)
     this.updateRoot(owner, root.id)
   }
   }
   start(): void { this.wake() }
   start(): void { this.wake() }
@@ -166,7 +167,7 @@ export abstract class WorkspaceScheduler {
       const root = this.store.require<Run>(owner, 'run', work.runId), c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
       const root = this.store.require<Run>(owner, 'run', work.runId), c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
       work.planned = true
       work.planned = true
       this.store.put(owner, 'turn', work.id, work)
       this.store.put(owner, 'turn', work.id, work)
-      if (root.stopRequested || c.archived || c.kind === 'direct' || root.assignmentId) { this.updateRoot(owner, root.id); return }
+      if (root.stopRequested || c.archived || c.kind === 'direct' || root.assignmentId || root.goalId) { this.updateRoot(owner, root.id); return }
       const config = work.turnConfiguration ?? { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => this.store.require<Agent>(owner, 'agent', id)) }
       const config = work.turnConfiguration ?? { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => this.store.require<Agent>(owner, 'agent', id)) }
       const message = work.currentMessageId ? this.store.get<Message>(owner, 'message', work.currentMessageId) : undefined
       const message = work.currentMessageId ? this.store.get<Message>(owner, 'message', work.currentMessageId) : undefined
       if (config.mode === 'host' && work.agentId !== config.administratorId) {
       if (config.mode === 'host' && work.agentId !== config.administratorId) {

+ 24 - 8
src/server/workspaceTeamTools.ts

@@ -7,7 +7,7 @@ import type { WorkspaceRuntime } from './workspaceRuntime.js'
 import type { Work } from './workspaceScheduler.js'
 import type { Work } from './workspaceScheduler.js'
 import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
 import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
 import { requireTeamToolBridge } from './workspaceToolLease.js'
 import { requireTeamToolBridge } from './workspaceToolLease.js'
-import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel, finishGoalInput, resumeGoalInput } from './taskCoordinator.js'
+import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel, finishGoalInput, resumeGoalInput, goalCriteriaInput } from './taskCoordinator.js'
 
 
 const uuid = z.string().uuid()
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
 const empty = z.object({}).strict()
@@ -38,21 +38,22 @@ const definitions = [
   ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
   ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
   ['workspace_create_agent', '创建持久保存的 Bot 机器人,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
   ['workspace_create_agent', '创建持久保存的 Bot 机器人,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
   ['workspace_create_helper','为当前团队目标创建临时隔离助手并立即分派一个具体子任务。助手只参与此任务、不创建单聊、不获得组队权限;目标结束后退役。每个目标最多 8 个有效临时助手。优先用于一次性工作。',createHelper],
   ['workspace_create_helper','为当前团队目标创建临时隔离助手并立即分派一个具体子任务。助手只参与此任务、不创建单聊、不获得组队权限;目标结束后退役。每个目标最多 8 个有效临时助手。优先用于一次性工作。',createHelper],
-  ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实机器人 ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
-  ['workspace_start_team_task', '在你管理的团队启动独立任务,由管理员分派并复核。立即返回任务编号,不表示完成;请向用户提供团队名称和任务名称。requestId 使用新的 UUID,重试时复用。', startTask],
+  ['workspace_create_team', '只建立持久团队,自动把你加入并设为管理员,不启动交付目标、不生成子任务或验收要求。其他成员使用真实机器人 ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
+  ['workspace_start_team_task', '仅当用户明确要求完成并交付具体结果时启动目标。简单问答、方案讨论、一次性咨询或仅创建群聊时不要调用。在当前群聊中可将当前话题升级为目标,在其他团队中启动独立话题;提炼少量可核对的验收要求。管理员可直接完成,不强制分工。立即返回不表示完成,requestId 重试时复用。', startTask],
+  ['workspace_update_team_goal', '从用户的交付要求提炼验收条件,或按用户新指令调整。不要增加无关要求。expectedRevision 使用目标的 acceptanceRevision(旧记录默认为 1);冲突时先重新读取,不覆盖用户的新修改。', goalCriteriaInput.extend({ goalId: uuid })],
   ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
   ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
-  ['workspace_assign_task', '为团队目标创建有负责人、依赖和验收要求的子任务。仅可分派给团队内其他成员,依赖必须先完成复核。requestId 为重试复用的 UUID。', assignmentInput],
+  ['workspace_assign_task', '仅确实需要分工时创建有负责人、依赖和验收要求的子任务,能直接完成的工作不要拆分。仅可分派给团队内其他成员,依赖必须先完成复核。requestId 为重试复用的 UUID。', assignmentInput],
   ['workspace_review_assignment', '管理员复核子任务:accept 接受实际结果,retry 附具体修改意见后返工,blocked 记录受阻原因。失败执行不能直接验收通过。', assignmentReview],
   ['workspace_review_assignment', '管理员复核子任务:accept 接受实际结果,retry 附具体修改意见后返工,blocked 记录受阻原因。失败执行不能直接验收通过。', assignmentReview],
   ['workspace_update_assignment', '调整尚未运行或已失败子任务的说明、验收要求和依赖。禁止依赖循环;正在执行的任务需要先停止。', assignmentUpdate],
   ['workspace_update_assignment', '调整尚未运行或已失败子任务的说明、验收要求和依赖。禁止依赖循环;正在执行的任务需要先停止。', assignmentUpdate],
   ['workspace_cancel_assignment', '停止一个子任务,必须记录原因。执行停止尚未确认时显示正在停止,不能当作已经撤销外部副作用。其他子任务和团队不受影响。', assignmentCancel],
   ['workspace_cancel_assignment', '停止一个子任务,必须记录原因。执行停止尚未确认时显示正在停止,不能当作已经撤销外部副作用。其他子任务和团队不受影响。', assignmentCancel],
-  ['workspace_finish_team_task', '根据实际结果记录目标完成、受阻或等待用户。完成前所有子任务必须验收通过;结果会自动送回原始会话。', finishGoalInput],
+  ['workspace_finish_team_task', '根据实际结果记录目标完成、受阻或等待用户。无需为完成而创建子任务;已有有效子任务须验收通过。完成时传入当前 acceptanceRevision 并逐项提供依据;结果会自动送回原始会话。', finishGoalInput],
   ['workspace_resume_team_task', '仅在当前用户的新指令中恢复受阻、等待或停止的目标。旧执行状态不确定时拒绝恢复;自动回传不能自行重新激活旧目标。', resumeGoalInput],
   ['workspace_resume_team_task', '仅在当前用户的新指令中恢复受阻、等待或停止的目标。旧执行状态不确定时拒绝恢复;自动回传不能自行重新激活旧目标。', resumeGoalInput],
   ['workspace_update_created_agent', '调整你创建的成员的名称或职责,不能修改基础 Profile 或权限。变更从后续轮次生效。', editCreatedAgent],
   ['workspace_update_created_agent', '调整你创建的成员的名称或职责,不能修改基础 Profile 或权限。变更从后续轮次生效。', editCreatedAgent],
   ['workspace_archive_created_agent', '归档你创建且已不再被有效团队引用的空闲成员,保留历史;confirmName 必须与当前名称完全一致。不能归档其他人创建的成员。', archiveCreatedAgent],
   ['workspace_archive_created_agent', '归档你创建且已不再被有效团队引用的空闲成员,保留历史;confirmName 必须与当前名称完全一致。不能归档其他人创建的成员。', archiveCreatedAgent],
   ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
   ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
 ] as const
 ] as const
 
 
-export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础机器人、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。需要长期复用时创建持久成员;一次性子任务可使用 workspace_create_helper 创建任务级临时隔离助手。每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
+export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队,但组建团队与执行交付目标是两件事。用户只要求建群或组人时,先查看可用基础机器人、成员和团队,复用或创建最少成员,建立团队后告诉用户可以开始聊天,到此结束,不启动目标、不拆子任务、不生成验收清单。简单问答、方案讨论和一次性咨询直接回答,必要时在普通群聊 @成员,不升级为目标。只有用户明确要求把具体工作做完并交付结果,或选择了交付目标时,才使用 workspace_start_team_task;已存在当前目标时直接推进,不重复启动。验收要求从用户原始要求提炼,少量、具体、可核对,允许用户调整,不增加范围。管理员能直接完成就直接完成;只有需要不同成员协作的工作才用 workspace_assign_task 按需拆分,依赖也只在真实需要时设置,结果用 workspace_review_assignment 复核。确需一次性专家时可用 workspace_create_helper;长期角色才创建持久成员。每次变更生成 requestId(UUID),重试同一操作复用它。queued/running 只表示已启动;完成时使用 workspace_finish_team_task 提供当前 acceptanceRevision 和逐项实际依据。向用户主要报告进展、阻碍和最终结果,详细计划与验收留在可展开的进度面板,不在每次回复重复清单。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
 
 
 /** The caller identity comes exclusively from the running server-side turn. */
 /** The caller identity comes exclusively from the running server-side turn. */
 export class WorkspaceTeamTools {
 export class WorkspaceTeamTools {
@@ -188,9 +189,20 @@ export class WorkspaceTeamTools {
     if (toolId === 'workspace_start_team_task') {
     if (toolId === 'workspace_start_team_task') {
       const { requestId, ...body } = parse(startTask, input)
       const { requestId, ...body } = parse(startTask, input)
       const team = this.team(owner, agent.id, body.teamId)
       const team = this.team(owner, agent.id, body.teamId)
-      if (team.id === work.conversationId)
-        throw new HttpError(409, '你已在这个团队处理任务,请直接 @成员协作', 'team_task_reentrant')
+      const sourceRun = this.store.require<Run>(owner, 'run', work.runId)
+      const sourceMessage = this.store.require<{role: string}>(owner, 'message', sourceRun.messageId)
+      if (sourceRun.triggerKind || sourceMessage.role !== 'user' || sourceRun.conversationId !== work.conversationId)
+        throw new HttpError(409, '新目标需要用户的交付指令,自动回传不能重复启动目标', 'team_task_reentrant')
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
+        if (team.id === work.conversationId) {
+          const task = this.store.requireTask(owner, team.id, work.conversationTaskId!)
+          const goal = this.runtime.tasks.begin(owner, task, agent, body.content,
+            { conversationId: team.id, conversationTaskId: task.id, runId: work.runId, agentId: agent.id }, body.acceptanceCriteria)
+          sourceRun.goalId = goal.id
+          sourceRun.targetAgentId = agent.id
+          this.store.saveRun(owner, sourceRun)
+          return { teamId: team.id, teamName: team.name, task: { ...task, goal }, run: sourceRun, message: '当前话题已作为交付目标,请按需直接完成或分工,不要重复启动。' }
+        }
         if (this.store.activeTaskCount(owner, team.id) >= 4)
         if (this.store.activeTaskCount(owner, team.id) >= 4)
           throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
           throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
         const emptyTask = this.store.tasks(owner, team.id).find(t => t.messageCount === 0 && !t.activeRunId && t.titleSource === 'automatic')
         const emptyTask = this.store.tasks(owner, team.id).find(t => t.messageCount === 0 && !t.activeRunId && t.titleSource === 'automatic')
@@ -218,6 +230,10 @@ export class WorkspaceTeamTools {
         assignments: this.runtime.tasks.assignments(owner, task.id) }
         assignments: this.runtime.tasks.assignments(owner, task.id) }
     }
     }
     if (toolId === 'workspace_assign_task') return { assignment: this.runtime.tasks.createAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_assign_task') return { assignment: this.runtime.tasks.createAssignment(owner, agent.id, input) }
+    if (toolId === 'workspace_update_team_goal') {
+      const { goalId, ...body } = parse(goalCriteriaInput.extend({ goalId: uuid }), input)
+      return { goal: this.runtime.tasks.updateCriteria(owner, goalId, body, agent.id) }
+    }
     if (toolId === 'workspace_review_assignment') return { assignment: this.runtime.tasks.reviewAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_review_assignment') return { assignment: this.runtime.tasks.reviewAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_update_assignment') return { assignment: this.runtime.tasks.updateAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_update_assignment') return { assignment: this.runtime.tasks.updateAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_cancel_assignment') return { assignment: this.runtime.tasks.cancelAssignment(owner, agent.id, input) }
     if (toolId === 'workspace_cancel_assignment') return { assignment: this.runtime.tasks.cancelAssignment(owner, agent.id, input) }

+ 2 - 0
src/shared/agentTasks.ts

@@ -14,6 +14,8 @@ export interface AgentGoal {
   activation?: number
   activation?: number
   objective: string
   objective: string
   acceptanceCriteria: string[]
   acceptanceCriteria: string[]
+  /** Changes only when acceptance criteria change, independently of progress. */
+  acceptanceRevision?: number
   status: 'running' | 'review' | 'waiting' | 'blocked' | 'complete' | 'cancelling' | 'cancelled'
   status: 'running' | 'review' | 'waiting' | 'blocked' | 'complete' | 'cancelling' | 'cancelled'
   origin: TaskOrigin
   origin: TaskOrigin
   result?: string
   result?: string

+ 2 - 0
src/shared/workspace.ts

@@ -122,6 +122,8 @@ export interface WorkspaceRun {
   error?: string
   error?: string
   stopRequested?: boolean
   stopRequested?: boolean
   assignmentId?: string
   assignmentId?: string
+  /** Structured delivery run; ordinary chat does not carry a goal. */
+  goalId?: string
   targetAgentId?: string
   targetAgentId?: string
   triggerKind?: 'assignment' | 'task_review' | 'task_result'
   triggerKind?: 'assignment' | 'task_review' | 'task_result'
   internalInstruction?: string
   internalInstruction?: string

+ 39 - 0
tests/client/taskPlan.test.ts

@@ -0,0 +1,39 @@
+import { expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import TaskPlan from '@/components/workspace/TaskPlan.vue'
+import type { AgentGoal } from '@shared/agentTasks'
+
+const goal: AgentGoal = { id:'goal',conversationId:'group',coordinatorId:'lead',authorityRevision:1,objective:'交付报告',acceptanceCriteria:['提供来源'],acceptanceRevision:1,status:'running',origin:{conversationId:'group',runId:'run',agentId:'lead'},artifactIds:[],revision:1,automaticWakes:0,createdAt:1,updatedAt:1 }
+
+it('keeps progress and results visible while task details start collapsed', () => {
+  const wrapper = mount(TaskPlan,{props:{goal:{...goal,result:'初稿已交付'},assignments:[],agents:[]}})
+  expect(wrapper.get('details').attributes('open')).toBeUndefined()
+  expect(wrapper.get('header').text()).toContain('进行中')
+  expect(wrapper.get('.result').text()).toBe('初稿已交付')
+  wrapper.unmount()
+})
+
+it('preserves user edits across progress refreshes and reports stale-save errors without discarding the draft', async () => {
+  const saveCriteria = vi.fn().mockRejectedValueOnce(new Error('验收要求已更新,请重新查看后修改'))
+  const wrapper = mount(TaskPlan,{props:{goal,assignments:[],agents:[],saveCriteria}})
+  await wrapper.get('details').trigger('click')
+  await wrapper.findAll('button').find(button=>button.text()==='调整验收要求')!.trigger('click')
+  await wrapper.get('textarea').setValue('比较三种方案\n给出建议')
+  await wrapper.setProps({goal:{...goal,revision:9,status:'review',acceptanceRevision:2,acceptanceCriteria:['另一条最新要求']}})
+  expect(wrapper.get('textarea').element.value).toBe('比较三种方案\n给出建议')
+  await wrapper.get('form').trigger('submit')
+  await flushPromises()
+  expect(saveCriteria).toHaveBeenCalledWith('goal',1,['比较三种方案','给出建议'])
+  expect(wrapper.get('[role=alert]').text()).toContain('已更新')
+  expect(wrapper.get('textarea').element.value).toContain('比较三种方案')
+  await wrapper.findAll('button').find(button=>button.text()==='取消')!.trigger('click')
+  await wrapper.findAll('button').find(button=>button.text()==='调整验收要求')!.trigger('click')
+  expect(wrapper.get('textarea').element.value).toBe('另一条最新要求')
+  saveCriteria.mockResolvedValue(undefined)
+  await wrapper.get('textarea').setValue('最新要求与来源')
+  await wrapper.get('form').trigger('submit')
+  await flushPromises()
+  expect(saveCriteria).toHaveBeenLastCalledWith('goal',2,['最新要求与来源'])
+  expect(wrapper.find('textarea').exists()).toBe(false)
+  wrapper.unmount()
+})

+ 76 - 5
tests/e2e/team-tools.spec.ts

@@ -1,5 +1,77 @@
 import { test, expect } from '@playwright/test'
 import { test, expect } from '@playwright/test'
 
 
+test('simple group creation keeps chat lightweight and explicit delivery has editable collapsed details',async({page},testInfo)=>{
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+  for (const [name,canManageTeam] of [['交付负责人',true],['协作成员',false]] as const) {
+    await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
+    await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+    const editor=page.getByRole('dialog',{name:'创建机器人'})
+    await editor.getByRole('textbox',{name:'名称',exact:true}).fill(name)
+    if(canManageTeam)await editor.getByRole('checkbox',{name:'允许组建团队',exact:true}).check()
+    await editor.getByRole('button',{name:'保存',exact:true}).click()
+    await expect(page.getByRole('heading',{name,exact:true})).toBeVisible()
+  }
+  await page.reload()
+  await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
+  await page.getByRole('menuitem',{name:'新建群聊',exact:true}).click()
+  const dialog=page.getByRole('dialog',{name:'创建群聊'})
+  await expect(dialog.getByRole('combobox',{name:'协作方式',exact:true})).toBeHidden()
+  await expect(dialog.getByText('软件开发团队',{exact:true})).toBeHidden()
+  await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('轻量协作群')
+  await dialog.getByRole('checkbox',{name:'交付负责人',exact:true}).check()
+  await dialog.getByRole('checkbox',{name:'协作成员',exact:true}).check()
+  await dialog.getByRole('combobox',{name:'负责人',exact:true}).selectOption({label:'交付负责人'})
+  await dialog.getByRole('textbox',{name:'群规则(可选)',exact:true}).fill('回答简洁,结论注明来源。')
+  await page.screenshot({path:testInfo.outputPath('simple-group-desktop.png')})
+  await page.setViewportSize({width:390,height:844})
+  await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+  await expect(dialog.getByRole('button',{name:'保存',exact:true})).toBeInViewport()
+  await page.screenshot({path:testInfo.outputPath('simple-group-mobile.png')})
+  await dialog.getByRole('button',{name:'保存',exact:true}).click()
+  await expect(page.getByRole('heading',{name:'轻量协作群',exact:true})).toBeVisible()
+  const id=new URL(page.url()).pathname.split('/').at(-1)!
+  const snapshot=await (await page.request.get(`/api/app/conversations/${id}`)).json()
+  expect(snapshot.tasks).toHaveLength(1)
+  expect(snapshot.task.goal).toBeUndefined()
+  expect(snapshot.assignments).toEqual([])
+  await expect(page.locator('.task-plan')).toHaveCount(0)
+  const composer=page.getByRole('textbox',{name:'输入消息,Enter 发送,Shift + Enter 换行'})
+  await composer.fill('简单讨论一下这个方案')
+  await page.getByRole('button',{name:'发送消息',exact:true}).click()
+  await expect(page.locator('.message--assistant')).not.toHaveCount(0)
+  await expect(page.getByRole('button',{name:'交付目标',exact:true})).toBeEnabled()
+  const before=await (await page.request.get(`/api/app/conversations/${id}`)).json()
+  expect(before.task.goal).toBeUndefined()
+  await page.getByRole('button',{name:'交付目标',exact:true}).click()
+  await composer.fill('把方案做完并交付一份报告')
+  const sent=page.waitForResponse(r=>r.request().method()==='POST'&&r.url().endsWith(`/api/app/conversations/${id}/messages`))
+  await page.getByRole('button',{name:'发送消息',exact:true}).click()
+  const response=await sent
+  expect(response.request().postDataJSON().mode).toBe('goal')
+  expect(response.ok(),await response.text()).toBe(true)
+  await expect(page.getByRole('region',{name:'目标进度'})).toBeVisible()
+  const details=page.locator('.task-plan details')
+  await expect(details).not.toHaveAttribute('open')
+  expect((await (await page.request.get(`/api/app/conversations/${id}`)).json()).tasks).toHaveLength(1)
+  await details.locator('summary').click()
+  await page.getByRole('button',{name:'调整验收要求',exact:true}).click()
+  await page.getByRole('textbox',{name:'验收要求',exact:true}).fill('比较三种方案\n说明结论和依据')
+  await page.getByRole('button',{name:'保存验收要求',exact:true}).click()
+  await expect(details).toContainText('比较三种方案')
+  await expect(page.getByRole('button',{name:'调整验收要求',exact:true})).toBeVisible()
+  await page.screenshot({path:testInfo.outputPath('delivery-criteria-mobile.png')})
+  await page.setViewportSize({width:1280,height:900})
+  await page.emulateMedia({colorScheme:'light'})
+  await details.locator('summary').click()
+  await page.screenshot({path:testInfo.outputPath('delivery-progress-desktop.png')})
+  await page.getByRole('button',{name:'停止任务',exact:true}).click()
+  await expect(page.locator('.task-plan header')).toContainText('已停止')
+})
+
 test('team permission defaults off, saves, reloads and revokes in the Agent editor', async ({page},testInfo) => {
 test('team permission defaults off, saves, reloads and revokes in the Agent editor', async ({page},testInfo) => {
   await page.goto('/conversations')
   await page.goto('/conversations')
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
@@ -48,7 +120,7 @@ test('task plans show owners and keep independent text and attachment drafts whe
   await page.goto(`/conversations/${ids.sourceConversationId}`)
   await page.goto(`/conversations/${ids.sourceConversationId}`)
   await page.getByRole('link',{name:'打开任务',exact:true}).click()
   await page.getByRole('link',{name:'打开任务',exact:true}).click()
   await expect(page).toHaveURL(new RegExp(`/conversations/${ids.conversationId}\\?taskId=${ids.firstTaskId}`))
   await expect(page).toHaveURL(new RegExp(`/conversations/${ids.conversationId}\\?taskId=${ids.firstTaskId}`))
-  await page.locator('details.task-plan summary').click()
+  await page.locator('.task-plan details summary').click()
   await expect(page.locator('.task-plan')).toContainText('研究资料')
   await expect(page.locator('.task-plan')).toContainText('研究资料')
   await expect(page.locator('.task-plan')).toContainText('验收研究员')
   await expect(page.locator('.task-plan')).toContainText('验收研究员')
   await expect(page.locator('.task-plan')).toContainText('临时助手')
   await expect(page.locator('.task-plan')).toContainText('临时助手')
@@ -56,18 +128,17 @@ test('task plans show owners and keep independent text and attachment drafts whe
   const composer=page.getByRole('textbox',{name:'输入消息,Enter 发送,Shift + Enter 换行'})
   const composer=page.getByRole('textbox',{name:'输入消息,Enter 发送,Shift + Enter 换行'})
   await composer.fill('调研任务的草稿')
   await composer.fill('调研任务的草稿')
   await page.locator('.composer-area input[type=file]').first().setInputFiles({name:'draft.txt',mimeType:'text/plain',buffer:Buffer.from('待发送附件')})
   await page.locator('.composer-area input[type=file]').first().setInputFiles({name:'draft.txt',mimeType:'text/plain',buffer:Buffer.from('待发送附件')})
-  await page.getByRole('combobox',{name:'当前任务'}).selectOption(ids.secondTaskId)
+  await page.getByRole('combobox',{name:'当前话题'}).selectOption(ids.secondTaskId)
   await expect(composer).toHaveValue('')
   await expect(composer).toHaveValue('')
   await composer.fill('独立任务的草稿')
   await composer.fill('独立任务的草稿')
-  await page.getByRole('combobox',{name:'当前任务'}).selectOption(ids.firstTaskId)
+  await page.getByRole('combobox',{name:'当前话题'}).selectOption(ids.firstTaskId)
   await expect(composer).toHaveValue('调研任务的草稿')
   await expect(composer).toHaveValue('调研任务的草稿')
   await expect(page.locator('.composer-attachments')).toContainText('draft.txt')
   await expect(page.locator('.composer-attachments')).toContainText('draft.txt')
   await page.screenshot({path:testInfo.outputPath('task-plan-drafts.png')})
   await page.screenshot({path:testInfo.outputPath('task-plan-drafts.png')})
-  if(!await page.getByRole('button',{name:'停止任务',exact:true}).isVisible())await page.locator('details.task-plan summary').click()
   await page.getByRole('button',{name:'停止任务',exact:true}).click()
   await page.getByRole('button',{name:'停止任务',exact:true}).click()
   await expect(page.locator('.task-plan')).toContainText('已停止')
   await expect(page.locator('.task-plan')).toContainText('已停止')
   await expect(page.locator('.task-plan')).toContainText('已退役')
   await expect(page.locator('.task-plan')).toContainText('已退役')
-  if(!await page.getByText('验收研究员(临时) · 临时助手', {exact:false}).first().isVisible())await page.locator('details.task-plan summary').click()
+  if(!await page.getByText('验收研究员(临时) · 临时助手', {exact:false}).first().isVisible())await page.locator('.task-plan details summary').click()
   await page.screenshot({path:testInfo.outputPath('task-helper-retired.png')})
   await page.screenshot({path:testInfo.outputPath('task-helper-retired.png')})
 })
 })
 
 

+ 4 - 2
tests/e2e/workspace-chat.spec.ts

@@ -305,7 +305,7 @@ test('created roles and editable teams share a durable chat list without plugin
   await dialog.getByRole('checkbox', { name: '产品经理', exact: true }).check()
   await dialog.getByRole('checkbox', { name: '产品经理', exact: true }).check()
   await dialog.getByRole('checkbox', { name: '开发工程师', exact: true }).check()
   await dialog.getByRole('checkbox', { name: '开发工程师', exact: true }).check()
   await dialog
   await dialog
-    .getByRole('combobox', { name: '管理员', exact: true })
+    .getByRole('combobox', { name: '负责人', exact: true })
     .selectOption({ label: '产品经理' })
     .selectOption({ label: '产品经理' })
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name: '产品开发团队', exact: true })).toBeVisible()
   await expect(page.getByRole('heading', { name: '产品开发团队', exact: true })).toBeVisible()
@@ -392,11 +392,13 @@ test('created roles and editable teams share a durable chat list without plugin
   await second.close()
   await second.close()
   await page.goto('/conversations')
   await page.goto('/conversations')
   await openCreate(page, '新建群聊')
   await openCreate(page, '新建群聊')
+  await dialog.locator('summary').filter({hasText:'从团队模板选择'}).click()
   await expect(dialog.getByRole('button', { name: '信息收集团队', exact: true })).toBeDisabled()
   await expect(dialog.getByRole('button', { name: '信息收集团队', exact: true })).toBeDisabled()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
   await createAgent(page, '模板成员四')
   await createAgent(page, '模板成员四')
   await createAgent(page, '模板成员五')
   await createAgent(page, '模板成员五')
   await openCreate(page, '新建群聊')
   await openCreate(page, '新建群聊')
+  await dialog.locator('summary').filter({hasText:'从团队模板选择'}).click()
   for (const name of ['信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队', '项目管理团队', '数据分析团队', '会议协作团队', '运维保障团队']) {
   for (const name of ['信息收集团队', '产品设计团队', '软件开发团队', '文案书写团队', '项目管理团队', '数据分析团队', '会议协作团队', '运维保障团队']) {
     await expect(dialog.getByRole('button', { name, exact: true })).toBeEnabled()
     await expect(dialog.getByRole('button', { name, exact: true })).toBeEnabled()
   }
   }
@@ -522,7 +524,7 @@ for (const mode of ['普通聊天', 'Bot 单聊', '群聊', '普通长回复'] a
         await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('流式验证群')
         await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('流式验证群')
         await dialog.getByRole('checkbox', { name: `${mode}流式验证助手`, exact: true }).check()
         await dialog.getByRole('checkbox', { name: `${mode}流式验证助手`, exact: true }).check()
         await dialog.getByRole('checkbox', { name: '群聊流式协作成员', exact: true }).check()
         await dialog.getByRole('checkbox', { name: '群聊流式协作成员', exact: true }).check()
-        await dialog.getByRole('combobox', { name: '管理员', exact: true }).selectOption({ label: `${mode}流式验证助手` })
+        await dialog.getByRole('combobox', { name: '负责人', exact: true }).selectOption({ label: `${mode}流式验证助手` })
         await dialog.getByRole('button', { name: '保存', exact: true }).click()
         await dialog.getByRole('button', { name: '保存', exact: true }).click()
         await expect(page.getByRole('heading', { name: '流式验证群', exact: true })).toBeVisible()
         await expect(page.getByRole('heading', { name: '流式验证群', exact: true })).toBeVisible()
       }
       }

+ 6 - 1
tests/fixtures/workspace-server.ts

@@ -1,7 +1,7 @@
 import {nativeEnvironmentFixture} from './native-environment.js'
 import {nativeEnvironmentFixture} from './native-environment.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 import { FixtureBotPlugins } from './bot-plugins.js'
 import { FixtureBotPlugins } from './bot-plugins.js'
-/** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
+/** Isolated, deterministic Hermes fixture. Team-tool transport is simulated, with no live model. */
 import { createServer } from 'node:http'
 import { createServer } from 'node:http'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
@@ -72,6 +72,11 @@ const upstream = createServer((req, res) => {
     return
     return
   }
   }
   if (url.pathname.includes('/plugins/')) {
   if (url.pathname.includes('/plugins/')) {
+    if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && ['/api/plugins/yaoyao-bot-bridge/bind', '/api/plugins/yaoyao-bot-bridge/unbind'].includes(url.pathname)) {
+      req.resume()
+      send({ok:true,native_tools:true})
+      return
+    }
     if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && url.pathname === '/api/plugins/yaoyao-bot-bridge/capabilities') {
     if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && url.pathname === '/api/plugins/yaoyao-bot-bridge/capabilities') {
       send({ version: 1, ready: true, native_tools: true, in_process: true })
       send({ version: 1, ready: true, native_tools: true, in_process: true })
       return
       return

+ 57 - 0
tests/server/taskCoordinator.test.ts

@@ -42,6 +42,63 @@ function settle(runId:string,status:WorkspaceRun['status']='complete',text='有
 const assignment=(id:string)=>store.require<AgentAssignment>(owner,'assignment',id)
 const assignment=(id:string)=>store.require<AgentAssignment>(owner,'assignment',id)
 
 
 describe('durable team task coordination',()=>{
 describe('durable team task coordination',()=>{
+  it('keeps chat lightweight and starts one explicit goal in the existing topic', () => {
+    const conversation = store.createGroup(owner, {name:'日常群聊',memberIds:[lead.id,worker.id],administratorId:lead.id,mode:'free',autoReplyIds:[worker.id]})
+    const task = store.tasks(owner, conversation.id)[0]!
+    const chat = runtime.send(owner, conversation.id, {requestId:randomUUID(),taskId:task.id,content:'讨论一下方案'})
+    expect(store.get(owner,'goal',task.id)).toBeUndefined()
+    expect(runtime.tasks.assignments(owner,task.id)).toEqual([])
+    settle(chat.id)
+    const input = {requestId:randomUUID(),taskId:task.id,content:'完成方案并交付报告',mode:'goal'}
+    const run = runtime.send(owner,conversation.id,input)
+    expect(runtime.send(owner,conversation.id,input).id).toBe(run.id)
+    expect(store.tasks(owner,conversation.id)).toHaveLength(1)
+    expect(store.require<AgentGoal>(owner,'goal',task.id)).toMatchObject({objective:input.content,coordinatorId:lead.id})
+    expect(run).toMatchObject({goalId:task.id,targetAgentId:lead.id})
+    expect(store.list<{runId:string;agentId:string}>(owner,'turn').filter(w=>w.runId===run.id).map(w=>w.agentId)).toEqual([lead.id])
+    expect(runtime.tasks.assignments(owner,task.id)).toEqual([])
+    const review = runtime.dispatch(owner,conversation.id,{requestId:randomUUID(),taskId:task.id,content:'复核进展'}, {agentId:lead.id,kind:'task_review'})
+    expect(store.list<{runId:string;agentId:string}>(owner,'turn').filter(w=>w.runId===review.id).map(w=>w.agentId)).toEqual([lead.id])
+    settle(review.id)
+    expect(runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:task.id,status:'complete',result:'交付完整报告',checks:[{criterion:0,passed:true,evidence:'报告已对照请求核验'}]},run.id).status).toBe('complete')
+  })
+  it('rejects delivery without a valid coordinator or during existing chat work without leaving a goal', () => {
+    const conversation = store.createGroup(owner,{name:'讨论群',memberIds:[lead.id,worker.id],administratorId:worker.id})
+    const task = store.tasks(owner,conversation.id)[0]!
+    const input = {requestId:randomUUID(),taskId:task.id,mode:'goal',content:'完成报告'}
+    expect(()=>runtime.send(owner,conversation.id,input)).toThrow('允许组建团队')
+    expect(store.get(owner,'goal',task.id)).toBeUndefined()
+    expect(store.messages(owner,conversation.id)).toEqual([])
+    store.updateConversation(owner,conversation.id,{administratorId:lead.id})
+    runtime.send(owner,conversation.id,{requestId:randomUUID(),taskId:task.id,content:'先讨论'})
+    expect(()=>runtime.send(owner,conversation.id,input)).toThrow('等待当前回复')
+    expect(store.get(owner,'goal',task.id)).toBeUndefined()
+  })
+  it('edits criteria with concurrency protection and refuses completion against superseded criteria', () => {
+    const input = {requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['报告应比较三种方案']}
+    const edited = runtime.tasks.updateCriteria(owner,goal.id,input)
+    expect(edited.acceptanceRevision).toBe(2)
+    expect(runtime.tasks.updateCriteria(owner,goal.id,input)).toEqual(edited)
+    expect(()=>runtime.tasks.updateCriteria(owner,goal.id,{...input,requestId:randomUUID(),acceptanceCriteria:['覆盖用户的新要求']})).toThrow('已更新')
+    const finish = {requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成',checks:[{criterion:0,passed:true,evidence:'原有验收依据'}]}
+    expect(()=>runtime.tasks.finish(owner,lead.id,finish)).toThrow('最新要求')
+    expect(()=>runtime.tasks.updateCriteria('other',goal.id,{...input,requestId:randomUUID()})).toThrow()
+    expect(runtime.tasks.finish(owner,lead.id,{...finish,acceptanceRevision:2,checks:[{criterion:0,passed:true,evidence:'报告包含三种方案及逐项比较'}]}).status).toBe('complete')
+    expect(()=>runtime.tasks.updateCriteria(owner,goal.id,{...input,requestId:randomUUID(),expectedRevision:2})).toThrow('进行中的目标')
+  })
+  it('wakes an idle coordinator once for newly edited criteria without creating assignments', async () => {
+    const run = runtime.dispatch(owner,team.id,{requestId:randomUUID(),taskId:goal.id,content:'直接交付报告'}, {agentId:lead.id,kind:'task_review'})
+    settle(run.id)
+    await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(1))
+    const firstDelivery = store.list<TaskDelivery>(owner,'task-delivery')[0]!
+    settle(firstDelivery.runId!)
+    runtime.tasks.updateCriteria(owner,goal.id,{requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['增加三种方案的比较']})
+    await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(2))
+    runtime.tasks.wake()
+    await new Promise(resolve=>setTimeout(resolve,5))
+    expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(2)
+    expect(runtime.tasks.assignments(owner,goal.id)).toEqual([])
+  })
   it('runs dependencies only after review, returns a result to the original conversation once',async()=>{
   it('runs dependencies only after review, returns a result to the original conversation once',async()=>{
     const a=assign('调研'),b=assign('撰写',second,[a.id])
     const a=assign('调研'),b=assign('撰写',second,[a.id])
     await vi.waitFor(()=>expect(assignment(a.id).status).toBe('running'))
     await vi.waitFor(()=>expect(assignment(a.id).status).toBe('running'))

+ 17 - 0
tests/server/workspace.test.ts

@@ -130,6 +130,23 @@ it('executes a structured assignment on its actual worker and leaves acceptance
   expect(submitted.params.text).toContain('执行子任务')
   expect(submitted.params.text).toContain('执行子任务')
   expect(store.get<any>(owner,'goal',goal.id)?.status).not.toBe('complete')
   expect(store.get<any>(owner,'goal',goal.id)?.status).not.toBe('complete')
 })
 })
+it('keeps structured goal coordination on the lead and does not relay mention text as duplicate work', async () => {
+  const lead=store.updateAgent(owner,agent('交付负责人').id,{canManageTeam:true}),worker=agent('研究成员')
+  const target=nodes.target(owner,'local'), request=target.session.request.bind(target.session)
+  vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>path.startsWith('/api/plugins/yaoyao-bot-bridge/')
+    ? {status:200,body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,in_process:true,native_tools:true})),headers:new Headers()}
+    : request(path,options))
+  const team=store.createGroup(owner,{name:'自由协作交付',memberIds:[lead.id,worker.id],administratorId:lead.id,mode:'free',autoReplyIds:[worker.id]})
+  reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'@研究成员 请核对资料',status:'complete'}}}))
+  const run=runtime.send(owner,team.id,{requestId:randomUUID(),content:'交付研究报告',mode:'goal'})
+  await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status))
+  const settled=store.require<WorkspaceRun>(owner,'run',run.id)
+  expect(settled.status,settled.error).toBe('complete')
+  const work=store.list<Work>(owner,'turn').filter(w=>w.runId===run.id)
+  expect(work.map(w=>w.agentId)).toEqual([lead.id])
+  expect(runtime.tasks.assignments(owner,run.goalId!)).toEqual([])
+  expect(requests.filter(r=>r.method==='prompt.submit').every(r=>!String(r.params.text).startsWith('你是 研究成员'))).toBe(true)
+})
 function direct(id: string, user = owner) {
 function direct(id: string, user = owner) {
   return store
   return store
     .list<WorkspaceConversation>(user, 'conversation')
     .list<WorkspaceConversation>(user, 'conversation')

+ 22 - 1
tests/server/workspaceRoutes.test.ts

@@ -265,12 +265,33 @@ describe('application workspace HTTP contract', () => {
     expect(repeated.body.run.id).toBe(resumed.body.run.id)
     expect(repeated.body.run.id).toBe(resumed.body.run.id)
     await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`,'second').send({requestId:randomUUID()}).expect(404)
     await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`,'second').send({requestId:randomUUID()}).expect(404)
   })
   })
+  it('starts an explicit delivery and lets only its owner edit versioned acceptance criteria',async()=>{
+    vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
+    vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()
+    const lead=runtime.workspace.createAgent('first',{name:'负责人',profile:'default',canManageTeam:true})
+    const member=runtime.workspace.createAgent('first',{name:'成员',profile:'default'})
+    const team=runtime.workspace.createGroup('first',{name:'交付群',memberIds:[lead.id,member.id],administratorId:lead.id})
+    const task=runtime.workspace.tasks('first',team.id)[0]!
+    const path=`/api/app/conversations/${team.id}`
+    const input={requestId:randomUUID(),taskId:task.id,content:'完成并交付报告',mode:'goal'}
+    const sent=await req('post',`${path}/messages`).send(input).expect(202)
+    expect(sent.body.run.goalId).toBe(task.id)
+    expect((await req('post',`${path}/messages`).send(input).expect(202)).body.run.id).toBe(sent.body.run.id)
+    expect((await req('get',`${path}/tasks/${task.id}/plan`).expect(200)).body.assignments).toEqual([])
+    const update={requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['包含三种方案的比较']}
+    const edited=await req('patch',`${path}/tasks/${task.id}/plan`).send(update).expect(200)
+    expect(edited.body.goal).toMatchObject({acceptanceRevision:2,acceptanceCriteria:update.acceptanceCriteria})
+    await req('patch',`${path}/tasks/${task.id}/plan`,'second').send({...update,requestId:randomUUID()}).expect(404)
+    expect((await req('patch',`${path}/tasks/${task.id}/plan`).send({...update,requestId:randomUUID()}).expect(409)).body.code).toBe('goal_criteria_changed')
+  })
   it('grants team management only with a ready bridge and persists explicit revocation', async () => {
   it('grants team management only with a ready bridge and persists explicit revocation', async () => {
     const denied = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(409)
     const denied = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(409)
     expect(denied.body.code).toBe('team_tools_unavailable')
     expect(denied.body.code).toBe('team_tools_unavailable')
     expect((await req('get','/api/app/agents')).body.agents).toEqual([])
     expect((await req('get','/api/app/agents')).body.agents).toEqual([])
     bridgeReady = true
     bridgeReady = true
-    const agent = (await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(201)).body.agent
+    const created = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true})
+    expect(created.status, JSON.stringify(created.body)).toBe(201)
+    const agent = created.body.agent
     expect(agent.canManageTeam).toBe(true)
     expect(agent.canManageTeam).toBe(true)
     await req('patch',`/api/app/agents/${agent.id}`,'second').send({canManageTeam:false}).expect(404)
     await req('patch',`/api/app/agents/${agent.id}`,'second').send({canManageTeam:false}).expect(404)
     bridgeReady = false
     bridgeReady = false

+ 25 - 0
tests/server/workspaceTeamTools.test.ts

@@ -62,6 +62,31 @@ async function buildTeam() {
 }
 }
 
 
 describe('Agent-owned team tools', () => {
 describe('Agent-owned team tools', () => {
+  it('only assembles members when creating a team, without goals or assignments', async () => {
+    const before = store.list(owner,'run').length
+    const {team,task} = await buildTeam()
+    expect(store.tasks(owner,team.id)).toHaveLength(1)
+    expect(task.goal).toBeUndefined()
+    expect(store.list(owner,'goal')).toEqual([])
+    expect(store.list(owner,'assignment')).toEqual([])
+    expect(store.list(owner,'run')).toHaveLength(before)
+  })
+  it('promotes the current user conversation into a goal without creating another task or turn', async () => {
+    const {team,task} = await buildTeam()
+    const run = runtime.send(owner,team.id,{requestId:randomUUID(),taskId:task.id,content:'把这件事做完并交付报告'})
+    work = store.list<Work>(owner,'turn').find(w=>w.runId===run.id)!
+    work.status='running';work.teamManagementRevision=manager.revision;store.put(owner,'turn',work.id,work)
+    const before = store.list(owner,'run').length
+    const input = {requestId:randomUUID(),teamId:team.id,title:'报告',content:'交付报告',acceptanceCriteria:['包含结论和依据']}
+    const started = await call('start_team_task',input)
+    expect((await call('start_team_task',input)).task.id).toBe(task.id)
+    expect(store.tasks(owner,team.id)).toHaveLength(1)
+    expect(store.list(owner,'run')).toHaveLength(before)
+    expect(started.run.goalId).toBe(task.id)
+    const edited = await call('update_team_goal',{requestId:randomUUID(),goalId:task.id,expectedRevision:1,acceptanceCriteria:['包含三条结论及对应来源']})
+    expect(edited.goal.acceptanceRevision).toBe(2)
+    await expect(call('start_team_task',{...input,requestId:randomUUID()})).rejects.toMatchObject({code:'goal_exists'})
+  })
   it('records creator authority and archives only an unused member it created',async()=>{
   it('records creator authority and archives only an unused member it created',async()=>{
     const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default'})).agent
     const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default'})).agent
     expect(child.createdByAgentId).toBe(manager.id)
     expect(child.createdByAgentId).toBe(manager.id)